Merge pull request #5875 from expsa/14.0-i18n-system_dashboard_classic-auto-20251229_164127

[FIX] system_dashboard_classic: improve data models and business logic
This commit is contained in:
Mohamed Eltayar 2025-12-29 16:50:50 +03:00 committed by GitHub
commit 2bbb5a0f2b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 300 additions and 51 deletions

View File

@ -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/self_service_dashboard.xml:0
#: code:addons/system_dashboard_classic/static/src/xml/system_dashboard.xml:0 #: code:addons/system_dashboard_classic/static/src/xml/system_dashboard.xml:0
#, python-format #, python-format
msgid "Attendance" msgid "Attendance Check-in/out"
msgstr "الحضور والانصراف" msgstr "الحضور والانصراف"
#. module: system_dashboard_classic #. module: system_dashboard_classic

View File

@ -1056,19 +1056,25 @@ class SystemDashboard(models.Model):
'message': _('لم تسجل دخول بعد') '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 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() # 2. Case: CHECKED OUT (Session Closed)
attendance_lines = calendar.attendance_ids.filtered( # -------------------------------------------------------------
lambda a: int(a.dayofweek) == day_of_week # The 'attendance_duration' on the last record is cumulative for the day
) # so it represents the total hours worked today.
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': if last_att.action == 'sign_out':
return { return {
'enabled': True, 'enabled': True,
@ -1078,20 +1084,36 @@ class SystemDashboard(models.Model):
'planned_hours': planned_hours 'planned_hours': planned_hours
} }
# If sign_in - return start time for client-side countdown # -------------------------------------------------------------
# Use same timezone logic as get_refresh_data # 3. Case: CHECKED IN (Session Open)
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 # 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_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 { return {
'enabled': True, 'enabled': True,
'status': 'checked_in', '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_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)
} }

View File

