From c99b324c9ba8a37f43ac7b01d98a32eefea6af1e Mon Sep 17 00:00:00 2001 From: maltayyar2 Date: Mon, 29 Dec 2025 11:45:35 +0300 Subject: [PATCH] [IMP] system_dashboard_classic: automatic update Auto-generated commit based on local changes. --- .../system_dashboard_classic/models/config.py | 13 + .../system_dashboard_classic/models/models.py | 74 ++++ .../src/js/system_dashboard_self_service.js | 280 +++++++++++++-- .../static/src/scss/core.scss | 318 ++++++++++++++++++ .../views/dashboard_settings.xml | 17 + 5 files changed, 676 insertions(+), 26 deletions(-) diff --git a/odex25_base/system_dashboard_classic/models/config.py b/odex25_base/system_dashboard_classic/models/config.py index 61857cf13..762e3e26f 100644 --- a/odex25_base/system_dashboard_classic/models/config.py +++ b/odex25_base/system_dashboard_classic/models/config.py @@ -581,6 +581,14 @@ class DashboardConfigSettings(models.TransientModel): config_parameter='system_dashboard_classic.refresh_interval', help='How often to refresh data (minimum: 30 seconds, maximum: 3600 seconds / 1 hour)' ) + + # Work Timer Settings + dashboard_show_work_timer = fields.Boolean( + string='Show Work Timer', + default=False, + help='Display live work hour countdown showing remaining time until end of planned shift. Updates every second and shows progress bar.' + ) + @@ -607,9 +615,11 @@ class DashboardConfigSettings(models.TransientModel): dashboard_show_attendance_hours=get_bool_param('system_dashboard_classic.show_attendance_hours'), dashboard_show_attendance_section=get_bool_param('system_dashboard_classic.show_attendance_section'), dashboard_enable_attendance_button=get_bool_param('system_dashboard_classic.enable_attendance_button', 'False'), + dashboard_show_work_timer=get_bool_param('system_dashboard_classic.show_work_timer'), ) return res + def set_values(self): """Override to explicitly store Boolean visibility settings as strings @@ -632,9 +642,12 @@ class DashboardConfigSettings(models.TransientModel): 'True' if self.dashboard_show_attendance_section else 'False') ICPSudo.set_param('system_dashboard_classic.enable_attendance_button', 'True' if self.dashboard_enable_attendance_button else 'False') + ICPSudo.set_param('system_dashboard_classic.show_work_timer', + 'True' if self.dashboard_show_work_timer else 'False') return res + @api.model def get_stats_visibility(self): """API method to get statistics visibility settings for JavaScript diff --git a/odex25_base/system_dashboard_classic/models/models.py b/odex25_base/system_dashboard_classic/models/models.py index 84aba6f41..56ebb4b6b 100644 --- a/odex25_base/system_dashboard_classic/models/models.py +++ b/odex25_base/system_dashboard_classic/models/models.py @@ -1021,3 +1021,77 @@ class SystemDashboard(models.Model): result['approval_count'] = approval_count return result + + @api.model + def get_work_timer_data(self): + """ + Returns live work timer data for countdown display + Leverages existing attendance.attendance model and logic + """ + employee = self.env.user.employee_id + + if not employee: + return {'enabled': False, 'reason': 'no_employee'} + + # Check if feature is enabled in settings + ICP = self.env['ir.config_parameter'].sudo() + + is_enabled = ICP.get_param('system_dashboard_classic.show_work_timer', 'True') == 'True' + + if not is_enabled: + return {'enabled': False, 'reason': 'disabled_in_settings'} + + today = fields.Date.today() + + # Get last attendance record today using same model as attendance_duration + last_att = self.env['attendance.attendance'].sudo().search([ + ('employee_id', '=', employee.id), + ('action_date', '=', today) + ], order='name DESC', limit=1) + + if not last_att: + return { + 'enabled': True, + 'status': 'not_checked_in', + 'message': _('لم تسجل دخول بعد') + } + + # Get planned hours from resource calendar + calendar = employee.resource_calendar_id + planned_hours = 8.0 # Default fallback + + if calendar: + day_of_week = today.weekday() + attendance_lines = calendar.attendance_ids.filtered( + lambda a: int(a.dayofweek) == day_of_week + ) + if attendance_lines: + planned_hours = sum(line.hour_to - line.hour_from for line in attendance_lines) + + # If sign_out - use attendance_duration directly (existing logic) + if last_att.action == 'sign_out': + return { + 'enabled': True, + 'status': 'checked_out', + 'hours_worked': last_att.attendance_duration, + 'hours_worked_formatted': last_att.attendance_duration_hhmmss, + 'planned_hours': planned_hours + } + + # If sign_in - return start time for client-side countdown + # Use same timezone logic as get_refresh_data + import pytz + user_tz = pytz.timezone(self.env.context.get('tz', 'Asia/Riyadh') or self.env.user.tz or 'UTC') + + # Convert UTC datetime to user timezone + sign_in_utc = last_att.name + sign_in_local = pytz.utc.localize(sign_in_utc).astimezone(user_tz) + + return { + 'enabled': True, + 'status': 'checked_in', + 'sign_in_time': sign_in_local.isoformat(), # ISO format for JS Date parsing + 'planned_hours': planned_hours, + 'planned_seconds': int(planned_hours * 3600) + } + diff --git a/odex25_base/system_dashboard_classic/static/src/js/system_dashboard_self_service.js b/odex25_base/system_dashboard_classic/static/src/js/system_dashboard_self_service.js index 5d1e1fb31..37783e3f1 100644 --- a/odex25_base/system_dashboard_classic/static/src/js/system_dashboard_self_service.js +++ b/odex25_base/system_dashboard_classic/static/src/js/system_dashboard_self_service.js @@ -111,7 +111,7 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require if (type === 'birthday') { emoji = '🎂'; badgeClass = 'birthday-mode'; - title = isRtl ? 'عيد ميلاد سعيد!' : 'Happy Birthday!'; + title = isRtl ? 'يوم ميلاد سعيد!' : 'Happy Birthday!'; // Gendered pronouns: لك (male) / لكِ (female) var genderInfo = window.dashboardGenderInfo || {pronoun_you: 'ك'}; var forYou = 'ل' + genderInfo.pronoun_you; // لك or لكِ @@ -309,7 +309,7 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require var greetingHtml = '' + greetingWithHonorific + ''; if (result.celebration && result.celebration.is_birthday) { - var birthdayText = isRtl ? 'عيد ميلاد سعيد!' : 'Happy Birthday!'; + var birthdayText = isRtl ? 'يوم ميلاد سعيد!' : 'Happy Birthday!'; greetingHtml = '🎂 ' + birthdayText + ''; } else if (result.celebration && result.celebration.is_anniversary) { var anniversaryText = isRtl ? 'شكراً لعطائك!' : 'Thank You!'; @@ -562,7 +562,7 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require if ($approveContainer.length === 0 || $trackContainer.length === 0) { // DOM not ready - retry after 100ms if (retryCount < maxRetries) { - console.log('[Dashboard] Waiting for DOM... attempt ' + (retryCount + 1)); + // console.log('[Dashboard] Waiting for DOM... attempt ' + (retryCount + 1)); setTimeout(function() { buildApprovalCards(resultData, retryCount + 1); }, 100); @@ -621,9 +621,8 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require view_type: 'list', views: [[list_view, 'list'], [form_view, 'form']], domain: domain, - target: 'main', - flags: { reload: true } }, { on_reverse_breadcrumb: function() { return self.reload(); } }); + }); // Update pending count badge - Sum ACTUAL pending requests from data @@ -680,6 +679,23 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require // Initial build of approval cards (pass full result object) buildApprovalCards(result); + // ============================================================ + // ACTIVE TAB HANDLING - Activate correct tab from context + // Used when returning from approval record view via breadcrumb + // Note: 'context' here is actually the action object + // ============================================================ + var activeTab = (context && context.params && context.params.active_tab) || + (context && context.context && context.context.active_tab); + if (activeTab === 'to_approve') { + setTimeout(function() { + $('a[href="#to_approve"]').tab('show'); + }, 100); + } else if (activeTab === 'to_track') { + setTimeout(function() { + $('a[href="#to_track"]').tab('show'); + }, 100); + } + // ============================================================ // TAB CLICK REFRESH - Refresh data when clicking approval tabs // Only refreshes when coming FROM Self Services TO approval tabs @@ -705,14 +721,14 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require // Only refresh when coming from Self Services tab // Not when switching between To Approve ↔ To Track if (lastActiveTab === 'approval') { - console.log('[Dashboard] Already in approval section, no refresh needed'); + // console.log('[Dashboard] Already in approval section, no refresh needed'); lastActiveTab = 'approval'; // Keep it as approval return; } // Prevent double refresh if (isRefreshing) { - console.log('[Dashboard] Refresh already in progress, skipping'); + // console.log('[Dashboard] Refresh already in progress, skipping'); return; } isRefreshing = true; @@ -789,27 +805,30 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require var model = $(this).attr('data-model'); var name = $(this).attr('data-name'); var domain = $(this).attr('data-domain'); - var form_view = parseInt($(this).attr('data-form-view')) || false; - var list_view = parseInt($(this).attr('data-list-view')) || false; - var count = parseInt($(this).attr('data-count')) || 0; - var context = $(this).attr('data-context') || '{}'; + var form_view = parseInt($(this).attr('form-view')) || false; + var list_view = parseInt($(this).attr('list-view')) || false; - try { context = JSON.parse(context.replace(/'/g, '"')); } - catch(e) { context = {}; } + if (domain === undefined || domain === '') { + domain = false; + } else { + try { domain = JSON.parse(domain); } + catch(e) { domain = false; } + } + if (isNaN(form_view)) form_view = false; + if (isNaN(list_view)) list_view = false; - if (count > 0 && domain) { + if (domain) { return self.do_action({ name: _t(name), type: 'ir.actions.act_window', res_model: model, view_mode: 'tree,form', + view_type: 'list', views: [[list_view, 'list'], [form_view, 'form']], - context: context, - domain: JSON.parse(domain.replace(/'/g, '"')), - target: 'current', - flags: { reload: true } + domain: domain, }, { on_reverse_breadcrumb: function() { return self.reload(); } }); } + }); }, 100); @@ -831,7 +850,7 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require $('.pending-count-badge').hide(); } - console.log('[Dashboard] Approval cards refreshed on tab click'); + // console.log('[Dashboard] Approval cards refreshed on tab click'); // Reset flag after a short delay to allow for any animation setTimeout(function() { isRefreshing = false; }, 500); @@ -897,16 +916,16 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require function startPeriodicRefresh() { if (!refreshEnabled || refreshInterval <= 0) { - console.log('[Dashboard] Periodic refresh disabled'); + // console.log('[Dashboard] Periodic refresh disabled'); return; } - console.log('[Dashboard] Starting periodic refresh (interval: ' + (refreshInterval/1000) + 's)'); + // console.log('[Dashboard] Starting periodic refresh (interval: ' + (refreshInterval/1000) + 's)'); self.periodicRefreshId = setInterval(function() { // Skip if page is hidden (browser tab switch) if (document.hidden) { - console.log('[Dashboard] Skipping refresh - tab hidden'); + // console.log('[Dashboard] Skipping refresh - tab hidden'); return; } @@ -917,7 +936,7 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require }, []).then(function(data) { updateAttendanceStatus(data.attendance); updateApprovalCount(data.approval_count); - console.log('[Dashboard] Periodic refresh completed - Count: ' + data.approval_count); + // console.log('[Dashboard] Periodic refresh completed - Count: ' + data.approval_count); }).catch(function(err) { console.warn('[Dashboard] Refresh failed:', err); }); @@ -927,6 +946,194 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require // Start polling startPeriodicRefresh(); + // ===== WORK TIMER WIDGET ===== + // Live countdown timer for remaining work hours + var WorkTimer = { + enabled: false, + intervalId: null, + signInTime: null, + plannedSeconds: 0, + + init: function() { + var widget = this; + + // console.log('[WorkTimer] ===== INITIALIZATION START ====='); + // console.log('[WorkTimer] self object available:', typeof self !== 'undefined'); + // console.log('[WorkTimer] self._rpc available:', typeof self._rpc); + // console.log('[WorkTimer] DOM container exists:', $('.attendance-section-body').length); + + self._rpc({ + model: 'system_dashboard_classic.dashboard', + method: 'get_work_timer_data', + }).then(function(data) { + // console.log('[WorkTimer] RPC Response received:', data); + + if (!data.enabled) { + // console.log('[WorkTimer] Feature disabled -', data.reason); + return; + } + + widget.enabled = true; + // console.log('[WorkTimer] Feature enabled, status:', data.status); + + if (data.status === 'not_checked_in') { + // console.log('[WorkTimer] Rendering: Not Checked In'); + widget.renderNotCheckedIn(data.message); + } else if (data.status === 'checked_out') { + // console.log('[WorkTimer] Rendering: Checked Out'); + widget.renderCheckedOut(data); + } else if (data.status === 'checked_in') { + // console.log('[WorkTimer] Rendering: Live Countdown'); + // Parse ISO datetime to JavaScript Date + widget.signInTime = new Date(data.sign_in_time); + widget.plannedSeconds = data.planned_seconds; + // console.log('[WorkTimer] Sign-in time:', widget.signInTime); + // console.log('[WorkTimer] Planned seconds:', widget.plannedSeconds); + widget.startCountdown(); + } + }).catch(function(err) { + console.error('[WorkTimer] !!!!! RPC FAILED !!!!!'); + console.error('[WorkTimer] Error details:', err); + }); + }, + + startCountdown: function() { + var widget = this; + + // Clear existing interval + if (this.intervalId) { + clearInterval(this.intervalId); + } + + // Update immediately + this.tick(); + + // Update every second with page visibility check + this.intervalId = setInterval(function() { + if (!document.hidden) { // Best practice: Page Visibility API + widget.tick(); + } + }, 1000); + }, + + tick: function() { + var now = new Date(); + var elapsedSeconds = Math.floor((now - this.signInTime) / 1000); + var remainingSeconds = Math.max(0, this.plannedSeconds - elapsedSeconds); + + var progress = Math.min(100, (elapsedSeconds / this.plannedSeconds) * 100); + var isOvertime = elapsedSeconds > this.plannedSeconds; + + this.updateUI({ + elapsed: this.formatTime(elapsedSeconds), + remaining: this.formatTime(remainingSeconds), + progress: progress.toFixed(1), + isOvertime: isOvertime + }); + }, + + formatTime: function(seconds) { + var h = Math.floor(seconds / 3600); + var m = Math.floor((seconds % 3600) / 60); + var s = Math.floor(seconds % 60); + return h.toString().padStart(2, '0') + ':' + + m.toString().padStart(2, '0') + ':' + + s.toString().padStart(2, '0'); + }, + + updateUI: function(data) { + var $container = $('.work-timer-compact'); + + if ($container.length === 0) { + this.renderCountdown(data); + } else { + $container.find('.timer-value').text(data.remaining); + $container.find('.timer-elapsed').text(($ ('body').hasClass('o_rtl') ? 'منقضي' : 'Elapsed') + ': ' + data.elapsed); + $container.find('.timer-progress-fill').css('width', data.progress + '%'); + $container.toggleClass('overtime', data.isOvertime); + } + }, + + renderCountdown: function(data) { + var isRtl = $('body').hasClass('o_rtl'); + + // console.log('[WorkTimer] Rendering countdown widget...'); + // console.log('[WorkTimer] Data:', data); + // console.log('[WorkTimer] RTL mode:', isRtl); + + // Compact inline design - integrated with attendance info + var html = ` +
+
+ +
+
${data.remaining}
+
${isRtl ? 'متبقي' : 'remaining'}
+
+
+
+
+
+
${isRtl ? 'منقضي' : 'Elapsed'}: ${data.elapsed}
+
+ `; + + // Insert after attendance title + var $target = $('.attendance-title'); + // console.log('[WorkTimer] Target (title) found:', $target.length); + + if ($target.length > 0) { + $target.after(html); + // console.log('[WorkTimer] ✅ Widget HTML injected after title!'); + } else { + console.error('[WorkTimer] ❌ Attendance title NOT FOUND'); + } + }, + + renderNotCheckedIn: function(message) { + var html = ` +
+
+ + ${message} +
+
+ `; + $('.attendance-title').after(html); + }, + + renderCheckedOut: function(data) { + var isRtl = $('body').hasClass('o_rtl'); + var html = ` +
+
+ +
+
${data.hours_worked_formatted}
+
${isRtl ? 'إجمالي ساعات العمل' : 'Total Hours'}
+
+
+
+ `; + $('.attendance-title').after(html); + }, + + destroy: function() { + // Best practice: Clear interval on destroy + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + $('.work-timer-compact').remove(); + } + }; + + // Initialize work timer after short delay + setTimeout(function() { + WorkTimer.init(); + }, 100); + + // Chart Settings setTimeout(function() { window.check = false; @@ -1077,6 +1284,16 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require setTimeout(function() { $notification.remove(); }, 400); }, 5500); + // ===== REFRESH WORK TIMER AFTER CHECK-IN/OUT ===== + // console.log('[WorkTimer] Attendance changed - will refresh widget...'); + setTimeout(function() { + if (typeof WorkTimer !== 'undefined' && WorkTimer.destroy && WorkTimer.init) { + // console.log('[WorkTimer] Refreshing after attendance change...'); + WorkTimer.destroy(); + WorkTimer.init(); + } + }, 1500); + }).guardedCatch(function(error) { // Handle unexpected server errors only var errorMsg = isRtl ? 'حدث خطأ غير متوقع. حاول مرة أخرى' : 'An unexpected error occurred. Please try again.'; @@ -1642,8 +1859,19 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require $(".o_control_panel").addClass("o_hidden"); var self = this; }, - reload: function() { - window.location.href = this.href; + reload: function(options) { + // Properly reload dashboard by triggering the action again + // options.active_tab can be passed to specify which tab to open + var activeTab = (options && options.active_tab) || 'to_approve'; + this.do_action({ + name: _t('Self-Service'), + type: 'ir.actions.client', + tag: 'system_dashboard_classic.dashboard_self_services', + target: 'main', + params: { + active_tab: activeTab + } + }); }, /** * Cleanup on widget destruction - prevents memory leak @@ -1659,7 +1887,7 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require if (this.periodicRefreshId) { clearInterval(this.periodicRefreshId); this.periodicRefreshId = null; - console.log('[Dashboard] Periodic refresh stopped'); + // console.log('[Dashboard] Periodic refresh stopped'); } // Call parent destroy this._super.apply(this, arguments); diff --git a/odex25_base/system_dashboard_classic/static/src/scss/core.scss b/odex25_base/system_dashboard_classic/static/src/scss/core.scss index 8099d4598..736e9abed 100644 --- a/odex25_base/system_dashboard_classic/static/src/scss/core.scss +++ b/odex25_base/system_dashboard_classic/static/src/scss/core.scss @@ -724,3 +724,321 @@ $border-color_1: #2eac96; } } } + +/* ==================================== + WORK TIMER WIDGET + Compact, professional design using dashboard colors + ==================================== */ + +.work-timer-widget { + // Use dashboard primary color with intelligent gradient + background: linear-gradient(135deg, + var(--dash-primary, #667eea) 0%, + var(--dash-primary-dark, #5a67d8) 100% + ); + border-radius: 6px; + padding: 10px 12px; + color: white; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); + margin-bottom: 12px; + transition: all 0.3s ease; + width: 100%; + max-width: 100%; + box-sizing: border-box; + + &.overtime { + // Use warning color for overtime + background: linear-gradient(135deg, + var(--dash-warning, #f59e0b) 0%, + #dc2626 100% + ); + animation: pulse-timer-warning 2s infinite; + } + + &.inactive, &.completed { + // Use secondary color for inactive states + background: linear-gradient(135deg, + var(--dash-secondary, #64748b) 0%, + var(--dash-secondary-dark, #475569) 100% + ); + text-align: center; + padding: 12px; + + i { + font-size: 18px; + margin-bottom: 4px; + display: block; + } + + p { + margin: 0; + opacity: 0.95; + font-size: 11px; + } + } +} + +@keyframes pulse-timer-warning { + 0%, 100% { + box-shadow: 0 2px 6px rgba(245, 158, 11, 0.3); + } + 50% { + box-shadow: 0 2px 12px rgba(245, 158, 11, 0.5); + } +} + +.timer-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; + justify-content: center; + + i { + font-size: 14px; + opacity: 0.9; + } + + span { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + opacity: 0.95; + } +} + +.timer-body { + display: flex; + flex-direction: column; + gap: 6px; +} + +.timer-main { + text-align: center; + + .timer-value { + font-size: 22px; + font-weight: 700; + font-family: 'Courier New', monospace; + letter-spacing: 1px; + line-height: 1.1; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + } + + .timer-label { + font-size: 9px; + opacity: 0.85; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-top: 1px; + } +} + +.timer-progress-bar { + background: rgba(255, 255, 255, 0.2); + border-radius: 8px; + height: 4px; + overflow: hidden; + margin: 2px 0; + + .timer-progress-fill { + // Use success color for progress + background: linear-gradient(90deg, + var(--dash-success, #10b981), + var(--dash-success-light, #34d399) + ); + height: 100%; + border-radius: 8px; + transition: width 0.5s ease; + } +} + +.work-timer-widget.overtime .timer-progress-fill { + // Use warning gradient for overtime + background: linear-gradient(90deg, + var(--dash-warning, #fbbf24), + #f97316 + ); +} + +.timer-info { + text-align: center; + font-size: 10px; + opacity: 0.9; + + strong { + font-family: 'Courier New', monospace; + font-weight: 600; + } +} + +.timer-summary { + text-align: center; + padding: 4px 0; + + .summary-value { + font-size: 24px; + font-weight: 700; + font-family: 'Courier New', monospace; + line-height: 1.1; + margin-bottom: 4px; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + } + + .summary-label { + font-size: 10px; + opacity: 0.9; + letter-spacing: 0.3px; + } +} + +/* RTL Support */ +body.o_rtl { + .timer-header { + flex-direction: row-reverse; + } +} + +/* Responsive Design */ +@media (max-width: 768px) { + .work-timer-widget { + padding: 8px 10px; + + .timer-value { + font-size: 20px !important; + } + + .summary-value { + font-size: 20px !important; + } + } +} + + +/* ==================================== + WORK TIMER - COMPACT INLINE DESIGN + Integrated genius layout within attendance section + ==================================== */ + +.work-timer-compact { + background: linear-gradient(135deg, + var(--dash-primary, #667eea) 0%, + var(--dash-primary-dark, #5a67d8) 100% + ); + border-radius: 6px; + padding: 8px 10px; + margin: 8px 0 12px 0; + color: white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + + &.overtime { + background: linear-gradient(135deg, + var(--dash-warning, #f59e0b) 0%, + #dc2626 100% + ); + } + + &.completed { + background: linear-gradient(135deg, + var(--dash-success, #10b981) 0%, + var(--dash-success-dark, #059669) 100% + ); + } + + &.inactive { + background: linear-gradient(135deg, + var(--dash-secondary, #64748b) 0%, + var(--dash-secondary-dark, #475569) 100% + ); + padding: 10px; + + .timer-info-msg { + display: flex; + align-items: center; + gap: 8px; + justify-content: center; + font-size: 11px; + + i { + font-size: 14px; + opacity: 0.9; + } + } + } +} + +.timer-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; +} + +.timer-icon { + font-size: 18px; + opacity: 0.9; + flex-shrink: 0; +} + +.timer-content { + flex: 1; + display: flex; + align-items: baseline; + gap: 8px; +} + +.work-timer-compact .timer-value { + font-size: 20px; + font-weight: 700; + font-family: 'Courier New', monospace; + letter-spacing: 0.5px; + line-height: 1; +} + +.work-timer-compact .timer-label { + font-size: 9px; + opacity: 0.85; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.work-timer-compact .timer-progress-bar { + background: rgba(255, 255, 255, 0.2); + border-radius: 6px; + height: 3px; + overflow: hidden; + margin: 4px 0; +} + +.work-timer-compact .timer-progress-fill { + background: linear-gradient(90deg, + var(--dash-success, #10b981), + var(--dash-success-light, #34d399) + ); + height: 100%; + border-radius: 6px; + transition: width 0.5s ease; +} + +.work-timer-compact.overtime .timer-progress-fill { + background: linear-gradient(90deg, + var(--dash-warning, #fbbf24), + #f97316 + ); +} + +.work-timer-compact .timer-elapsed { + font-size: 9px; + opacity: 0.85; + text-align: center; + display: block; + margin-top: 2px; +} + +/* RTL Support */ +body.o_rtl { + .timer-row { + flex-direction: row-reverse; + } +} diff --git a/odex25_base/system_dashboard_classic/views/dashboard_settings.xml b/odex25_base/system_dashboard_classic/views/dashboard_settings.xml index 0ec8b3a66..bfff35a64 100644 --- a/odex25_base/system_dashboard_classic/views/dashboard_settings.xml +++ b/odex25_base/system_dashboard_classic/views/dashboard_settings.xml @@ -233,6 +233,23 @@ + + +

Work Timer Settings

+
+
+
+ +
+
+
+
+