From b925766966fec0f9be6d0bedbb20e70244c3b4dc Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Fri, 27 Feb 2026 14:32:32 -0500 Subject: [PATCH] changes --- .../controllers/portal_main.py | 108 +++- fusion_authorizer_portal/models/assessment.py | 3 + .../models/loaner_checkout.py | 1 + .../models/res_partner.py | 3 + fusion_authorizer_portal/models/sale_order.py | 3 + .../static/src/css/technician_portal.css | 158 ++++- .../src/js/chatter_message_authorizer.js | 3 + .../static/src/js/technician_location.js | 212 +++++-- .../views/portal_technician_templates.xml | 358 ++++++++--- .../views/portal_templates.xml | 234 ++++++- fusion_claims/__manifest__.py | 6 +- fusion_claims/models/account_move.py | 6 +- fusion_claims/models/res_partner.py | 63 ++ fusion_claims/models/sale_order.py | 584 ++++++++++++------ fusion_claims/models/sale_order_line.py | 43 ++ fusion_claims/models/technician_task.py | 231 ++++++- fusion_claims/report/report_actions.xml | 32 +- .../report/report_approved_items.xml | 162 +++++ .../report/report_rental_agreement.xml | 365 ----------- .../static/src/css/fusion_task_map_view.scss | 31 +- .../static/src/js/debug_required_fields.js | 20 + .../static/src/js/fusion_task_map_view.js | 582 +++++++++++++++-- .../static/src/xml/fusion_task_map_view.xml | 10 + fusion_claims/views/sale_order_views.xml | 37 +- .../views/technician_location_views.xml | 17 +- fusion_claims/views/technician_task_views.xml | 35 +- .../wizard/assessment_completed_wizard.py | 227 ++++++- .../assessment_completed_wizard_views.xml | 34 +- .../wizard/ready_for_submission_wizard.py | 4 +- .../ready_for_submission_wizard_views.xml | 2 +- fusion_clock/__manifest__.py | 49 +- fusion_clock/controllers/__init__.py | 1 + fusion_clock/controllers/clock_api.py | 419 +++++++++++-- fusion_clock/controllers/clock_kiosk.py | 159 +++++ .../data/ir_config_parameter_data.xml | 76 ++- fusion_clock/data/ir_cron_data.xml | 36 ++ fusion_clock/data/mail_template_data.xml | 82 +++ fusion_clock/models/__init__.py | 4 + fusion_clock/models/clock_activity_log.py | 133 ++++ fusion_clock/models/clock_correction.py | 165 +++++ fusion_clock/models/clock_leave_request.py | 113 ++++ fusion_clock/models/clock_location.py | 46 +- fusion_clock/models/clock_penalty.py | 5 + fusion_clock/models/clock_report.py | 78 +++ fusion_clock/models/clock_shift.py | 63 ++ fusion_clock/models/hr_attendance.py | 343 +++++++++- fusion_clock/models/hr_employee.py | 156 ++++- fusion_clock/models/res_config_settings.py | 131 +++- fusion_clock/security/ir.model.access.csv | 12 + fusion_clock/security/security.xml | 186 +++++- fusion_clock/static/src/css/portal_clock.css | 342 +++++++++- .../static/src/js/fusion_clock_dashboard.js | 67 ++ .../static/src/js/fusion_clock_kiosk.js | 228 +++++++ .../src/js/fusion_clock_location_map.js | 247 ++++++++ .../src/js/fusion_clock_location_places.js | 150 +++++ .../static/src/js/fusion_clock_portal.js | 152 ++++- .../static/src/js/fusion_clock_portal_fab.js | 163 +++++ .../static/src/js/fusion_clock_systray.js | 84 +++ .../static/src/scss/fusion_clock.scss | 434 +++++++++++++ .../static/src/xml/fusion_clock_dashboard.xml | 155 +++++ .../static/src/xml/fusion_clock_location.xml | 40 ++ fusion_clock/static/src/xml/systray_clock.xml | 77 ++- .../views/clock_activity_log_views.xml | 88 +++ fusion_clock/views/clock_correction_views.xml | 76 +++ fusion_clock/views/clock_dashboard_views.xml | 10 + .../views/clock_leave_request_views.xml | 56 ++ fusion_clock/views/clock_location_views.xml | 18 +- fusion_clock/views/clock_menus.xml | 50 +- fusion_clock/views/clock_penalty_views.xml | 2 + fusion_clock/views/clock_report_views.xml | 4 + fusion_clock/views/clock_shift_views.xml | 63 ++ fusion_clock/views/hr_attendance_views.xml | 16 +- fusion_clock/views/hr_employee_views.xml | 167 +++++ fusion_clock/views/kiosk_templates.xml | 63 ++ fusion_clock/views/portal_clock_templates.xml | 148 ++++- .../views/portal_timesheet_templates.xml | 10 + .../views/res_config_settings_views.xml | 110 +++- fusion_rental/models/sale_order.py | 8 +- fusion_rental/models/sale_order_line.py | 11 +- fusion_rental/views/sale_order_views.xml | 2 +- 80 files changed, 7831 insertions(+), 1041 deletions(-) create mode 100644 fusion_claims/report/report_approved_items.xml delete mode 100644 fusion_claims/report/report_rental_agreement.xml create mode 100644 fusion_claims/static/src/js/debug_required_fields.js create mode 100644 fusion_clock/controllers/clock_kiosk.py create mode 100644 fusion_clock/models/clock_activity_log.py create mode 100644 fusion_clock/models/clock_correction.py create mode 100644 fusion_clock/models/clock_leave_request.py create mode 100644 fusion_clock/models/clock_shift.py create mode 100644 fusion_clock/static/src/js/fusion_clock_dashboard.js create mode 100644 fusion_clock/static/src/js/fusion_clock_kiosk.js create mode 100644 fusion_clock/static/src/js/fusion_clock_location_map.js create mode 100644 fusion_clock/static/src/js/fusion_clock_location_places.js create mode 100644 fusion_clock/static/src/xml/fusion_clock_dashboard.xml create mode 100644 fusion_clock/static/src/xml/fusion_clock_location.xml create mode 100644 fusion_clock/views/clock_activity_log_views.xml create mode 100644 fusion_clock/views/clock_correction_views.xml create mode 100644 fusion_clock/views/clock_dashboard_views.xml create mode 100644 fusion_clock/views/clock_leave_request_views.xml create mode 100644 fusion_clock/views/clock_shift_views.xml create mode 100644 fusion_clock/views/hr_employee_views.xml create mode 100644 fusion_clock/views/kiosk_templates.xml diff --git a/fusion_authorizer_portal/controllers/portal_main.py b/fusion_authorizer_portal/controllers/portal_main.py index 8b60fecc..e923f3f9 100644 --- a/fusion_authorizer_portal/controllers/portal_main.py +++ b/fusion_authorizer_portal/controllers/portal_main.py @@ -26,6 +26,7 @@ class AuthorizerPortal(CustomerPortal): if hasattr(response, 'qcontext') and (partner.is_authorizer or partner.is_sales_rep_portal or partner.is_client_portal or partner.is_technician_portal): posting_info = self._get_adp_posting_info() response.qcontext.update(posting_info) + response.qcontext.update(self._get_clock_status_data()) # Add signature count (documents to sign) - only if Sign module is installed sign_count = 0 @@ -724,7 +725,7 @@ class AuthorizerPortal(CustomerPortal): 'sale_type_filter': sale_type, 'status_filter': status, } - + values.update(self._get_clock_status_data()) return request.render('fusion_authorizer_portal.portal_sales_dashboard', values) @http.route(['/my/sales/cases', '/my/sales/cases/page/'], type='http', auth='user', website=True) @@ -1090,14 +1091,60 @@ class AuthorizerPortal(CustomerPortal): _logger.error(f"Error downloading proof of delivery: {e}") return request.redirect('/my/funding-claims') + # ==================== CLOCK STATUS HELPER ==================== + + def _get_clock_status_data(self): + """Get clock in/out status for the current portal user.""" + try: + user = request.env.user + Employee = request.env['hr.employee'].sudo() + employee = Employee.search([('user_id', '=', user.id)], limit=1) + if not employee: + employee = Employee.search([ + ('name', '=', user.partner_id.name), + ('user_id', '=', False), + ], limit=1) + if not employee or not getattr(employee, 'x_fclk_enable_clock', False): + return {'clock_enabled': False} + + is_checked_in = employee.attendance_state == 'checked_in' + check_in_time = '' + location_name = '' + if is_checked_in: + att = request.env['hr.attendance'].sudo().search([ + ('employee_id', '=', employee.id), + ('check_out', '=', False), + ], limit=1) + if att: + check_in_time = att.check_in.isoformat() if att.check_in else '' + location_name = att.x_fclk_location_id.name if att.x_fclk_location_id else '' + + return { + 'clock_enabled': True, + 'clock_checked_in': is_checked_in, + 'clock_check_in_time': check_in_time, + 'clock_location_name': location_name, + } + except Exception as e: + _logger.warning("Clock status check failed: %s", e) + return {'clock_enabled': False} + # ==================== TECHNICIAN PORTAL ==================== def _check_technician_access(self): """Check if current user is a technician portal user.""" partner = request.env.user.partner_id - if not partner.is_technician_portal: - return False - return True + if partner.is_technician_portal: + return True + has_tasks = request.env['fusion.technician.task'].sudo().search_count([ + '|', + ('technician_id', '=', request.env.user.id), + ('additional_technician_ids', 'in', [request.env.user.id]), + ], limit=1) + if has_tasks: + partner.sudo().write({'is_technician_portal': True}) + return True + return False @http.route(['/my/technician', '/my/technician/dashboard'], type='http', auth='user', website=True) def technician_dashboard(self, **kw): @@ -1159,6 +1206,8 @@ class AuthorizerPortal(CustomerPortal): ICP = request.env['ir.config_parameter'].sudo() google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '') + clock_data = self._get_clock_status_data() + values = { 'today_tasks': today_tasks, 'current_task': current_task, @@ -1174,6 +1223,7 @@ class AuthorizerPortal(CustomerPortal): 'google_maps_api_key': google_maps_api_key, 'page_name': 'technician_dashboard', } + values.update(clock_data) return request.render('fusion_authorizer_portal.portal_technician_dashboard', values) @http.route(['/my/technician/tasks', '/my/technician/tasks/page/'], type='http', auth='user', website=True) @@ -1423,11 +1473,17 @@ class AuthorizerPortal(CustomerPortal): return {'success': False, 'error': str(e)} @http.route('/my/technician/task//action', type='json', auth='user', website=True) - def technician_task_action(self, task_id, action, **kw): - """Handle task status changes (start, complete, en_route, cancel).""" + def technician_task_action(self, task_id, action, latitude=None, longitude=None, accuracy=None, **kw): + """Handle task status changes (start, complete, en_route, cancel). + Location is mandatory -- the client must send GPS coordinates.""" if not self._check_technician_access(): return {'success': False, 'error': 'Access denied'} + if not latitude or not longitude: + return {'success': False, 'error': 'Location is required. Please enable GPS and try again.'} + if not (-90 <= latitude <= 90 and -180 <= longitude <= 180): + return {'success': False, 'error': 'Invalid GPS coordinates.'} + user = request.env.user Task = request.env['fusion.technician.task'].sudo() @@ -1439,21 +1495,32 @@ class AuthorizerPortal(CustomerPortal): ): return {'success': False, 'error': 'Task not found or not assigned to you'} + request.env['fusion.technician.location'].sudo().log_location( + latitude=latitude, + longitude=longitude, + accuracy=accuracy, + ) + + location_ctx = { + 'action_latitude': latitude, + 'action_longitude': longitude, + 'action_accuracy': accuracy or 0, + } + if action == 'en_route': - task.action_start_en_route() + task.with_context(**location_ctx).action_start_en_route() elif action == 'start': - task.action_start_task() + task.with_context(**location_ctx).action_start_task() elif action == 'complete': completion_notes = kw.get('completion_notes', '') if completion_notes: task.completion_notes = completion_notes - task.action_complete_task() + task.with_context(**location_ctx).action_complete_task() elif action == 'cancel': - task.action_cancel_task() + task.with_context(**location_ctx).action_cancel_task() else: return {'success': False, 'error': f'Unknown action: {action}'} - # For completion, also return next task info result = { 'success': True, 'status': task.status, @@ -1600,10 +1667,14 @@ class AuthorizerPortal(CustomerPortal): return {'success': False, 'error': str(e)} @http.route('/my/technician/task//voice-complete', type='json', auth='user', website=True) - def technician_voice_complete(self, task_id, transcription, **kw): + def technician_voice_complete(self, task_id, transcription, latitude=None, longitude=None, accuracy=None, **kw): """Format transcription with GPT and complete the task.""" if not self._check_technician_access(): return {'success': False, 'error': 'Access denied'} + if not latitude or not longitude: + return {'success': False, 'error': 'Location is required. Please enable GPS and try again.'} + if not (-90 <= latitude <= 90 and -180 <= longitude <= 180): + return {'success': False, 'error': 'Invalid GPS coordinates.'} user = request.env.user Task = request.env['fusion.technician.task'].sudo() @@ -1675,7 +1746,18 @@ class AuthorizerPortal(CustomerPortal): 'completion_notes': completion_html, 'voice_note_transcription': transcription, }) - task.action_complete_task() + + request.env['fusion.technician.location'].sudo().log_location( + latitude=latitude, + longitude=longitude, + accuracy=accuracy, + ) + location_ctx = { + 'action_latitude': latitude, + 'action_longitude': longitude, + 'action_accuracy': accuracy or 0, + } + task.with_context(**location_ctx).action_complete_task() return { 'success': True, diff --git a/fusion_authorizer_portal/models/assessment.py b/fusion_authorizer_portal/models/assessment.py index be76e66d..4f827493 100644 --- a/fusion_authorizer_portal/models/assessment.py +++ b/fusion_authorizer_portal/models/assessment.py @@ -499,6 +499,7 @@ class FusionAssessment(models.Model): 'res_model': 'sale.order', 'res_id': sale_order.id, 'view_mode': 'form', + 'views': [(False, 'form')], 'target': 'current', } @@ -1482,6 +1483,7 @@ class FusionAssessment(models.Model): 'name': _('Documents'), 'res_model': 'fusion.adp.document', 'view_mode': 'list,form', + 'views': [(False, 'list'), (False, 'form')], 'domain': [('assessment_id', '=', self.id)], 'context': {'default_assessment_id': self.id}, } @@ -1497,6 +1499,7 @@ class FusionAssessment(models.Model): 'res_model': 'sale.order', 'res_id': self.sale_order_id.id, 'view_mode': 'form', + 'views': [(False, 'form')], 'target': 'current', } diff --git a/fusion_authorizer_portal/models/loaner_checkout.py b/fusion_authorizer_portal/models/loaner_checkout.py index 6330b6bd..2f3e0ee8 100644 --- a/fusion_authorizer_portal/models/loaner_checkout.py +++ b/fusion_authorizer_portal/models/loaner_checkout.py @@ -23,5 +23,6 @@ class FusionLoanerCheckoutAssessment(models.Model): 'type': 'ir.actions.act_window', 'res_model': 'fusion.assessment', 'view_mode': 'form', + 'views': [(False, 'form')], 'res_id': self.assessment_id.id, } diff --git a/fusion_authorizer_portal/models/res_partner.py b/fusion_authorizer_portal/models/res_partner.py index 129a5a8b..5dfc571e 100644 --- a/fusion_authorizer_portal/models/res_partner.py +++ b/fusion_authorizer_portal/models/res_partner.py @@ -596,6 +596,7 @@ class ResPartner(models.Model): 'name': _('Assigned Cases'), 'res_model': 'sale.order', 'view_mode': 'list,form', + 'views': [(False, 'list'), (False, 'form')], 'domain': [('x_fc_authorizer_id', '=', self.id)], 'context': {'default_x_fc_authorizer_id': self.id}, } @@ -614,6 +615,7 @@ class ResPartner(models.Model): 'name': _('Assessments'), 'res_model': 'fusion.assessment', 'view_mode': 'list,form', + 'views': [(False, 'list'), (False, 'form')], 'domain': domain, } @@ -697,6 +699,7 @@ class ResPartner(models.Model): 'name': _('Assigned Deliveries'), 'res_model': 'sale.order', 'view_mode': 'list,form', + 'views': [(False, 'list'), (False, 'form')], 'domain': [('x_fc_delivery_technician_ids', 'in', [self.authorizer_portal_user_id.id])], } diff --git a/fusion_authorizer_portal/models/sale_order.py b/fusion_authorizer_portal/models/sale_order.py index a216c6a5..38f2c0cd 100644 --- a/fusion_authorizer_portal/models/sale_order.py +++ b/fusion_authorizer_portal/models/sale_order.py @@ -101,6 +101,7 @@ class SaleOrder(models.Model): 'name': 'Message Authorizer', 'res_model': 'mail.compose.message', 'view_mode': 'form', + 'views': [(False, 'form')], 'target': 'new', 'context': { 'default_model': 'sale.order', @@ -137,6 +138,7 @@ class SaleOrder(models.Model): 'name': _('Portal Comments'), 'res_model': 'fusion.authorizer.comment', 'view_mode': 'list,form', + 'views': [(False, 'list'), (False, 'form')], 'domain': [('sale_order_id', '=', self.id)], 'context': {'default_sale_order_id': self.id}, } @@ -149,6 +151,7 @@ class SaleOrder(models.Model): 'name': _('Portal Documents'), 'res_model': 'fusion.adp.document', 'view_mode': 'list,form', + 'views': [(False, 'list'), (False, 'form')], 'domain': [('sale_order_id', '=', self.id)], 'context': {'default_sale_order_id': self.id}, } diff --git a/fusion_authorizer_portal/static/src/css/technician_portal.css b/fusion_authorizer_portal/static/src/css/technician_portal.css index 18139f2f..a4e38739 100644 --- a/fusion_authorizer_portal/static/src/css/technician_portal.css +++ b/fusion_authorizer_portal/static/src/css/technician_portal.css @@ -14,16 +14,12 @@ .tech-stats-bar { display: flex; gap: 0.5rem; - overflow-x: auto; - padding-bottom: 0.5rem; - scrollbar-width: none; } -.tech-stats-bar::-webkit-scrollbar { display: none; } .tech-stat-card { - flex: 0 0 auto; - min-width: 100px; - padding: 0.75rem 1rem; + flex: 1 1 0; + min-width: 0; + padding: 0.75rem 0.5rem; border-radius: 12px; text-align: center; color: #fff; @@ -42,7 +38,145 @@ .tech-stat-total { background: linear-gradient(135deg, #5ba848, #3a8fb7); } .tech-stat-remaining { background: linear-gradient(135deg, #3498db, #2980b9); } .tech-stat-completed { background: linear-gradient(135deg, #27ae60, #219a52); } -.tech-stat-travel { background: linear-gradient(135deg, #8e44ad, #7d3c98); } + +/* ---- Clock In/Out Card ---- */ +.tech-clock-card { + background: var(--o-main-card-bg, #fff); + border: 1px solid var(--o-main-border-color, #e9ecef); + border-radius: 14px; + padding: 0.875rem 1rem; +} +.tech-clock-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: #adb5bd; + flex-shrink: 0; +} +.tech-clock-dot--active { + background: #10b981; + box-shadow: 0 0 6px rgba(16, 185, 129, 0.5); + animation: tech-clock-pulse 2s ease-in-out infinite; +} +@keyframes tech-clock-pulse { + 0%, 100% { box-shadow: 0 0 6px rgba(16, 185, 129, 0.5); } + 50% { box-shadow: 0 0 12px rgba(16, 185, 129, 0.8); } +} +.tech-clock-status { + font-size: 0.85rem; + font-weight: 600; + color: var(--o-main-text-color, #212529); + line-height: 1.2; +} +.tech-clock-timer { + font-size: 0.75rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: #6c757d; +} +.tech-clock-btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.5rem 1rem; + border-radius: 10px; + border: none; + font-weight: 600; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} +.tech-clock-btn:active { transform: scale(0.96); } +.tech-clock-btn--in { + background: #10b981; + color: #fff; +} +.tech-clock-btn--in:hover { background: #059669; } +.tech-clock-btn--out { + background: #ef4444; + color: #fff; +} +.tech-clock-btn--out:hover { background: #dc2626; } +.tech-clock-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} +.tech-clock-error { + display: flex; + align-items: center; + gap: 0.4rem; + margin-top: 0.5rem; + padding: 0.4rem 0.75rem; + border-radius: 8px; + background: #fef2f2; + color: #dc2626; + font-size: 0.8rem; + font-weight: 500; +} + +/* ---- Quick Links (All Tasks / Tomorrow / Repair Form) ---- */ +.tech-quick-links { + display: flex; + gap: 0.5rem; +} + +.tech-quick-link { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.35rem; + padding: 0.875rem 0.5rem; + border-radius: 12px; + border: 1.5px solid; + text-decoration: none !important; + font-weight: 600; + font-size: 0.8rem; + transition: all 0.15s; + position: relative; +} +.tech-quick-link:active { transform: scale(0.97); } +.tech-quick-link i { font-size: 1.1rem; } + +.tech-quick-link-primary { + border-color: #3498db; + color: #3498db !important; + background: rgba(52, 152, 219, 0.04); +} +.tech-quick-link-primary:hover { background: rgba(52, 152, 219, 0.1); } + +.tech-quick-link-secondary { + border-color: #6c757d; + color: #6c757d !important; + background: rgba(108, 117, 125, 0.04); +} +.tech-quick-link-secondary:hover { background: rgba(108, 117, 125, 0.1); } + +.tech-quick-link-warning { + border-color: #e67e22; + color: #e67e22 !important; + background: rgba(230, 126, 34, 0.04); +} +.tech-quick-link-warning:hover { background: rgba(230, 126, 34, 0.1); } + +.tech-quick-link-badge { + position: absolute; + top: -6px; + right: -6px; + background: #3498db; + color: #fff; + font-size: 0.65rem; + font-weight: 700; + min-width: 18px; + height: 18px; + line-height: 18px; + text-align: center; + border-radius: 9px; + padding: 0 4px; +} /* ---- Hero Card (Dashboard Current Task) ---- */ .tech-hero-card { @@ -475,12 +609,18 @@ gap: 1rem; } .tech-stat-card { - min-width: 130px; padding: 1rem 1.5rem; } .tech-stat-card .stat-number { font-size: 2rem; } + .tech-quick-links { + gap: 1rem; + } + .tech-quick-link { + padding: 1rem 0.75rem; + font-size: 0.85rem; + } .tech-bottom-bar { position: static; box-shadow: none; diff --git a/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js b/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js index 68023959..8626a1b6 100644 --- a/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js +++ b/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js @@ -28,6 +28,9 @@ patch(Chatter.prototype, { [thread.id], ); if (result && result.type === "ir.actions.act_window") { + if (!result.views && result.view_mode) { + result.views = result.view_mode.split(",").map(v => [false, v.trim()]); + } this._fapActionService.doAction(result); } } catch (e) { diff --git a/fusion_authorizer_portal/static/src/js/technician_location.js b/fusion_authorizer_portal/static/src/js/technician_location.js index 25b6e82d..2464b4c0 100644 --- a/fusion_authorizer_portal/static/src/js/technician_location.js +++ b/fusion_authorizer_portal/static/src/js/technician_location.js @@ -1,15 +1,144 @@ /** - * Technician Location Logger - * Logs GPS location every 5 minutes during working hours (9 AM - 6 PM) - * Only logs while the browser tab is visible. + * Technician Location Services + * + * 1. Background logger -- logs GPS every 5 minutes during working hours. + * 2. getLocation() -- returns a Promise that resolves to {latitude, longitude, accuracy}. + * If the user denies permission or the request times out a blocking modal is shown + * and the promise is rejected. + * 3. Blocking modal -- cannot be dismissed; forces the technician to grant permission. */ (function () { 'use strict'; - var INTERVAL_MS = 5 * 60 * 1000; // 5 minutes + var INTERVAL_MS = 5 * 60 * 1000; var STORE_OPEN_HOUR = 9; var STORE_CLOSE_HOUR = 18; var locationTimer = null; + var permissionDenied = false; + + // ===================================================================== + // BLOCKING MODAL + // ===================================================================== + + var modalEl = null; + + function ensureModal() { + if (modalEl) return; + var div = document.createElement('div'); + div.id = 'fusionLocationModal'; + div.innerHTML = + '
' + + '
' + + '
' + + '

Location Required

' + + '

Your GPS location is mandatory to perform this action. ' + + 'Please allow location access in your browser settings and try again.

' + + '

If you previously denied access, open your browser settings ' + + 'and reset the location permission for this site.