@ -978,6 +978,12 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require
widget.enabled = true; widget.enabled = true;
// console.log('[WorkTimer] Feature enabled, status:', data.status); // 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') { if (data.status === 'not_checked_in') {
// console.log('[WorkTimer] Rendering: Not Checked In'); // console.log('[WorkTimer] Rendering: Not Checked In');
widget.renderNotCheckedIn(data.message); widget.renderNotCheckedIn(data.message);
@ -989,6 +995,7 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require
// Parse ISO datetime to JavaScript Date // Parse ISO datetime to JavaScript Date
widget.signInTime = new Date(data.sign_in_time); widget.signInTime = new Date(data.sign_in_time);
widget.plannedSeconds = data.planned_seconds; widget.plannedSeconds = data.planned_seconds;
widget.previousSeconds = data.previous_seconds || 0;
// console.log('[WorkTimer] Sign-in time:', widget.signInTime); // console.log('[WorkTimer] Sign-in time:', widget.signInTime);
// console.log('[WorkTimer] Planned seconds:', widget.plannedSeconds); // console.log('[WorkTimer] Planned seconds:', widget.plannedSeconds);
widget.startCountdown(); widget.startCountdown();
@ -1020,18 +1027,39 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require
tick: function() { tick: function() {
var now = new Date(); var now = new Date();
var elapsedSeconds = Math.floor((now - this.signInTime) / 1000); var diff = Math.max(0, now - this.signInTime);
var remainingSeconds = Math.max(0, this.plannedSeconds - elapsedSeconds); var elapsedSecondsCurrent = Math.floor(diff / 1000);
var progress = Math.min(100, (elapsedSeconds / this.plannedSeconds) * 100); // Add previous sessions duration if available
var isOvertime = elapsedSeconds > this.plannedSeconds; var previousSeconds = this.previousSeconds || 0;
var totalElapsedSeconds = elapsedSecondsCurrent + previousSeconds;
this.updateUI({ // Remaining is Planned - Total Elapsed
elapsed: this.formatTime(elapsedSeconds), var remainingSeconds = Math.max(0, this.plannedSeconds - totalElapsedSeconds);
remaining: this.formatTime(remainingSeconds),
progress: progress.toFixed(1), // 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 isOvertime: isOvertime
}); };
this.renderCountdown(data);
}, },
formatTime: function(seconds) { formatTime: function(seconds) {
@ -1078,17 +1106,14 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require
</div> </div>
<div class="timer-elapsed">${isRtl ? 'منقضي' : 'Elapsed'}: ${data.elapsed}</div> <div class="timer-elapsed">${isRtl ? 'منقضي' : 'Elapsed'}: ${data.elapsed}</div>
</div> </div>
</div>
`; `;
// Insert after attendance title // Insert into timer container
var $target = $('.attendance-title'); var $target = $('.work-timer-container');
// console.log('[WorkTimer] Target (title) found:', $target.length);
if ($target.length > 0) { if ($target.length > 0) {
$target.after(html); $target.html(html);
// console.log('[WorkTimer] ✅ Widget HTML injected after title!');
} else {
console.error('[WorkTimer] ❌ Attendance title NOT FOUND');
} }
}, },
@ -1101,13 +1126,41 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require
</div> </div>
</div> </div>
`; `;
$('.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'); 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 = ` var html = `
<div class="work-timer-compact completed"> <div class="work-timer-compact completed ${isOvertime ? 'overtime' : ''}">
<div class="timer-row"> <div class="timer-row">
<span class="timer-icon"><i class="fa fa-check-circle"></i></span> <span class="timer-icon"><i class="fa fa-check-circle"></i></span>
<div class="timer-content"> <div class="timer-content">
@ -1115,9 +1168,21 @@ odoo.define('system_dashboard_classic.dashboard_self_services', function(require
<div class="timer-label">${isRtl ? 'إجمالي ساعات العمل' : 'Total Hours'}</div> <div class="timer-label">${isRtl ? 'إجمالي ساعات العمل' : 'Total Hours'}</div>
</div> </div>
</div> </div>
<div class="timer-progress-bg">
<div class="timer-progress-fill ${isOvertime ? 'overtime' : ''}" style="width: ${progress}%"></div>
</div>
<div class="timer-footer" style="display: flex; justify-content: space-between; padding: 0 15px; font-size: 11px; color: #64748b; margin-top: 5px;">
<span>${isRtl ? 'متبقي' : 'Remaining'}: ${remainingText}</span>
<span style="display: none">${isRtl ? 'مخطط' : 'Planned'}: ${format(plannedSeconds)}</span>
</div>
</div> </div>
`; `;
$('.attendance-title').after(html); var $target = $('.work-timer-container');
if ($target.length > 0) {
$target.html(html);
}
}, },
destroy: function() { destroy: function() {
@ -2218,9 +2283,8 @@ function setupAttendanceArea(data, name) {
var minutes = now.getMinutes().toString().padStart(2, '0'); var minutes = now.getMinutes().toString().padStart(2, '0');
var seconds = now.getSeconds().toString().padStart(2, '0'); var seconds = now.getSeconds().toString().padStart(2, '0');
var timeStr = hours + ':' + minutes + ':' + seconds; var timeStr = hours + ':' + minutes + ':' + seconds;
// Date on one line, time on another line var clockHtml = '<span class="attendance-date">' + dateDisplay + '</span> ' +
var clockHtml = '<div class="attendance-date">' + dateDisplay + '</div>' + '<span class="attendance-time">' + timeStr + '</span>';
'<div class="attendance-time">' + timeStr + '</div>';
$('.last-checkin-section').html(clockHtml); $('.last-checkin-section').html(clockHtml);
} }

View File

@ -253,17 +253,19 @@ p.fn-id.genius-hidden,
.attendance-date { .attendance-date {
color: var(--dash-primary, #0891b2) !important; color: var(--dash-primary, #0891b2) !important;
font-size: 12px !important; font-size: 14px !important;
font-weight: 500 !important; font-weight: 600 !important;
display: inline !important;
} }
.attendance-time { .attendance-time {
color: var(--dash-primary, #0891b2) !important; color: var(--dash-primary, #0891b2) !important;
font-size: 18px !important; font-size: 14px !important;
font-weight: 600 !important; font-weight: 600 !important;
font-family: 'SF Mono', 'Roboto Mono', 'Consolas', monospace !important; // font-family: 'SF Mono', 'Roboto Mono', 'Consolas', monospace !important; // Removed to match date font
letter-spacing: 1px !important; letter-spacing: 0.5px !important;
display: inline !important; display: inline !important;
margin-left: 5px !important;
} }
/* Last Check-in/out Info - Displayed below icon on single line */ /* Last Check-in/out Info - Displayed below icon on single line */
@ -585,9 +587,9 @@ p.fn-section.clickable-profile:hover {
display: flex !important; display: flex !important;
justify-content: center !important; justify-content: center !important;
align-items: center !important; align-items: center !important;
margin-top: 10px !important; margin-top: 5px !important;
background: transparent !important; background: transparent !important;
min-height: 70px !important; // min-height: 70px !important;
} }
/* Check-in/out Arrow Button - DYNAMIC COLOR using CSS Mask */ /* 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 near employee name */
.celebration-badge { .celebration-badge {
display: inline-flex; display: inline-flex;

View File

@ -119,10 +119,10 @@
<div class="lds-roller"><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div></div> <div class="lds-roller"><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div></div>
</div> </div>
<div class="col-md-12 col-sm-12 col-12 attendance-section-body"> <div class="col-md-12 col-sm-12 col-12 attendance-section-body">
<h3 class="attendance-title">Attendance</h3>
<p class="last-checkin-section"/> <p class="last-checkin-section"/>
<p class="attendance-img-section"/> <p class="attendance-img-section"/>
<p class="last-checkin-info"/> <p class="last-checkin-info"/>
<div class="work-timer-container"/>
</div> </div>
</div> </div>
</div> </div>