diff --git a/odex25_base/system_dashboard_classic/i18n/ar_001.po b/odex25_base/system_dashboard_classic/i18n/ar_001.po index 21750d69a..66d941b78 100644 --- a/odex25_base/system_dashboard_classic/i18n/ar_001.po +++ b/odex25_base/system_dashboard_classic/i18n/ar_001.po @@ -95,7 +95,7 @@ msgstr "" #: code:addons/system_dashboard_classic/static/src/xml/self_service_dashboard.xml:0 #: code:addons/system_dashboard_classic/static/src/xml/system_dashboard.xml:0 #, python-format -msgid "Attendance" +msgid "Attendance Check-in/out" msgstr "الحضور والانصراف" #. module: system_dashboard_classic diff --git a/odex25_base/system_dashboard_classic/models/models.py b/odex25_base/system_dashboard_classic/models/models.py index 56ebb4b6b..0e58e3dfe 100644 --- a/odex25_base/system_dashboard_classic/models/models.py +++ b/odex25_base/system_dashboard_classic/models/models.py @@ -1056,19 +1056,25 @@ class SystemDashboard(models.Model): 'message': _('لم تسجل دخول بعد') } - # Get planned hours from resource calendar + # ------------------------------------------------------------- + # 1. Total Planned Hours + # ------------------------------------------------------------- + # As per user request: Use the static 'working_hours' field from the calendar. + # This assumes the field represents the daily target. calendar = employee.resource_calendar_id - planned_hours = 8.0 # Default fallback + # Robustness: Check working_hours -> hours_per_day (Odoo standard) -> 8.0 default + if calendar and calendar.working_hours: + planned_hours = calendar.working_hours + elif calendar and calendar.hours_per_day: + planned_hours = calendar.hours_per_day + else: + planned_hours = 8.0 - 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) + # ------------------------------------------------------------- + # 2. Case: CHECKED OUT (Session Closed) + # ------------------------------------------------------------- + # The 'attendance_duration' on the last record is cumulative for the day + # so it represents the total hours worked today. if last_att.action == 'sign_out': return { 'enabled': True, @@ -1078,20 +1084,36 @@ class SystemDashboard(models.Model): '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') + # ------------------------------------------------------------- + # 3. Case: CHECKED IN (Session Open) + # ------------------------------------------------------------- - # Convert UTC datetime to user timezone + # A. Get Last Cumulative Duration (Pre-existing work today) + # We need the accumulation of all PREVIOUS completed sessions today. + # Logic: Find the most recent 'sign_out' record for today that happened BEFORE this current sign_in. + # By design, its 'attendance_duration' holds the sum of all sessions up to that point. + last_sign_out = self.env['attendance.attendance'].sudo().search([ + ('employee_id', '=', employee.id), + ('action_date', '=', today), + ('action', '=', 'sign_out'), + ('id', '<', last_att.id) # Strictly before current session + ], order='name DESC', limit=1) + + previous_duration = last_sign_out.attendance_duration if last_sign_out else 0.0 + + # B. Get Current Session Start Time (Local) + # Convert the current sign-in UTC time to the user's configured timezone. + # Best Practice: Use Odoo's built-in context_timestamp method. sign_in_utc = last_att.name - sign_in_local = pytz.utc.localize(sign_in_utc).astimezone(user_tz) + sign_in_local = fields.Datetime.context_timestamp(self, sign_in_utc) return { 'enabled': True, 'status': 'checked_in', - 'sign_in_time': sign_in_local.isoformat(), # ISO format for JS Date parsing + 'sign_in_time': sign_in_local.isoformat(), 'planned_hours': planned_hours, - 'planned_seconds': int(planned_hours * 3600) + 'planned_seconds': int(planned_hours * 3600), + 'previous_duration': previous_duration, + 'previous_seconds': int(previous_duration * 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 10337ba89..7ffc3cc86 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 @@ -978,6 +978,12 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require widget.enabled = true; // console.log('[WorkTimer] Feature enabled, status:', data.status); + // Stop any existing timer first + if (widget.intervalId) { + clearInterval(widget.intervalId); + widget.intervalId = null; + } + if (data.status === 'not_checked_in') { // console.log('[WorkTimer] Rendering: Not Checked In'); widget.renderNotCheckedIn(data.message); @@ -989,6 +995,7 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require // Parse ISO datetime to JavaScript Date widget.signInTime = new Date(data.sign_in_time); widget.plannedSeconds = data.planned_seconds; + widget.previousSeconds = data.previous_seconds || 0; // console.log('[WorkTimer] Sign-in time:', widget.signInTime); // console.log('[WorkTimer] Planned seconds:', widget.plannedSeconds); widget.startCountdown(); @@ -1020,18 +1027,39 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require tick: function() { var now = new Date(); - var elapsedSeconds = Math.floor((now - this.signInTime) / 1000); - var remainingSeconds = Math.max(0, this.plannedSeconds - elapsedSeconds); + var diff = Math.max(0, now - this.signInTime); + var elapsedSecondsCurrent = Math.floor(diff / 1000); - var progress = Math.min(100, (elapsedSeconds / this.plannedSeconds) * 100); - var isOvertime = elapsedSeconds > this.plannedSeconds; + // Add previous sessions duration if available + var previousSeconds = this.previousSeconds || 0; + var totalElapsedSeconds = elapsedSecondsCurrent + previousSeconds; - this.updateUI({ - elapsed: this.formatTime(elapsedSeconds), - remaining: this.formatTime(remainingSeconds), - progress: progress.toFixed(1), + // Remaining is Planned - Total Elapsed + var remainingSeconds = Math.max(0, this.plannedSeconds - totalElapsedSeconds); + + // Calculate visualization data + var isOvertime = totalElapsedSeconds > this.plannedSeconds; + var progress = 0; + + if (isOvertime) { + // Overtime logic + remainingSeconds = totalElapsedSeconds - this.plannedSeconds; // Show overtime amount + progress = 100; + } else { + // Normal progress + if (this.plannedSeconds > 0) { + progress = (totalElapsedSeconds / this.plannedSeconds) * 100; + } + } + + var data = { + remaining: new Date(remainingSeconds * 1000).toISOString().substr(11, 8), + elapsed: new Date(totalElapsedSeconds * 1000).toISOString().substr(11, 8), + progress: progress, isOvertime: isOvertime - }); + }; + + this.renderCountdown(data); }, formatTime: function(seconds) { @@ -1078,17 +1106,14 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require
${isRtl ? 'منقضي' : 'Elapsed'}: ${data.elapsed}
+ `; - // Insert after attendance title - var $target = $('.attendance-title'); - // console.log('[WorkTimer] Target (title) found:', $target.length); + // Insert into timer container + var $target = $('.work-timer-container'); if ($target.length > 0) { - $target.after(html); - // console.log('[WorkTimer] ✅ Widget HTML injected after title!'); - } else { - console.error('[WorkTimer] ❌ Attendance title NOT FOUND'); + $target.html(html); } }, @@ -1101,13 +1126,41 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require `; - $('.attendance-title').after(html); + var $target = $('.work-timer-container'); + if ($target.length > 0) { + $target.html(html); + } }, - renderCheckedOut: function(data) { + renderCheckedOut: function(data) { var isRtl = $('body').hasClass('o_rtl'); + + // Calculate static stats for the finished day + var elapsedSeconds = data.hours_worked * 3600; + var plannedSeconds = data.planned_hours * 3600; + var remainingSeconds = Math.max(0, plannedSeconds - elapsedSeconds); + + var isOvertime = elapsedSeconds > plannedSeconds; + var progress = 0; + + if (isOvertime) { + remainingSeconds = elapsedSeconds - plannedSeconds; + progress = 100; + } else { + if (plannedSeconds > 0) { + progress = (elapsedSeconds / plannedSeconds) * 100; + } + } + + // Format time helper + var format = function(secs) { + return new Date(Math.floor(secs) * 1000).toISOString().substr(11, 8); + }; + + var remainingText = format(remainingSeconds); + var html = ` -
+
@@ -1115,9 +1168,21 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require
${isRtl ? 'إجمالي ساعات العمل' : 'Total Hours'}
+ +
+
+
+ +
`; - $('.attendance-title').after(html); + var $target = $('.work-timer-container'); + if ($target.length > 0) { + $target.html(html); + } }, destroy: function() { @@ -2218,9 +2283,8 @@ function setupAttendanceArea(data, name) { var minutes = now.getMinutes().toString().padStart(2, '0'); var seconds = now.getSeconds().toString().padStart(2, '0'); var timeStr = hours + ':' + minutes + ':' + seconds; - // Date on one line, time on another line - var clockHtml = '
' + dateDisplay + '
' + - '
' + timeStr + '
'; + var clockHtml = '' + dateDisplay + ' ' + + '' + timeStr + ''; $('.last-checkin-section').html(clockHtml); } diff --git a/odex25_base/system_dashboard_classic/static/src/scss/genius-enhancements.scss b/odex25_base/system_dashboard_classic/static/src/scss/genius-enhancements.scss index d02e2ba8f..552eaabfb 100644 --- a/odex25_base/system_dashboard_classic/static/src/scss/genius-enhancements.scss +++ b/odex25_base/system_dashboard_classic/static/src/scss/genius-enhancements.scss @@ -253,17 +253,19 @@ p.fn-id.genius-hidden, .attendance-date { color: var(--dash-primary, #0891b2) !important; - font-size: 12px !important; - font-weight: 500 !important; + font-size: 14px !important; + font-weight: 600 !important; + display: inline !important; } .attendance-time { color: var(--dash-primary, #0891b2) !important; - font-size: 18px !important; + font-size: 14px !important; font-weight: 600 !important; - font-family: 'SF Mono', 'Roboto Mono', 'Consolas', monospace !important; - letter-spacing: 1px !important; + // font-family: 'SF Mono', 'Roboto Mono', 'Consolas', monospace !important; // Removed to match date font + letter-spacing: 0.5px !important; display: inline !important; + margin-left: 5px !important; } /* Last Check-in/out Info - Displayed below icon on single line */ @@ -585,9 +587,9 @@ p.fn-section.clickable-profile:hover { display: flex !important; justify-content: center !important; align-items: center !important; - margin-top: 10px !important; + margin-top: 5px !important; background: transparent !important; - min-height: 70px !important; + // min-height: 70px !important; } /* Check-in/out Arrow Button - DYNAMIC COLOR using CSS Mask */ @@ -2457,6 +2459,167 @@ p.fn-section.clickable-profile:hover { } } +/* ==================================== + WORK TIMER WIDGET - Bottom Placement + Minimal, professional design + ==================================== */ + +.work-timer-widget, +.work-timer-compact { + background: transparent !important; + border-top: 1px solid rgba(0, 0, 0, 0.05) !important; + border-radius: 0 !important; + padding: 8px 0 0 0 !important; + margin-top: 8px !important; + margin-bottom: 0 !important; + color: var(--dash-text) !important; + box-shadow: none !important; + width: 100% !important; + text-align: center !important; + + &.overtime { + // Subtle warning text for overtime + .timer-value { + color: var(--dash-warning, #f59e0b) !important; + } + animation: none !important; + } + + &.inactive, &.completed { + background: transparent !important; + padding: 8px 0 0 0 !important; + + i { + display: none !important; // Hide icon for cleaner look + } + + p, .timer-label { + margin: 0 !important; + opacity: 0.8 !important; + font-size: 11px !important; + color: var(--dash-text-light, #94a3b8) !important; + } + + .timer-info-msg { + display: flex !important; + justify-content: center !important; + align-items: center !important; + gap: 5px !important; + font-size: 11px !important; + color: var(--dash-text-light, #94a3b8) !important; + + i { + display: block !important; + font-size: 12px !important; + margin: 0 !important; + color: var(--dash-text-light, #94a3b8) !important; + } + } + } +} + +// Remove pulse animation +@keyframes pulse-timer-warning { + // Empty +} + +.timer-header { + display: none !important; // Hide header/icon for minimal look +} + +// Timer Value (The big numbers) +.timer-value { + font-size: 16px !important; + font-weight: 700 !important; + color: var(--dash-primary, #0891b2) !important; + font-family: 'SF Mono', 'Roboto Mono', 'Consolas', monospace !important; + letter-spacing: 0.5px !important; + line-height: 1.2 !important; +} + +// Timer Label (Elapsed / Total Hours) +.timer-label { + font-size: 10px !important; + font-weight: 500 !important; + color: var(--dash-text-light, #94a3b8) !important; + text-transform: uppercase !important; + letter-spacing: 0.5px !important; + margin-top: 2px !important; +} + +/* Progress Bar - Visible & Professional Design */ +.timer-progress-bg { + width: auto !important; + background-color: #e2e8f0 !important; /* Light gray-blue track - more visible */ + border-radius: 6px !important; + height: 12px !important; /* Thicker for visibility */ + overflow: hidden !important; + margin: 12px 15px !important; + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1) !important; + position: relative !important; + border: 1px solid rgba(0,0,0,0.05) !important; +} + +.timer-progress-fill { + height: 100% !important; + border-radius: 6px !important; + /* Solid Teal/Cyan - Clear and Professional */ + background: linear-gradient(135deg, #0891b2 0%, #06b6d4 50%, #22d3ee 100%) !important; + box-shadow: + inset 0 2px 4px rgba(255, 255, 255, 0.3), + 0 2px 6px rgba(8, 145, 178, 0.4) !important; + transition: width 1s ease-out !important; + position: relative !important; + min-width: 2px !important; /* Always show a bit */ + + /* Animated Shimmer Effect */ + &::before { + content: '' !important; + position: absolute !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(255, 255, 255, 0.4) 50%, + transparent 100% + ) !important; + animation: shimmer 2s infinite !important; + } + + /* Overtime State - Warm Amber/Orange */ + &.overtime { + background: linear-gradient(135deg, #ea580c 0%, #f59e0b 50%, #fbbf24 100%) !important; + box-shadow: + inset 0 2px 4px rgba(255, 255, 255, 0.3), + 0 2px 6px rgba(245, 158, 11, 0.4) !important; + } +} + +/* Shimmer Animation */ +@keyframes shimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} + +// Layout for checked-out state (Compact row) +.timer-row { + display: flex !important; + flex-direction: column !important; + justify-content: center !important; + align-items: center !important; + + .timer-icon { + display: none !important; + } + + .timer-content { + text-align: center !important; + } +} + /* Celebration badge near employee name */ .celebration-badge { display: inline-flex; diff --git a/odex25_base/system_dashboard_classic/static/src/xml/self_service_dashboard.xml b/odex25_base/system_dashboard_classic/static/src/xml/self_service_dashboard.xml index 270589f2e..709380bdd 100644 --- a/odex25_base/system_dashboard_classic/static/src/xml/self_service_dashboard.xml +++ b/odex25_base/system_dashboard_classic/static/src/xml/self_service_dashboard.xml @@ -119,10 +119,10 @@
-

Attendance

+