' + + '' + + '
' + + '
'; + document.body.appendChild(div); + modalEl = div; + document.getElementById('fusionLocationRetryBtn').addEventListener('click', function () { + hideModal(); + window.fusionGetLocation().catch(function () { + showModal(); + }); + }); + } + + function showModal() { + ensureModal(); + modalEl.style.display = ''; + } + + function hideModal() { + if (modalEl) modalEl.style.display = 'none'; + } + + // ===================================================================== + // PERMISSION-DENIED BANNER (persistent warning for background logger) + // ===================================================================== + + var bannerEl = null; + + function showDeniedBanner() { + if (bannerEl) return; + bannerEl = document.createElement('div'); + bannerEl.id = 'fusionLocationBanner'; + bannerEl.style.cssText = + 'position:fixed;top:0;left:0;right:0;z-index:9999;background:#dc3545;color:#fff;' + + 'padding:10px 16px;text-align:center;font-size:0.9rem;font-weight:600;box-shadow:0 2px 8px rgba(0,0,0,.2);'; + bannerEl.innerHTML = + '' + + 'Location access is denied. Your location is not being tracked. ' + + 'Please enable location in browser settings.'; + document.body.appendChild(bannerEl); + } + + // ===================================================================== + // getLocation() -- public API + // ===================================================================== + + function getLocation() { + return new Promise(function (resolve, reject) { + if (!navigator.geolocation) { + reject(new Error('Geolocation is not supported by this browser.')); + return; + } + navigator.geolocation.getCurrentPosition( + function (position) { + permissionDenied = false; + if (bannerEl) { bannerEl.remove(); bannerEl = null; } + resolve({ + latitude: position.coords.latitude, + longitude: position.coords.longitude, + accuracy: position.coords.accuracy || 0, + }); + }, + function (error) { + permissionDenied = true; + showDeniedBanner(); + console.error('Fusion Location: GPS error', error.code, error.message); + reject(error); + }, + { enableHighAccuracy: true, timeout: 15000, maximumAge: 30000 } + ); + }); + } + + window.fusionGetLocation = getLocation; + + // ===================================================================== + // NAVIGATE -- opens Google Maps app on iOS/Android, browser fallback + // ===================================================================== + + function openGoogleMapsNav(el) { + var addr = (el.dataset.navAddr || '').trim(); + var fallbackUrl = el.dataset.navUrl || ''; + if (!addr && !fallbackUrl) return; + + var dest = encodeURIComponent(addr) || fallbackUrl.split('destination=')[1]; + var isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); + var isAndroid = /Android/i.test(navigator.userAgent); + + if (isIOS) { + window.location.href = 'comgooglemaps://?daddr=' + dest + '&directionsmode=driving'; + } else if (isAndroid) { + window.location.href = 'google.navigation:q=' + dest; + } else { + window.open(fallbackUrl, '_blank'); + } + } + + window.openGoogleMapsNav = openGoogleMapsNav; + + // ===================================================================== + // BACKGROUND LOGGER + // ===================================================================== function isWorkingHours() { var now = new Date(); @@ -18,77 +147,54 @@ } function isTechnicianPortal() { - // Check if we're on a technician portal page return window.location.pathname.indexOf('/my/technician') !== -1; } function logLocation() { - if (!isWorkingHours()) { - return; - } - if (document.hidden) { - return; - } - if (!navigator.geolocation) { - return; - } + if (!isWorkingHours() || document.hidden || !navigator.geolocation) return; - navigator.geolocation.getCurrentPosition( - function (position) { - var data = { + getLocation().then(function (coords) { + fetch('/my/technician/location/log', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params: { - latitude: position.coords.latitude, - longitude: position.coords.longitude, - accuracy: position.coords.accuracy || 0, + latitude: coords.latitude, + longitude: coords.longitude, + accuracy: coords.accuracy, } - }; - fetch('/my/technician/location/log', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }).catch(function () { - // Silently fail - location logging is best-effort - }); - }, - function () { - // Geolocation permission denied or error - silently ignore - }, - { enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 } - ); + }), + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.result && !data.result.success) { + console.warn('Fusion Location: server rejected log', data.result); + } + }) + .catch(function (err) { + console.warn('Fusion Location: network error', err); + }); + }).catch(function () { + /* permission denied -- banner already shown */ + }); } function startLocationLogging() { - if (!isTechnicianPortal()) { - return; - } - - // Log immediately on page load + if (!isTechnicianPortal()) return; logLocation(); - - // Set interval for periodic logging locationTimer = setInterval(logLocation, INTERVAL_MS); - - // Pause/resume on tab visibility change document.addEventListener('visibilitychange', function () { if (document.hidden) { - // Tab hidden - clear interval to save battery - if (locationTimer) { - clearInterval(locationTimer); - locationTimer = null; - } + if (locationTimer) { clearInterval(locationTimer); locationTimer = null; } } else { - // Tab visible again - log immediately and restart interval logLocation(); - if (!locationTimer) { - locationTimer = setInterval(logLocation, INTERVAL_MS); - } + if (!locationTimer) { locationTimer = setInterval(logLocation, INTERVAL_MS); } } }); } - // Start when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', startLocationLogging); } else { diff --git a/fusion_authorizer_portal/views/portal_technician_templates.xml b/fusion_authorizer_portal/views/portal_technician_templates.xml index d89e7007..7e5c4bc4 100644 --- a/fusion_authorizer_portal/views/portal_technician_templates.xml +++ b/fusion_authorizer_portal/views/portal_technician_templates.xml @@ -18,6 +18,41 @@ + + +
+
+
+
+
+
+ Clocked In + Not Clocked In +
+
00:00:00
+
+
+ +
+ +
+ +
@@ -32,10 +67,6 @@
Done
-
-
-
Travel min
-
@@ -61,8 +92,11 @@

- + Navigate + + + @@ -1086,7 +1174,42 @@

Welcome back, !

- + + + +
+
+
+
+
+
+ Clocked In + Not Clocked In +
+
00:00:00
+
+
+ +
+ +
+ +
@@ -1377,6 +1500,113 @@ + + + diff --git a/fusion_claims/__manifest__.py b/fusion_claims/__manifest__.py index 90f14d17..27f5d439 100644 --- a/fusion_claims/__manifest__.py +++ b/fusion_claims/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Claims', - 'version': '19.0.7.0.0', + 'version': '19.0.7.2.0', 'category': 'Sales', 'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.', 'description': """ @@ -153,7 +153,8 @@ 'report/report_proof_of_delivery.xml', 'report/report_proof_of_delivery_standard.xml', 'report/report_proof_of_pickup.xml', - 'report/report_rental_agreement.xml', + + 'report/report_approved_items.xml', 'report/report_grab_bar_waiver.xml', 'report/report_accessibility_contract.xml', 'report/report_mod_quotation.xml', @@ -178,6 +179,7 @@ 'fusion_claims/static/src/js/calendar_store_hours.js', 'fusion_claims/static/src/js/fusion_task_map_view.js', 'fusion_claims/static/src/js/attachment_image_compress.js', + 'fusion_claims/static/src/js/debug_required_fields.js', 'fusion_claims/static/src/xml/document_preview.xml', 'fusion_claims/static/src/xml/fusion_task_map_view.xml', ], diff --git a/fusion_claims/models/account_move.py b/fusion_claims/models/account_move.py index 3ed55178..48500ee3 100644 --- a/fusion_claims/models/account_move.py +++ b/fusion_claims/models/account_move.py @@ -105,9 +105,11 @@ class AccountMove(models.Model): try: report = self.env.ref('fusion_claims.action_report_mod_invoice') pdf_content, _ = report._render_qweb_pdf(report.id, [self.id]) - client_name = (so.partner_id.name or 'Client').replace(' ', '_').replace(',', '') + name_parts = (so.partner_id.name or 'Client').strip().split() + first = name_parts[0] if name_parts else 'Client' + last = name_parts[-1] if len(name_parts) > 1 else '' att = Attachment.create({ - 'name': f'Invoice - {client_name} - {self.name}.pdf', + 'name': f'{first}_{last}_MOD_Invoice_{self.name}.pdf', 'type': 'binary', 'datas': base64.b64encode(pdf_content), 'res_model': 'account.move', diff --git a/fusion_claims/models/res_partner.py b/fusion_claims/models/res_partner.py index efa2d5c8..a8892fe0 100644 --- a/fusion_claims/models/res_partner.py +++ b/fusion_claims/models/res_partner.py @@ -2,8 +2,12 @@ # Copyright 2024-2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) +import logging +import requests from odoo import models, fields, api +_logger = logging.getLogger(__name__) + class ResPartner(models.Model): _inherit = 'res.partner' @@ -14,6 +18,65 @@ class ResPartner(models.Model): 'Used as origin for first travel time calculation. ' 'If empty, the company default HQ address is used.', ) + x_fc_start_address_lat = fields.Float( + string='Start Latitude', digits=(10, 7), + ) + x_fc_start_address_lng = fields.Float( + string='Start Longitude', digits=(10, 7), + ) + + def _geocode_start_address(self, address): + if not address or not address.strip(): + return 0.0, 0.0 + api_key = self.env['ir.config_parameter'].sudo().get_param( + 'fusion_claims.google_maps_api_key', '') + if not api_key: + return 0.0, 0.0 + try: + resp = requests.get( + 'https://maps.googleapis.com/maps/api/geocode/json', + params={'address': address.strip(), 'key': api_key, 'region': 'ca'}, + timeout=10, + ) + data = resp.json() + if data.get('status') == 'OK' and data.get('results'): + loc = data['results'][0]['geometry']['location'] + return loc['lat'], loc['lng'] + except Exception as e: + _logger.warning("Start address geocoding failed for '%s': %s", address, e) + return 0.0, 0.0 + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + for rec, vals in zip(records, vals_list): + addr = vals.get('x_fc_start_address') + if addr: + lat, lng = rec._geocode_start_address(addr) + if lat and lng: + rec.write({ + 'x_fc_start_address_lat': lat, + 'x_fc_start_address_lng': lng, + }) + return records + + def write(self, vals): + res = super().write(vals) + if 'x_fc_start_address' in vals: + addr = vals['x_fc_start_address'] + if addr and addr.strip(): + lat, lng = self._geocode_start_address(addr) + if lat and lng: + super().write({ + 'x_fc_start_address_lat': lat, + 'x_fc_start_address_lng': lng, + }) + else: + super().write({ + 'x_fc_start_address_lat': 0.0, + 'x_fc_start_address_lng': 0.0, + }) + return res # ========================================================================== # CONTACT TYPE diff --git a/fusion_claims/models/sale_order.py b/fusion_claims/models/sale_order.py index 045f85e6..5886aa58 100644 --- a/fusion_claims/models/sale_order.py +++ b/fusion_claims/models/sale_order.py @@ -22,7 +22,7 @@ class SaleOrder(models.Model): for order in self: name = order.name or '' if order.partner_id and order.partner_id.name: - name = f"{name} -- {order.partner_id.name}" + name = f"{name} - {order.partner_id.name}" order.display_name = name # ========================================================================== @@ -1318,17 +1318,12 @@ class SaleOrder(models.Model): att_ids = [] att_names = [] - # 1. Signed SA Form -- reuse existing attachment created by attachment=True signed_field = 'x_fc_sa_signed_form' if self.x_fc_sa_signed_form else ( 'x_fc_sa_physical_signed_copy' if self.x_fc_sa_physical_signed_copy else None) if signed_field: - att = Attachment.search([ - ('res_model', '=', 'sale.order'), - ('res_id', '=', self.id), - ('res_field', '=', signed_field), - ], order='id desc', limit=1) - if att: - att_ids.append(att.id) + att_id = self._get_and_prepare_field_attachment(signed_field, 'Signed SA Form') + if att_id: + att_ids.append(att_id) att_names.append('Signed SA Form') # 2. Internal POD -- generate on-the-fly from the standard report @@ -1353,12 +1348,14 @@ class SaleOrder(models.Model): try: report = self.env.ref('account.account_invoices') pdf_content, _ct = report._render_qweb_pdf(report.id, [invoice.id]) + first, last = self._get_client_name_parts() att = Attachment.create({ - 'name': f'Invoice_{invoice.name}.pdf', + 'name': f'{first}_{last}_Invoice_{invoice.name}.pdf', 'type': 'binary', 'datas': base64.b64encode(pdf_content), 'res_model': 'sale.order', 'res_id': self.id, + 'mimetype': 'application/pdf', }) att_ids.append(att.id) att_names.append(f'Invoice ({invoice.name})') @@ -1388,16 +1385,10 @@ class SaleOrder(models.Model): att_ids = [] att_names = [] - # 1. Approval document - if self.x_fc_odsp_approval_document: - att = Attachment.search([ - ('res_model', '=', 'sale.order'), - ('res_id', '=', self.id), - ('res_field', '=', 'x_fc_odsp_approval_document'), - ], order='id desc', limit=1) - if att: - att_ids.append(att.id) - att_names.append('ODSP Approval Document') + att_id = self._get_and_prepare_field_attachment('x_fc_odsp_approval_document', 'ODSP Approval Document') + if att_id: + att_ids.append(att_id) + att_names.append('ODSP Approval Document') # 2. Internal POD try: @@ -1426,12 +1417,14 @@ class SaleOrder(models.Model): try: report = self.env.ref('account.account_invoices') pdf_content, _ct = report._render_qweb_pdf(report.id, [invoice.id]) + first, last = self._get_client_name_parts() att = Attachment.create({ - 'name': f'Invoice_{invoice.name}.pdf', + 'name': f'{first}_{last}_Invoice_{invoice.name}.pdf', 'type': 'binary', 'datas': base64.b64encode(pdf_content), 'res_model': 'sale.order', 'res_id': self.id, + 'mimetype': 'application/pdf', }) att_ids.append(att.id) att_names.append(f'Invoice ({invoice.name})') @@ -1680,44 +1673,6 @@ class SaleOrder(models.Model): ) _logger.info("POD signature applied to approval form for %s", self.name) - # ========================================================================== - # DELIVERY STATUS FIELDS - # ========================================================================== - x_fc_delivery_status = fields.Selection( - selection=[ - ('waiting', 'Waiting'), - ('waiting_approval', 'Waiting for Approval'), - ('ready', 'Ready for Delivery'), - ('scheduled', 'Delivery Scheduled'), - ('shipped_warehouse', 'Shipped to Warehouse'), - ('received_warehouse', 'Received in Warehouse'), - ('delivered', 'Delivered'), - ('hold', 'Hold'), - ('cancelled', 'Cancelled'), - ], - string='Delivery Status', - tracking=True, - help='Current delivery status of the order', - ) - - x_fc_delivery_datetime = fields.Datetime( - string='Delivery Date & Time', - tracking=True, - help='Scheduled or actual delivery date and time', - ) - - # Computed field to show/hide delivery datetime - x_fc_show_delivery_datetime = fields.Boolean( - compute='_compute_show_delivery_datetime', - string='Show Delivery DateTime', - ) - - @api.depends('x_fc_delivery_status') - def _compute_show_delivery_datetime(self): - """Compute whether to show delivery datetime field.""" - for order in self: - order.x_fc_show_delivery_datetime = order.x_fc_delivery_status in ('scheduled', 'delivered') - # ========================================================================== # ADP CLAIM FIELDS # ========================================================================== @@ -1729,11 +1684,11 @@ class SaleOrder(models.Model): ) x_fc_client_ref_1 = fields.Char( string='Client Reference 1', - help='Primary client reference (e.g., Health Card Number)', + help='First two letters of the client\'s first name and last two letters of their last name. Example: John Doe = JODO', ) x_fc_client_ref_2 = fields.Char( string='Client Reference 2', - help='Secondary client reference', + help='Last four digits of the client\'s health card number. Example: 1234', ) x_fc_adp_delivery_date = fields.Date( string='ADP Delivery Date', @@ -2988,10 +2943,136 @@ class SaleOrder(models.Model): # ========================================================================== # PDF DOCUMENT PREVIEW ACTIONS (opens in new tab using browser/system PDF handler) # ========================================================================== + MIME_TO_EXT = { + 'application/pdf': '.pdf', + 'application/xml': '.xml', + 'text/xml': '.xml', + 'text/plain': '.txt', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx', + 'application/msword': '.doc', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx', + 'application/vnd.ms-excel': '.xls', + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/gif': '.gif', + 'image/webp': '.webp', + 'application/zip': '.zip', + 'application/octet-stream': '', + } + + FIELD_NAME_TEMPLATE = { + 'x_fc_final_submitted_application': '{first}_{last}.pdf', + 'x_fc_xml_file': '{first}_{last}_data.xml', + 'x_fc_original_application': '{first}_{last}_Original_Application.pdf', + 'x_fc_signed_pages_11_12': '{first}_{last}_Signed_Pages.pdf', + 'x_fc_proof_of_delivery': '{first}_{last}_Proof_of_Delivery.pdf', + 'x_fc_approval_letter': '{first}_{last}_Approval_Letter.pdf', + 'x_fc_sa_signed_form': '{first}_{last}_SA_Form_Signed.pdf', + 'x_fc_sa_physical_signed_copy': '{first}_{last}_SA_Form_Signed.pdf', + 'x_fc_sa_approval_form': '{first}_{last}_SA_Approval.pdf', + 'x_fc_odsp_approval_document': '{first}_{last}_ODSP_Approval.pdf', + 'x_fc_odsp_authorizer_letter': '{first}_{last}_ODSP_Authorizer_Letter.pdf', + 'x_fc_ow_discretionary_form': '{first}_{last}_OW_Discretionary_Form.pdf', + 'x_fc_ow_authorizer_letter': '{first}_{last}_OW_Authorizer_Letter.pdf', + 'x_fc_mod_drawing': '{first}_{last}_Drawing.pdf', + 'x_fc_mod_initial_photos': '{first}_{last}_Initial_Photos.pdf', + 'x_fc_mod_pca_document': '{first}_{last}_PCA_Document.pdf', + 'x_fc_mod_proof_of_delivery': '{first}_{last}_Proof_of_Delivery.pdf', + 'x_fc_mod_completion_photos': '{first}_{last}_Completion_Photos.pdf', + } + + FIELD_FILENAME_MAP = { + 'x_fc_original_application': 'x_fc_original_application_filename', + 'x_fc_signed_pages_11_12': 'x_fc_signed_pages_filename', + 'x_fc_final_submitted_application': 'x_fc_final_application_filename', + 'x_fc_xml_file': 'x_fc_xml_filename', + 'x_fc_proof_of_delivery': 'x_fc_proof_of_delivery_filename', + 'x_fc_approval_letter': 'x_fc_approval_letter_filename', + 'x_fc_sa_signed_form': 'x_fc_sa_signed_form_filename', + 'x_fc_sa_physical_signed_copy': 'x_fc_sa_physical_signed_copy_filename', + 'x_fc_sa_approval_form': 'x_fc_sa_approval_form_filename', + 'x_fc_odsp_approval_document': 'x_fc_odsp_approval_document_filename', + 'x_fc_odsp_authorizer_letter': 'x_fc_odsp_authorizer_letter_filename', + 'x_fc_ow_discretionary_form': 'x_fc_ow_discretionary_form_filename', + 'x_fc_ow_authorizer_letter': 'x_fc_ow_authorizer_letter_filename', + 'x_fc_mod_drawing': 'x_fc_mod_drawing_filename', + 'x_fc_mod_initial_photos': 'x_fc_mod_initial_photos_filename', + 'x_fc_mod_pca_document': 'x_fc_mod_pca_filename', + 'x_fc_mod_proof_of_delivery': 'x_fc_mod_pod_filename', + 'x_fc_mod_completion_photos': 'x_fc_mod_completion_photos_filename', + } + + def _get_ext_from_mime(self, mimetype): + """Return a file extension (with dot) for a MIME type.""" + return self.MIME_TO_EXT.get(mimetype or '', '') + + def _get_client_name_parts(self): + """Return (first_name, last_name) cleaned for filenames.""" + full_name = (self.partner_id.name or 'Client').strip() + parts = full_name.split() + first = parts[0] if parts else 'Client' + last = parts[-1] if len(parts) > 1 else '' + clean = lambda s: s.replace(',', '').replace("'", '').replace('"', '') + return clean(first), clean(last) + + def _build_attachment_name(self, field_name, mimetype=None): + """Build the proper filename for a field attachment. + + Uses FIELD_NAME_TEMPLATE for known fields with Firstname_Lastname convention. + For the XML file, respects the actual mimetype (could be .xml, .docx, .txt). + """ + first, last = self._get_client_name_parts() + template = self.FIELD_NAME_TEMPLATE.get(field_name) + + if template: + name = template.format(first=first, last=last) + if field_name == 'x_fc_xml_file' and mimetype: + ext = self._get_ext_from_mime(mimetype) + if ext and ext != '.xml': + name = f'{first}_{last}_data{ext}' + return name + + ext = self._get_ext_from_mime(mimetype) if mimetype else '.pdf' + return f'{first}_{last}_Document{ext}' + + def _prepare_attachment_for_email(self, attachment, field_name=None, label=None): + """Rename an attachment to a clean, professional filename. + + Always renames to the standard convention (Firstname_Lastname pattern) + so recipients get consistently named files regardless of what was uploaded. + """ + if not attachment: + return None + + new_name = self._build_attachment_name(field_name, attachment.mimetype) + + if attachment.name == new_name: + return attachment.id + + try: + attachment.sudo().write({'name': new_name}) + except Exception: + _logger.warning("Could not rename attachment %s to %s", attachment.id, new_name) + + return attachment.id + + def _get_and_prepare_field_attachment(self, field_name, label=None): + """Find the ir.attachment for a binary field, rename it properly, return its id. + + Convenience wrapper combining _get_document_attachment + _prepare_attachment_for_email. + Returns None if the field has no data or attachment is not found. + """ + self.ensure_one() + if not getattr(self, field_name, None): + return None + attachment = self._get_document_attachment(field_name) + if not attachment: + return None + return self._prepare_attachment_for_email(attachment, field_name=field_name, label=label) + def _get_document_attachment(self, field_name): """Get the ir.attachment record for a binary field stored as attachment.""" self.ensure_one() - # Find the attachment by field name - get most recent one attachment = self.env['ir.attachment'].sudo().search([ ('res_model', '=', 'sale.order'), ('res_id', '=', self.id), @@ -3001,9 +3082,9 @@ class SaleOrder(models.Model): def _get_or_create_attachment(self, field_name, document_label): """Get the current attachment for a binary field (attachment=True). - + For attachment=True fields, Odoo creates attachments automatically. - We find the one with res_field set and return it. + We find the one with res_field set, ensure it has a proper name, and return it. """ self.ensure_one() @@ -3011,43 +3092,20 @@ class SaleOrder(models.Model): if not data: return None - # For attachment=True fields, Odoo creates/updates an attachment with res_field set attachment = self.env['ir.attachment'].sudo().search([ ('res_model', '=', 'sale.order'), ('res_id', '=', self.id), ('res_field', '=', field_name), ], order='id desc', limit=1) - + if attachment: - # If attachment name is the field name (Odoo default), use the actual filename - if attachment.name == field_name: - filename_mapping = { - 'x_fc_original_application': 'x_fc_original_application_filename', - 'x_fc_signed_pages_11_12': 'x_fc_signed_pages_filename', - 'x_fc_final_submitted_application': 'x_fc_final_application_filename', - 'x_fc_xml_file': 'x_fc_xml_filename', - 'x_fc_proof_of_delivery': 'x_fc_proof_of_delivery_filename', - } - filename_field = filename_mapping.get(field_name) - if filename_field: - filename = getattr(self, filename_field, None) - if filename and filename != field_name: - attachment.sudo().write({'name': filename}) + self._prepare_attachment_for_email(attachment, field_name=field_name, label=document_label) return attachment - # Fallback: create attachment manually (shouldn't happen for attachment=True fields) - filename_mapping = { - 'x_fc_original_application': 'x_fc_original_application_filename', - 'x_fc_signed_pages_11_12': 'x_fc_signed_pages_filename', - 'x_fc_final_submitted_application': 'x_fc_final_application_filename', - 'x_fc_xml_file': 'x_fc_xml_filename', - 'x_fc_proof_of_delivery': 'x_fc_proof_of_delivery_filename', - } - filename_field = filename_mapping.get(field_name) - filename = getattr(self, filename_field) if filename_field else f'{document_label}.pdf' - + filename = self._build_attachment_name(field_name) + attachment = self.env['ir.attachment'].sudo().create({ - 'name': filename or f'{document_label}.pdf', + 'name': filename, 'datas': data, 'res_model': 'sale.order', 'res_id': self.id, @@ -3399,16 +3457,20 @@ class SaleOrder(models.Model): } def action_complete_assessment(self): - """Open wizard to mark assessment as completed with date.""" + """Open wizard to mark assessment as completed with date. + Allowed from 'quotation' (override) or 'assessment_scheduled' (normal flow).""" self.ensure_one() - if self.x_fc_adp_application_status != 'assessment_scheduled': - raise UserError("Can only complete assessment from 'Assessment Scheduled' status.") - + if self.x_fc_adp_application_status not in ('quotation', 'assessment_scheduled'): + raise UserError( + _("Can only complete assessment from 'Quotation' or 'Assessment Scheduled' status.") + ) + return { 'name': 'Assessment Completed', 'type': 'ir.actions.act_window', 'res_model': 'fusion_claims.assessment.completed.wizard', 'view_mode': 'form', + 'views': [(False, 'form')], 'target': 'new', 'context': { 'active_id': self.id, @@ -4627,82 +4689,67 @@ class SaleOrder(models.Model): # ========================================================================== # DOCUMENT CHATTER POSTING # ========================================================================== - def _post_document_to_chatter(self, field_name, document_label=None): - """Post a document attachment to the chatter with a link. + def _post_document_to_chatter(self, field_name, document_label=None, preserve_copy=False): + """Post a document to the chatter, reusing the existing field attachment. + + By default, references the existing ir.attachment (created by Odoo for + attachment=True fields) instead of creating a duplicate. Args: field_name: The binary field name (e.g., 'x_fc_final_submitted_application') document_label: Optional label for the document (defaults to field string) + preserve_copy: If True, creates a separate copy (used when the original + is about to be deleted/replaced and we need to keep a snapshot). """ self.ensure_one() - # Map field names to filename fields - filename_mapping = { - 'x_fc_original_application': 'x_fc_original_application_filename', - 'x_fc_signed_pages_11_12': 'x_fc_signed_pages_filename', - 'x_fc_final_submitted_application': 'x_fc_final_application_filename', - 'x_fc_xml_file': 'x_fc_xml_filename', - 'x_fc_proof_of_delivery': 'x_fc_proof_of_delivery_filename', - } - - data_field = field_name - filename_field = filename_mapping.get(field_name, field_name + '_filename') - - data = getattr(self, data_field, None) - original_filename = getattr(self, filename_field, None) or 'document' - + data = getattr(self, field_name, None) if not data: return - - # Get document label from field definition if not provided + if not document_label: - field_obj = self._fields.get(data_field) - document_label = field_obj.string if field_obj else data_field - - # Check for existing attachments with same name for revision numbering - existing_count = self.env['ir.attachment'].sudo().search_count([ - ('res_model', '=', 'sale.order'), - ('res_id', '=', self.id), - ('name', '=like', original_filename.rsplit('.', 1)[0] + '%'), - ]) - - # Add revision number if this is a replacement - if existing_count > 0 and '(replaced)' in (document_label or ''): - # This is an old document being replaced - add revision number - base_name, ext = original_filename.rsplit('.', 1) if '.' in original_filename else (original_filename, '') - filename = f"R{existing_count}_{base_name}.{ext}" if ext else f"R{existing_count}_{base_name}" + field_obj = self._fields.get(field_name) + document_label = field_obj.string if field_obj else field_name + + if preserve_copy: + proper_name = self._build_attachment_name(field_name) + base, _, ext = proper_name.rpartition('.') + if base: + copy_name = f"{base}_archived.{ext}" + else: + copy_name = f"{proper_name}_archived" + + attachment = self.env['ir.attachment'].sudo().create({ + 'name': copy_name, + 'datas': data, + 'res_model': 'sale.order', + 'res_id': self.id, + }) else: - filename = original_filename - - # Create attachment with the original/revised filename - attachment = self.env['ir.attachment'].sudo().create({ - 'name': filename, - 'datas': data, - 'res_model': 'sale.order', - 'res_id': self.id, - }) - - # Post message with attachment (shows as native Odoo attachment with preview) + attachment = self._get_document_attachment(field_name) + if not attachment: + return + self._prepare_attachment_for_email(attachment, field_name=field_name) + user_name = self.env.user.name now = fields.Datetime.now() - - body = Markup(""" -

