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 @@