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 = ` +