{label} uploaded by {user}

-

{timestamp}

- """).format( + + body = Markup( + '

{label} uploaded by {user}

' + '

{timestamp}

' + ).format( label=document_label, user=user_name, - timestamp=now.strftime('%Y-%m-%d %H:%M:%S') + timestamp=now.strftime('%Y-%m-%d %H:%M:%S'), ) - - # Use attachment_ids to show as native attachment with preview capability + self.message_post( body=body, message_type='notification', subtype_xmlid='mail.mt_note', attachment_ids=[attachment.id], ) - + return attachment # ========================================================================== @@ -4844,28 +4891,15 @@ class SaleOrder(models.Model): if not to_emails and not cc_emails: return False - # Reuse existing field attachments (created by Odoo for attachment=True fields) - # instead of creating duplicates attachments = [] attachment_names = [] - Attachment = self.env['ir.attachment'].sudo() - if self.x_fc_final_submitted_application: - att = Attachment.search([ - ('res_model', '=', 'sale.order'), - ('res_id', '=', self.id), - ('res_field', '=', 'x_fc_final_submitted_application'), - ], order='id desc', limit=1) - if att: - attachments.append(att.id) + att_id = self._get_and_prepare_field_attachment('x_fc_final_submitted_application', 'ADP Application') + if att_id: + attachments.append(att_id) attachment_names.append('Final ADP Application (PDF)') - if self.x_fc_xml_file: - att = Attachment.search([ - ('res_model', '=', 'sale.order'), - ('res_id', '=', self.id), - ('res_field', '=', 'x_fc_xml_file'), - ], order='id desc', limit=1) - if att: - attachments.append(att.id) + att_id = self._get_and_prepare_field_attachment('x_fc_xml_file', 'ADP XML Data') + if att_id: + attachments.append(att_id) attachment_names.append('XML Data File') client_name = recipients.get('client', self.partner_id).name or 'Client' @@ -5113,8 +5147,144 @@ class SaleOrder(models.Model): _logger.error(f"Failed to send billed email for {self.name}: {e}") return False + def _build_approved_items_html(self, for_pdf=False): + """Build an HTML table of approved order line items. + + Columns: S/N, ADP Code, Device Type, Product Name, Qty, + ADP Portion, Client Portion, Deduction. + """ + self.ensure_one() + lines = self.order_line.filtered( + lambda l: l.product_id and l.display_type not in ('line_section', 'line_note') + ) + if not lines: + return '' + + font = "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;" + if for_pdf: + font = "font-family:Arial,Helvetica,sans-serif;" + + hdr_style = ( + f'style="background:#2d3748;color:#fff;padding:8px 10px;' + f'font-size:11px;font-weight:600;text-align:left;' + f'border-bottom:2px solid #4a5568;{font}"' + ) + cell_style = ( + 'style="padding:7px 10px;font-size:12px;color:#2d3748;' + 'border-bottom:1px solid #e2e8f0;"' + ) + alt_row = 'style="background:#f7fafc;"' + amt_style = ( + 'style="padding:7px 10px;font-size:12px;color:#2d3748;' + 'border-bottom:1px solid #e2e8f0;text-align:right;"' + ) + hdr_r = hdr_style.replace('text-align:left', 'text-align:right') + + has_deduction = any( + l.x_fc_deduction_type and l.x_fc_deduction_type != 'none' + for l in lines + ) + + html = ( + '
' + f'

Approved Items

' + '' + '' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + ) + if has_deduction: + html += f'' + html += '' + + total_adp = 0.0 + total_client = 0.0 + + for idx, line in enumerate(lines, 1): + row_attr = alt_row if idx % 2 == 0 else '' + adp_code = line._get_adp_code_for_report() + device_type = line._get_adp_device_type() + product_name = line.product_id.name or '-' + if len(product_name) > 40 and not for_pdf: + product_name = product_name[:37] + '...' + qty = int(line.product_uom_qty) if line.product_uom_qty == int(line.product_uom_qty) else line.product_uom_qty + adp_portion = line.x_fc_adp_portion or 0.0 + client_portion = line.x_fc_client_portion or 0.0 + total_adp += adp_portion + total_client += client_portion + + deduction_str = '-' + if line.x_fc_deduction_type == 'pct' and line.x_fc_deduction_value: + deduction_str = f'{line.x_fc_deduction_value:.0f}%' + elif line.x_fc_deduction_type == 'amt' and line.x_fc_deduction_value: + deduction_str = f'${line.x_fc_deduction_value:,.2f}' + + html += f'' + html += f'' + html += f'' + html += f'' + html += f'' + html += f'' + html += f'' + html += f'' + if has_deduction: + html += f'' + html += '' + + # Totals row + colspan = 5 + total_style = ( + 'style="padding:8px 10px;font-size:12px;font-weight:700;' + 'color:#1a202c;border-top:2px solid #2d3748;text-align:right;"' + ) + total_label_style = ( + f'style="padding:8px 10px;font-size:12px;font-weight:700;' + f'color:#1a202c;border-top:2px solid #2d3748;text-align:right;"' + ) + html += f'' + html += f'' + html += f'' + html += f'' + if has_deduction: + html += f'' + html += '' + + html += '
S/NADP CodeDevice TypeProductQtyADP PortionClient PortionDeduction
{idx}{adp_code}{device_type}{product_name}{qty}${adp_portion:,.2f}${client_portion:,.2f}{deduction_str}
Total${total_adp:,.2f}${total_client:,.2f}
' + return html + + def _generate_approved_items_pdf(self): + """Generate the Approved Items PDF using the QWeb report and return an ir.attachment id.""" + self.ensure_one() + import base64 + + first, last = self._get_client_name_parts() + + try: + report = self.env.ref('fusion_claims.action_report_approved_items') + pdf_content, _ = report._render_qweb_pdf(report.id, [self.id]) + except Exception as e: + _logger.error("Failed to generate approved items PDF for %s: %s", self.name, e) + return None + + filename = f'{first}_{last}_Approved_Items.pdf' + att = self.env['ir.attachment'].sudo().create({ + 'name': filename, + 'type': 'binary', + 'datas': base64.b64encode(pdf_content) if isinstance(pdf_content, bytes) else pdf_content, + 'res_model': 'sale.order', + 'res_id': self.id, + 'mimetype': 'application/pdf', + }) + return att.id + def _send_approval_email(self): - """Send notification when ADP application is approved.""" + """Send notification when ADP application is approved, with approved items report.""" self.ensure_one() if not self._is_email_notifications_enabled(): return False @@ -5138,27 +5308,46 @@ class SaleOrder(models.Model): 'contact you with the details and next steps for delivery.' ) + items_html = self._build_approved_items_html() + body_html = self._email_build( title='Application Approved', summary=f'The ADP application for {client_name} has been ' f'{status_label.lower()}.', email_type='success', sections=[('Case Details', self._build_case_detail_rows(include_amounts=True))], + extra_html=items_html, note=note_text, note_color='#38a169', + attachments_note='Approved Items Report (PDF)' if items_html else None, button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form', sender_name=sales_rep_name, ) + + attachment_ids = [] + try: + att_id = self._generate_approved_items_pdf() + if att_id: + attachment_ids.append(att_id) + except Exception as e: + _logger.warning("Could not generate approved items PDF for %s: %s", self.name, e) + email_to = ', '.join(to_emails) email_cc = ', '.join(cc_emails) if cc_emails else '' try: - self.env['mail.mail'].sudo().create({ + mail_vals = { 'subject': f'Application {status_label} - {client_name} - {self.name}', 'body_html': body_html, 'email_to': email_to, 'email_cc': email_cc, 'model': 'sale.order', 'res_id': self.id, - }).send() - self._email_chatter_log(f'Application {status_label} email sent', email_to, email_cc) + } + if attachment_ids: + mail_vals['attachment_ids'] = [(6, 0, attachment_ids)] + self.env['mail.mail'].sudo().create(mail_vals).send() + self._email_chatter_log( + f'Application {status_label} email sent', email_to, email_cc, + ['Attached: Approved Items Report'] if attachment_ids else None, + ) return True except Exception as e: _logger.error(f"Failed to send approval email for {self.name}: {e}") @@ -5492,6 +5681,18 @@ class SaleOrder(models.Model): # ========================================================================== # OVERRIDE WRITE # ========================================================================== + def web_save(self, vals, specification): + """TEMP DEBUG: Intercept web_save to diagnose 'Missing required fields' on old orders.""" + _logger.warning( + "DEBUG web_save() on %s: vals keys = %s", + [r.name for r in self], list(vals.keys()) + ) + try: + return super().web_save(vals, specification) + except Exception as e: + _logger.error("DEBUG web_save() FAILED on %s: %s", [r.name for r in self], e) + raise + def write(self, vals): """Override write to handle ADP status changes, date auto-population, and document tracking.""" from datetime import date as date_class @@ -5690,19 +5891,17 @@ class SaleOrder(models.Model): label = document_labels.get(field_name, field_name) if old_data and new_data: - # REPLACEMENT: Old document being replaced with new one - # Preserve old document in chatter as attachment order._post_document_to_chatter( - field_name, - f"{label} (replaced)" + field_name, + f"{label} (replaced)", + preserve_copy=True, ) elif old_data and not new_data: - # DELETION: Document is being deleted - # Preserve the deleted document in chatter order._post_document_to_chatter( - field_name, - f"{label} (DELETED)" + field_name, + f"{label} (DELETED)", + preserve_copy=True, ) # Post deletion notice @@ -5737,11 +5936,17 @@ class SaleOrder(models.Model): for order in self: # Post existing final application to chatter before clearing if order.x_fc_final_submitted_application: - order._post_document_to_chatter('x_fc_final_submitted_application', - 'Final Application (before correction)') + order._post_document_to_chatter( + 'x_fc_final_submitted_application', + 'Final Application (before correction)', + preserve_copy=True, + ) if order.x_fc_xml_file: - order._post_document_to_chatter('x_fc_xml_file', - 'XML File (before correction)') + order._post_document_to_chatter( + 'x_fc_xml_file', + 'XML File (before correction)', + preserve_copy=True, + ) # Clear the document fields AND submission date # Use _correction_cleared to prevent the audit trail from posting duplicates @@ -6131,8 +6336,9 @@ class SaleOrder(models.Model): for order in self: order._send_correction_needed_email() elif new_app_status == 'case_closed': - for order in self: - order._send_case_closed_email() + if not self.env.context.get('skip_status_emails'): + for order in self: + order._send_case_closed_email() # ================================================================== # MARCH OF DIMES STATUS-TRIGGERED EMAILS & SMS @@ -6679,7 +6885,7 @@ class SaleOrder(models.Model): for order in orders_to_close: try: # Use context to skip status validation for automated process - order.with_context(skip_status_validation=True).write({ + order.with_context(skip_status_validation=True, skip_status_emails=True).write({ 'x_fc_adp_application_status': 'case_closed', }) @@ -6725,7 +6931,7 @@ class SaleOrder(models.Model): if order.x_fc_odsp_division != 'ontario_works' and status != 'payment_received': continue try: - order._odsp_advance_status( + order.with_context(skip_status_emails=True)._odsp_advance_status( 'case_closed', "Case automatically closed 7 days after %s." % status.replace('_', ' '), ) diff --git a/fusion_claims/models/sale_order_line.py b/fusion_claims/models/sale_order_line.py index c7118ef8..99b6e8be 100644 --- a/fusion_claims/models/sale_order_line.py +++ b/fusion_claims/models/sale_order_line.py @@ -306,6 +306,49 @@ class SaleOrderLine(models.Model): # 5. Final fallback - return default_code even if not in ADP database return self.product_id.default_code or '' + def _get_adp_code_for_report(self): + """Return the ADP device code for display on reports. + + Uses the product's x_fc_adp_device_code field (not default_code). + Returns 'NON-FUNDED' for non-ADP products. + """ + self.ensure_one() + if not self.product_id: + return 'NON-FUNDED' + if self.product_id.is_non_adp_funded(): + return 'NON-FUNDED' + product_tmpl = self.product_id.product_tmpl_id + code = '' + if hasattr(product_tmpl, 'x_fc_adp_device_code'): + code = getattr(product_tmpl, 'x_fc_adp_device_code', '') or '' + if not code and hasattr(product_tmpl, 'x_adp_code'): + code = getattr(product_tmpl, 'x_adp_code', '') or '' + if not code: + return 'NON-FUNDED' + ADPDevice = self.env['fusion.adp.device.code'].sudo() + if ADPDevice.search_count([('device_code', '=', code), ('active', '=', True)]) > 0: + return code + return 'NON-FUNDED' + + def _get_adp_device_type(self): + """Live lookup of device type from the ADP device code table. + + Returns 'No Funding Available' for non-ADP products. + """ + self.ensure_one() + if not self.product_id or self.product_id.is_non_adp_funded(): + return 'No Funding Available' + code = self._get_adp_code_for_report() + if code == 'NON-FUNDED': + return 'No Funding Available' + if self.x_fc_adp_device_type: + return self.x_fc_adp_device_type + adp_device = self.env['fusion.adp.device.code'].sudo().search([ + ('device_code', '=', code), + ('active', '=', True), + ], limit=1) + return adp_device.device_type if adp_device else 'No Funding Available' + def _get_serial_number(self): """Get serial number from mapped field or native field.""" self.ensure_one() diff --git a/fusion_claims/models/technician_task.py b/fusion_claims/models/technician_task.py index 20a263b7..22865490 100644 --- a/fusion_claims/models/technician_task.py +++ b/fusion_claims/models/technician_task.py @@ -350,6 +350,13 @@ class FusionTechnicianTask(models.Model): compute='_compute_address_display', ) + # In-store flag -- uses company address instead of client address + is_in_store = fields.Boolean( + string='In Store', + default=False, + help='Task takes place at the store/office. Uses company address automatically.', + ) + # Geocoding address_lat = fields.Float(string='Latitude', digits=(10, 7)) address_lng = fields.Float(string='Longitude', digits=(10, 7)) @@ -382,6 +389,30 @@ class FusionTechnicianTask(models.Model): string='Completed At', tracking=True, ) + + # GPS location captured at task actions + started_latitude = fields.Float( + string='Started Latitude', digits=(10, 7), readonly=True, + ) + started_longitude = fields.Float( + string='Started Longitude', digits=(10, 7), readonly=True, + ) + completed_latitude = fields.Float( + string='Completed Latitude', digits=(10, 7), readonly=True, + ) + completed_longitude = fields.Float( + string='Completed Longitude', digits=(10, 7), readonly=True, + ) + action_latitude = fields.Float( + string='Last Action Latitude', digits=(10, 7), readonly=True, + ) + action_longitude = fields.Float( + string='Last Action Longitude', digits=(10, 7), readonly=True, + ) + action_location_accuracy = fields.Float( + string='Location Accuracy (m)', readonly=True, + ) + voice_note_audio = fields.Binary( string='Voice Recording', attachment=True, @@ -961,9 +992,21 @@ class FusionTechnicianTask(models.Model): # ONCHANGE - Auto-fill address from client # ------------------------------------------------------------------ + @api.onchange('is_in_store') + def _onchange_is_in_store(self): + """Auto-fill company address when task is marked as in-store.""" + if self.is_in_store: + company_partner = self.env.company.partner_id + if company_partner and company_partner.street: + self._fill_address_from_partner(company_partner) + else: + self.address_street = self.env.company.name or 'In Store' + @api.onchange('partner_id') def _onchange_partner_id(self): """Auto-fill address fields from the selected client's address.""" + if self.is_in_store: + return if self.partner_id: addr = self.partner_id self.address_partner_id = addr.id @@ -1046,6 +1089,19 @@ class FusionTechnicianTask(models.Model): "A task must be linked to either a Sale Order (Case) or a Purchase Order." )) + @api.constrains('address_street', 'address_lat', 'address_lng', 'is_in_store') + def _check_address_required(self): + """Non-in-store tasks must have a geocoded address.""" + for task in self: + if task.x_fc_sync_source: + continue + if task.is_in_store: + continue + if not task.address_street: + raise ValidationError(_( + "A valid address is required. If this task is at the store, " + "please check the 'In Store' option." + )) @api.constrains('technician_id', 'additional_technician_ids', 'scheduled_date', 'time_start', 'time_end') @@ -1334,6 +1390,14 @@ class FusionTechnicianTask(models.Model): vals['name'] = self.env['ir.sequence'].next_by_code('fusion.technician.task') or _('New') if not vals.get('x_fc_sync_uuid') and not vals.get('x_fc_sync_source'): vals['x_fc_sync_uuid'] = str(uuid.uuid4()) + # In-store tasks: auto-fill company address + if vals.get('is_in_store') and not vals.get('address_street'): + company_partner = self.env.company.partner_id + if company_partner and company_partner.street: + self._fill_address_vals(vals, company_partner) + else: + vals['address_street'] = self.env.company.name or 'In Store' + # Auto-populate address from sale order if not provided if vals.get('sale_order_id') and not vals.get('address_street'): order = self.env['sale.order'].browse(vals['sale_order_id']) @@ -1676,6 +1740,22 @@ class FusionTechnicianTask(models.Model): "Please complete previous task %s first before starting this one." ) % earlier_incomplete.name) + def _write_action_location(self, extra_vals=None): + """Write GPS coordinates from context onto the task record.""" + ctx = self.env.context + lat = ctx.get('action_latitude', 0) + lng = ctx.get('action_longitude', 0) + acc = ctx.get('action_accuracy', 0) + vals = { + 'action_latitude': lat, + 'action_longitude': lng, + 'action_location_accuracy': acc, + } + if extra_vals: + vals.update(extra_vals) + if lat and lng: + self.with_context(skip_travel_recalc=True).write(vals) + def action_start_en_route(self): """Mark task as En Route.""" for task in self: @@ -1683,6 +1763,7 @@ class FusionTechnicianTask(models.Model): raise UserError(_("Only scheduled tasks can be marked as En Route.")) task._check_previous_tasks_completed() task.status = 'en_route' + task._write_action_location() task._post_status_message('en_route') def action_start_task(self): @@ -1692,6 +1773,11 @@ class FusionTechnicianTask(models.Model): raise UserError(_("Task must be scheduled or en route to start.")) task._check_previous_tasks_completed() task.status = 'in_progress' + ctx = self.env.context + task._write_action_location({ + 'started_latitude': ctx.get('action_latitude', 0), + 'started_longitude': ctx.get('action_longitude', 0), + }) task._post_status_message('in_progress') def action_view_sale_order(self): @@ -1742,9 +1828,15 @@ class FusionTechnicianTask(models.Model): "technician portal first." )) + ctx = self.env.context task.with_context(skip_travel_recalc=True).write({ 'status': 'completed', 'completion_datetime': fields.Datetime.now(), + 'completed_latitude': ctx.get('action_latitude', 0), + 'completed_longitude': ctx.get('action_longitude', 0), + 'action_latitude': ctx.get('action_latitude', 0), + 'action_longitude': ctx.get('action_longitude', 0), + 'action_location_accuracy': ctx.get('action_accuracy', 0), }) task._post_status_message('completed') if task.completion_notes and (task.sale_order_id or task.purchase_order_id): @@ -1811,6 +1903,7 @@ class FusionTechnicianTask(models.Model): if task.status == 'completed': raise UserError(_("Cannot cancel a completed task.")) task.status = 'cancelled' + task._write_action_location() task._post_status_message('cancelled') # If this was a delivery task linked to a sale order that is # currently in "Ready for Delivery" -- revert the order back. @@ -2302,20 +2395,144 @@ class FusionTechnicianTask(models.Model): base_domain, ['name', 'partner_id', 'technician_id', 'task_type', 'address_lat', 'address_lng', 'address_display', - 'time_start', 'time_start_display', 'time_end_display', + 'time_start', 'time_end', 'time_start_display', 'time_end_display', 'status', 'scheduled_date', 'travel_time_minutes', 'x_fc_sync_client_name', 'x_fc_is_shadow', 'x_fc_sync_source'], order='scheduled_date asc NULLS LAST, time_start asc', limit=500, ) locations = self.env['fusion.technician.location'].get_latest_locations() + tech_starts = self._get_tech_start_locations(tasks, api_key) return { 'api_key': api_key, 'tasks': tasks, 'locations': locations, 'local_instance_id': local_instance, + 'tech_start_locations': tech_starts, } + @api.model + def _get_tech_start_locations(self, tasks, api_key): + """Build a dict of technician start locations for route origins. + + Priority per technician: + 1. Today's fusion_clock check-in location (if module installed) + 2. Personal start address (x_fc_start_address with cached lat/lng) + 3. Company default HQ address + """ + tech_ids = { + t['technician_id'][0] + for t in tasks + if t.get('technician_id') + } + if not tech_ids: + return {} + + result = {} + today = fields.Date.today() + + clock_locations = self._get_clock_in_locations(tech_ids, today) + + hq_address = ( + self.env['ir.config_parameter'].sudo() + .get_param('fusion_claims.technician_start_address', '') or '' + ).strip() + hq_lat, hq_lng = 0.0, 0.0 + + for uid in tech_ids: + if uid in clock_locations: + result[uid] = clock_locations[uid] + continue + + user = self.env['res.users'].sudo().browse(uid) + if not user.exists(): + continue + partner = user.partner_id + + if partner.x_fc_start_address and partner.x_fc_start_address.strip(): + lat = partner.x_fc_start_address_lat + lng = partner.x_fc_start_address_lng + if not lat or not lng: + lat, lng = self._geocode_address_string( + partner.x_fc_start_address, api_key) + if lat and lng: + partner.sudo().write({ + 'x_fc_start_address_lat': lat, + 'x_fc_start_address_lng': lng, + }) + if lat and lng: + result[uid] = { + 'lat': lat, 'lng': lng, + 'address': partner.x_fc_start_address.strip(), + 'source': 'start_address', + } + continue + + if hq_address: + if not hq_lat and not hq_lng: + hq_lat, hq_lng = self._geocode_address_string( + hq_address, api_key) + if hq_lat and hq_lng: + result[uid] = { + 'lat': hq_lat, 'lng': hq_lng, + 'address': hq_address, + 'source': 'company_hq', + } + + return result + + @api.model + def _get_clock_in_locations(self, tech_ids, today): + """Get today's clock-in lat/lng from fusion_clock if installed.""" + result = {} + try: + module = self.env['ir.module.module'].sudo().search([ + ('name', '=', 'fusion_clock'), + ('state', '=', 'installed'), + ], limit=1) + if not module: + return result + except Exception: + return result + + try: + Attendance = self.env['hr.attendance'].sudo() + Employee = self.env['hr.employee'].sudo() + except KeyError: + return result + + employees = Employee.search([ + ('user_id', 'in', list(tech_ids)), + ]) + emp_to_user = {e.id: e.user_id.id for e in employees} + + if not employees: + return result + + today_start = dt_datetime.combine(today, dt_datetime.min.time()) + today_end = today_start + timedelta(days=1) + + attendances = Attendance.search([ + ('employee_id', 'in', employees.ids), + ('check_in', '>=', today_start), + ('check_in', '<', today_end), + ], order='check_in asc') + + for att in attendances: + uid = emp_to_user.get(att.employee_id.id) + if not uid or uid in result: + continue + loc = att.x_fclk_location_id if hasattr(att, 'x_fclk_location_id') else False + if loc and loc.latitude and loc.longitude: + result[uid] = { + 'lat': loc.latitude, + 'lng': loc.longitude, + 'address': loc.address or loc.name or '', + 'source': 'clock_in', + } + + return result + def _geocode_address(self): """Geocode the task address using Google Geocoding API.""" self.ensure_one() @@ -2573,12 +2790,14 @@ class FusionTechnicianTask(models.Model): return f'{display_hour}:{minutes:02d} {period}' def get_google_maps_url(self): - """Get Google Maps navigation URL. Uses lat/lng coordinates to - navigate to the exact location (text addresses cause Google to - resolve to nearby business names instead).""" + """Get Google Maps navigation URL using the text address so the + destination shows a proper street name instead of raw coordinates. + Returns a google.com/maps URL that Android auto-opens in the app; + iOS handling is done client-side via JS to launch comgooglemaps://.""" self.ensure_one() + if self.address_display: + addr = urllib.parse.quote(self.address_display) + return f'https://www.google.com/maps/dir/?api=1&destination={addr}&travelmode=driving' if self.address_lat and self.address_lng: return f'https://www.google.com/maps/dir/?api=1&destination={self.address_lat},{self.address_lng}&travelmode=driving' - elif self.address_display: - return f'https://www.google.com/maps/dir/?api=1&destination={urllib.parse.quote(self.address_display)}&travelmode=driving' return '' diff --git a/fusion_claims/report/report_actions.xml b/fusion_claims/report/report_actions.xml index ba103ca2..a5b64bd9 100644 --- a/fusion_claims/report/report_actions.xml +++ b/fusion_claims/report/report_actions.xml @@ -32,7 +32,7 @@ report - + Quotation / Order (Landscape - ADP) sale.order @@ -40,7 +40,7 @@ fusion_claims.report_saleorder_landscape fusion_claims.report_saleorder_landscape '%s - %s' % (object.name, object.partner_id.name) - + report @@ -127,19 +127,6 @@ report - - - - - Rental Agreement - sale.order - qweb-pdf - fusion_claims.report_rental_agreement - fusion_claims.report_rental_agreement - 'Rental Agreement - %s' % object.name - - report - @@ -169,6 +156,21 @@ report + + + + + Approved Items Report + sale.order + qweb-pdf + fusion_claims.report_approved_items + fusion_claims.report_approved_items + 'Approved Items - %s - %s' % (object.name, object.partner_id.name) + + report + + + diff --git a/fusion_claims/report/report_approved_items.xml b/fusion_claims/report/report_approved_items.xml new file mode 100644 index 00000000..3ef899a8 --- /dev/null +++ b/fusion_claims/report/report_approved_items.xml @@ -0,0 +1,162 @@ + + + + + diff --git a/fusion_claims/report/report_rental_agreement.xml b/fusion_claims/report/report_rental_agreement.xml deleted file mode 100644 index 4b424bd0..00000000 --- a/fusion_claims/report/report_rental_agreement.xml +++ /dev/null @@ -1,365 +0,0 @@ - - - - - diff --git a/fusion_claims/static/src/css/fusion_task_map_view.scss b/fusion_claims/static/src/css/fusion_task_map_view.scss index 5b7318b0..9f1351bf 100644 --- a/fusion_claims/static/src/css/fusion_task_map_view.scss +++ b/fusion_claims/static/src/css/fusion_task_map_view.scss @@ -320,6 +320,25 @@ $transition-speed: .25s; .fa { opacity: .8; } } +.fc_task_edit_btn { + display: inline-flex; + align-items: center; + font-size: 10px; + font-weight: 600; + color: var(--btn-primary-color, #fff); + background: var(--btn-primary-bg, #{$primary}); + padding: 2px 10px; + border-radius: 4px; + cursor: pointer; + margin-left: auto; + transition: all .15s; + + &:hover { + opacity: .85; + filter: brightness(1.15); + } +} + // ── Map area ──────────────────────────────────────────────────────── .fc_map_area { flex: 1 1 auto; @@ -341,15 +360,21 @@ $transition-speed: .25s; min-height: 400px; } -// ── Google Maps InfoWindow override (always light bg) ─────────────── -// InfoWindow is rendered by Google outside our DOM; we style via -// the .gm-style-iw container that Google injects. +// ── Google Maps InfoWindow override ────────────────────────────────── .gm-style-iw-d { overflow: auto !important; } .gm-style .gm-style-iw-c { padding: 0 !important; border-radius: 10px !important; + overflow: hidden !important; + box-shadow: 0 4px 20px rgba(0,0,0,.15) !important; +} +.gm-style .gm-style-iw-tc { + display: none !important; +} +.gm-style .gm-ui-hover-effect { + display: none !important; } // ── Responsive ────────────────────────────────────────────────────── diff --git a/fusion_claims/static/src/js/debug_required_fields.js b/fusion_claims/static/src/js/debug_required_fields.js new file mode 100644 index 00000000..37cf3cc8 --- /dev/null +++ b/fusion_claims/static/src/js/debug_required_fields.js @@ -0,0 +1,20 @@ +/** @odoo-module **/ + +import { Record } from "@web/model/relational_model/record"; +import { patch } from "@web/core/utils/patch"; + +patch(Record.prototype, { + _displayInvalidFieldNotification() { + const fieldNames = []; + for (const fieldName of this._invalidFields) { + const fieldDef = this.fields[fieldName]; + const label = fieldDef?.string || fieldName; + fieldNames.push(`${label} (${fieldName})`); + } + const message = fieldNames.length + ? `Missing required fields:\n${fieldNames.join(", ")}` + : "Missing required fields (unknown)"; + console.error("FUSION DEBUG:", message, Array.from(this._invalidFields)); + return this.model.notification.add(message, { type: "danger" }); + }, +}); diff --git a/fusion_claims/static/src/js/fusion_task_map_view.js b/fusion_claims/static/src/js/fusion_task_map_view.js index 8ee59239..46aa850e 100644 --- a/fusion_claims/static/src/js/fusion_task_map_view.js +++ b/fusion_claims/static/src/js/fusion_task_map_view.js @@ -203,11 +203,12 @@ function groupTasks(tasksData, localInstanceId) { }; } - let globalIdx = 0; + const dayCounters = {}; for (const task of sorted) { - globalIdx++; const g = classifyTask(task); - task._scheduleNum = globalIdx; + const dayKey = task.scheduled_date || "none"; + dayCounters[dayKey] = (dayCounters[dayKey] || 0) + 1; + task._scheduleNum = dayCounters[dayKey]; task._group = g; task._dayColor = DAY_COLORS[g] || "#6b7280"; // Pin colour by day task._statusColor = STATUS_COLORS[task.status] || "#6b7280"; @@ -255,6 +256,7 @@ export class FusionTaskMapController extends Component { showTasks: true, showTechnicians: true, showTraffic: true, + showRoute: true, taskCount: 0, techCount: 0, // Sidebar @@ -264,11 +266,11 @@ export class FusionTaskMapController extends Component { activeTaskId: null, // Highlighted task // Day filters for map pins (which groups show on map) visibleGroups: { - [GROUP_YESTERDAY]: false, // hidden by default + [GROUP_YESTERDAY]: false, [GROUP_TODAY]: true, - [GROUP_TOMORROW]: true, - [GROUP_THIS_WEEK]: false, // hidden by default - [GROUP_LATER]: false, // hidden by default + [GROUP_TOMORROW]: false, + [GROUP_THIS_WEEK]: false, + [GROUP_LATER]: false, }, }); @@ -280,7 +282,11 @@ export class FusionTaskMapController extends Component { this.taskMarkers = []; this.taskMarkerMap = {}; // id → marker this.techMarkers = []; + this.routeLines = []; // route polylines + this.routeLabels = []; // travel time overlay labels + this.routeAnimFrameId = null; this.infoWindow = null; + this.techStartLocations = {}; this.apiKey = ""; this.tasksData = []; this.locationsData = []; @@ -312,6 +318,7 @@ export class FusionTaskMapController extends Component { }); onWillUnmount(() => { this._clearMarkers(); + this._clearRoute(); window.__fusionMapOpenTask = () => {}; }); } @@ -327,17 +334,22 @@ export class FusionTaskMapController extends Component { } // ── Data ───────────────────────────────────────────────────────── + _storeResult(result) { + this.localInstanceId = result.local_instance_id || this.localInstanceId || ""; + this.tasksData = result.tasks || []; + this.locationsData = result.locations || []; + this.techStartLocations = result.tech_start_locations || {}; + this.state.taskCount = this.tasksData.length; + this.state.techCount = this.locationsData.length; + this.state.groups = groupTasks(this.tasksData, this.localInstanceId); + } + async _loadAndRender() { try { const domain = this._getDomain(); const result = await this.orm.call("fusion.technician.task", "get_map_data", [domain]); this.apiKey = result.api_key; - this.localInstanceId = result.local_instance_id || ""; - this.tasksData = result.tasks || []; - this.locationsData = result.locations || []; - this.state.taskCount = this.tasksData.length; - this.state.techCount = this.locationsData.length; - this.state.groups = groupTasks(this.tasksData, this.localInstanceId); + this._storeResult(result); if (!this.apiKey) { this.state.error = _t("Google Maps API key not configured. Go to Settings > Fusion Claims."); @@ -345,7 +357,11 @@ export class FusionTaskMapController extends Component { return; } await loadGoogleMaps(this.apiKey); - if (this.mapRef.el) this._initMap(); + if (this.map) { + this._renderMarkers(); + } else if (this.mapRef.el) { + this._initMap(); + } this.state.loading = false; } catch (e) { console.error("FusionTaskMap load error:", e); @@ -354,17 +370,33 @@ export class FusionTaskMapController extends Component { } } + async _softRefresh() { + if (!this.map) return; + try { + const center = this.map.getCenter(); + const zoom = this.map.getZoom(); + + const domain = this._getDomain(); + const result = await this.orm.call("fusion.technician.task", "get_map_data", [domain]); + this._storeResult(result); + + this._placeMarkers(); + + if (center && zoom != null) { + this.map.setCenter(center); + this.map.setZoom(zoom); + } + } catch (e) { + console.error("FusionTaskMap soft refresh error:", e); + } + } + async _onModelUpdate() { if (!this.map) return; try { const domain = this._getDomain(); const result = await this.orm.call("fusion.technician.task", "get_map_data", [domain]); - this.localInstanceId = result.local_instance_id || this.localInstanceId || ""; - this.tasksData = result.tasks || []; - this.locationsData = result.locations || []; - this.state.taskCount = this.tasksData.length; - this.state.techCount = this.locationsData.length; - this.state.groups = groupTasks(this.tasksData, this.localInstanceId); + this._storeResult(result); this._renderMarkers(); } catch (e) { console.error("FusionTaskMap update error:", e); @@ -407,12 +439,27 @@ export class FusionTaskMapController extends Component { this.techMarkers = []; } - _renderMarkers() { - this._clearMarkers(); + _clearRoute() { + if (this.routeAnimFrameId) { + cancelAnimationFrame(this.routeAnimFrameId); + this.routeAnimFrameId = null; + } + for (const l of this.routeLines) l.setMap(null); + this.routeLines = []; + for (const lb of this.routeLabels) lb.setMap(null); + this.routeLabels = []; + } + + _placeMarkers() { + for (const m of this.taskMarkers) m.setMap(null); + for (const m of this.techMarkers) m.setMap(null); + this.taskMarkers = []; + this.taskMarkerMap = {}; + this.techMarkers = []; + const bounds = new google.maps.LatLngBounds(); let hasBounds = false; - // Task pins: only show groups that are enabled in the day filter if (this.state.showTasks) { for (const group of this.state.groups) { const groupVisible = this.state.visibleGroups[group.key] !== false; @@ -444,7 +491,6 @@ export class FusionTaskMapController extends Component { } } - // Technician markers if (this.state.showTechnicians) { for (const loc of this.locationsData) { if (!loc.latitude || !loc.longitude) continue; @@ -485,45 +531,410 @@ export class FusionTaskMapController extends Component { } } + const starts = this.techStartLocations || {}; + for (const uid of Object.keys(starts)) { + const sl = starts[uid]; + if (sl && sl.lat && sl.lng) { + bounds.extend({ lat: sl.lat, lng: sl.lng }); + hasBounds = true; + } + } + + return { bounds, hasBounds }; + } + + _renderMarkers() { + this._clearRoute(); + const { bounds, hasBounds } = this._placeMarkers(); + + if (this.state.showRoute && this.state.showTasks) { + this._renderRoute(); + } + if (hasBounds) { - this.map.fitBounds(bounds); - if (this.taskMarkers.length + this.techMarkers.length === 1) { - this.map.setZoom(14); + try { + this.map.fitBounds(bounds); + if (this.taskMarkers.length + this.techMarkers.length === 1) { + this.map.setZoom(14); + } + } catch (_e) { + // bounds not ready yet } } } + _renderRoute() { + this._clearRoute(); + + const routeSegments = {}; + for (const group of this.state.groups) { + if (this.state.visibleGroups[group.key] === false) continue; + for (const task of group.tasks) { + if (!task._hasCoords) continue; + const techId = task.technician_id ? task.technician_id[0] : 0; + if (!techId) continue; + const dayKey = task.scheduled_date || "none"; + const segKey = `${techId}_${dayKey}`; + if (!routeSegments[segKey]) { + routeSegments[segKey] = { + name: task._techName, day: dayKey, + techId, tasks: [], + }; + } + routeSegments[segKey].tasks.push(task); + } + } + + const LEG_COLORS = [ + "#3b82f6", "#f59e0b", "#8b5cf6", "#ec4899", + "#f97316", "#0ea5e9", "#d946ef", "#06b6d4", + "#a855f7", "#6366f1", "#eab308", "#0284c7", + "#c026d3", "#7c3aed", "#2563eb", "#db2777", + "#9333ea", "#0891b2", "#4f46e5", "#be185d", + ]; + let globalLegIdx = 0; + + if (!this._directionsService) { + this._directionsService = new google.maps.DirectionsService(); + } + + const allAnimLines = []; + const starts = this.techStartLocations || {}; + + for (const segKey of Object.keys(routeSegments)) { + const seg = routeSegments[segKey]; + const tasks = seg.tasks; + tasks.sort((a, b) => (a.time_start || 0) - (b.time_start || 0)); + + const startLoc = starts[seg.techId]; + const hasStart = startLoc && startLoc.lat && startLoc.lng; + + if (tasks.length < 2 && !hasStart) continue; + if (tasks.length < 1) continue; + + const segBaseColor = LEG_COLORS[globalLegIdx % LEG_COLORS.length]; + + let origin, destination, waypoints, hasStartLeg; + + if (hasStart) { + origin = { lat: startLoc.lat, lng: startLoc.lng }; + destination = { + lat: tasks[tasks.length - 1].address_lat, + lng: tasks[tasks.length - 1].address_lng, + }; + waypoints = tasks.slice(0, -1).map(t => ({ + location: { lat: t.address_lat, lng: t.address_lng }, + stopover: true, + })); + hasStartLeg = true; + } else { + origin = { lat: tasks[0].address_lat, lng: tasks[0].address_lng }; + destination = { + lat: tasks[tasks.length - 1].address_lat, + lng: tasks[tasks.length - 1].address_lng, + }; + waypoints = tasks.slice(1, -1).map(t => ({ + location: { lat: t.address_lat, lng: t.address_lng }, + stopover: true, + })); + hasStartLeg = false; + } + + if (hasStart) { + const startSvg = + `` + + `` + + `` + + ``; + const startMarker = new google.maps.Marker({ + position: origin, + map: this.map, + title: `${seg.name} - Start`, + icon: { + url: "data:image/svg+xml;charset=UTF-8," + encodeURIComponent(startSvg), + scaledSize: new google.maps.Size(32, 32), + anchor: new google.maps.Point(16, 16), + }, + zIndex: 5, + }); + startMarker.addListener("click", () => { + this.infoWindow.setContent(` +
+
+ ${seg.name} - Start +
+
+ ${startLoc.address || 'Start location'} +
${startLoc.source === 'clock_in' ? 'Clock-in location' : startLoc.source === 'start_address' ? 'Home address' : 'Company HQ'}
+
+
`); + this.infoWindow.open(this.map, startMarker); + }); + this.routeLines.push(startMarker); + } + + this._directionsService.route({ + origin, + destination, + waypoints, + optimizeWaypoints: false, + travelMode: google.maps.TravelMode.DRIVING, + avoidTolls: true, + drivingOptions: { + departureTime: new Date(), + trafficModel: "bestguess", + }, + }, (result, status) => { + if (status !== "OK" || !result.routes || !result.routes[0]) return; + + const route = result.routes[0]; + + for (let li = 0; li < route.legs.length; li++) { + const leg = route.legs[li]; + const legColor = LEG_COLORS[globalLegIdx % LEG_COLORS.length]; + globalLegIdx++; + + const legPath = []; + for (const step of leg.steps) { + for (const pt of step.path) legPath.push(pt); + } + if (legPath.length < 2) continue; + + const baseLine = new google.maps.Polyline({ + path: legPath, map: this.map, + strokeColor: legColor, strokeOpacity: 0.25, strokeWeight: 6, + zIndex: 1, + }); + this.routeLines.push(baseLine); + + const animLine = new google.maps.Polyline({ + path: legPath, map: this.map, + strokeOpacity: 0, strokeWeight: 0, zIndex: 2, + icons: [{ + icon: { + path: "M 0,-0.5 0,0.5", + strokeOpacity: 0.8, strokeColor: legColor, + strokeWeight: 3, scale: 4, + }, + offset: "0%", repeat: "16px", + }], + }); + this.routeLines.push(animLine); + allAnimLines.push(animLine); + + const arrowLine = new google.maps.Polyline({ + path: legPath, map: this.map, + strokeOpacity: 0, strokeWeight: 0, zIndex: 3, + icons: [{ + icon: { + path: google.maps.SymbolPath.FORWARD_OPEN_ARROW, + scale: 3, strokeColor: legColor, + strokeOpacity: 0.9, strokeWeight: 2.5, + }, + offset: "0%", repeat: "80px", + }], + }); + this.routeLines.push(arrowLine); + allAnimLines.push(arrowLine); + + const dur = leg.duration_in_traffic || leg.duration; + const dist = leg.distance; + if (dur) { + const totalMins = Math.round(dur.value / 60); + const totalKm = dist ? (dist.value / 1000).toFixed(1) : null; + + const destIdx = hasStartLeg ? li : li + 1; + const destTask = destIdx < tasks.length ? tasks[destIdx] : tasks[tasks.length - 1]; + const etaFloat = destTask.time_start || 0; + const etaStr = etaFloat ? floatToTime12(etaFloat) : ""; + + const techName = seg.name; + this.routeLabels.push(this._createTravelLabel( + legPath, totalMins, totalKm, legColor, techName, etaStr, + )); + } + } + + if (!this.routeAnimFrameId) { + this._startRouteAnimation(allAnimLines); + } + }); + } + } + + _pointAlongLeg(leg, fraction) { + const points = []; + for (const step of leg.steps) { + for (const pt of step.path) { + points.push(pt); + } + } + if (points.length < 2) return leg.start_location; + + const segDists = []; + let totalDist = 0; + for (let i = 1; i < points.length; i++) { + const d = google.maps.geometry + ? google.maps.geometry.spherical.computeDistanceBetween(points[i - 1], points[i]) + : this._haversine(points[i - 1], points[i]); + segDists.push(d); + totalDist += d; + } + + const target = totalDist * fraction; + let acc = 0; + for (let i = 0; i < segDists.length; i++) { + if (acc + segDists[i] >= target) { + const remain = target - acc; + const ratio = segDists[i] > 0 ? remain / segDists[i] : 0; + return new google.maps.LatLng( + points[i].lat() + (points[i + 1].lat() - points[i].lat()) * ratio, + points[i].lng() + (points[i + 1].lng() - points[i].lng()) * ratio, + ); + } + acc += segDists[i]; + } + return points[points.length - 1]; + } + + _haversine(a, b) { + const R = 6371000; + const dLat = (b.lat() - a.lat()) * Math.PI / 180; + const dLng = (b.lng() - a.lng()) * Math.PI / 180; + const s = Math.sin(dLat / 2) ** 2 + + Math.cos(a.lat() * Math.PI / 180) * Math.cos(b.lat() * Math.PI / 180) * + Math.sin(dLng / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s)); + } + + _createTravelLabel(legPath, mins, km, color, techName, eta) { + if (!this._TravelLabel) { + this._TravelLabel = class extends google.maps.OverlayView { + constructor(path, html) { + super(); + this._path = path; + this._html = html; + this._div = null; + } + onAdd() { + this._div = document.createElement("div"); + this._div.style.position = "absolute"; + this._div.style.whiteSpace = "nowrap"; + this._div.style.pointerEvents = "none"; + this._div.style.zIndex = "50"; + this._div.style.transition = "left .3s ease, top .3s ease"; + this._div.innerHTML = this._html; + this.getPanes().floatPane.appendChild(this._div); + } + draw() { + const proj = this.getProjection(); + if (!proj || !this._div) return; + const map = this.getMap(); + if (!map) return; + const bounds = map.getBounds(); + if (!bounds) return; + + const visible = this._path.filter(p => bounds.contains(p)); + if (visible.length === 0) { + this._div.style.display = "none"; + return; + } + this._div.style.display = ""; + + const anchor = visible[Math.floor(visible.length / 2)]; + + const px = proj.fromLatLngToDivPixel(anchor); + if (px) { + this._div.style.left = (px.x - this._div.offsetWidth / 2) + "px"; + this._div.style.top = (px.y - this._div.offsetHeight - 8) + "px"; + } + } + onRemove() { + if (this._div && this._div.parentNode) { + this._div.parentNode.removeChild(this._div); + } + this._div = null; + } + }; + } + + const timeStr = mins < 60 + ? `${mins} min` + : `${Math.floor(mins / 60)}h ${mins % 60}m`; + const distStr = km ? `${km} km` : ""; + + const firstName = techName ? techName.split(" ")[0] : ""; + const html = `
${firstName ? `${firstName}|` : ""}🚗${timeStr}${distStr ? `· ${distStr}` : ""}${eta ? `|ETA ${eta}` : ""}
`; + + const label = new this._TravelLabel(legPath, html); + label.setMap(this.map); + return label; + } + + _startRouteAnimation(animLines) { + let off = 0; + let last = 0; + const animate = (ts) => { + this.routeAnimFrameId = requestAnimationFrame(animate); + if (ts - last < 50) return; + last = ts; + off = (off + 0.08) % 100; + const pct = off + "%"; + for (const line of animLines) { + const icons = line.get("icons"); + if (icons && icons.length > 0) { + icons[0].offset = pct; + line.set("icons", icons); + } + } + }; + this.routeAnimFrameId = requestAnimationFrame(animate); + } + _openTaskPopup(task, marker) { const c = task._dayColor; + const sc = task._statusColor; + const navDest = task.address_lat && task.address_lng + ? `${task.address_lat},${task.address_lng}` + : encodeURIComponent(task.address_display || ""); const html = ` -
-
- #${task._scheduleNum}  ${task.name} -
- ${task._statusLabel} - +
+
+
+ #${task._scheduleNum} ${task.name} + ${task._statusLabel}
+
${task._clientName}
-
-
Client: ${task._clientName}
-
Type: ${task._typeLbl}
-
Technician: ${task._techName}
-
Date: ${task.scheduled_date || ""}
-
Time: ${task._timeRange}
- ${task.address_display ? `
Address: ${task.address_display}
` : ""} - ${task.travel_time_minutes ? `
Travel: ${task.travel_time_minutes} min
` : ""} +
+ + ${task._typeLbl} + + + ${task._timeRange} + + ${task.travel_time_minutes ? `${task.travel_time_minutes} min` : ""}
-
+
+
👤${task._techName}
+
📅${task.scheduled_date || "No date"}
+ ${task.address_display ? `
📍${task.address_display}
` : ""} +
+
- - Navigate → + + Navigate →
`; @@ -605,26 +1016,69 @@ export class FusionTaskMapController extends Component { this.state.showTechnicians = !this.state.showTechnicians; this._renderMarkers(); } + toggleRoute() { + this.state.showRoute = !this.state.showRoute; + if (this.state.showRoute) { + this._renderRoute(); + } else { + this._clearRoute(); + } + } onRefresh() { this.state.loading = true; this._loadAndRender(); } - openTask(taskId) { - this.actionService.switchView("form", { resId: taskId }); + async openTask(taskId) { + if (!taskId) return; + try { + await this.actionService.doAction( + { + type: "ir.actions.act_window", + res_model: "fusion.technician.task", + res_id: taskId, + view_mode: "form", + views: [[false, "form"]], + target: "new", + context: { dialog_size: "extra-large" }, + }, + { onClose: () => this._softRefresh() }, + ); + } catch (e) { + console.error("[FusionMap] openTask failed:", e); + this.actionService.doAction({ + type: "ir.actions.act_window", + res_model: "fusion.technician.task", + res_id: taskId, + view_mode: "form", + views: [[false, "form"]], + target: "current", + }); + } } - createNewTask() { - this.actionService.doAction({ - type: "ir.actions.act_window", - res_model: "fusion.technician.task", - views: [[false, "form"]], - target: "new", - context: { default_task_type: "delivery", dialog_size: "extra-large" }, - }, { - onClose: () => { - // Refresh map data after dialog closes (task may have been created) - this.onRefresh(); - }, - }); + async createNewTask() { + try { + await this.actionService.doAction( + { + type: "ir.actions.act_window", + res_model: "fusion.technician.task", + view_mode: "form", + views: [[false, "form"]], + target: "new", + context: { default_task_type: "delivery", dialog_size: "extra-large" }, + }, + { onClose: () => this._softRefresh() }, + ); + } catch (e) { + console.error("[FusionMap] createNewTask failed:", e); + this.actionService.doAction({ + type: "ir.actions.act_window", + res_model: "fusion.technician.task", + view_mode: "form", + views: [[false, "form"]], + target: "current", + context: { default_task_type: "delivery" }, + }); + } } } diff --git a/fusion_claims/static/src/xml/fusion_task_map_view.xml b/fusion_claims/static/src/xml/fusion_task_map_view.xml index f41cd925..957edfea 100644 --- a/fusion_claims/static/src/xml/fusion_task_map_view.xml +++ b/fusion_claims/static/src/xml/fusion_task_map_view.xml @@ -113,6 +113,11 @@ + + Edit +
@@ -170,6 +175,11 @@ Upcoming Yesterday +
+ ]]> + + + + + + Fusion Clock: Correction Request + + Timesheet Correction Request: {{ object.employee_id.name }} + {{ (object.company_id.email or user.email_formatted) }} + + + + + + + + +
+

Fusion Clock

+

Correction Request

+
+

{{ object.employee_id.name }} has submitted a timesheet correction request.

+

Reason: {{ object.reason }}

+

Please review and approve/reject from the Fusion Clock backend.

+
+
+ ]]> + + + diff --git a/fusion_clock/models/__init__.py b/fusion_clock/models/__init__.py index 6a968a3b..b4643901 100644 --- a/fusion_clock/models/__init__.py +++ b/fusion_clock/models/__init__.py @@ -6,3 +6,7 @@ from . import hr_employee from . import clock_penalty from . import clock_report from . import res_config_settings +from . import clock_activity_log +from . import clock_leave_request +from . import clock_shift +from . import clock_correction diff --git a/fusion_clock/models/clock_activity_log.py b/fusion_clock/models/clock_activity_log.py new file mode 100644 index 00000000..6cbb8afa --- /dev/null +++ b/fusion_clock/models/clock_activity_log.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from odoo import models, fields, api + + +class FusionClockActivityLog(models.Model): + _name = 'fusion.clock.activity.log' + _description = 'Clock Activity Log' + _order = 'log_date desc, id desc' + _rec_name = 'display_name' + + employee_id = fields.Many2one( + 'hr.employee', + string='Employee', + required=True, + index=True, + ondelete='cascade', + ) + log_type = fields.Selection( + [ + ('clock_in', 'Clock In'), + ('clock_out', 'Clock Out'), + ('late_clock_in', 'Late Clock-In'), + ('early_clock_out', 'Early Clock-Out'), + ('outside_geofence', 'Outside Geofence'), + ('auto_clock_out', 'Auto Clock-Out'), + ('missed_clock_out', 'Missed Clock-Out'), + ('absent', 'Absent'), + ('leave_request', 'Leave Request'), + ('reason_provided', 'Reason Provided'), + ('overtime', 'Overtime'), + ('correction_request', 'Correction Request'), + ('ip_fallback', 'IP Fallback Used'), + ('streak_milestone', 'Streak Milestone'), + ], + string='Log Type', + required=True, + index=True, + ) + log_date = fields.Datetime( + string='Date/Time', + required=True, + default=fields.Datetime.now, + index=True, + ) + description = fields.Text(string='Description') + attendance_id = fields.Many2one( + 'hr.attendance', + string='Attendance', + ondelete='set null', + index=True, + ) + location_id = fields.Many2one( + 'fusion.clock.location', + string='Location', + ondelete='set null', + ) + latitude = fields.Float(string='Latitude', digits=(10, 7)) + longitude = fields.Float(string='Longitude', digits=(10, 7)) + distance = fields.Float( + string='Distance (m)', + digits=(10, 2), + help="Distance from location center in meters.", + ) + source = fields.Selection( + [ + ('portal', 'Portal'), + ('portal_fab', 'Portal FAB'), + ('systray', 'Systray'), + ('backend_fab', 'Backend FAB'), + ('kiosk', 'Kiosk'), + ('system', 'System (Cron)'), + ], + string='Source', + ) + company_id = fields.Many2one( + 'res.company', + string='Company', + related='employee_id.company_id', + store=True, + ) + attempt_map_url = fields.Char( + string='Map Preview', + compute='_compute_attempt_map_url', + ) + display_name = fields.Char( + compute='_compute_display_name', + store=True, + ) + + LOG_TYPE_LABELS = { + 'clock_in': 'Clock In', + 'clock_out': 'Clock Out', + 'late_clock_in': 'Late Clock-In', + 'early_clock_out': 'Early Clock-Out', + 'outside_geofence': 'Outside Geofence', + 'auto_clock_out': 'Auto Clock-Out', + 'missed_clock_out': 'Missed Clock-Out', + 'absent': 'Absent', + 'leave_request': 'Leave Request', + 'reason_provided': 'Reason Provided', + 'overtime': 'Overtime', + 'correction_request': 'Correction Request', + 'ip_fallback': 'IP Fallback Used', + 'streak_milestone': 'Streak Milestone', + } + + @api.depends('latitude', 'longitude') + def _compute_attempt_map_url(self): + api_key = self.env['ir.config_parameter'].sudo().get_param( + 'fusion_clock.google_maps_api_key', '' + ) + for rec in self: + if rec.latitude and rec.longitude and api_key: + lat, lng = rec.latitude, rec.longitude + rec.attempt_map_url = ( + f"https://maps.googleapis.com/maps/api/staticmap?" + f"center={lat},{lng}&zoom=16&size=600x250&maptype=roadmap" + f"&markers=color:red%7C{lat},{lng}" + f"&key={api_key}" + ) + else: + rec.attempt_map_url = False + + @api.depends('employee_id', 'log_type', 'log_date') + def _compute_display_name(self): + for rec in self: + emp = rec.employee_id.name or '' + ltype = self.LOG_TYPE_LABELS.get(rec.log_type, rec.log_type or '') + date_str = rec.log_date.strftime('%Y-%m-%d %H:%M') if rec.log_date else '' + rec.display_name = f"{emp} - {ltype} ({date_str})" diff --git a/fusion_clock/models/clock_correction.py b/fusion_clock/models/clock_correction.py new file mode 100644 index 00000000..5a96ebda --- /dev/null +++ b/fusion_clock/models/clock_correction.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +import logging +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class FusionClockCorrection(models.Model): + _name = 'fusion.clock.correction' + _description = 'Timesheet Correction Request' + _order = 'create_date desc, id desc' + _rec_name = 'display_name' + _inherit = ['mail.thread'] + + employee_id = fields.Many2one( + 'hr.employee', + string='Employee', + required=True, + index=True, + ondelete='cascade', + ) + attendance_id = fields.Many2one( + 'hr.attendance', + string='Attendance Record', + required=True, + ondelete='cascade', + ) + original_check_in = fields.Datetime( + string='Original Clock-In', + related='attendance_id.check_in', + ) + original_check_out = fields.Datetime( + string='Original Clock-Out', + related='attendance_id.check_out', + ) + requested_check_in = fields.Datetime( + string='Corrected Clock-In', + ) + requested_check_out = fields.Datetime( + string='Corrected Clock-Out', + ) + reason = fields.Text( + string='Reason for Correction', + required=True, + ) + state = fields.Selection( + [ + ('pending', 'Pending'), + ('approved', 'Approved'), + ('rejected', 'Rejected'), + ], + string='Status', + default='pending', + tracking=True, + index=True, + ) + reviewed_by = fields.Many2one( + 'res.users', + string='Reviewed By', + ) + reviewed_date = fields.Datetime(string='Reviewed Date') + company_id = fields.Many2one( + 'res.company', + string='Company', + related='employee_id.company_id', + store=True, + ) + display_name = fields.Char( + compute='_compute_display_name', + store=True, + ) + + @api.depends('employee_id', 'attendance_id', 'state') + def _compute_display_name(self): + for rec in self: + emp = rec.employee_id.name or '' + date_str = rec.attendance_id.check_in.strftime('%Y-%m-%d') if rec.attendance_id and rec.attendance_id.check_in else '' + rec.display_name = f"{emp} - Correction ({date_str}) [{rec.state}]" + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + for rec in records: + rec._notify_office_user() + rec._create_activity_log('pending') + return records + + def action_approve(self): + """Approve the correction and update the attendance record.""" + self.ensure_one() + if self.state != 'pending': + raise UserError(_("Only pending corrections can be approved.")) + + vals = {} + if self.requested_check_in: + vals['check_in'] = self.requested_check_in + if self.requested_check_out: + vals['check_out'] = self.requested_check_out + + if vals: + self.attendance_id.sudo().write(vals) + + self.write({ + 'state': 'approved', + 'reviewed_by': self.env.uid, + 'reviewed_date': fields.Datetime.now(), + }) + self._create_activity_log('approved') + + def action_reject(self): + """Reject the correction request.""" + self.ensure_one() + if self.state != 'pending': + raise UserError(_("Only pending corrections can be rejected.")) + + self.write({ + 'state': 'rejected', + 'reviewed_by': self.env.uid, + 'reviewed_date': fields.Datetime.now(), + }) + self._create_activity_log('rejected') + + def _notify_office_user(self): + """Schedule a mail.activity for the office user.""" + ICP = self.env['ir.config_parameter'].sudo() + office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0')) + if not office_user_id: + return + office_user = self.env['res.users'].sudo().browse(office_user_id) + if not office_user.exists(): + return + try: + date_str = self.attendance_id.check_in.strftime('%Y-%m-%d') if self.attendance_id.check_in else 'unknown' + self.env['mail.activity'].sudo().create({ + 'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id, + 'summary': f"Timesheet Correction: {self.employee_id.name} ({date_str})", + 'note': f"Reason: {self.reason}", + 'user_id': office_user.id, + 'res_model_id': self.env['ir.model']._get_id('fusion.clock.correction'), + 'res_id': self.id, + 'date_deadline': fields.Date.today(), + }) + except Exception as e: + _logger.error("Fusion Clock: Failed to create correction activity: %s", e) + + def _create_activity_log(self, action): + """Log the correction event.""" + try: + desc = f"Correction {action} for attendance on " + if self.attendance_id.check_in: + desc += self.attendance_id.check_in.strftime('%Y-%m-%d') + desc += f": {self.reason}" + self.env['fusion.clock.activity.log'].sudo().create({ + 'employee_id': self.employee_id.id, + 'log_type': 'correction_request', + 'description': desc, + 'attendance_id': self.attendance_id.id, + 'source': 'system', + }) + except Exception as e: + _logger.error("Fusion Clock: Failed to create correction log: %s", e) diff --git a/fusion_clock/models/clock_leave_request.py b/fusion_clock/models/clock_leave_request.py new file mode 100644 index 00000000..2fe2592a --- /dev/null +++ b/fusion_clock/models/clock_leave_request.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +import logging +from odoo import models, fields, api + +_logger = logging.getLogger(__name__) + + +class FusionClockLeaveRequest(models.Model): + _name = 'fusion.clock.leave.request' + _description = 'Clock Leave Request' + _order = 'leave_date desc, id desc' + _rec_name = 'display_name' + _inherit = ['mail.thread'] + + employee_id = fields.Many2one( + 'hr.employee', + string='Employee', + required=True, + index=True, + ondelete='cascade', + ) + leave_date = fields.Date( + string='Leave Date', + required=True, + index=True, + ) + reason = fields.Text( + string='Reason', + required=True, + ) + state = fields.Selection( + [ + ('auto_approved', 'Auto-Approved'), + ('reviewed', 'Reviewed'), + ], + string='Status', + default='auto_approved', + tracking=True, + ) + created_from = fields.Selection( + [ + ('portal', 'Portal'), + ('backend', 'Backend'), + ], + string='Created From', + default='portal', + ) + company_id = fields.Many2one( + 'res.company', + string='Company', + related='employee_id.company_id', + store=True, + ) + display_name = fields.Char( + compute='_compute_display_name', + store=True, + ) + + @api.depends('employee_id', 'leave_date') + def _compute_display_name(self): + for rec in self: + emp = rec.employee_id.name or '' + date_str = str(rec.leave_date) if rec.leave_date else '' + rec.display_name = f"{emp} - Leave ({date_str})" + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + for rec in records: + rec._notify_office_user() + rec._create_activity_log() + return records + + def _notify_office_user(self): + """Schedule a mail.activity for the office user.""" + ICP = self.env['ir.config_parameter'].sudo() + office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0')) + if not office_user_id: + return + office_user = self.env['res.users'].sudo().browse(office_user_id) + if not office_user.exists(): + return + try: + self.env['mail.activity'].sudo().create({ + 'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id, + 'summary': f"Leave Request: {self.employee_id.name} on {self.leave_date}", + 'note': f"Reason: {self.reason}", + 'user_id': office_user.id, + 'res_model_id': self.env['ir.model']._get_id('fusion.clock.leave.request'), + 'res_id': self.id, + 'date_deadline': self.leave_date, + }) + except Exception as e: + _logger.error("Fusion Clock: Failed to create leave activity: %s", e) + + def _create_activity_log(self): + """Log the leave request in the activity log.""" + try: + self.env['fusion.clock.activity.log'].sudo().create({ + 'employee_id': self.employee_id.id, + 'log_type': 'leave_request', + 'description': f"Leave requested for {self.leave_date}: {self.reason}", + 'source': 'portal' if self.created_from == 'portal' else 'system', + }) + except Exception as e: + _logger.error("Fusion Clock: Failed to create leave activity log: %s", e) + + def action_mark_reviewed(self): + """Mark the leave request as reviewed by the office user.""" + self.write({'state': 'reviewed'}) diff --git a/fusion_clock/models/clock_location.py b/fusion_clock/models/clock_location.py index 43afe9ce..14c968db 100644 --- a/fusion_clock/models/clock_location.py +++ b/fusion_clock/models/clock_location.py @@ -2,6 +2,7 @@ # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) +import ipaddress import json import logging import requests @@ -54,6 +55,19 @@ class FusionClockLocation(models.Model): default=lambda self: self.env.user.tz or 'UTC', ) + # IP whitelist fallback + ip_whitelist = fields.Text( + string='IP Whitelist', + help="One IP address or CIDR per line. Used as fallback when GPS is unavailable.", + ) + + # Photo verification + require_photo = fields.Boolean( + string='Require Photo on Clock-In', + default=False, + help="If enabled, employees must take a selfie when clocking in at this location.", + ) + # Computed attendance_count = fields.Integer( string='Total Attendances', @@ -89,6 +103,28 @@ class FusionClockLocation(models.Model): ('x_fclk_location_id', '=', rec.id), ]) + def check_ip_whitelist(self, client_ip): + """Check if a client IP matches this location's whitelist. + Returns True if matched, False otherwise. + """ + if not self.ip_whitelist or not client_ip: + return False + try: + client = ipaddress.ip_address(client_ip) + for line in self.ip_whitelist.strip().split('\n'): + line = line.strip() + if not line or line.startswith('#'): + continue + try: + network = ipaddress.ip_network(line, strict=False) + if client in network: + return True + except ValueError: + continue + except ValueError: + return False + return False + def action_geocode_address(self): """Geocode the address to get lat/lng using Google Geocoding API. Falls back to Nominatim (OpenStreetMap) if Google fails. @@ -97,7 +133,6 @@ class FusionClockLocation(models.Model): if not self.address: raise UserError(_("Please enter an address first.")) - # Try Google first api_key = self.env['ir.config_parameter'].sudo().get_param('fusion_clock.google_maps_api_key', '') if api_key: try: @@ -126,13 +161,12 @@ class FusionClockLocation(models.Model): }, } elif data.get('status') == 'REQUEST_DENIED': - _logger.warning("Google Geocoding API denied. Enable the Geocoding API in Google Cloud Console. Falling back to Nominatim.") + _logger.warning("Google Geocoding API denied. Falling back to Nominatim.") else: - _logger.warning("Google geocoding returned: %s. Trying Nominatim fallback.", data.get('status')) + _logger.warning("Google geocoding returned: %s. Trying Nominatim.", data.get('status')) except requests.exceptions.RequestException as e: - _logger.warning("Google geocoding network error: %s. Trying Nominatim fallback.", e) + _logger.warning("Google geocoding network error: %s. Trying Nominatim.", e) - # Fallback: Nominatim (OpenStreetMap) - free, no API key needed try: url = 'https://nominatim.openstreetmap.org/search' params = { @@ -165,7 +199,7 @@ class FusionClockLocation(models.Model): }, } else: - raise UserError(_("Could not geocode address. No results found. Try a more specific address.")) + raise UserError(_("Could not geocode address. No results found.")) except requests.exceptions.RequestException as e: raise UserError(_("Network error during geocoding: %s") % str(e)) diff --git a/fusion_clock/models/clock_penalty.py b/fusion_clock/models/clock_penalty.py index 3eace999..c4c3fb6f 100644 --- a/fusion_clock/models/clock_penalty.py +++ b/fusion_clock/models/clock_penalty.py @@ -38,6 +38,11 @@ class FusionClockPenalty(models.Model): compute='_compute_difference', store=True, ) + penalty_minutes = fields.Float( + string='Deducted (min)', + default=0.0, + help="Minutes deducted from worked hours as penalty.", + ) date = fields.Date(string='Date', required=True, index=True) company_id = fields.Many2one( 'res.company', diff --git a/fusion_clock/models/clock_report.py b/fusion_clock/models/clock_report.py index 7a70191f..169ed97f 100644 --- a/fusion_clock/models/clock_report.py +++ b/fusion_clock/models/clock_report.py @@ -182,6 +182,84 @@ class FusionClockReport(models.Model): else: _logger.warning("Fusion Clock: Mail template not found for report %s", self.id) + def action_export_csv(self): + """Export the report data as a CSV file for payroll.""" + import csv + import io + + self.ensure_one() + if not self.attendance_ids: + self._collect_attendance_records() + + ICP = self.env['ir.config_parameter'].sudo() + mapping_raw = ICP.get_param('fusion_clock.csv_column_mapping', '') + import json as json_mod + try: + col_map = json_mod.loads(mapping_raw) if mapping_raw else {} + except Exception: + col_map = {} + + default_cols = { + 'employee': 'Employee', + 'date': 'Date', + 'clock_in': 'Clock In', + 'clock_out': 'Clock Out', + 'worked_hours': 'Worked Hours', + 'net_hours': 'Net Hours', + 'break_min': 'Break (min)', + 'overtime': 'Overtime (h)', + 'penalties': 'Penalties', + 'location': 'Location', + } + for k in default_cols: + if k in col_map: + default_cols[k] = col_map[k] + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(list(default_cols.values())) + + for att in self.attendance_ids.sorted(key=lambda a: a.check_in): + date_str = att.check_in.strftime('%Y-%m-%d') if att.check_in else '' + in_str = att.check_in.strftime('%H:%M') if att.check_in else '' + out_str = att.check_out.strftime('%H:%M') if att.check_out else '' + penalties = self.env['fusion.clock.penalty'].search_count([ + ('attendance_id', '=', att.id), + ]) + writer.writerow([ + att.employee_id.name or '', + date_str, + in_str, + out_str, + round(att.worked_hours or 0, 2), + round(att.x_fclk_net_hours or 0, 2), + round(att.x_fclk_break_minutes or 0, 0), + round(att.x_fclk_overtime_hours or 0, 2), + penalties, + att.x_fclk_location_id.name or '', + ]) + + csv_data = output.getvalue().encode('utf-8') + output.close() + + filename = f"clock_export_{self.date_start}_{self.date_end}" + if self.employee_id: + filename += f"_{self.employee_id.name.replace(' ', '_')}" + filename += ".csv" + + attachment = self.env['ir.attachment'].create({ + 'name': filename, + 'type': 'binary', + 'datas': base64.b64encode(csv_data), + 'mimetype': 'text/csv', + }) + + return { + 'type': 'ir.actions.act_url', + 'url': f'/web/content/{attachment.id}/{filename}?download=true', + 'target': 'new', + } + @api.model def _cron_generate_period_reports(self): """Cron: Generate reports when a pay period ends.""" diff --git a/fusion_clock/models/clock_shift.py b/fusion_clock/models/clock_shift.py new file mode 100644 index 00000000..f788aeed --- /dev/null +++ b/fusion_clock/models/clock_shift.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from odoo import models, fields + + +class FusionClockShift(models.Model): + _name = 'fusion.clock.shift' + _description = 'Clock Shift Schedule' + _order = 'sequence, name' + _rec_name = 'name' + + name = fields.Char( + string='Shift Name', + required=True, + help="E.g. 'Morning Shift', 'Evening Shift'.", + ) + start_time = fields.Float( + string='Start Time', + required=True, + default=9.0, + help="Shift start in 24h float (e.g. 7.0 = 7:00 AM).", + ) + end_time = fields.Float( + string='End Time', + required=True, + default=17.0, + help="Shift end in 24h float (e.g. 15.0 = 3:00 PM).", + ) + break_minutes = fields.Float( + string='Break Duration (min)', + default=30.0, + help="Unpaid break duration in minutes for this shift.", + ) + sequence = fields.Integer(default=10) + company_id = fields.Many2one( + 'res.company', + string='Company', + default=lambda self: self.env.company, + required=True, + ) + active = fields.Boolean(default=True) + color = fields.Char(string='Color', default='#3B82F6') + employee_ids = fields.One2many( + 'hr.employee', + 'x_fclk_shift_id', + string='Assigned Employees', + ) + employee_count = fields.Integer( + string='Employees', + compute='_compute_employee_count', + ) + + def _compute_employee_count(self): + for rec in self: + rec.employee_count = len(rec.employee_ids) + + @property + def scheduled_hours(self): + """Return the scheduled work hours for this shift (excluding break).""" + raw = self.end_time - self.start_time + return max(raw - (self.break_minutes / 60.0), 0.0) diff --git a/fusion_clock/models/hr_attendance.py b/fusion_clock/models/hr_attendance.py index ce7e04d3..3ee4fa59 100644 --- a/fusion_clock/models/hr_attendance.py +++ b/fusion_clock/models/hr_attendance.py @@ -21,12 +21,15 @@ class HrAttendance(models.Model): x_fclk_clock_source = fields.Selection( [ ('portal', 'Portal'), + ('portal_fab', 'Portal FAB'), ('systray', 'Systray'), + ('backend_fab', 'Backend FAB'), ('kiosk', 'Kiosk'), ('manual', 'Manual'), ('auto', 'Auto Clock-Out'), ], string='Clock Source', + tracking=True, help="How this attendance was recorded.", ) x_fclk_in_distance = fields.Float( @@ -42,12 +45,14 @@ class HrAttendance(models.Model): x_fclk_break_minutes = fields.Float( string='Break (min)', default=0.0, + tracking=True, help="Break duration in minutes to deduct from worked hours.", ) x_fclk_net_hours = fields.Float( string='Net Hours', compute='_compute_net_hours', store=True, + tracking=True, help="Worked hours minus break deduction.", ) x_fclk_penalty_ids = fields.One2many( @@ -66,6 +71,26 @@ class HrAttendance(models.Model): help="Whether the grace period was consumed before auto clock-out.", ) + # Overtime + x_fclk_overtime_hours = fields.Float( + string='Overtime (h)', + compute='_compute_overtime_hours', + store=True, + help="Hours beyond the scheduled shift for this day.", + ) + x_fclk_is_overtime = fields.Boolean( + string='Has Overtime', + compute='_compute_overtime_hours', + store=True, + ) + + # Photo verification + x_fclk_checkin_photo = fields.Binary( + string='Check-In Photo', + attachment=True, + help="Selfie captured at clock-in for verification.", + ) + @api.depends('worked_hours', 'x_fclk_break_minutes') def _compute_net_hours(self): for att in self: @@ -73,51 +98,61 @@ class HrAttendance(models.Model): raw = att.worked_hours or 0.0 att.x_fclk_net_hours = max(raw - break_hours, 0.0) + @api.depends('x_fclk_net_hours') + def _compute_overtime_hours(self): + ICP = self.env['ir.config_parameter'].sudo() + enable_ot = ICP.get_param('fusion_clock.enable_overtime', 'True') == 'True' + daily_threshold = float(ICP.get_param('fusion_clock.daily_overtime_threshold', '8.0')) + + for att in self: + if not enable_ot or not att.check_out: + att.x_fclk_overtime_hours = 0.0 + att.x_fclk_is_overtime = False + continue + + employee = att.employee_id + scheduled_hours = employee._get_fclk_scheduled_hours() if employee else daily_threshold + net = att.x_fclk_net_hours or 0.0 + + if net > scheduled_hours: + att.x_fclk_overtime_hours = round(net - scheduled_hours, 2) + att.x_fclk_is_overtime = True + else: + att.x_fclk_overtime_hours = 0.0 + att.x_fclk_is_overtime = False + @api.model def _cron_fusion_auto_clock_out(self): - """Cron job: auto clock-out employees after shift + grace period. - - Runs every 15 minutes. Finds open attendances that have exceeded - the maximum shift length plus grace period, and closes them. - """ + """Cron job: auto clock-out employees after shift + grace period.""" ICP = self.env['ir.config_parameter'].sudo() if ICP.get_param('fusion_clock.enable_auto_clockout', 'True') != 'True': return max_shift = float(ICP.get_param('fusion_clock.max_shift_hours', '12.0')) grace_min = float(ICP.get_param('fusion_clock.grace_period_minutes', '15')) - clock_out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0')) + office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0')) now = fields.Datetime.now() - # Find all open attendances (no check_out) open_attendances = self.sudo().search([ ('check_out', '=', False), ]) + ActivityLog = self.env['fusion.clock.activity.log'].sudo() + for att in open_attendances: check_in = att.check_in if not check_in: continue - # Calculate the scheduled end + grace for this attendance - check_in_date = check_in.date() - out_h = int(clock_out_hour) - out_m = int((clock_out_hour - out_h) * 60) - scheduled_end = datetime.combine( - check_in_date, - datetime.min.time().replace(hour=out_h, minute=out_m), - ) - deadline = scheduled_end + timedelta(minutes=grace_min) + employee = att.employee_id + _, scheduled_out = employee._get_fclk_scheduled_times(check_in.date()) - # Also check max shift safety net + deadline = scheduled_out + timedelta(minutes=grace_min) max_deadline = check_in + timedelta(hours=max_shift) - - # Use the earlier of the two deadlines effective_deadline = min(deadline, max_deadline) if now > effective_deadline: - # Auto clock-out at the deadline time (not now) clock_out_time = min(effective_deadline, now) try: att.sudo().write({ @@ -128,19 +163,42 @@ class HrAttendance(models.Model): }) # Apply break deduction - employee = att.employee_id - threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '5.0')) + threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0')) if (att.worked_hours or 0) >= threshold: break_min = employee._get_fclk_break_minutes() att.sudo().write({'x_fclk_break_minutes': break_min}) - # Post chatter message att.sudo().message_post( body=f"Auto clocked out at {clock_out_time.strftime('%H:%M')} " f"(grace period expired). Net hours: {att.x_fclk_net_hours:.1f}h", message_type='comment', subtype_xmlid='mail.mt_note', ) + + # Log to activity log + ActivityLog.create({ + 'employee_id': employee.id, + 'log_type': 'auto_clock_out', + 'description': f"Auto clocked out at {clock_out_time.strftime('%H:%M')}. " + f"Net hours: {att.x_fclk_net_hours:.1f}h", + 'attendance_id': att.id, + 'location_id': att.x_fclk_location_id.id if att.x_fclk_location_id else False, + 'source': 'system', + }) + + # Set pending reason + employee.sudo().write({'x_fclk_pending_reason': True}) + + # Notify office user + self._fclk_notify_office( + office_user_id, + f"Auto Clock-Out: {employee.name}", + f"{employee.name} was auto-clocked out at {clock_out_time.strftime('%H:%M')}. " + f"Please review and correct if needed.", + 'hr.attendance', + att.id, + ) + _logger.info( "Fusion Clock: Auto clocked out %s (attendance %s)", employee.name, att.id, @@ -150,3 +208,242 @@ class HrAttendance(models.Model): "Fusion Clock: Failed to auto clock-out attendance %s: %s", att.id, str(e), ) + + @api.model + def _cron_fusion_check_absences(self): + """Cron job: check for absent employees (no attendance on workday).""" + ICP = self.env['ir.config_parameter'].sudo() + office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0')) + max_absences = int(ICP.get_param('fusion_clock.max_monthly_absences', '3')) + + yesterday = fields.Date.today() - timedelta(days=1) + + # Skip weekends + if yesterday.weekday() >= 5: + return + + # Skip public holidays + holidays = self.env['resource.calendar.leaves'].sudo().search([ + ('resource_id', '=', False), + ('date_from', '<=', datetime.combine(yesterday, datetime.max.time())), + ('date_to', '>=', datetime.combine(yesterday, datetime.min.time())), + ]) + if holidays: + return + + employees = self.env['hr.employee'].sudo().search([ + ('x_fclk_enable_clock', '=', True), + ]) + + ActivityLog = self.env['fusion.clock.activity.log'].sudo() + LeaveRequest = self.env['fusion.clock.leave.request'].sudo() + + for emp in employees: + # Check for attendance yesterday + att_count = self.sudo().search_count([ + ('employee_id', '=', emp.id), + ('check_in', '>=', datetime.combine(yesterday, datetime.min.time())), + ('check_in', '<', datetime.combine(yesterday + timedelta(days=1), datetime.min.time())), + ]) + if att_count > 0: + continue + + # Check for approved leave + leave = LeaveRequest.search([ + ('employee_id', '=', emp.id), + ('leave_date', '=', yesterday), + ], limit=1) + if leave: + continue + + # Mark absent + ActivityLog.create({ + 'employee_id': emp.id, + 'log_type': 'absent', + 'log_date': datetime.combine(yesterday, datetime.min.time().replace(hour=9)), + 'description': f"No attendance recorded for {yesterday}", + 'source': 'system', + }) + + emp.sudo().write({'x_fclk_pending_reason': True}) + + # Check monthly threshold + month_start = yesterday.replace(day=1) + absence_count = ActivityLog.search_count([ + ('employee_id', '=', emp.id), + ('log_type', '=', 'absent'), + ('log_date', '>=', datetime.combine(month_start, datetime.min.time())), + ]) + + if absence_count >= max_absences: + self._fclk_notify_office( + office_user_id, + f"Excessive Absences: {emp.name}", + f"{emp.name} has {absence_count} absences this month " + f"(threshold: {max_absences}). Please review.", + 'hr.employee', + emp.id, + ) + + _logger.info("Fusion Clock: Marked %s as absent for %s", emp.name, yesterday) + + @api.model + def _cron_fusion_employee_reminders(self): + """Cron job: send clock-in/out reminders to employees.""" + ICP = self.env['ir.config_parameter'].sudo() + if ICP.get_param('fusion_clock.enable_employee_notifications', 'True') != 'True': + return + + reminder_in_min = float(ICP.get_param('fusion_clock.reminder_before_shift_minutes', '30')) + reminder_out_min = float(ICP.get_param('fusion_clock.reminder_before_end_minutes', '15')) + + now = fields.Datetime.now() + today = fields.Date.today() + + # Skip weekends + if today.weekday() >= 5: + return + + employees = self.env['hr.employee'].sudo().search([ + ('x_fclk_enable_clock', '=', True), + ]) + + for emp in employees: + if emp.x_fclk_last_reminder_date == today: + continue + + scheduled_in, scheduled_out = emp._get_fclk_scheduled_times(today) + is_checked_in = emp.attendance_state == 'checked_in' + + # Missed clock-in reminder + reminder_deadline = scheduled_in + timedelta(minutes=reminder_in_min) + if not is_checked_in and now > reminder_deadline: + has_attendance = self.sudo().search_count([ + ('employee_id', '=', emp.id), + ('check_in', '>=', datetime.combine(today, datetime.min.time())), + ]) + if has_attendance == 0: + self._fclk_send_employee_reminder( + emp, + "Clock-In Reminder", + f"Hi {emp.name}, you haven't clocked in yet today. " + f"Your shift started at {scheduled_in.strftime('%I:%M %p')}.", + ) + emp.sudo().write({'x_fclk_last_reminder_date': today}) + + # Clock-out reminder + reminder_before_end = scheduled_out - timedelta(minutes=reminder_out_min) + if is_checked_in and now > reminder_before_end and now < scheduled_out: + self._fclk_send_employee_reminder( + emp, + "Clock-Out Reminder", + f"Hi {emp.name}, your shift ends at {scheduled_out.strftime('%I:%M %p')}. " + f"Don't forget to clock out.", + ) + emp.sudo().write({'x_fclk_last_reminder_date': today}) + + @api.model + def _cron_fusion_weekly_summary(self): + """Cron job: send weekly summary email to employees (Monday 8 AM).""" + ICP = self.env['ir.config_parameter'].sudo() + if ICP.get_param('fusion_clock.send_weekly_summary', 'True') != 'True': + return + + today = fields.Date.today() + if today.weekday() != 0: + return + + week_start = today - timedelta(days=7) + week_end = today - timedelta(days=1) + + employees = self.env['hr.employee'].sudo().search([ + ('x_fclk_enable_clock', '=', True), + ]) + + template = self.env.ref('fusion_clock.mail_template_weekly_summary', raise_if_not_found=False) + + for emp in employees: + if not emp.work_email: + continue + + atts = self.sudo().search([ + ('employee_id', '=', emp.id), + ('check_in', '>=', datetime.combine(week_start, datetime.min.time())), + ('check_in', '<', datetime.combine(week_end + timedelta(days=1), datetime.min.time())), + ('check_out', '!=', False), + ]) + + total_net = sum(a.x_fclk_net_hours or 0 for a in atts) + total_ot = sum(a.x_fclk_overtime_hours or 0 for a in atts) + penalties = self.env['fusion.clock.penalty'].sudo().search_count([ + ('employee_id', '=', emp.id), + ('date', '>=', week_start), + ('date', '<=', week_end), + ]) + + ActivityLog = self.env['fusion.clock.activity.log'].sudo() + absences = ActivityLog.search_count([ + ('employee_id', '=', emp.id), + ('log_type', '=', 'absent'), + ('log_date', '>=', datetime.combine(week_start, datetime.min.time())), + ('log_date', '<', datetime.combine(week_end + timedelta(days=1), datetime.min.time())), + ]) + + if template: + try: + template.with_context( + week_start=week_start, + week_end=week_end, + total_hours=round(total_net, 1), + overtime_hours=round(total_ot, 1), + penalty_count=penalties, + absence_count=absences, + streak=emp.x_fclk_ontime_streak, + ).send_mail(emp.id, force_send=False) + except Exception as e: + _logger.error("Fusion Clock: Failed to send weekly summary to %s: %s", emp.name, e) + + @api.model + def _fclk_notify_office(self, office_user_id, summary, note, res_model, res_id): + """Create a mail.activity for the office user.""" + if not office_user_id: + return + office_user = self.env['res.users'].sudo().browse(office_user_id) + if not office_user.exists(): + return + try: + self.env['mail.activity'].sudo().create({ + 'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id, + 'summary': summary, + 'note': note, + 'user_id': office_user_id, + 'res_model_id': self.env['ir.model']._get_id(res_model), + 'res_id': res_id, + 'date_deadline': fields.Date.today(), + }) + except Exception as e: + _logger.error("Fusion Clock: Failed to create office activity: %s", e) + + @api.model + def _fclk_send_employee_reminder(self, employee, subject, body): + """Send a notification to an employee via internal note.""" + try: + if employee.user_id: + employee.user_id.sudo().notify_info( + message=body, + title=subject, + sticky=False, + ) + except Exception: + pass + try: + if employee.work_email: + mail_values = { + 'subject': f"Fusion Clock: {subject}", + 'body_html': f"

{body}

", + 'email_to': employee.work_email, + 'auto_delete': True, + } + self.env['mail.mail'].sudo().create(mail_values).send() + except Exception as e: + _logger.error("Fusion Clock: Failed to send reminder to %s: %s", employee.name, e) diff --git a/fusion_clock/models/hr_employee.py b/fusion_clock/models/hr_employee.py index 912a1f08..de174e91 100644 --- a/fusion_clock/models/hr_employee.py +++ b/fusion_clock/models/hr_employee.py @@ -2,6 +2,7 @@ # Copyright 2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) +from datetime import datetime, timedelta from odoo import models, fields, api @@ -21,16 +22,167 @@ class HrEmployee(models.Model): x_fclk_break_minutes = fields.Float( string='Custom Break (min)', default=0.0, - help="Override default break duration for this employee. 0 = use company default.", + help="Override default break duration for this employee. 0 = use shift or company default.", + ) + + # Shift scheduling + x_fclk_shift_id = fields.Many2one( + 'fusion.clock.shift', + string='Work Shift', + help="Assigned shift schedule. Leave empty to use global defaults.", + ) + + # Pending reason enforcement + x_fclk_pending_reason = fields.Boolean( + string='Pending Reason Required', + default=False, + help="If set, employee must explain a missed clock-out before clocking in again.", + ) + + # Kiosk PIN + x_fclk_kiosk_pin = fields.Char( + string='Kiosk PIN', + help="PIN code for kiosk clock-in/out identification.", + groups="fusion_clock.group_fusion_clock_manager", + ) + + # On-time streak + x_fclk_ontime_streak = fields.Integer( + string='On-Time Streak', + default=0, + help="Consecutive workdays clocked in on time.", + ) + + # Absence tracking (computed) + x_fclk_absences_this_month = fields.Integer( + string='Absences This Month', + compute='_compute_absence_counts', + ) + x_fclk_absences_this_year = fields.Integer( + string='Absences This Year', + compute='_compute_absence_counts', + ) + + # Overtime tracking (computed) + x_fclk_overtime_this_week = fields.Float( + string='Overtime This Week (h)', + compute='_compute_overtime', + ) + x_fclk_overtime_this_month = fields.Float( + string='Overtime This Month (h)', + compute='_compute_overtime', + ) + + # Activity log relation + x_fclk_activity_log_ids = fields.One2many( + 'fusion.clock.activity.log', + 'employee_id', + string='Activity Logs', + ) + + # Leave request relation + x_fclk_leave_request_ids = fields.One2many( + 'fusion.clock.leave.request', + 'employee_id', + string='Leave Requests', + ) + + # Correction request relation + x_fclk_correction_ids = fields.One2many( + 'fusion.clock.correction', + 'employee_id', + string='Correction Requests', + ) + + # Reminder tracking + x_fclk_last_reminder_date = fields.Date( + string='Last Reminder Date', + help="Tracks the last date a reminder was sent to avoid duplicates.", ) def _get_fclk_break_minutes(self): - """Return effective break minutes for this employee.""" + """Return effective break minutes for this employee. + Priority: employee override > shift > global setting. + """ self.ensure_one() if self.x_fclk_break_minutes > 0: return self.x_fclk_break_minutes + if self.x_fclk_shift_id and self.x_fclk_shift_id.break_minutes > 0: + return self.x_fclk_shift_id.break_minutes return float( self.env['ir.config_parameter'].sudo().get_param( 'fusion_clock.default_break_minutes', '30' ) ) + + def _get_fclk_scheduled_times(self, date): + """Return (scheduled_in_dt, scheduled_out_dt) for a given date. + Uses employee shift if assigned, otherwise global settings. + """ + self.ensure_one() + if self.x_fclk_shift_id: + in_hour = self.x_fclk_shift_id.start_time + out_hour = self.x_fclk_shift_id.end_time + else: + ICP = self.env['ir.config_parameter'].sudo() + in_hour = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0')) + out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0')) + + in_h = int(in_hour) + in_m = int((in_hour - in_h) * 60) + out_h = int(out_hour) + out_m = int((out_hour - out_h) * 60) + + scheduled_in = datetime.combine(date, datetime.min.time().replace(hour=in_h, minute=in_m)) + scheduled_out = datetime.combine(date, datetime.min.time().replace(hour=out_h, minute=out_m)) + return scheduled_in, scheduled_out + + def _get_fclk_scheduled_hours(self): + """Return the expected work hours for this employee's shift.""" + self.ensure_one() + if self.x_fclk_shift_id: + return self.x_fclk_shift_id.scheduled_hours + ICP = self.env['ir.config_parameter'].sudo() + in_hour = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0')) + out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0')) + break_hrs = self._get_fclk_break_minutes() / 60.0 + return max((out_hour - in_hour) - break_hrs, 0.0) + + def _compute_absence_counts(self): + ActivityLog = self.env['fusion.clock.activity.log'].sudo() + today = fields.Date.today() + month_start = today.replace(day=1) + year_start = today.replace(month=1, day=1) + + for emp in self: + emp.x_fclk_absences_this_month = ActivityLog.search_count([ + ('employee_id', '=', emp.id), + ('log_type', '=', 'absent'), + ('log_date', '>=', datetime.combine(month_start, datetime.min.time())), + ]) + emp.x_fclk_absences_this_year = ActivityLog.search_count([ + ('employee_id', '=', emp.id), + ('log_type', '=', 'absent'), + ('log_date', '>=', datetime.combine(year_start, datetime.min.time())), + ]) + + def _compute_overtime(self): + Attendance = self.env['hr.attendance'].sudo() + today = fields.Date.today() + week_start = today - timedelta(days=today.weekday()) + month_start = today.replace(day=1) + + for emp in self: + week_atts = Attendance.search([ + ('employee_id', '=', emp.id), + ('check_in', '>=', datetime.combine(week_start, datetime.min.time())), + ('check_out', '!=', False), + ]) + emp.x_fclk_overtime_this_week = sum(a.x_fclk_overtime_hours or 0 for a in week_atts) + + month_atts = Attendance.search([ + ('employee_id', '=', emp.id), + ('check_in', '>=', datetime.combine(month_start, datetime.min.time())), + ('check_out', '!=', False), + ]) + emp.x_fclk_overtime_this_month = sum(a.x_fclk_overtime_hours or 0 for a in month_atts) diff --git a/fusion_clock/models/res_config_settings.py b/fusion_clock/models/res_config_settings.py index c9c6af78..bab10f6c 100644 --- a/fusion_clock/models/res_config_settings.py +++ b/fusion_clock/models/res_config_settings.py @@ -38,7 +38,7 @@ class ResConfigSettings(models.TransientModel): fclk_break_threshold_hours = fields.Float( string='Break Threshold (hours)', config_parameter='fusion_clock.break_threshold_hours', - default=5.0, + default=4.0, help="Only deduct break if shift is longer than this many hours.", ) @@ -73,6 +73,116 @@ class ResConfigSettings(models.TransientModel): default=5.0, help="Minutes of grace before a late/early penalty is recorded.", ) + fclk_penalty_deduction_minutes = fields.Float( + string='Penalty Deduction (min)', + config_parameter='fusion_clock.penalty_deduction_minutes', + default=15.0, + help="Minutes deducted from worked hours per penalty occurrence.", + ) + + # -- Office User & Notifications -- + fclk_office_user_id = fields.Many2one( + 'res.users', + string='Office User', + help="User who receives activity notifications for attendance issues.", + ) + fclk_very_late_threshold_minutes = fields.Float( + string='Very Late Threshold (min)', + config_parameter='fusion_clock.very_late_threshold_minutes', + default=15.0, + help="Minutes late before an activity is scheduled for the office user.", + ) + fclk_max_monthly_absences = fields.Integer( + string='Max Monthly Absences', + config_parameter='fusion_clock.max_monthly_absences', + default=3, + help="Alert office user when an employee reaches this many absences in a month.", + ) + fclk_enable_employee_notifications = fields.Boolean( + string='Enable Employee Notifications', + config_parameter='fusion_clock.enable_employee_notifications', + default=True, + help="Send clock-in/out reminders to employees.", + ) + fclk_reminder_before_shift_minutes = fields.Float( + string='Remind After Shift Start (min)', + config_parameter='fusion_clock.reminder_before_shift_minutes', + default=30.0, + help="Send reminder if employee hasn't clocked in this many minutes after shift start.", + ) + fclk_reminder_before_end_minutes = fields.Float( + string='Remind Before Shift End (min)', + config_parameter='fusion_clock.reminder_before_end_minutes', + default=15.0, + help="Send clock-out reminder this many minutes before shift end.", + ) + fclk_send_weekly_summary = fields.Boolean( + string='Send Weekly Summary', + config_parameter='fusion_clock.send_weekly_summary', + default=True, + help="Send weekly attendance summary to each employee on Monday.", + ) + + # -- Overtime -- + fclk_enable_overtime = fields.Boolean( + string='Enable Overtime Tracking', + config_parameter='fusion_clock.enable_overtime', + default=True, + ) + fclk_daily_overtime_threshold = fields.Float( + string='Daily OT Threshold (hours)', + config_parameter='fusion_clock.daily_overtime_threshold', + default=8.0, + help="Net hours beyond this threshold count as daily overtime.", + ) + fclk_weekly_overtime_threshold = fields.Float( + string='Weekly OT Threshold (hours)', + config_parameter='fusion_clock.weekly_overtime_threshold', + default=40.0, + help="Net hours beyond this threshold count as weekly overtime.", + ) + + # -- Location -- + fclk_enable_ip_fallback = fields.Boolean( + string='Enable IP Fallback', + config_parameter='fusion_clock.enable_ip_fallback', + default=False, + help="Allow IP-based location verification when GPS is unavailable.", + ) + fclk_enable_photo_verification = fields.Boolean( + string='Enable Photo Verification', + config_parameter='fusion_clock.enable_photo_verification', + default=False, + help="Global toggle for selfie verification on clock-in (per-location control).", + ) + + # -- Kiosk -- + fclk_enable_kiosk = fields.Boolean( + string='Enable Kiosk Mode', + config_parameter='fusion_clock.enable_kiosk', + default=False, + ) + fclk_kiosk_pin_required = fields.Boolean( + string='Require PIN for Kiosk', + config_parameter='fusion_clock.kiosk_pin_required', + default=True, + help="Require employees to enter a PIN when using kiosk mode.", + ) + + # -- Corrections -- + fclk_enable_correction_requests = fields.Boolean( + string='Enable Correction Requests', + config_parameter='fusion_clock.enable_correction_requests', + default=True, + help="Allow employees to request timesheet corrections from the portal.", + ) + + # -- CSV Export -- + fclk_csv_column_mapping = fields.Char( + string='CSV Column Mapping', + config_parameter='fusion_clock.csv_column_mapping', + help="Custom column names for CSV export (JSON format). Leave blank for defaults.", + ) # -- Pay Period -- fclk_pay_period_type = fields.Selection( @@ -89,7 +199,7 @@ class ResConfigSettings(models.TransientModel): fclk_pay_period_start = fields.Char( string='Pay Period Anchor Date', config_parameter='fusion_clock.pay_period_start', - help="Start date for pay period calculations (YYYY-MM-DD format, anchor for weekly/biweekly).", + help="Start date for pay period calculations (YYYY-MM-DD format).", ) # -- Reports -- @@ -122,3 +232,20 @@ class ResConfigSettings(models.TransientModel): config_parameter='fusion_clock.enable_sounds', default=True, ) + + def set_values(self): + super().set_values() + ICP = self.env['ir.config_parameter'].sudo() + if self.fclk_office_user_id: + ICP.set_param('fusion_clock.office_user_id', str(self.fclk_office_user_id.id)) + else: + ICP.set_param('fusion_clock.office_user_id', '0') + + @api.model + def get_values(self): + res = super().get_values() + ICP = self.env['ir.config_parameter'].sudo() + office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0')) + if office_user_id: + res['fclk_office_user_id'] = office_user_id + return res diff --git a/fusion_clock/security/ir.model.access.csv b/fusion_clock/security/ir.model.access.csv index 4254739c..657c8976 100644 --- a/fusion_clock/security/ir.model.access.csv +++ b/fusion_clock/security/ir.model.access.csv @@ -5,8 +5,20 @@ access_fusion_clock_penalty_user,fusion.clock.penalty.user,model_fusion_clock_pe access_fusion_clock_penalty_manager,fusion.clock.penalty.manager,model_fusion_clock_penalty,group_fusion_clock_manager,1,1,1,1 access_fusion_clock_report_user,fusion.clock.report.user,model_fusion_clock_report,group_fusion_clock_user,1,0,0,0 access_fusion_clock_report_manager,fusion.clock.report.manager,model_fusion_clock_report,group_fusion_clock_manager,1,1,1,1 +access_fusion_clock_activity_log_user,fusion.clock.activity.log.user,model_fusion_clock_activity_log,group_fusion_clock_user,1,0,0,0 +access_fusion_clock_activity_log_manager,fusion.clock.activity.log.manager,model_fusion_clock_activity_log,group_fusion_clock_manager,1,1,1,1 +access_fusion_clock_leave_request_user,fusion.clock.leave.request.user,model_fusion_clock_leave_request,group_fusion_clock_user,1,0,0,0 +access_fusion_clock_leave_request_manager,fusion.clock.leave.request.manager,model_fusion_clock_leave_request,group_fusion_clock_manager,1,1,1,1 +access_fusion_clock_shift_user,fusion.clock.shift.user,model_fusion_clock_shift,group_fusion_clock_user,1,0,0,0 +access_fusion_clock_shift_manager,fusion.clock.shift.manager,model_fusion_clock_shift,group_fusion_clock_manager,1,1,1,1 +access_fusion_clock_correction_user,fusion.clock.correction.user,model_fusion_clock_correction,group_fusion_clock_user,1,0,0,0 +access_fusion_clock_correction_manager,fusion.clock.correction.manager,model_fusion_clock_correction,group_fusion_clock_manager,1,1,1,1 access_fusion_clock_location_portal,fusion.clock.location.portal,model_fusion_clock_location,base.group_portal,1,0,0,0 access_fusion_clock_penalty_portal,fusion.clock.penalty.portal,model_fusion_clock_penalty,base.group_portal,1,0,0,0 access_fusion_clock_report_portal,fusion.clock.report.portal,model_fusion_clock_report,base.group_portal,1,0,0,0 +access_fusion_clock_activity_log_portal,fusion.clock.activity.log.portal,model_fusion_clock_activity_log,base.group_portal,1,0,0,0 +access_fusion_clock_leave_request_portal,fusion.clock.leave.request.portal,model_fusion_clock_leave_request,base.group_portal,1,0,0,0 +access_fusion_clock_correction_portal,fusion.clock.correction.portal,model_fusion_clock_correction,base.group_portal,1,0,0,0 access_hr_attendance_portal,hr.attendance.portal,hr_attendance.model_hr_attendance,base.group_portal,1,0,0,0 access_hr_employee_portal_clock,hr.employee.portal.clock,hr.model_hr_employee,base.group_portal,1,0,0,0 +access_fusion_clock_shift_portal,fusion.clock.shift.portal,model_fusion_clock_shift,base.group_portal,1,0,0,0 diff --git a/fusion_clock/security/security.xml b/fusion_clock/security/security.xml index ac329f2d..3eb293e7 100644 --- a/fusion_clock/security/security.xml +++ b/fusion_clock/security/security.xml @@ -8,20 +8,27 @@ Can clock in/out and view own attendance + + Fusion Clock / Team Lead + + Can view direct reports attendance (read-only) + + Fusion Clock / Manager - + Can manage locations, view all attendance, generate reports - - - + + + + - - - + Clock Location: User sees active company locations @@ -40,7 +47,9 @@ - + Clock Penalty: User sees own penalties @@ -52,6 +61,17 @@ + + Clock Penalty: Team Lead sees direct reports + + ['|', ('employee_id.user_id', '=', user.id), ('employee_id.parent_id.user_id', '=', user.id)] + + + + + + + Clock Penalty: Manager full access @@ -59,7 +79,9 @@ - + Clock Report: User sees own reports @@ -78,7 +100,115 @@ - + + + Activity Log: User sees own logs + + [('employee_id.user_id', '=', user.id)] + + + + + + + + + Activity Log: Team Lead sees direct reports + + ['|', ('employee_id.user_id', '=', user.id), ('employee_id.parent_id.user_id', '=', user.id)] + + + + + + + + + Activity Log: Manager full access + + [(1, '=', 1)] + + + + + + Leave Request: User sees own + + [('employee_id.user_id', '=', user.id)] + + + + + + + + + Leave Request: Manager full access + + [(1, '=', 1)] + + + + + + Shift: User reads active + + [('active', '=', True)] + + + + + + + + + Shift: Manager full access + + [(1, '=', 1)] + + + + + + Correction: User sees own + + [('employee_id.user_id', '=', user.id)] + + + + + + + + + Correction: Team Lead sees direct reports + + ['|', ('employee_id.user_id', '=', user.id), ('employee_id.parent_id.user_id', '=', user.id)] + + + + + + + + + Correction: Manager full access + + [(1, '=', 1)] + + + + HR Attendance: Portal user sees own @@ -90,7 +220,6 @@ - Clock Location: Portal user sees active @@ -102,7 +231,6 @@ - Clock Report: Portal user sees own @@ -114,7 +242,6 @@ - Clock Penalty: Portal user sees own @@ -126,4 +253,37 @@ + + Activity Log: Portal user sees own + + [('employee_id.user_id', '=', user.id)] + + + + + + + + + Leave Request: Portal user sees own + + [('employee_id.user_id', '=', user.id)] + + + + + + + + + Correction: Portal user sees own + + [('employee_id.user_id', '=', user.id)] + + + + + + + diff --git a/fusion_clock/static/src/css/portal_clock.css b/fusion_clock/static/src/css/portal_clock.css index c54654c0..ded64ab9 100644 --- a/fusion_clock/static/src/css/portal_clock.css +++ b/fusion_clock/static/src/css/portal_clock.css @@ -357,6 +357,52 @@ html.o_dark .fclk-app, font-size: 12px; } +/* ---- Request Leave Button ---- */ +.fclk-leave-btn { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + background: var(--fclk-card); + border: 1px solid var(--fclk-card-border); + border-radius: 14px; + padding: 16px 20px; + margin-bottom: 28px; + color: var(--fclk-text); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: var(--fclk-shadow); + text-align: left; + font-family: inherit; +} + +.fclk-leave-btn svg:first-child { + color: var(--fclk-green); + flex-shrink: 0; +} + +.fclk-leave-btn-arrow { + margin-left: auto; + color: var(--fclk-text-dim); + flex-shrink: 0; + transition: transform 0.2s ease; +} + +.fclk-leave-btn:hover { + background: var(--fclk-hover-bg); + border-color: rgba(16, 185, 129, 0.3); +} + +.fclk-leave-btn:hover .fclk-leave-btn-arrow { + transform: translateX(2px); +} + +.fclk-leave-btn:active { + transform: scale(0.99); +} + /* ---- Recent Activity ---- */ .fclk-recent-section { margin-bottom: 24px; @@ -486,7 +532,7 @@ html.o_dark .fclk-app, text-decoration: none; } -/* ---- Modal ---- */ +/* ---- Legacy Modal (location picker still uses this) ---- */ .fclk-modal { position: fixed; top: 0; @@ -533,6 +579,300 @@ html.o_dark .fclk-app, to { transform: translateY(0); } } +/* ============================================================ + Wizard Dialogs - Professional modals for reasons, confirmations + Theme-aware, works in both light and dark mode + ============================================================ */ + +/* Standalone fallbacks for wizard modals rendered outside .fclk-app */ +.fclk-wizard-overlay { + --fclk-card: var(--fclk-card, #ffffff); + --fclk-card-border: var(--fclk-card-border, #e5e7eb); + --fclk-bg: var(--fclk-bg, #f3f4f6); + --fclk-text: var(--fclk-text, #1f2937); + --fclk-text-muted: var(--fclk-text-muted, #6b7280); + --fclk-text-dim: var(--fclk-text-dim, #9ca3af); + --fclk-green: var(--fclk-green, #10B981); + --fclk-green-glow: var(--fclk-green-glow, rgba(16, 185, 129, 0.25)); + --fclk-hover-bg: var(--fclk-hover-bg, #f9fafb); + + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 300; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +@media (prefers-color-scheme: dark) { + .fclk-wizard-overlay { + --fclk-card: #1a1d23; + --fclk-card-border: #2a2d35; + --fclk-bg: #0f1117; + --fclk-text: #ffffff; + --fclk-text-muted: #9ca3af; + --fclk-text-dim: #6b7280; + --fclk-green-glow: rgba(16, 185, 129, 0.3); + --fclk-hover-bg: #1e2128; + } +} + +html.o_dark .fclk-wizard-overlay { + --fclk-card: #1a1d23; + --fclk-card-border: #2a2d35; + --fclk-bg: #0f1117; + --fclk-text: #ffffff; + --fclk-text-muted: #9ca3af; + --fclk-text-dim: #6b7280; + --fclk-green-glow: rgba(16, 185, 129, 0.3); + --fclk-hover-bg: #1e2128; +} + +.fclk-wizard-backdrop { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} + +.fclk-wizard-dialog { + position: relative; + background: var(--fclk-card); + border: 1px solid var(--fclk-card-border); + border-radius: 20px; + width: 100%; + max-width: 440px; + max-height: 85vh; + overflow-y: auto; + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(255, 255, 255, 0.05); + animation: fclk-wizard-enter 0.3s cubic-bezier(0.32, 0.72, 0, 1); +} + +.fclk-wizard-dialog--compact { + max-width: 380px; +} + +@keyframes fclk-wizard-enter { + from { + opacity: 0; + transform: scale(0.95) translateY(8px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.fclk-wizard-header { + padding: 28px 24px 20px; + text-align: center; + border-bottom: 1px solid var(--fclk-card-border); +} + +.fclk-wizard-header-icon { + width: 56px; + height: 56px; + border-radius: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 16px; +} + +.fclk-wizard-header--warning .fclk-wizard-header-icon { + background: rgba(245, 158, 11, 0.12); + color: #f59e0b; +} + +.fclk-wizard-header--danger .fclk-wizard-header-icon { + background: rgba(239, 68, 68, 0.12); + color: #ef4444; +} + +.fclk-wizard-header--info .fclk-wizard-header-icon { + background: rgba(59, 130, 246, 0.12); + color: #3b82f6; +} + +.fclk-wizard-title { + color: var(--fclk-text); + font-size: 20px; + font-weight: 700; + margin: 0 0 6px; + letter-spacing: -0.3px; +} + +.fclk-wizard-subtitle { + color: var(--fclk-text-muted); + font-size: 13px; + line-height: 1.5; + margin: 0; +} + +.fclk-wizard-body { + padding: 24px; +} + +.fclk-wizard-field { + margin-bottom: 20px; +} + +.fclk-wizard-field:last-child { + margin-bottom: 0; +} + +.fclk-wizard-label { + display: flex; + align-items: center; + gap: 6px; + color: var(--fclk-text); + font-size: 13px; + font-weight: 600; + margin-bottom: 8px; +} + +.fclk-wizard-label svg { + color: var(--fclk-text-muted); + flex-shrink: 0; +} + +.fclk-wizard-required { + color: #ef4444; + font-weight: 700; +} + +.fclk-wizard-input { + width: 100%; + background: var(--fclk-bg); + border: 1.5px solid var(--fclk-card-border); + border-radius: 12px; + padding: 12px 14px; + font-size: 14px; + color: var(--fclk-text); + transition: border-color 0.2s, box-shadow 0.2s; + outline: none; + font-family: inherit; +} + +.fclk-wizard-input:focus { + border-color: var(--fclk-green); + box-shadow: 0 0 0 3px var(--fclk-green-glow); +} + +.fclk-wizard-input::placeholder { + color: var(--fclk-text-dim); +} + +.fclk-wizard-textarea { + resize: vertical; + min-height: 80px; +} + +.fclk-wizard-hint { + display: block; + color: var(--fclk-text-dim); + font-size: 11px; + margin-top: 6px; +} + +.fclk-wizard-footer { + padding: 16px 24px 20px; + display: flex; + gap: 10px; + justify-content: flex-end; + border-top: 1px solid var(--fclk-card-border); +} + +.fclk-wizard-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 10px 20px; + border-radius: 12px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + border: none; + transition: all 0.2s ease; + letter-spacing: 0.2px; +} + +.fclk-wizard-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.fclk-wizard-btn--primary { + background: linear-gradient(135deg, #10B981, #059669); + color: #fff; + box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3); +} + +.fclk-wizard-btn--primary:hover:not(:disabled) { + box-shadow: 0 4px 16px rgba(16, 185, 129, 0.4); + transform: translateY(-1px); +} + +.fclk-wizard-btn--danger { + background: linear-gradient(135deg, #ef4444, #dc2626); + color: #fff; + box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3); +} + +.fclk-wizard-btn--danger:hover:not(:disabled) { + box-shadow: 0 4px 16px rgba(239, 68, 68, 0.4); + transform: translateY(-1px); +} + +.fclk-wizard-btn--secondary { + background: var(--fclk-bg); + color: var(--fclk-text-muted); + border: 1px solid var(--fclk-card-border); +} + +.fclk-wizard-btn--secondary:hover:not(:disabled) { + background: var(--fclk-hover-bg); + color: var(--fclk-text); +} + +/* Clock-out confirmation summary card */ +.fclk-clockout-summary { + background: var(--fclk-bg); + border: 1px solid var(--fclk-card-border); + border-radius: 12px; + padding: 16px; +} + +.fclk-clockout-summary-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; +} + +.fclk-clockout-summary-row + .fclk-clockout-summary-row { + border-top: 1px solid var(--fclk-card-border); +} + +.fclk-clockout-summary-label { + color: var(--fclk-text-muted); + font-size: 13px; +} + +.fclk-clockout-summary-value { + color: var(--fclk-text); + font-size: 14px; + font-weight: 600; +} + .fclk-modal-list { display: flex; flex-direction: column; diff --git a/fusion_clock/static/src/js/fusion_clock_dashboard.js b/fusion_clock/static/src/js/fusion_clock_dashboard.js new file mode 100644 index 00000000..46dd29b8 --- /dev/null +++ b/fusion_clock/static/src/js/fusion_clock_dashboard.js @@ -0,0 +1,67 @@ +/** @odoo-module **/ + +import { Component, useState, onWillStart } from "@odoo/owl"; +import { rpc } from "@web/core/network/rpc"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; + +export class FusionClockDashboard extends Component { + static template = "fusion_clock.Dashboard"; + static props = { "*": true }; + + setup() { + this.action = useService("action"); + this.state = useState({ + loading: true, + clocked_in: [], + total_employees: 0, + present_count: 0, + absent_count: 0, + late_count: 0, + pending_reasons: 0, + pending_corrections: 0, + error: "", + }); + + onWillStart(async () => { + await this._fetchData(); + }); + } + + async _fetchData() { + this.state.loading = true; + try { + const data = await rpc("/fusion_clock/dashboard_data", {}); + if (data.error) { + this.state.error = data.error; + } else { + Object.assign(this.state, data); + } + } catch (e) { + this.state.error = "Failed to load dashboard data."; + } + this.state.loading = false; + } + + async onRefresh() { + await this._fetchData(); + } + + onViewAttendances() { + this.action.doAction("hr_attendance.hr_attendance_action"); + } + + onViewCorrections() { + this.action.doAction("fusion_clock.action_fusion_clock_correction"); + } + + onViewActivityLogs() { + this.action.doAction("fusion_clock.action_fusion_clock_activity_log"); + } + + onViewPenalties() { + this.action.doAction("fusion_clock.action_fusion_clock_penalty"); + } +} + +registry.category("actions").add("fusion_clock.Dashboard", FusionClockDashboard); diff --git a/fusion_clock/static/src/js/fusion_clock_kiosk.js b/fusion_clock/static/src/js/fusion_clock_kiosk.js new file mode 100644 index 00000000..7bb55ab9 --- /dev/null +++ b/fusion_clock/static/src/js/fusion_clock_kiosk.js @@ -0,0 +1,228 @@ +/** @odoo-module **/ + +import { Interaction } from "@web/public/interaction"; +import { registry } from "@web/core/registry"; + +export class FusionClockKiosk extends Interaction { + static selector = "#fclk-kiosk"; + + setup() { + this.selectedEmployeeId = 0; + this.resetTimer = null; + this.searchTimeout = null; + + const pinAttr = this.el.dataset.pinRequired; + this.pinRequired = pinAttr === "true" || pinAttr === "True"; + + this._startClock(); + this._bindEvents(); + } + + _startClock() { + const el = document.getElementById("fclk-kiosk-time"); + if (!el) return; + const update = () => { + el.textContent = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + }; + update(); + setInterval(update, 1000); + } + + _bindEvents() { + const queryInput = document.getElementById("fclk-kiosk-query"); + if (queryInput) { + queryInput.addEventListener("input", (e) => this._onSearch(e.target.value)); + } + + const backBtn = document.getElementById("fclk-kiosk-back-btn"); + if (backBtn) { + backBtn.addEventListener("click", () => this._resetKiosk()); + } + + const clockBtn = document.getElementById("fclk-kiosk-clock-btn"); + if (clockBtn) { + clockBtn.addEventListener("click", () => this._onClock()); + } + } + + _resetKiosk() { + const search = document.getElementById("fclk-kiosk-search"); + const pin = document.getElementById("fclk-kiosk-pin"); + const result = document.getElementById("fclk-kiosk-result"); + const error = document.getElementById("fclk-kiosk-error"); + const query = document.getElementById("fclk-kiosk-query"); + const results = document.getElementById("fclk-kiosk-results"); + const pinInput = document.getElementById("fclk-kiosk-pin-input"); + + if (search) search.style.display = ""; + if (pin) pin.style.display = "none"; + if (result) result.style.display = "none"; + if (error) error.style.display = "none"; + if (query) query.value = ""; + if (results) results.innerHTML = ""; + if (pinInput) pinInput.value = ""; + + this.selectedEmployeeId = 0; + if (this.resetTimer) clearTimeout(this.resetTimer); + } + + _showError(msg) { + const el = document.getElementById("fclk-kiosk-error"); + if (el) { + el.textContent = msg; + el.style.display = ""; + } + } + + _onSearch(value) { + if (this.searchTimeout) clearTimeout(this.searchTimeout); + const q = value.trim(); + if (q.length < 2) { + const container = document.getElementById("fclk-kiosk-results"); + if (container) container.innerHTML = ""; + return; + } + this.searchTimeout = setTimeout(async () => { + try { + const resp = await fetch("/fusion_clock/kiosk/search", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", method: "call", params: { query: q } }), + }); + const data = await resp.json(); + const employees = (data.result || {}).employees || []; + const container = document.getElementById("fclk-kiosk-results"); + if (!container) return; + container.innerHTML = ""; + for (const emp of employees) { + const item = document.createElement("a"); + item.href = "#"; + item.className = "list-group-item list-group-item-action d-flex justify-content-between"; + const statusBadge = emp.is_checked_in ? "bg-success" : "bg-secondary"; + const statusText = emp.is_checked_in ? "In" : "Out"; + item.innerHTML = + `${emp.name} ${emp.department}` + + `${statusText}`; + item.addEventListener("click", (e) => { + e.preventDefault(); + this._selectEmployee(emp); + }); + container.appendChild(item); + } + } catch { + this._showError("Search failed."); + } + }, 300); + } + + _selectEmployee(emp) { + this.selectedEmployeeId = emp.id; + const nameEl = document.getElementById("fclk-kiosk-emp-name"); + if (nameEl) nameEl.textContent = emp.name; + + const searchEl = document.getElementById("fclk-kiosk-search"); + const pinEl = document.getElementById("fclk-kiosk-pin"); + const errorEl = document.getElementById("fclk-kiosk-error"); + if (searchEl) searchEl.style.display = "none"; + if (pinEl) pinEl.style.display = ""; + if (errorEl) errorEl.style.display = "none"; + + const clockBtn = document.getElementById("fclk-kiosk-clock-btn"); + if (clockBtn) { + clockBtn.textContent = emp.is_checked_in ? "Clock Out" : "Clock In"; + clockBtn.className = "btn btn-lg " + (emp.is_checked_in ? "btn-danger" : "btn-success"); + } + } + + async _onClock() { + if (!this.selectedEmployeeId) return; + + const btn = document.getElementById("fclk-kiosk-clock-btn"); + if (btn) btn.disabled = true; + + const pinInput = document.getElementById("fclk-kiosk-pin-input"); + const pin = pinInput ? pinInput.value : ""; + + if (this.pinRequired && pin.length === 0) { + this._showError("Please enter your PIN."); + if (btn) btn.disabled = false; + return; + } + + try { + if (this.pinRequired) { + const vResp = await fetch("/fusion_clock/kiosk/verify_pin", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "call", + params: { employee_id: this.selectedEmployeeId, pin }, + }), + }); + const vData = await vResp.json(); + if (vData.result && vData.result.error) { + this._showError(vData.result.error); + if (btn) btn.disabled = false; + return; + } + } + + let lat = 0; + let lng = 0; + try { + const pos = await new Promise((resolve, reject) => { + navigator.geolocation.getCurrentPosition(resolve, reject, { + timeout: 10000, + enableHighAccuracy: true, + }); + }); + lat = pos.coords.latitude; + lng = pos.coords.longitude; + } catch { + // GPS unavailable on kiosk device + } + + const resp = await fetch("/fusion_clock/kiosk/clock", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "call", + params: { employee_id: this.selectedEmployeeId, latitude: lat, longitude: lng }, + }), + }); + const data = await resp.json(); + const result = data.result || {}; + + if (result.error) { + this._showError(result.error); + if (btn) btn.disabled = false; + return; + } + + const pinEl = document.getElementById("fclk-kiosk-pin"); + const resultEl = document.getElementById("fclk-kiosk-result"); + if (pinEl) pinEl.style.display = "none"; + if (resultEl) resultEl.style.display = ""; + + const msgEl = document.getElementById("fclk-kiosk-result-msg"); + if (msgEl) { + const icon = result.action === "clock_in" ? "fa-check-circle text-success" : "fa-hand-paper-o text-warning"; + let html = `
`; + html += `
${result.message || "Done"}
`; + if (result.net_hours !== undefined) { + html += `
Net hours: ${result.net_hours}h
`; + } + msgEl.innerHTML = html; + } + + this.resetTimer = setTimeout(() => this._resetKiosk(), 10000); + } catch { + this._showError("Operation failed."); + } + if (btn) btn.disabled = false; + } +} + +registry.category("public.interactions").add("fusion_clock.kiosk", FusionClockKiosk); diff --git a/fusion_clock/static/src/js/fusion_clock_location_map.js b/fusion_clock/static/src/js/fusion_clock_location_map.js new file mode 100644 index 00000000..6cb265f3 --- /dev/null +++ b/fusion_clock/static/src/js/fusion_clock_location_map.js @@ -0,0 +1,247 @@ +/** @odoo-module **/ + +import { Component, onMounted, onWillUnmount, useRef, useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { standardFieldProps } from "@web/views/fields/standard_field_props"; +import { rpc } from "@web/core/network/rpc"; + +export class FusionClockLocationMap extends Component { + static template = "fusion_clock.LocationMap"; + static props = { ...standardFieldProps }; + + setup() { + this.mapRef = useRef("mapContainer"); + this.map = null; + this.marker = null; + this.circle = null; + this._suppress = false; + this._interval = null; + this._AdvancedMarkerElement = null; + + this.state = useState({ + loading: true, + error: "", + mapVisible: false, + }); + + onMounted(() => this._init()); + onWillUnmount(() => this._cleanup()); + } + + get lat() { return this.props.record.data.latitude || 0; } + get lng() { return this.props.record.data.longitude || 0; } + get radius() { return this.props.record.data.radius || 100; } + get color() { return this.props.record.data.color || "#10B981"; } + get hasCoords() { return this.lat !== 0 || this.lng !== 0; } + + async _init() { + const apiKey = await this._getApiKey(); + if (!apiKey) { + this.state.loading = false; + this.state.error = "Google Maps API key not configured. Set it in Fusion Clock Settings."; + return; + } + try { + await this._loadScript(apiKey); + } catch { + this.state.loading = false; + this.state.error = "Failed to load Google Maps API."; + return; + } + + try { + const { AdvancedMarkerElement } = await google.maps.importLibrary("marker"); + this._AdvancedMarkerElement = AdvancedMarkerElement; + } catch { + this.state.loading = false; + this.state.error = "Failed to load marker library."; + return; + } + + this.state.loading = false; + + if (!this.hasCoords) { + this._startWatcher(); + return; + } + + this.state.mapVisible = true; + await new Promise((r) => requestAnimationFrame(r)); + await new Promise((r) => requestAnimationFrame(r)); + this._buildMap(); + } + + _buildMap() { + const el = this.mapRef.el; + if (!el || !window.google || !this._AdvancedMarkerElement) return; + + const center = { lat: this.lat, lng: this.lng }; + + this.map = new google.maps.Map(el, { + center, + zoom: 17, + mapId: "DEMO_MAP_ID", + mapTypeControl: true, + mapTypeControlOptions: { + style: google.maps.MapTypeControlStyle.DROPDOWN_MENU, + position: google.maps.ControlPosition.TOP_RIGHT, + mapTypeIds: ["roadmap", "satellite", "hybrid"], + }, + streetViewControl: false, + fullscreenControl: true, + zoomControl: true, + gestureHandling: "greedy", + }); + + this._placeMarker(center); + this._drawCircle(center); + + if (!this.props.readonly) { + this.map.addListener("click", (e) => { + const pos = { lat: e.latLng.lat(), lng: e.latLng.lng() }; + this._placeMarker(pos); + this._drawCircle(pos); + this._suppress = true; + this._saveCoords(pos.lat, pos.lng); + }); + } + + this._startWatcher(); + } + + _placeMarker(pos) { + if (this.marker) { + this.marker.position = pos; + return; + } + + this.marker = new this._AdvancedMarkerElement({ + map: this.map, + position: pos, + gmpDraggable: !this.props.readonly, + title: "Drag to fine-tune location", + }); + + if (!this.props.readonly) { + this.marker.addListener("dragend", () => { + const p = this.marker.position; + const newPos = { lat: p.lat, lng: p.lng }; + this._drawCircle(newPos); + this._suppress = true; + this._saveCoords(newPos.lat, newPos.lng); + }); + } + } + + _drawCircle(center) { + if (this.circle) { + this.circle.setCenter(center); + this.circle.setRadius(this.radius); + this.circle.setOptions({ fillColor: this.color, strokeColor: this.color }); + } else { + this.circle = new google.maps.Circle({ + map: this.map, + center, + radius: this.radius, + fillColor: this.color, + fillOpacity: 0.15, + strokeColor: this.color, + strokeOpacity: 0.6, + strokeWeight: 2, + clickable: false, + }); + } + } + + async _saveCoords(lat, lng) { + if (this.props.readonly) return; + await this.props.record.update({ + latitude: Math.round(lat * 10000000) / 10000000, + longitude: Math.round(lng * 10000000) / 10000000, + }); + } + + _startWatcher() { + if (this._interval) return; + this._lastLat = this.lat; + this._lastLng = this.lng; + this._lastRadius = this.radius; + + this._interval = setInterval(() => { + const lat = this.lat; + const lng = this.lng; + const r = this.radius; + + const moved = Math.abs(this._lastLat - lat) > 0.0000001 + || Math.abs(this._lastLng - lng) > 0.0000001; + const resized = Math.abs(this._lastRadius - r) > 0.5; + + if (moved && this.map) { + this._lastLat = lat; + this._lastLng = lng; + if (this._suppress) { this._suppress = false; return; } + const pos = { lat, lng }; + this._placeMarker(pos); + this._drawCircle(pos); + this.map.panTo(pos); + } + + if (resized && this.circle) { + this._lastRadius = r; + this.circle.setRadius(r); + } + + if (!this.map && this.hasCoords && !this.state.error && this._AdvancedMarkerElement) { + this.state.mapVisible = true; + requestAnimationFrame(() => { + requestAnimationFrame(() => { + this._buildMap(); + }); + }); + } + }, 500); + } + + async _getApiKey() { + try { + return await rpc("/web/dataset/call_kw", { + model: "ir.config_parameter", + method: "get_param", + args: ["fusion_clock.google_maps_api_key", ""], + kwargs: {}, + }) || ""; + } catch { return ""; } + } + + async _loadScript(apiKey) { + if (window.google && window.google.maps) return; + return new Promise((resolve, reject) => { + if (document.querySelector('script[src*="maps.googleapis.com"]')) { + const t = setInterval(() => { + if (window.google && window.google.maps) { clearInterval(t); resolve(); } + }, 100); + setTimeout(() => { clearInterval(t); resolve(); }, 5000); + return; + } + const s = document.createElement("script"); + s.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places&callback=__fclkMapCb`; + s.async = true; + s.defer = true; + window.__fclkMapCb = () => { delete window.__fclkMapCb; resolve(); }; + s.onerror = () => reject(new Error("script load failed")); + document.head.appendChild(s); + }); + } + + _cleanup() { + if (this._interval) clearInterval(this._interval); + if (this.marker) { this.marker.map = null; this.marker = null; } + if (this.circle) { this.circle.setMap(null); this.circle = null; } + this.map = null; + } +} + +registry.category("fields").add("fclk_location_map", { + component: FusionClockLocationMap, + supportedTypes: ["char"], +}); diff --git a/fusion_clock/static/src/js/fusion_clock_location_places.js b/fusion_clock/static/src/js/fusion_clock_location_places.js new file mode 100644 index 00000000..09dc709a --- /dev/null +++ b/fusion_clock/static/src/js/fusion_clock_location_places.js @@ -0,0 +1,150 @@ +/** @odoo-module **/ + +import { Component, onMounted, onWillUnmount, useRef, useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { standardFieldProps } from "@web/views/fields/standard_field_props"; +import { rpc } from "@web/core/network/rpc"; + +/** + * Google Places Autocomplete widget for the address field. + * Automatically geocodes the selected place and updates lat/lng on the record. + */ +export class FusionClockPlacesAutocomplete extends Component { + static template = "fusion_clock.PlacesAutocomplete"; + static props = { ...standardFieldProps }; + + setup() { + this.inputRef = useRef("addressInput"); + this.autocomplete = null; + this._apiReady = false; + + this.state = useState({ + value: this.props.record.data[this.props.name] || "", + }); + + onMounted(() => this._init()); + onWillUnmount(() => this._cleanup()); + } + + get isReadonly() { + return this.props.readonly; + } + + async _getApiKey() { + try { + return await rpc("/web/dataset/call_kw", { + model: "ir.config_parameter", + method: "get_param", + args: ["fusion_clock.google_maps_api_key", ""], + kwargs: {}, + }) || ""; + } catch (e) { + return ""; + } + } + + async _waitForGoogleMaps() { + if (window.google && window.google.maps && window.google.maps.places) { + return true; + } + return new Promise((resolve) => { + let attempts = 0; + const check = setInterval(() => { + attempts++; + if (window.google && window.google.maps && window.google.maps.places) { + clearInterval(check); + resolve(true); + } + if (attempts > 50) { + clearInterval(check); + resolve(false); + } + }, 100); + }); + } + + async _loadGoogleMaps(apiKey) { + if (window.google && window.google.maps) return; + + if (document.querySelector('script[src*="maps.googleapis.com"]')) { + await this._waitForGoogleMaps(); + return; + } + + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places&callback=__fclkPlacesInit`; + script.async = true; + script.defer = true; + window.__fclkPlacesInit = () => { + delete window.__fclkPlacesInit; + resolve(); + }; + script.onerror = () => reject(new Error("Failed to load Google Maps")); + document.head.appendChild(script); + }); + } + + async _init() { + if (this.isReadonly) return; + + const apiKey = await this._getApiKey(); + if (!apiKey) return; + + try { + await this._loadGoogleMaps(apiKey); + } catch (e) { + return; + } + + await this._waitForGoogleMaps(); + + if (!this.inputRef.el || !window.google || !window.google.maps.places) return; + + this.autocomplete = new google.maps.places.Autocomplete(this.inputRef.el, { + types: ["geocode", "establishment"], + fields: ["formatted_address", "geometry", "name"], + }); + + this.autocomplete.addListener("place_changed", () => { + const place = this.autocomplete.getPlace(); + if (!place || !place.geometry) return; + + const lat = place.geometry.location.lat(); + const lng = place.geometry.location.lng(); + const address = place.formatted_address || place.name || ""; + + this.state.value = address; + this.props.record.update({ + [this.props.name]: address, + latitude: Math.round(lat * 10000000) / 10000000, + longitude: Math.round(lng * 10000000) / 10000000, + }); + }); + } + + onInput(ev) { + this.state.value = ev.target.value; + } + + onChange(ev) { + this.props.record.update({ [this.props.name]: ev.target.value }); + } + + _cleanup() { + if (this.autocomplete) { + google.maps.event.clearInstanceListeners(this.autocomplete); + this.autocomplete = null; + } + + const containers = document.querySelectorAll(".pac-container"); + containers.forEach((c) => c.remove()); + } +} + +FusionClockPlacesAutocomplete.template = "fusion_clock.PlacesAutocomplete"; + +registry.category("fields").add("fclk_places_autocomplete", { + component: FusionClockPlacesAutocomplete, + supportedTypes: ["char"], +}); diff --git a/fusion_clock/static/src/js/fusion_clock_portal.js b/fusion_clock/static/src/js/fusion_clock_portal.js index f7d6d8f5..fc00ef2a 100644 --- a/fusion_clock/static/src/js/fusion_clock_portal.js +++ b/fusion_clock/static/src/js/fusion_clock_portal.js @@ -79,6 +79,37 @@ export class FusionClockPortal extends Interaction { }); } + const reasonSubmitBtn = document.getElementById("fclk-reason-submit"); + if (reasonSubmitBtn) { + reasonSubmitBtn.addEventListener("click", () => this._submitReason()); + } + + const leaveBtn = document.getElementById("fclk-leave-btn"); + if (leaveBtn) { + leaveBtn.addEventListener("click", () => { + const modal = document.getElementById("fclk-leave-modal"); + if (modal) modal.style.display = "flex"; + }); + } + + const leaveSubmitBtn = document.getElementById("fclk-leave-submit"); + if (leaveSubmitBtn) { + leaveSubmitBtn.addEventListener("click", () => this._submitLeave()); + } + + const clockoutConfirmBtn = document.getElementById("fclk-clockout-confirm-btn"); + if (clockoutConfirmBtn) { + clockoutConfirmBtn.addEventListener("click", () => this._confirmClockOut()); + } + + document.querySelectorAll("[data-dismiss]").forEach((btn) => { + btn.addEventListener("click", () => { + const targetId = btn.dataset.dismiss; + const modal = document.getElementById(targetId); + if (modal) modal.style.display = "none"; + }); + }); + document.querySelectorAll(".fclk-modal-item").forEach((item) => { item.addEventListener("click", () => { this.selectedLocationId = parseInt(item.dataset.id); @@ -100,9 +131,54 @@ export class FusionClockPortal extends Interaction { e.preventDefault(); const btn = document.getElementById("fclk-clock-btn"); if (!btn || btn.disabled) return; + + if (this.isCheckedIn) { + this._showClockOutConfirmation(); + return; + } + + this._beginClockAction(); + } + + _showClockOutConfirmation() { + const modal = document.getElementById("fclk-clockout-confirm-modal"); + if (!modal) { + this._beginClockAction(); + return; + } + + const checkinEl = document.getElementById("fclk-confirm-checkin-time"); + const durationEl = document.getElementById("fclk-confirm-duration"); + + if (checkinEl && this.checkInTime) { + const h = this.checkInTime.getHours(); + const m = this.checkInTime.getMinutes(); + const ampm = h >= 12 ? "PM" : "AM"; + const hour12 = h % 12 || 12; + checkinEl.textContent = hour12 + ":" + (m < 10 ? "0" : "") + m + " " + ampm; + } + + if (durationEl && this.checkInTime) { + const diff = Math.max(0, Math.floor((new Date() - this.checkInTime) / 1000)); + const dh = Math.floor(diff / 3600); + const dm = Math.floor((diff % 3600) / 60); + durationEl.textContent = dh + "h " + dm + "m"; + } + + modal.style.display = "flex"; + } + + _confirmClockOut() { + const modal = document.getElementById("fclk-clockout-confirm-modal"); + if (modal) modal.style.display = "none"; + this._beginClockAction(); + } + + _beginClockAction() { + const btn = document.getElementById("fclk-clock-btn"); + if (!btn || btn.disabled) return; btn.disabled = true; - // Ripple effect const ripple = btn.querySelector(".fclk-btn-ripple"); if (ripple) { ripple.classList.remove("fclk-ripple-active"); @@ -150,6 +226,11 @@ export class FusionClockPortal extends Interaction { this._hideGPSOverlay(); if (btn) btn.disabled = false; + if (result.requires_reason) { + this._showReasonModal(); + return; + } + if (result.error) { this._showToast(result.error, "error"); this._shakeButton(); @@ -413,6 +494,75 @@ export class FusionClockPortal extends Interaction { } catch (e) {} } + // ========================================================================= + // Reason Modal & Leave Request + // ========================================================================= + + _showReasonModal() { + const modal = document.getElementById("fclk-reason-modal"); + if (modal) modal.style.display = "flex"; + } + + async _submitReason() { + const reasonEl = document.getElementById("fclk-reason-text"); + const timeEl = document.getElementById("fclk-reason-time"); + const reason = reasonEl ? reasonEl.value.trim() : ""; + const depTime = timeEl ? timeEl.value.trim() : ""; + + if (!reason) { + this._showToast("Please provide a reason.", "error"); + return; + } + + try { + const result = await rpc("/fusion_clock/submit_reason", { + reason: reason, + departure_time: depTime, + }); + if (result.success) { + const modal = document.getElementById("fclk-reason-modal"); + if (modal) modal.style.display = "none"; + this._showToast(result.message, "success"); + if (reasonEl) reasonEl.value = ""; + if (timeEl) timeEl.value = ""; + } else { + this._showToast(result.error || "Failed to submit.", "error"); + } + } catch (e) { + this._showToast("Network error.", "error"); + } + } + + async _submitLeave() { + const dateEl = document.getElementById("fclk-leave-date"); + const reasonEl = document.getElementById("fclk-leave-reason"); + const leaveDate = dateEl ? dateEl.value : ""; + const reason = reasonEl ? reasonEl.value.trim() : ""; + + if (!leaveDate || !reason) { + this._showToast("Please provide both a date and reason.", "error"); + return; + } + + try { + const result = await rpc("/fusion_clock/request_leave", { + leave_date: leaveDate, + reason: reason, + }); + if (result.success) { + const modal = document.getElementById("fclk-leave-modal"); + if (modal) modal.style.display = "none"; + this._showToast(result.message, "success"); + if (dateEl) dateEl.value = ""; + if (reasonEl) reasonEl.value = ""; + } else { + this._showToast(result.error || "Failed to submit.", "error"); + } + } catch (e) { + this._showToast("Network error.", "error"); + } + } + // ========================================================================= // Sync on visibility change // ========================================================================= diff --git a/fusion_clock/static/src/js/fusion_clock_portal_fab.js b/fusion_clock/static/src/js/fusion_clock_portal_fab.js index 11a71f5e..eba2ded4 100644 --- a/fusion_clock/static/src/js/fusion_clock_portal_fab.js +++ b/fusion_clock/static/src/js/fusion_clock_portal_fab.js @@ -223,6 +223,163 @@ export class FusionClockPortalFAB extends Interaction { // ========================================================================= async _onClockAction() { + if (this.isCheckedIn) { + this._showClockOutConfirm(); + return; + } + await this._executeClockAction(); + } + + _showClockOutConfirm() { + let modal = document.getElementById("fclk-pfab-clockout-modal"); + if (!modal) { + modal = document.createElement("div"); + modal.id = "fclk-pfab-clockout-modal"; + modal.className = "fclk-wizard-overlay"; + modal.innerHTML = ` +
+
+
+
+ +
+

Clock Out?

+

Are you sure you want to end your current shift?

+
+
+
+
+ Clocked in at + -- +
+
+ Duration + -- +
+
+
+ +
`; + document.body.appendChild(modal); + + modal.querySelectorAll("[data-pfab-dismiss]").forEach((btn) => { + btn.addEventListener("click", () => { modal.style.display = "none"; }); + }); + document.getElementById("fclk-pfab-confirm-clockout-btn").addEventListener("click", () => { + modal.style.display = "none"; + this._executeClockAction(); + }); + } + + if (this.checkInTime) { + const h = this.checkInTime.getHours(); + const m = this.checkInTime.getMinutes(); + const ampm = h >= 12 ? "PM" : "AM"; + const hour12 = h % 12 || 12; + const timeEl = document.getElementById("fclk-pfab-confirm-time"); + if (timeEl) timeEl.textContent = hour12 + ":" + (m < 10 ? "0" : "") + m + " " + ampm; + + const diff = Math.max(0, Math.floor((new Date() - this.checkInTime) / 1000)); + const dh = Math.floor(diff / 3600); + const dm = Math.floor((diff % 3600) / 60); + const durEl = document.getElementById("fclk-pfab-confirm-dur"); + if (durEl) durEl.textContent = dh + "h " + dm + "m"; + } + + modal.style.display = "flex"; + } + + _showReasonDialog() { + let modal = document.getElementById("fclk-pfab-reason-modal"); + if (!modal) { + modal = document.createElement("div"); + modal.id = "fclk-pfab-reason-modal"; + modal.className = "fclk-wizard-overlay"; + modal.innerHTML = ` +
+
+
+
+ + + + +
+

Missed Clock-Out

+

You didn't clock out on your last shift. Please provide details before continuing.

+
+
+
+ + +
+
+ + + When did you actually leave? (optional) +
+
+ +
`; + document.body.appendChild(modal); + + modal.querySelectorAll("[data-pfab-dismiss]").forEach((btn) => { + btn.addEventListener("click", () => { modal.style.display = "none"; }); + }); + document.getElementById("fclk-pfab-reason-submit-btn").addEventListener("click", async () => { + const reasonEl = document.getElementById("fclk-pfab-reason-text"); + const timeEl = document.getElementById("fclk-pfab-reason-time"); + const reason = reasonEl ? reasonEl.value.trim() : ""; + if (!reason) { + this._showError("Please provide a reason."); + return; + } + const submitBtn = document.getElementById("fclk-pfab-reason-submit-btn"); + if (submitBtn) submitBtn.disabled = true; + try { + await rpc("/fusion_clock/submit_reason", { + reason: reason, + departure_time: timeEl ? timeEl.value : "", + }); + modal.style.display = "none"; + if (reasonEl) reasonEl.value = ""; + if (timeEl) timeEl.value = ""; + if (submitBtn) submitBtn.disabled = false; + await this._executeClockAction(); + } catch (e) { + this._showError("Failed to submit reason."); + if (submitBtn) submitBtn.disabled = false; + } + }); + } + + const reasonEl = document.getElementById("fclk-pfab-reason-text"); + const timeEl = document.getElementById("fclk-pfab-reason-time"); + if (reasonEl) reasonEl.value = ""; + if (timeEl) timeEl.value = ""; + modal.style.display = "flex"; + } + + async _executeClockAction() { if (this.actionBtn) this.actionBtn.disabled = true; this._clearError(); @@ -255,6 +412,12 @@ export class FusionClockPortalFAB extends Interaction { source: "portal_fab", }); + if (result.requires_reason) { + if (this.actionBtn) this.actionBtn.disabled = false; + this._showReasonDialog(); + return; + } + if (result.error) { this._showError(result.error); if (this.actionBtn) this.actionBtn.disabled = false; diff --git a/fusion_clock/static/src/js/fusion_clock_systray.js b/fusion_clock/static/src/js/fusion_clock_systray.js index efa0d8e5..12583baf 100644 --- a/fusion_clock/static/src/js/fusion_clock_systray.js +++ b/fusion_clock/static/src/js/fusion_clock_systray.js @@ -23,6 +23,11 @@ export class FusionClockFAB extends Component { weekHours: "0.0", loading: false, error: "", + showReasonDialog: false, + showClockoutConfirm: false, + reasonText: "", + reasonTime: "", + reasonSubmitting: false, }); this._timerInterval = null; @@ -95,6 +100,23 @@ export class FusionClockFAB extends Component { } async onClockAction() { + if (this.state.isCheckedIn) { + this.state.showClockoutConfirm = true; + return; + } + await this._executeClockAction(); + } + + async confirmClockOut() { + this.state.showClockoutConfirm = false; + await this._executeClockAction(); + } + + cancelClockOut() { + this.state.showClockoutConfirm = false; + } + + async _executeClockAction() { this.state.loading = true; this.state.error = ""; @@ -126,6 +148,14 @@ export class FusionClockFAB extends Component { source: "backend_fab", }); + if (result.requires_reason) { + this.state.loading = false; + this.state.showReasonDialog = true; + this.state.reasonText = ""; + this.state.reasonTime = ""; + return; + } + if (result.error) { this.state.error = result.error; this.state.loading = false; @@ -153,6 +183,60 @@ export class FusionClockFAB extends Component { this.state.loading = false; } + onReasonTextInput(ev) { + this.state.reasonText = ev.target.value; + } + + onReasonTimeInput(ev) { + this.state.reasonTime = ev.target.value; + } + + cancelReason() { + this.state.showReasonDialog = false; + this.state.reasonText = ""; + this.state.reasonTime = ""; + } + + async submitReason() { + if (!this.state.reasonText.trim()) { + this.state.error = "Please provide a reason."; + return; + } + this.state.reasonSubmitting = true; + try { + await rpc("/fusion_clock/submit_reason", { + reason: this.state.reasonText.trim(), + departure_time: this.state.reasonTime || "", + }); + this.state.showReasonDialog = false; + this.state.reasonText = ""; + this.state.reasonTime = ""; + this.state.reasonSubmitting = false; + await this._executeClockAction(); + } catch (e) { + this.state.error = "Failed to submit reason."; + this.state.reasonSubmitting = false; + } + } + + get confirmCheckinDisplay() { + if (!this.state.checkInTime) return "--"; + const d = this.state.checkInTime; + let h = d.getHours(); + const m = d.getMinutes(); + const ampm = h >= 12 ? "PM" : "AM"; + h = h % 12 || 12; + return h + ":" + (m < 10 ? "0" : "") + m + " " + ampm; + } + + get confirmDurationDisplay() { + if (!this.state.checkInTime) return "--"; + const diff = Math.max(0, Math.floor((new Date() - this.state.checkInTime) / 1000)); + const dh = Math.floor(diff / 3600); + const dm = Math.floor((diff % 3600) / 60); + return dh + "h " + dm + "m"; + } + _startTimer() { this._stopTimer(); this._updateTimer(); diff --git a/fusion_clock/static/src/scss/fusion_clock.scss b/fusion_clock/static/src/scss/fusion_clock.scss index 71e0ca0d..7053bbb0 100644 --- a/fusion_clock/static/src/scss/fusion_clock.scss +++ b/fusion_clock/static/src/scss/fusion_clock.scss @@ -376,3 +376,437 @@ $fclk-gradient-active: linear-gradient(135deg, $fclk-green 0%, $fclk-teal 100%); 25% { transform: translateX(-4px); } 75% { transform: translateX(4px); } } + +// =========================================================== +// FAB Dialog Overlays (reason, clock-out confirmation) +// =========================================================== +.fclk-fab-dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +.fclk-fab-dialog-backdrop { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(6px); +} + +.fclk-fab-dialog { + position: relative; + width: 100%; + max-width: 420px; + background: var(--fclk-fab-panel-bg); + border: 1px solid var(--fclk-fab-panel-border); + border-radius: 20px; + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.05); + animation: fclk-dialog-enter 0.3s cubic-bezier(0.32, 0.72, 0, 1); + max-height: 85vh; + overflow-y: auto; + + &.fclk-fab-dialog--compact { + max-width: 360px; + } +} + +@keyframes fclk-dialog-enter { + from { + opacity: 0; + transform: scale(0.95) translateY(8px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.fclk-fab-dialog-header { + padding: 28px 24px 20px; + text-align: center; + border-bottom: 1px solid var(--fclk-fab-panel-border); +} + +.fclk-fab-dialog-icon { + width: 52px; + height: 52px; + border-radius: 14px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 14px; + font-size: 22px; +} + +.fclk-fab-dialog-header--warning .fclk-fab-dialog-icon { + background: rgba(245, 158, 11, 0.12); + color: #f59e0b; +} + +.fclk-fab-dialog-header--danger .fclk-fab-dialog-icon { + background: rgba($fclk-red, 0.12); + color: $fclk-red; +} + +.fclk-fab-dialog-title { + color: var(--fclk-fab-text); + font-size: 18px; + font-weight: 700; + margin: 0 0 6px; + letter-spacing: -0.3px; +} + +.fclk-fab-dialog-subtitle { + color: var(--fclk-fab-muted); + font-size: 12px; + line-height: 1.5; + margin: 0; +} + +.fclk-fab-dialog-body { + padding: 20px 24px; +} + +.fclk-fab-dialog-field { + margin-bottom: 16px; + + &:last-child { + margin-bottom: 0; + } +} + +.fclk-fab-dialog-label { + display: flex; + align-items: center; + gap: 6px; + color: var(--fclk-fab-text); + font-size: 12px; + font-weight: 600; + margin-bottom: 6px; + + .fa { color: var(--fclk-fab-muted); font-size: 13px; } +} + +.fclk-fab-dialog-required { + color: $fclk-red; + font-weight: 700; +} + +.fclk-fab-dialog-input { + width: 100%; + background: var(--fclk-fab-location-bg, rgba(0, 0, 0, 0.04)); + border: 1.5px solid var(--fclk-fab-panel-border); + border-radius: 10px; + padding: 10px 12px; + font-size: 13px; + color: var(--fclk-fab-text); + font-family: inherit; + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; + resize: vertical; + + &:focus { + border-color: $fclk-green; + box-shadow: 0 0 0 3px rgba($fclk-green, 0.15); + } + + &::placeholder { + color: var(--fclk-fab-muted); + } +} + +.fclk-fab-dialog-hint { + display: block; + color: var(--fclk-fab-muted); + font-size: 10px; + margin-top: 4px; +} + +.fclk-fab-dialog-footer { + padding: 14px 24px 18px; + display: flex; + gap: 10px; + justify-content: flex-end; + border-top: 1px solid var(--fclk-fab-panel-border); +} + +.fclk-fab-dialog-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 9px 18px; + border-radius: 10px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + border: none; + transition: all 0.2s ease; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .fa { font-size: 13px; } +} + +.fclk-fab-dialog-btn--cancel { + background: transparent; + color: var(--fclk-fab-muted); + border: 1px solid var(--fclk-fab-panel-border); + + &:hover:not(:disabled) { + background: var(--fclk-fab-location-bg); + color: var(--fclk-fab-text); + } +} + +.fclk-fab-dialog-btn--submit { + background: $fclk-gradient-active; + color: #fff; + box-shadow: 0 2px 8px rgba($fclk-green, 0.3); + + &:hover:not(:disabled) { + box-shadow: 0 4px 16px rgba($fclk-green, 0.4); + transform: translateY(-1px); + } +} + +.fclk-fab-dialog-btn--danger { + background: $fclk-red; + color: #fff; + box-shadow: 0 2px 8px rgba($fclk-red, 0.3); + + &:hover:not(:disabled) { + box-shadow: 0 4px 16px rgba($fclk-red, 0.4); + transform: translateY(-1px); + } +} + +// Summary card (used in clock-out confirmation) +.fclk-fab-dialog-summary { + background: var(--fclk-fab-location-bg, rgba(0, 0, 0, 0.04)); + border: 1px solid var(--fclk-fab-panel-border); + border-radius: 10px; + padding: 14px; +} + +.fclk-fab-dialog-summary-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 0; + + + .fclk-fab-dialog-summary-row { + border-top: 1px solid var(--fclk-fab-panel-border); + } +} + +.fclk-fab-dialog-summary-label { + color: var(--fclk-fab-muted); + font-size: 12px; +} + +.fclk-fab-dialog-summary-value { + color: var(--fclk-fab-text); + font-size: 13px; + font-weight: 600; +} + +// =========================================================== +// Location Map Widget +// =========================================================== +.fclk-map-widget { + width: 100%; + margin: 8px 0; +} + +.fclk-map-container { + display: block; + border: 1px solid var(--fclk-fab-panel-border, #e5e7eb); +} + +.fclk-map-loading, +.fclk-map-error, +.fclk-map-placeholder { + display: flex; + align-items: center; + gap: 8px; + padding: 20px 16px; + font-size: 13px; + border-radius: 8px; + border: 1px dashed var(--fclk-fab-panel-border, #e5e7eb); +} + +.fclk-map-loading { + color: var(--fclk-fab-muted, #6b7280); + background: rgba(59, 130, 246, 0.04); +} + +.fclk-map-error { + color: $fclk-red; + background: rgba($fclk-red, 0.04); +} + +.fclk-map-placeholder { + color: var(--fclk-fab-muted, #6b7280); + background: rgba(0, 0, 0, 0.02); +} + +html.o_dark { + .fclk-map-loading { background: rgba(59, 130, 246, 0.08); } + .fclk-map-error { background: rgba($fclk-red, 0.08); } + .fclk-map-placeholder { background: rgba(255, 255, 255, 0.03); } +} + +.fclk-map-hint { + text-align: center; + padding: 6px 12px; + font-size: 11px; + color: var(--fclk-fab-muted, #6b7280); + + .fa { margin-right: 4px; } +} + +// Google Places dropdown z-index fix +.pac-container { + z-index: 2100 !important; + border-radius: 8px; + margin-top: 4px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); + font-family: inherit; +} + +.fclk-places-input { + width: 100%; +} + +// =========================================================== +// Dashboard Summary Cards +// =========================================================== +.fclk-dash-card { + position: relative; + border-radius: 12px; + padding: 20px; + text-align: center; + overflow: hidden; + transition: transform 0.2s ease, box-shadow 0.2s ease; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + } +} + +.fclk-dash-card-icon { + width: 44px; + height: 44px; + border-radius: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 20px; + margin-bottom: 12px; +} + +.fclk-dash-card-value { + font-size: 32px; + font-weight: 700; + line-height: 1; + margin-bottom: 4px; +} + +.fclk-dash-card-label { + font-size: 13px; + font-weight: 500; + letter-spacing: 0.2px; +} + +// -- Total (blue/slate) -- +.fclk-dash-card--total { + background: linear-gradient(135deg, #eff6ff 0%, #e0e7ff 100%); + border: 1px solid #bfdbfe; + + .fclk-dash-card-icon { background: rgba(59, 130, 246, 0.15); color: #2563eb; } + .fclk-dash-card-value { color: #1e3a5f; } + .fclk-dash-card-label { color: #3b82f6; } +} + +// -- Present (green) -- +.fclk-dash-card--present { + background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%); + border: 1px solid #a7f3d0; + + .fclk-dash-card-icon { background: rgba(16, 185, 129, 0.15); color: #059669; } + .fclk-dash-card-value { color: #064e3b; } + .fclk-dash-card-label { color: #10b981; } +} + +// -- Absent (red) -- +.fclk-dash-card--absent { + background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%); + border: 1px solid #fecaca; + + .fclk-dash-card-icon { background: rgba(239, 68, 68, 0.12); color: #dc2626; } + .fclk-dash-card-value { color: #7f1d1d; } + .fclk-dash-card-label { color: #ef4444; } +} + +// -- Late (amber) -- +.fclk-dash-card--late { + background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%); + border: 1px solid #fde68a; + + .fclk-dash-card-icon { background: rgba(245, 158, 11, 0.15); color: #d97706; } + .fclk-dash-card-value { color: #78350f; } + .fclk-dash-card-label { color: #f59e0b; } +} + +// -- Dark mode overrides -- +html.o_dark { + .fclk-dash-card--total { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(99, 102, 241, 0.1) 100%); + border-color: rgba(59, 130, 246, 0.25); + .fclk-dash-card-value { color: #93c5fd; } + .fclk-dash-card-label { color: #60a5fa; } + .fclk-dash-card-icon { background: rgba(59, 130, 246, 0.2); color: #60a5fa; } + } + + .fclk-dash-card--present { + background: linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(52, 211, 153, 0.08) 100%); + border-color: rgba(16, 185, 129, 0.25); + .fclk-dash-card-value { color: #6ee7b7; } + .fclk-dash-card-label { color: #34d399; } + .fclk-dash-card-icon { background: rgba(16, 185, 129, 0.2); color: #34d399; } + } + + .fclk-dash-card--absent { + background: linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(248, 113, 113, 0.08) 100%); + border-color: rgba(239, 68, 68, 0.25); + .fclk-dash-card-value { color: #fca5a5; } + .fclk-dash-card-label { color: #f87171; } + .fclk-dash-card-icon { background: rgba(239, 68, 68, 0.18); color: #f87171; } + } + + .fclk-dash-card--late { + background: linear-gradient(135deg, rgba(245, 158, 11, 0.1) 0%, rgba(251, 191, 36, 0.08) 100%); + border-color: rgba(245, 158, 11, 0.25); + .fclk-dash-card-value { color: #fcd34d; } + .fclk-dash-card-label { color: #fbbf24; } + .fclk-dash-card-icon { background: rgba(245, 158, 11, 0.2); color: #fbbf24; } + } + + .fclk-dash-card:hover { + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); + } +} diff --git a/fusion_clock/static/src/xml/fusion_clock_dashboard.xml b/fusion_clock/static/src/xml/fusion_clock_dashboard.xml new file mode 100644 index 00000000..34cb7a92 --- /dev/null +++ b/fusion_clock/static/src/xml/fusion_clock_dashboard.xml @@ -0,0 +1,155 @@ + + + + +
+
+ + +
+

Fusion Clock Dashboard

+ +
+ + +
+ +

Loading dashboard...

+
+
+ + +
+ +
+
+ + + +
+
+
+
+ +
+
+
Total Employees
+
+
+
+
+
+ +
+
+
Present Today
+
+
+
+
+
+ +
+
+
Absent Today
+
+
+
+
+
+ +
+
+
Late Today
+
+
+
+ +
+ +
+
+
+
Currently Clocked In
+ active +
+
+ +
+ No employees currently clocked in +
+
+ + + + + + + + + + + + + + + + + + +
EmployeeClock-InLocation
+
+
+
+
+ + +
+
+
+
Alerts
+
+
+
+ Pending Reasons + +
+
+ Pending Corrections + +
+
+ Late Today + +
+
+
+ + +
+
+
Quick Actions
+
+
+ + +
+
+
+
+
+ +
+
+
+ +
diff --git a/fusion_clock/static/src/xml/fusion_clock_location.xml b/fusion_clock/static/src/xml/fusion_clock_location.xml new file mode 100644 index 00000000..c71a84d2 --- /dev/null +++ b/fusion_clock/static/src/xml/fusion_clock_location.xml @@ -0,0 +1,40 @@ + + + + + +
+
+ Loading map... +
+
+ +
+ +
+
+ Click the map or drag the marker to fine-tune the location +
+
+ + + + + + + + + + + + + diff --git a/fusion_clock/static/src/xml/systray_clock.xml b/fusion_clock/static/src/xml/systray_clock.xml index 2db33418..78aa1a8e 100644 --- a/fusion_clock/static/src/xml/systray_clock.xml +++ b/fusion_clock/static/src/xml/systray_clock.xml @@ -68,20 +68,91 @@ + + +
+
+
+
+
+ +
+

Missed Clock-Out

+

You didn't clock out on your last shift. Please provide details before continuing.

+
+
+
+ + +
+
+ + + When did you actually leave? (optional) +
+
+ +
+
+ + + + + + +