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:
commit
2bbb5a0f2b
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|||
</div>
|
||||
<div class="timer-elapsed">${isRtl ? 'منقضي' : 'Elapsed'}: ${data.elapsed}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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
|
|||
</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');
|
||||
|
||||
// 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 = `
|
||||
<div class="work-timer-compact completed">
|
||||
<div class="work-timer-compact completed ${isOvertime ? 'overtime' : ''}">
|
||||
<div class="timer-row">
|
||||
<span class="timer-icon"><i class="fa fa-check-circle"></i></span>
|
||||
<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>
|
||||
</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>
|
||||
`;
|
||||
$('.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 = '<div class="attendance-date">' + dateDisplay + '</div>' +
|
||||
'<div class="attendance-time">' + timeStr + '</div>';
|
||||
var clockHtml = '<span class="attendance-date">' + dateDisplay + '</span> ' +
|
||||
'<span class="attendance-time">' + timeStr + '</span>';
|
||||
$('.last-checkin-section').html(clockHtml);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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="attendance-img-section"/>
|
||||
<p class="last-checkin-info"/>
|
||||
<div class="work-timer-container"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue