diff --git a/fusion-plating/.claude/settings.local.json b/fusion-plating/.claude/settings.local.json new file mode 100644 index 00000000..5874a774 --- /dev/null +++ b/fusion-plating/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(ls /k/Github/Odoo-Modules/ | grep -i -E \"shopfloor|tablet|fusion_plating\")" + ] + } +} diff --git a/fusion_clock/CLAUDE.md b/fusion_clock/CLAUDE.md new file mode 100644 index 00000000..912e4c02 --- /dev/null +++ b/fusion_clock/CLAUDE.md @@ -0,0 +1,358 @@ +# Fusion Clock - Claude Code Instructions + +> Read together with the repo-root `../CLAUDE.md` for global Odoo 19 rules, asset-cache handling, Supabase KB notes, and shared Fusion conventions. This file is only for the `fusion_clock` module. + +## 1. What This Module Is + +- **Name**: Fusion Clock. +- **Version**: `19.0.3.3.0`. +- **Category**: Human Resources/Attendances. +- **License**: OPL-1, Nexa Systems Inc. +- **Purpose**: complete time and attendance app built on Odoo `hr.attendance`. +- **Top-level menu**: `Fusion Clock`. +- **Main surfaces**: + - Portal clock page at `/my/clock`. + - Portal timesheets at `/my/clock/timesheets`. + - Portal reports at `/my/clock/reports`. + - Shared PIN kiosk at `/fusion_clock/kiosk`. + - NFC tap kiosk at `/fusion_clock/kiosk/nfc`. + - Backend systray clock widget. + - Backend manager/team-lead dashboard client action. + +Core behaviours: geofenced clock-in/out, IP whitelist fallback, shift scheduling, break deduction, penalties, overtime, auto clock-out, absence detection, leave requests, correction workflow, payroll CSV export, PDF reports, weekly summaries, shared kiosk, NFC kiosk with photo capture, and activity audit logs. + +## 2. Dependencies + +Declared in `__manifest__.py`: + +``` +hr_attendance, hr, portal, mail, resource +``` + +External Python used directly: + +- `pytz` for timezone-safe local day boundaries. +- `requests` for Google Geocoding, OpenStreetMap/Nominatim fallback, and IP metadata. +- `dateutil.relativedelta` inside pay-period calculations. + +External browser APIs: + +- Browser geolocation. +- `ipapi.co` fallback geolocation in frontend/backend clock widgets. +- Google Maps/Places when `fusion_clock.google_maps_api_key` is configured. +- Web NFC and camera APIs for the NFC kiosk. + +## 3. Naming And Field Prefixes + +This module uses the module-specific prefix **`x_fclk_*`** on inherited Odoo models, not `x_fc_*`. + +Examples: + +- `hr.employee.x_fclk_enable_clock` +- `hr.employee.x_fclk_nfc_card_uid` +- `hr.attendance.x_fclk_clock_source` +- `res.company.x_fclk_nfc_kiosk_location_id` + +New inherited fields in this module should keep the `x_fclk_*` prefix unless there is a strong migration reason not to. + +## 4. Model Map + +Custom models: + +| Model | File | Purpose | +|---|---|---| +| `fusion.clock.location` | `models/clock_location.py` | Geofenced/IP-whitelisted clock locations. | +| `fusion.clock.shift` | `models/clock_shift.py` | Shift start/end/break schedule assigned to employees. | +| `fusion.clock.penalty` | `models/clock_penalty.py` | Late clock-in / early clock-out penalty records. | +| `fusion.clock.activity.log` | `models/clock_activity_log.py` | Append-style audit log for clock activity, geofence misses, absences, NFC enrolment, corrections. | +| `fusion.clock.leave.request` | `models/clock_leave_request.py` | Portal leave requests, auto-approved but office-notified. | +| `fusion.clock.correction` | `models/clock_correction.py` | Timesheet correction requests with approve/reject workflow. | +| `fusion.clock.report` | `models/clock_report.py` | Employee or batch pay-period report with PDF/CSV export and email send. | +| `fusion.clock.nfc.enrollment.wizard` | `wizard/clock_nfc_enrollment_wizard.py` | Backend NFC card enrolment/reassignment wizard. | + +Inherited models: + +- `hr.employee`: enable clock, default location, shift, kiosk PIN, NFC UID, pending reason flag, streaks, absence/overtime counters, and One2many links. +- `hr.attendance`: clock source, location, distances, photos, break minutes, net hours, penalties, auto clock-out flag, overtime fields. +- `res.config.settings`: all `fusion_clock.*` settings. +- `res.company`: NFC kiosk location binding. + +Timezone helpers live in `models/tz_utils.py`. Use `get_local_today()` and `get_local_day_boundaries()` for attendance domains instead of comparing UTC dates directly. + +## 5. Clocking Flow + +Primary API endpoint: `/fusion_clock/clock_action` in `controllers/clock_api.py`. + +Clock-in flow: + +1. Resolve current user to `hr.employee`. +2. Block if `x_fclk_enable_clock` is false. +3. If `x_fclk_pending_reason` is true, return `requires_reason`. +4. Verify location against allowed active `fusion.clock.location` records. +5. Call Odoo's `_attendance_action_change()`. +6. Write location, distance, source, and optional photo to `hr.attendance`. +7. Log `clock_in`. +8. Create `late_in` penalty when outside grace. +9. Increment/reset on-time streak; log milestone at 5, 10, 20, 50, 100. +10. Notify office user for very-late clock-ins. + +Clock-out flow: + +1. Verify location again. +2. Call `_attendance_action_change()`. +3. Write out-distance. +4. Apply break deduction when configured. +5. Create `early_out` penalty when outside grace. +6. Log `clock_out`. +7. Log overtime if computed overtime is positive. + +Location verification uses GPS when coordinates are available and geocoded locations exist. IP whitelist matching is attempted when a client IP is available. Error types include `no_locations`, `gps_unavailable`, `no_geocoded`, and `outside`. + +## 6. Kiosk And NFC + +Classic kiosk: + +- Page: `/fusion_clock/kiosk` +- JSON routes: + - `/fusion_clock/kiosk/search` + - `/fusion_clock/kiosk/verify_pin` + - `/fusion_clock/kiosk/clock` +- Requires `fusion_clock.group_fusion_clock_manager`. +- Controlled by `fusion_clock.enable_kiosk` and `fusion_clock.kiosk_pin_required`. +- Uses `hr.employee.x_fclk_kiosk_pin`. + +NFC kiosk: + +- Page: `/fusion_clock/kiosk/nfc` +- JSON routes: + - `/fusion_clock/kiosk/nfc/enroll` + - `/fusion_clock/kiosk/nfc/tap` + - `/fusion_clock/kiosk/nfc/employee_search` +- Requires `fusion_clock.group_fusion_clock_manager`. +- Controlled by: + - `fusion_clock.enable_nfc_kiosk` + - `fusion_clock.nfc_photo_required` + - `fusion_clock.nfc_enroll_password` + - `fusion_clock.nfc_kiosk_debug` + - `res.company.x_fclk_nfc_kiosk_location_id` +- Card UID canonical format is uppercase colon-separated hex, e.g. `04:A2:B5:62:C1:80`. +- Normalization lives in `FusionClockNfcKiosk._normalize_uid()` and is reused by the backend wizard. +- Tap debounce is module-level memory in `controllers/clock_nfc_kiosk.py`: same UID within 5 seconds returns `debounce`. +- Photo data URLs are stripped before writing binary fields. +- NFC clock-ins write `x_fclk_check_in_photo`; NFC clock-outs write `x_fclk_check_out_photo`. + +Important: unknown-card taps currently return `card_unknown`; the `unknown_card_tap` log type exists but is not written by the endpoint. + +## 7. Reports And Payroll Export + +`fusion.clock.report` supports: + +- Employee reports when `employee_id` is set. +- Batch reports when `employee_id` is empty. +- PDF generation through QWeb reports: + - `fusion_clock.action_report_clock_employee` + - `fusion_clock.action_report_clock_batch` +- CSV export via `action_export_csv()`. +- Custom CSV headings via JSON in `fusion_clock.csv_column_mapping`. +- Email send with generated PDF attached. + +Pay period types: + +``` +weekly, biweekly, semi_monthly, monthly +``` + +The anchor date setting is `fusion_clock.pay_period_start` as a string in `YYYY-MM-DD` format. + +Historical report generation is exposed through the `Generate Historical Reports` menu action and creates draft reports for completed attendance periods. The scheduled report cron only generates when yesterday is the period end. + +## 8. Scheduled Automation + +Configured in `data/ir_cron_data.xml`: + +| Cron | Model method | Frequency | +|---|---|---| +| Fusion Clock: Auto Clock-Out | `hr.attendance._cron_fusion_auto_clock_out()` | Every 15 minutes | +| Fusion Clock: Generate Period Reports | `fusion.clock.report._cron_generate_period_reports()` | Daily | +| Fusion Clock: Daily Absence Check | `hr.attendance._cron_fusion_check_absences()` | Daily | +| Fusion Clock: Employee Reminders | `hr.attendance._cron_fusion_employee_reminders()` | Every 15 minutes | +| Fusion Clock: Weekly Summary | `hr.attendance._cron_fusion_weekly_summary()` | Daily, internally sends Mondays | + +Auto clock-out closes open attendances after scheduled end plus grace, capped by max shift hours. It sets `x_fclk_pending_reason` so the employee must explain before clocking in again. + +Absence detection checks enabled employees, skips weekends and global resource calendar leaves, and logs `absent` when no attendance or leave request exists. + +## 9. Security + +Groups: + +- `group_fusion_clock_user` +- `group_fusion_clock_team_lead` +- `group_fusion_clock_manager` + +Admin is auto-assigned to manager in `security/security.xml`. + +Access pattern: + +- Users and portal users can read their own clock data. +- Team leads can read direct reports for penalties, activity logs, corrections, and dashboard data. +- Managers have full model access and all configuration/kiosk/report menus. +- Portal rules are defined for `hr.attendance`, `fusion.clock.location`, `fusion.clock.report`, `fusion.clock.penalty`, `fusion.clock.activity.log`, `fusion.clock.leave.request`, `fusion.clock.correction`, and `fusion.clock.shift`. + +Backend dashboard access is checked in `/fusion_clock/dashboard_data`: manager sees all enabled employees; team lead sees employees where `parent_id` is the current user's employee. + +## 10. Frontend Assets + +Frontend bundle: + +- `static/src/css/portal_clock.css` +- `static/src/scss/nfc_kiosk.scss` +- `static/src/js/fusion_clock_portal.js` +- `static/src/js/fusion_clock_kiosk.js` +- `static/src/js/fusion_clock_nfc_kiosk.js` + +Backend bundle: + +- `static/src/scss/fusion_clock.scss` +- `static/src/js/fusion_clock_systray.js` +- `static/src/xml/systray_clock.xml` +- `static/src/js/fusion_clock_dashboard.js` +- `static/src/xml/fusion_clock_dashboard.xml` +- `static/src/js/fusion_clock_location_map.js` +- `static/src/js/fusion_clock_location_places.js` +- `static/src/xml/fusion_clock_location.xml` + +Patterns: + +- Public portal/kiosk JS should use `Interaction` from `@web/public/interaction` and register in `registry.category("public.interactions")`. +- Backend OWL client actions and field widgets use standalone `rpc()` from `@web/core/network/rpc`. +- `fusion_clock_systray.js` is a systray OWL component registered as `fusion_clock.ClockSystray`. +- `fusion_clock_dashboard.js` is a client action registered as `fusion_clock.Dashboard`. +- Location widgets are registered field widgets: `fclk_location_map` and `fclk_places_autocomplete`. + +Known technical debt: + +- `static/src/js/fusion_clock_nfc_kiosk.js` is currently an isolated IIFE. If touching it, prefer migrating to an Odoo 19 `Interaction` instead of expanding the IIFE pattern. +- `static/src/css/portal_clock.css` and `static/src/scss/fusion_clock.scss` contain runtime dark-mode selectors/media rules. For backend SCSS changes, follow the repo-root Odoo 19 compile-time dark bundle guidance. +- `fusion_clock.scss` uses some Bootstrap CSS vars for status accents. Avoid relying on Bootstrap vars for card/background/border surfaces in new dashboard work. + +## 11. Settings Keys + +Important `ir.config_parameter` keys: + +``` +fusion_clock.default_clock_in_time +fusion_clock.default_clock_out_time +fusion_clock.default_break_minutes +fusion_clock.auto_deduct_break +fusion_clock.break_threshold_hours +fusion_clock.enable_auto_clockout +fusion_clock.grace_period_minutes +fusion_clock.max_shift_hours +fusion_clock.enable_penalties +fusion_clock.penalty_grace_minutes +fusion_clock.penalty_deduction_minutes +fusion_clock.enable_overtime +fusion_clock.daily_overtime_threshold +fusion_clock.weekly_overtime_threshold +fusion_clock.office_user_id +fusion_clock.very_late_threshold_minutes +fusion_clock.max_monthly_absences +fusion_clock.enable_employee_notifications +fusion_clock.reminder_before_shift_minutes +fusion_clock.reminder_before_end_minutes +fusion_clock.send_weekly_summary +fusion_clock.enable_ip_fallback +fusion_clock.enable_photo_verification +fusion_clock.google_maps_api_key +fusion_clock.enable_kiosk +fusion_clock.kiosk_pin_required +fusion_clock.enable_correction_requests +fusion_clock.enable_sounds +fusion_clock.pay_period_type +fusion_clock.pay_period_start +fusion_clock.auto_generate_reports +fusion_clock.send_employee_reports +fusion_clock.report_recipient_user_ids +fusion_clock.report_recipient_emails +fusion_clock.csv_column_mapping +fusion_clock.enable_nfc_kiosk +fusion_clock.nfc_photo_required +fusion_clock.nfc_enroll_password +fusion_clock.nfc_kiosk_debug +``` + +`fclk_report_recipient_user_ids` is a Many2many on settings but is persisted manually as comma-separated user IDs in `fusion_clock.report_recipient_user_ids`. + +## 12. Routes + +HTTP pages: + +``` +/my/clock +/my/clock/timesheets +/my/clock/reports +/my/clock/reports//download +/fusion_clock/kiosk +/fusion_clock/kiosk/nfc +``` + +JSON-RPC endpoints: + +``` +/fusion_clock/verify_location +/fusion_clock/clock_action +/fusion_clock/submit_reason +/fusion_clock/request_leave +/fusion_clock/request_correction +/fusion_clock/get_status +/fusion_clock/get_locations +/fusion_clock/get_settings +/fusion_clock/dashboard_data +/fusion_clock/kiosk/search +/fusion_clock/kiosk/verify_pin +/fusion_clock/kiosk/clock +/fusion_clock/kiosk/nfc/enroll +/fusion_clock/kiosk/nfc/tap +/fusion_clock/kiosk/nfc/employee_search +``` + +All new JSON endpoints must use `type="jsonrpc"`, not deprecated `type="json"`. + +## 13. Gotchas + +- Always use local-day helpers for date domains. UTC midnight boundaries will break attendance totals around timezone offsets. +- `hr.employee._get_fclk_scheduled_times(date)` returns naive UTC datetimes suitable for Odoo comparisons. +- Break deduction is stored as minutes in `hr.attendance.x_fclk_break_minutes`; penalties add to that same field. +- `x_fclk_net_hours` is computed from Odoo `worked_hours` minus break minutes. +- Daily overtime currently compares net hours to employee scheduled hours or daily threshold; weekly threshold is configured but not used in `hr.attendance._compute_overtime_hours()`. +- `fusion_clock.enable_ip_fallback` exists in settings, but server-side `_verify_location()` attempts IP whitelist matching whenever a client IP is present. +- NFC kiosk needs a company-level `x_fclk_nfc_kiosk_location_id`; without it taps return `no_location_configured`. +- Kiosk routes are authenticated (`auth='user'`) and manager-gated; wall tablets need a manager-authorised kiosk user. +- Portal report download manually streams the PDF binary rather than using `fusion_pdf_preview`. +- If CSS/assets change, bump `__manifest__.py` version so Odoo rebuilds bundles. + +## 14. Tests + +Tests are post-install tagged: + +``` +@tagged('-at_install', 'post_install', 'fusion_clock') +``` + +Coverage currently focuses on NFC: + +- `tests/test_nfc_models.py`: employee UID uniqueness, attendance NFC source/photo fields, company kiosk location field. +- `tests/test_clock_nfc_kiosk.py`: kiosk page gating, UID normalization, enroll endpoint, tap happy path, tap errors, photo-required handling, employee search. + +Run locally: + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --test-tags fusion_clock --stop-after-init +``` + +For a normal module upgrade: + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --stop-after-init +``` diff --git a/fusion_clock/__manifest__.py b/fusion_clock/__manifest__.py index 060b23fa..804fbc05 100644 --- a/fusion_clock/__manifest__.py +++ b/fusion_clock/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Clock', - 'version': '19.0.3.3.0', + 'version': '19.0.3.5.6', 'category': 'Human Resources/Attendances', 'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export', 'description': """ @@ -70,6 +70,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil 'views/clock_correction_views.xml', 'views/clock_dashboard_views.xml', 'views/hr_employee_views.xml', + 'views/clock_schedule_views.xml', # Wizards (must load before clock_menus.xml since menu references wizard action) 'wizard/clock_nfc_enrollment_views.xml', 'views/clock_menus.xml', @@ -89,15 +90,22 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil 'fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js', ], 'web.assets_backend': [ + 'fusion_clock/static/src/scss/_fusion_clock_shift_planner_tokens.scss', + 'fusion_clock/static/src/scss/fusion_clock_shift_planner.scss', 'fusion_clock/static/src/scss/fusion_clock.scss', 'fusion_clock/static/src/js/fusion_clock_systray.js', 'fusion_clock/static/src/xml/systray_clock.xml', 'fusion_clock/static/src/js/fusion_clock_dashboard.js', 'fusion_clock/static/src/xml/fusion_clock_dashboard.xml', + 'fusion_clock/static/src/js/fusion_clock_shift_planner.js', + 'fusion_clock/static/src/xml/fusion_clock_shift_planner.xml', 'fusion_clock/static/src/js/fusion_clock_location_map.js', 'fusion_clock/static/src/js/fusion_clock_location_places.js', 'fusion_clock/static/src/xml/fusion_clock_location.xml', ], + 'web.assets_web_dark': [ + 'fusion_clock/static/src/scss/fusion_clock_shift_planner.dark.scss', + ], }, 'installable': True, 'auto_install': False, diff --git a/fusion_clock/__pycache__/__init__.cpython-312.pyc b/fusion_clock/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..b9c37cd9 Binary files /dev/null and b/fusion_clock/__pycache__/__init__.cpython-312.pyc differ diff --git a/fusion_clock/controllers/__init__.py b/fusion_clock/controllers/__init__.py index 5ed5d851..e657e0f7 100644 --- a/fusion_clock/controllers/__init__.py +++ b/fusion_clock/controllers/__init__.py @@ -4,3 +4,4 @@ from . import portal_clock from . import clock_api from . import clock_kiosk from . import clock_nfc_kiosk +from . import shift_planner diff --git a/fusion_clock/controllers/__pycache__/__init__.cpython-312.pyc b/fusion_clock/controllers/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..bb280108 Binary files /dev/null and b/fusion_clock/controllers/__pycache__/__init__.cpython-312.pyc differ diff --git a/fusion_clock/controllers/__pycache__/clock_api.cpython-312.pyc b/fusion_clock/controllers/__pycache__/clock_api.cpython-312.pyc index f61f7854..68f6f442 100644 Binary files a/fusion_clock/controllers/__pycache__/clock_api.cpython-312.pyc and b/fusion_clock/controllers/__pycache__/clock_api.cpython-312.pyc differ diff --git a/fusion_clock/controllers/__pycache__/clock_kiosk.cpython-312.pyc b/fusion_clock/controllers/__pycache__/clock_kiosk.cpython-312.pyc new file mode 100644 index 00000000..29a88a21 Binary files /dev/null and b/fusion_clock/controllers/__pycache__/clock_kiosk.cpython-312.pyc differ diff --git a/fusion_clock/controllers/__pycache__/clock_nfc_kiosk.cpython-312.pyc b/fusion_clock/controllers/__pycache__/clock_nfc_kiosk.cpython-312.pyc new file mode 100644 index 00000000..9400206e Binary files /dev/null and b/fusion_clock/controllers/__pycache__/clock_nfc_kiosk.cpython-312.pyc differ diff --git a/fusion_clock/controllers/__pycache__/portal_clock.cpython-312.pyc b/fusion_clock/controllers/__pycache__/portal_clock.cpython-312.pyc index 9d5f15ad..9092d41d 100644 Binary files a/fusion_clock/controllers/__pycache__/portal_clock.cpython-312.pyc and b/fusion_clock/controllers/__pycache__/portal_clock.cpython-312.pyc differ diff --git a/fusion_clock/controllers/__pycache__/shift_planner.cpython-312.pyc b/fusion_clock/controllers/__pycache__/shift_planner.cpython-312.pyc new file mode 100644 index 00000000..4dbce076 Binary files /dev/null and b/fusion_clock/controllers/__pycache__/shift_planner.cpython-312.pyc differ diff --git a/fusion_clock/controllers/clock_api.py b/fusion_clock/controllers/clock_api.py index 3c1095c1..fc2efe57 100644 --- a/fusion_clock/controllers/clock_api.py +++ b/fusion_clock/controllers/clock_api.py @@ -5,6 +5,7 @@ import base64 import math import logging +import pytz from datetime import datetime, timedelta from odoo import http, fields, _ from odoo.http import request @@ -108,6 +109,9 @@ class FusionClockAPI(http.Controller): ICP = request.env['ir.config_parameter'].sudo() if ICP.get_param('fusion_clock.enable_penalties', 'True') != 'True': return + day_plan = employee._get_fclk_day_plan(get_local_today(request.env, employee)) + if day_plan.get('source') == 'schedule' and day_plan.get('is_off'): + return grace = float(ICP.get_param('fusion_clock.penalty_grace_minutes', '5')) deduction = float(ICP.get_param('fusion_clock.penalty_deduction_minutes', '15')) @@ -161,7 +165,16 @@ class FusionClockAPI(http.Controller): worked = attendance.worked_hours or 0.0 if worked >= threshold: - break_min = employee._get_fclk_break_minutes() + local_date = get_local_today(request.env, employee) + if attendance.check_in: + tz_name = ( + employee.resource_id.tz + or (employee.user_id.partner_id.tz if employee.user_id else False) + or employee.company_id.partner_id.tz + or 'UTC' + ) + local_date = pytz.UTC.localize(attendance.check_in).astimezone(pytz.timezone(tz_name)).date() + break_min = employee._get_fclk_break_minutes(local_date) current = attendance.x_fclk_break_minutes or 0.0 # Set to whichever is higher: configured break or existing (penalty-inflated) value new_val = max(break_min, current) @@ -268,6 +281,8 @@ class FusionClockAPI(http.Controller): now = fields.Datetime.now() today = get_local_today(request.env, employee) + day_plan = employee._get_fclk_day_plan(today) + is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off') geo_info = { 'latitude': latitude, @@ -307,6 +322,34 @@ class FusionClockAPI(http.Controller): source=source, ) + if is_scheduled_off: + self._log_activity( + employee, 'unscheduled_shift', + f"Clocked in on a scheduled OFF day at {location.name}.", + attendance=attendance, location=location, + latitude=latitude, longitude=longitude, distance=distance, + source=source, + ) + office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0')) + if office_user_id: + request.env['hr.attendance'].sudo()._fclk_notify_office( + office_user_id, + f"Unscheduled Shift: {employee.name}", + f"{employee.name} clocked in on a scheduled OFF day.", + 'hr.attendance', + attendance.id, + ) + return { + 'success': True, + 'action': 'clock_in', + 'attendance_id': attendance.id, + 'check_in': fields.Datetime.to_string(attendance.check_in), + 'location_name': location.name, + 'location_address': location.address or '', + 'message': f'Clocked in at {location.name} (unscheduled shift)', + 'streak': employee.x_fclk_ontime_streak, + } + # Check for late clock-in penalty scheduled_in, _ = self._get_scheduled_times(employee, today) self._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now) @@ -359,8 +402,9 @@ class FusionClockAPI(http.Controller): self._apply_break_deduction(attendance, employee) # Check for early clock-out penalty - _, scheduled_out = self._get_scheduled_times(employee, today) - self._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now) + if not is_scheduled_off: + _, scheduled_out = self._get_scheduled_times(employee, today) + self._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now) # Log clock-out self._log_activity( @@ -518,6 +562,13 @@ class FusionClockAPI(http.Controller): 'pending_reason': employee.x_fclk_pending_reason, 'ontime_streak': employee.x_fclk_ontime_streak, } + local_today = get_local_today(request.env, employee) + day_plan = employee._get_fclk_day_plan(local_today) + result.update({ + 'scheduled_shift': day_plan.get('label') or '', + 'scheduled_hours': round(day_plan.get('hours') or 0.0, 2), + 'scheduled_off': bool(day_plan.get('is_off')), + }) if is_checked_in: att = request.env['hr.attendance'].sudo().search([ @@ -533,7 +584,6 @@ class FusionClockAPI(http.Controller): 'location_id': att.x_fclk_location_id.id or False, }) - local_today = get_local_today(request.env, employee) today_start_utc, today_end_utc = get_local_day_boundaries(request.env, local_today, employee) today_atts = request.env['hr.attendance'].sudo().search([ ('employee_id', '=', employee.id), diff --git a/fusion_clock/controllers/clock_kiosk.py b/fusion_clock/controllers/clock_kiosk.py index 9f2b292c..30ca2fcb 100644 --- a/fusion_clock/controllers/clock_kiosk.py +++ b/fusion_clock/controllers/clock_kiosk.py @@ -5,6 +5,7 @@ import logging from odoo import http, fields, _ from odoo.http import request +from odoo.addons.fusion_clock.models.tz_utils import get_local_today _logger = logging.getLogger(__name__) @@ -93,7 +94,9 @@ class FusionClockKiosk(http.Controller): is_checked_in = employee.attendance_state == 'checked_in' now = fields.Datetime.now() - today = now.date() + today = get_local_today(request.env, employee) + day_plan = employee._get_fclk_day_plan(today) + is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off') geo_info = { 'latitude': latitude, @@ -120,8 +123,17 @@ class FusionClockKiosk(http.Controller): source='kiosk', ) - scheduled_in, _ = api._get_scheduled_times(employee, today) - api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now) + if is_scheduled_off: + api._log_activity( + employee, 'unscheduled_shift', + f"Kiosk clock-in on a scheduled OFF day at {location.name}", + attendance=attendance, location=location, + latitude=latitude, longitude=longitude, distance=distance, + source='kiosk', + ) + else: + scheduled_in, _ = api._get_scheduled_times(employee, today) + api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now) return { 'success': True, @@ -135,8 +147,9 @@ class FusionClockKiosk(http.Controller): }) api._apply_break_deduction(attendance, employee) - _, scheduled_out = api._get_scheduled_times(employee, today) - api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now) + if not is_scheduled_off: + _, scheduled_out = api._get_scheduled_times(employee, today) + api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now) api._log_activity( employee, 'clock_out', diff --git a/fusion_clock/controllers/clock_nfc_kiosk.py b/fusion_clock/controllers/clock_nfc_kiosk.py index d04c6d99..ae18ebc9 100644 --- a/fusion_clock/controllers/clock_nfc_kiosk.py +++ b/fusion_clock/controllers/clock_nfc_kiosk.py @@ -8,6 +8,7 @@ import time import threading from odoo import fields, http from odoo.http import request +from odoo.addons.fusion_clock.models.tz_utils import get_local_today _logger = logging.getLogger(__name__) _UID_HEX_PATTERN = re.compile(r'^[0-9A-F]+$') @@ -183,7 +184,9 @@ class FusionClockNfcKiosk(http.Controller): is_checked_in = employee.attendance_state == 'checked_in' now = fields.Datetime.now() - today = now.date() + today = get_local_today(request.env, employee) + day_plan = employee._get_fclk_day_plan(today) + is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off') geo_info = { 'latitude': 0, @@ -208,8 +211,17 @@ class FusionClockNfcKiosk(http.Controller): latitude=0, longitude=0, distance=0, source='nfc_kiosk', ) - scheduled_in, _ = api._get_scheduled_times(employee, today) - api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now) + if is_scheduled_off: + api._log_activity( + employee, 'unscheduled_shift', + f"NFC kiosk clock-in on a scheduled OFF day at {location.name}", + attendance=attendance, location=location, + latitude=0, longitude=0, distance=0, + source='nfc_kiosk', + ) + else: + scheduled_in, _ = api._get_scheduled_times(employee, today) + api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now) return { 'success': True, 'action': 'clock_in', @@ -224,8 +236,9 @@ class FusionClockNfcKiosk(http.Controller): 'x_fclk_check_out_photo': photo_bytes if photo_bytes else False, }) api._apply_break_deduction(attendance, employee) - _, scheduled_out = api._get_scheduled_times(employee, today) - api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now) + if not is_scheduled_off: + _, scheduled_out = api._get_scheduled_times(employee, today) + api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now) api._log_activity( employee, 'clock_out', f"NFC kiosk clock-out from {location.name}. Net: {attendance.x_fclk_net_hours:.1f}h", diff --git a/fusion_clock/controllers/portal_clock.py b/fusion_clock/controllers/portal_clock.py index a6876ea7..6c22953e 100644 --- a/fusion_clock/controllers/portal_clock.py +++ b/fusion_clock/controllers/portal_clock.py @@ -100,7 +100,9 @@ class FusionClockPortal(CustomerPortal): ], limit=1) # Today stats - today_start, _ = get_local_day_boundaries(request.env, get_local_today(request.env, employee), employee) + today = get_local_today(request.env, employee) + today_schedule = employee._get_fclk_day_plan(today) + today_start, _ = get_local_day_boundaries(request.env, today, employee) today_atts = request.env['hr.attendance'].sudo().search([ ('employee_id', '=', employee.id), ('check_in', '>=', today_start), @@ -109,7 +111,6 @@ class FusionClockPortal(CustomerPortal): today_hours = sum(a.x_fclk_net_hours or 0 for a in today_atts) # Week stats - today = get_local_today(request.env, employee) week_start = today - timedelta(days=today.weekday()) week_start_dt, _ = get_local_day_boundaries(request.env, week_start, employee) week_atts = request.env['hr.attendance'].sudo().search([ @@ -151,6 +152,7 @@ class FusionClockPortal(CustomerPortal): 'current_attendance': current_attendance, 'today_hours': round(today_hours, 1), 'week_hours': round(week_hours, 1), + 'today_schedule': today_schedule, 'recent_attendances': recent, 'google_maps_key': google_maps_key, 'enable_sounds': enable_sounds, diff --git a/fusion_clock/controllers/shift_planner.py b/fusion_clock/controllers/shift_planner.py new file mode 100644 index 00000000..e10f2505 --- /dev/null +++ b/fusion_clock/controllers/shift_planner.py @@ -0,0 +1,269 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +import base64 +import io +from collections import defaultdict +from datetime import timedelta + +from odoo import fields, http, _ +from odoo.exceptions import ValidationError +from odoo.http import request + + +class FusionClockShiftPlanner(http.Controller): + """Backend JSON-RPC API for the Excel-style weekly shift planner.""" + + def _check_manager(self): + return request.env.user.has_group('fusion_clock.group_fusion_clock_manager') + + def _week_start(self, week_start=None): + date_obj = fields.Date.to_date(week_start) if week_start else fields.Date.today() + return date_obj - timedelta(days=date_obj.weekday()) + + def _manager_employees(self): + return request.env['hr.employee'].sudo().search([ + ('x_fclk_enable_clock', '=', True), + ('company_id', 'in', request.env.user.company_ids.ids), + ], order='department_id, name') + + def _load_week_data(self, week_start=None): + start = self._week_start(week_start) + days = [start + timedelta(days=i) for i in range(7)] + employees = self._manager_employees() + Schedule = request.env['fusion.clock.schedule'].sudo() + + schedules = Schedule.search([ + ('employee_id', 'in', employees.ids), + ('schedule_date', '>=', start), + ('schedule_date', '<=', days[-1]), + ]) + schedule_map = { + (schedule.employee_id.id, schedule.schedule_date): schedule + for schedule in schedules + } + + grouped = defaultdict(list) + for employee in employees: + grouped[employee.department_id.id or 0].append(employee) + + departments = [] + employee_rows = [] + for department_id, department_employees in grouped.items(): + department = department_employees[0].department_id + departments.append({ + 'id': department_id, + 'name': department.name if department else _('No Department'), + 'employee_ids': [emp.id for emp in department_employees], + }) + for employee in department_employees: + cells = {} + for day in days: + cells[str(day)] = Schedule.fclk_cell_payload( + employee, + day, + schedule_map.get((employee.id, day)), + ) + employee_rows.append({ + 'id': employee.id, + 'name': employee.name, + 'department_id': department_id, + 'department_name': department.name if department else _('No Department'), + 'job_title': employee.job_title or '', + 'cells': cells, + }) + + shifts = request.env['fusion.clock.shift'].sudo().search([ + ('active', '=', True), + ('company_id', 'in', request.env.user.company_ids.ids), + ], order='sequence, name') + + return { + 'week_start': str(start), + 'week_end': str(days[-1]), + 'days': [{ + 'date': str(day), + 'weekday': day.strftime('%a').upper(), + 'label': day.strftime('%d-%b'), + } for day in days], + 'departments': departments, + 'employees': employee_rows, + 'shifts': [{ + 'id': shift.id, + 'name': shift.name, + 'start_time': shift.start_time, + 'end_time': shift.end_time, + 'break_minutes': shift.break_minutes, + 'hours': shift.scheduled_hours, + 'hours_display': Schedule.fclk_hours_display(shift.scheduled_hours), + 'label': '%s - %s' % ( + Schedule.fclk_float_to_display(shift.start_time), + Schedule.fclk_float_to_display(shift.end_time), + ), + 'option_label': '%s (%s - %s)' % ( + shift.name, + Schedule.fclk_float_to_display(shift.start_time), + Schedule.fclk_float_to_display(shift.end_time), + ), + } for shift in shifts], + } + + @http.route('/fusion_clock/shift_planner/load', type='jsonrpc', auth='user', methods=['POST']) + def load(self, week_start=None, **kw): + if not self._check_manager(): + return {'error': 'Access denied.'} + return self._load_week_data(week_start) + + @http.route('/fusion_clock/shift_planner/save', type='jsonrpc', auth='user', methods=['POST']) + def save(self, week_start=None, changes=None, **kw): + if not self._check_manager(): + return {'error': 'Access denied.'} + + employees = self._manager_employees() + employee_map = {employee.id: employee for employee in employees} + Schedule = request.env['fusion.clock.schedule'].sudo() + errors = [] + saved = 0 + + for change in changes or []: + employee_id = int(change.get('employee_id') or 0) + employee = employee_map.get(employee_id) + date_str = change.get('date') + if not employee: + errors.append({ + 'employee_id': employee_id, + 'date': date_str, + 'message': 'Employee not found or not allowed.', + }) + continue + try: + Schedule.fclk_apply_planner_cell(employee, date_str, change, request.env.user) + saved += 1 + except ValidationError as exc: + errors.append({ + 'employee_id': employee_id, + 'date': date_str, + 'message': str(exc.args[0] if exc.args else exc), + }) + + if errors: + return {'success': False, 'saved': saved, 'errors': errors} + return { + 'success': True, + 'saved': saved, + 'data': self._load_week_data(week_start), + } + + @http.route('/fusion_clock/shift_planner/copy_previous_week', type='jsonrpc', auth='user', methods=['POST']) + def copy_previous_week(self, week_start=None, **kw): + if not self._check_manager(): + return {'error': 'Access denied.'} + + start = self._week_start(week_start) + prev_start = start - timedelta(days=7) + employees = self._manager_employees() + Schedule = request.env['fusion.clock.schedule'].sudo() + prev_schedules = Schedule.search([ + ('employee_id', 'in', employees.ids), + ('schedule_date', '>=', prev_start), + ('schedule_date', '<=', prev_start + timedelta(days=6)), + ]) + prev_map = { + (schedule.employee_id.id, schedule.schedule_date): schedule + for schedule in prev_schedules + } + + before_count = request.env['fusion.clock.schedule.audit'].sudo().search_count([]) + for employee in employees: + for offset in range(7): + source_date = prev_start + timedelta(days=offset) + target_date = start + timedelta(days=offset) + source = prev_map.get((employee.id, source_date)) + if not source: + payload = {'input': ''} + elif source.is_off: + payload = {'input': 'OFF'} + elif source.shift_id: + payload = {'shift_id': source.shift_id.id, 'input': source.fclk_display_value()} + else: + payload = { + 'input': source.fclk_display_value(), + 'start_time': source.start_time, + 'end_time': source.end_time, + 'break_minutes': source.break_minutes, + } + Schedule.fclk_apply_planner_cell(employee, target_date, payload, request.env.user) + + after_count = request.env['fusion.clock.schedule.audit'].sudo().search_count([]) + return { + 'success': True, + 'changed': after_count - before_count, + 'data': self._load_week_data(start), + } + + @http.route('/fusion_clock/shift_planner/export_xlsx', type='jsonrpc', auth='user', methods=['POST']) + def export_xlsx(self, week_start=None, **kw): + if not self._check_manager(): + return {'error': 'Access denied.'} + + data = self._load_week_data(week_start) + output = io.BytesIO() + import xlsxwriter + + workbook = xlsxwriter.Workbook(output, {'in_memory': True}) + sheet = workbook.add_worksheet('Shift Planner') + + fmt_day = workbook.add_format({'bold': True, 'align': 'center', 'bg_color': '#b7dff5', 'border': 1}) + fmt_sub = workbook.add_format({'bold': True, 'align': 'center', 'bg_color': '#d8e9bd', 'border': 1}) + fmt_employee = workbook.add_format({'bold': True, 'border': 1}) + fmt_shift = workbook.add_format({'border': 1}) + fmt_hours = workbook.add_format({'border': 1, 'align': 'center', 'bg_color': '#f5d39b'}) + fmt_department = workbook.add_format({'bold': True, 'bg_color': '#eeeeee', 'border': 1}) + + sheet.set_column(0, 0, 22) + for col in range(1, 15, 2): + sheet.set_column(col, col, 24) + sheet.set_column(col + 1, col + 1, 9) + + sheet.write(0, 0, 'EMPLOYEE', fmt_day) + col = 1 + for day in data['days']: + sheet.merge_range(0, col, 0, col + 1, day['weekday'], fmt_day) + sheet.merge_range(1, col, 1, col + 1, day['label'], fmt_day) + sheet.write(2, col, 'Shift', fmt_sub) + sheet.write(2, col + 1, 'Hours', fmt_sub) + col += 2 + sheet.write(2, 0, 'EMPLOYEE', fmt_sub) + + row = 3 + employee_by_id = {emp['id']: emp for emp in data['employees']} + for department in data['departments']: + sheet.merge_range(row, 0, row, 14, department['name'], fmt_department) + row += 1 + for employee_id in department['employee_ids']: + employee = employee_by_id[employee_id] + sheet.write(row, 0, employee['name'], fmt_employee) + col = 1 + for day in data['days']: + cell = employee['cells'][day['date']] + sheet.write(row, col, cell.get('label') or '', fmt_shift) + sheet.write(row, col + 1, cell.get('hours_display') or '0:00', fmt_hours) + col += 2 + row += 1 + + workbook.close() + output.seek(0) + filename = 'shift_planner_%s.xlsx' % data['week_start'] + attachment = request.env['ir.attachment'].sudo().create({ + 'name': filename, + 'type': 'binary', + 'datas': base64.b64encode(output.read()), + 'mimetype': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }) + return { + 'success': True, + 'attachment_id': attachment.id, + 'filename': filename, + 'url': '/web/content/%s?download=true' % attachment.id, + } diff --git a/fusion_clock/models/__init__.py b/fusion_clock/models/__init__.py index 6e1d0c57..46f8ba48 100644 --- a/fusion_clock/models/__init__.py +++ b/fusion_clock/models/__init__.py @@ -9,5 +9,6 @@ from . import res_config_settings from . import clock_activity_log from . import clock_leave_request from . import clock_shift +from . import clock_schedule from . import clock_correction from . import res_company diff --git a/fusion_clock/models/__pycache__/__init__.cpython-312.pyc b/fusion_clock/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..fc9a5395 Binary files /dev/null and b/fusion_clock/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/fusion_clock/models/__pycache__/clock_activity_log.cpython-312.pyc b/fusion_clock/models/__pycache__/clock_activity_log.cpython-312.pyc new file mode 100644 index 00000000..10082a54 Binary files /dev/null and b/fusion_clock/models/__pycache__/clock_activity_log.cpython-312.pyc differ diff --git a/fusion_clock/models/__pycache__/clock_correction.cpython-312.pyc b/fusion_clock/models/__pycache__/clock_correction.cpython-312.pyc new file mode 100644 index 00000000..5f4338a8 Binary files /dev/null and b/fusion_clock/models/__pycache__/clock_correction.cpython-312.pyc differ diff --git a/fusion_clock/models/__pycache__/clock_leave_request.cpython-312.pyc b/fusion_clock/models/__pycache__/clock_leave_request.cpython-312.pyc new file mode 100644 index 00000000..d9dbc3b2 Binary files /dev/null and b/fusion_clock/models/__pycache__/clock_leave_request.cpython-312.pyc differ diff --git a/fusion_clock/models/__pycache__/clock_location.cpython-312.pyc b/fusion_clock/models/__pycache__/clock_location.cpython-312.pyc new file mode 100644 index 00000000..ce9b0228 Binary files /dev/null and b/fusion_clock/models/__pycache__/clock_location.cpython-312.pyc differ diff --git a/fusion_clock/models/__pycache__/clock_penalty.cpython-312.pyc b/fusion_clock/models/__pycache__/clock_penalty.cpython-312.pyc new file mode 100644 index 00000000..951eed60 Binary files /dev/null and b/fusion_clock/models/__pycache__/clock_penalty.cpython-312.pyc differ diff --git a/fusion_clock/models/__pycache__/clock_report.cpython-312.pyc b/fusion_clock/models/__pycache__/clock_report.cpython-312.pyc index fe27c898..a0b8b9a5 100644 Binary files a/fusion_clock/models/__pycache__/clock_report.cpython-312.pyc and b/fusion_clock/models/__pycache__/clock_report.cpython-312.pyc differ diff --git a/fusion_clock/models/__pycache__/clock_schedule.cpython-312.pyc b/fusion_clock/models/__pycache__/clock_schedule.cpython-312.pyc new file mode 100644 index 00000000..1f37eb6e Binary files /dev/null and b/fusion_clock/models/__pycache__/clock_schedule.cpython-312.pyc differ diff --git a/fusion_clock/models/__pycache__/clock_shift.cpython-312.pyc b/fusion_clock/models/__pycache__/clock_shift.cpython-312.pyc new file mode 100644 index 00000000..557582e0 Binary files /dev/null and b/fusion_clock/models/__pycache__/clock_shift.cpython-312.pyc differ diff --git a/fusion_clock/models/__pycache__/hr_attendance.cpython-312.pyc b/fusion_clock/models/__pycache__/hr_attendance.cpython-312.pyc index 03340488..99ad6695 100644 Binary files a/fusion_clock/models/__pycache__/hr_attendance.cpython-312.pyc and b/fusion_clock/models/__pycache__/hr_attendance.cpython-312.pyc differ diff --git a/fusion_clock/models/__pycache__/hr_employee.cpython-312.pyc b/fusion_clock/models/__pycache__/hr_employee.cpython-312.pyc index be868b36..115ef34c 100644 Binary files a/fusion_clock/models/__pycache__/hr_employee.cpython-312.pyc and b/fusion_clock/models/__pycache__/hr_employee.cpython-312.pyc differ diff --git a/fusion_clock/models/__pycache__/res_company.cpython-312.pyc b/fusion_clock/models/__pycache__/res_company.cpython-312.pyc new file mode 100644 index 00000000..2e3157be Binary files /dev/null and b/fusion_clock/models/__pycache__/res_company.cpython-312.pyc differ diff --git a/fusion_clock/models/__pycache__/res_config_settings.cpython-312.pyc b/fusion_clock/models/__pycache__/res_config_settings.cpython-312.pyc new file mode 100644 index 00000000..0e699f1a Binary files /dev/null and b/fusion_clock/models/__pycache__/res_config_settings.cpython-312.pyc differ diff --git a/fusion_clock/models/__pycache__/tz_utils.cpython-312.pyc b/fusion_clock/models/__pycache__/tz_utils.cpython-312.pyc index 5bcbef22..f23e55a1 100644 Binary files a/fusion_clock/models/__pycache__/tz_utils.cpython-312.pyc and b/fusion_clock/models/__pycache__/tz_utils.cpython-312.pyc differ diff --git a/fusion_clock/models/clock_activity_log.py b/fusion_clock/models/clock_activity_log.py index 67f012e5..d151257d 100644 --- a/fusion_clock/models/clock_activity_log.py +++ b/fusion_clock/models/clock_activity_log.py @@ -34,6 +34,7 @@ class FusionClockActivityLog(models.Model): ('correction_request', 'Correction Request'), ('ip_fallback', 'IP Fallback Used'), ('streak_milestone', 'Streak Milestone'), + ('unscheduled_shift', 'Unscheduled Shift'), ('card_enrollment', 'Card Enrollment'), ('unknown_card_tap', 'Unknown Card Tap'), ], @@ -108,6 +109,7 @@ class FusionClockActivityLog(models.Model): 'correction_request': 'Correction Request', 'ip_fallback': 'IP Fallback Used', 'streak_milestone': 'Streak Milestone', + 'unscheduled_shift': 'Unscheduled Shift', } @api.depends('latitude', 'longitude') diff --git a/fusion_clock/models/clock_schedule.py b/fusion_clock/models/clock_schedule.py new file mode 100644 index 00000000..0c021653 --- /dev/null +++ b/fusion_clock/models/clock_schedule.py @@ -0,0 +1,414 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +import re + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError + + +class FusionClockSchedule(models.Model): + _name = 'fusion.clock.schedule' + _description = 'Clock Shift Schedule Entry' + _order = 'schedule_date, employee_id' + _rec_name = 'display_name' + + employee_id = fields.Many2one( + 'hr.employee', + string='Employee', + required=True, + index=True, + ondelete='cascade', + ) + schedule_date = fields.Date( + string='Date', + required=True, + index=True, + ) + shift_id = fields.Many2one( + 'fusion.clock.shift', + string='Shift Template', + ondelete='set null', + ) + is_off = fields.Boolean( + string='Off', + default=False, + index=True, + ) + start_time = fields.Float( + string='Start Time', + default=9.0, + ) + end_time = fields.Float( + string='End Time', + default=17.0, + ) + break_minutes = fields.Float( + string='Break (min)', + default=30.0, + ) + planned_hours = fields.Float( + string='Hours', + compute='_compute_planned_hours', + store=True, + ) + note = fields.Char(string='Note') + company_id = fields.Many2one( + 'res.company', + string='Company', + related='employee_id.company_id', + store=True, + readonly=True, + ) + department_id = fields.Many2one( + 'hr.department', + string='Department', + related='employee_id.department_id', + store=True, + readonly=True, + ) + display_name = fields.Char( + compute='_compute_display_name', + store=True, + ) + + _employee_date_unique = models.Constraint( + 'UNIQUE(employee_id, schedule_date)', + 'Only one shift schedule is allowed per employee per day.', + ) + + @api.depends('is_off', 'start_time', 'end_time', 'break_minutes') + def _compute_planned_hours(self): + for rec in self: + if rec.is_off: + rec.planned_hours = 0.0 + continue + raw_hours = (rec.end_time or 0.0) - (rec.start_time or 0.0) + rec.planned_hours = round(max(raw_hours - ((rec.break_minutes or 0.0) / 60.0), 0.0), 2) + + @api.depends('employee_id', 'schedule_date', 'is_off', 'start_time', 'end_time') + def _compute_display_name(self): + for rec in self: + emp = rec.employee_id.name or '' + date_str = str(rec.schedule_date) if rec.schedule_date else '' + rec.display_name = f"{emp} - {date_str} - {rec.fclk_display_value()}" + + @api.constrains('is_off', 'start_time', 'end_time', 'break_minutes') + def _check_schedule_times(self): + for rec in self: + if rec.break_minutes < 0: + raise ValidationError(_("Break minutes cannot be negative.")) + if rec.is_off: + continue + if rec.start_time < 0 or rec.start_time >= 24: + raise ValidationError(_("Start time must be between 00:00 and 23:59.")) + if rec.end_time <= 0 or rec.end_time > 24: + raise ValidationError(_("End time must be between 00:01 and 24:00.")) + if rec.end_time <= rec.start_time: + raise ValidationError(_("End time must be after start time. Overnight shifts are not supported yet.")) + shift_minutes = (rec.end_time - rec.start_time) * 60.0 + if rec.break_minutes >= shift_minutes: + raise ValidationError(_("Break duration must be shorter than the scheduled shift.")) + + @api.onchange('shift_id') + def _onchange_shift_id(self): + for rec in self: + if rec.shift_id: + rec.is_off = False + rec.start_time = rec.shift_id.start_time + rec.end_time = rec.shift_id.end_time + rec.break_minutes = rec.shift_id.break_minutes + + @api.model + def fclk_float_to_display(self, value): + value = float(value or 0.0) + hour = int(value) + minute = int(round((value - hour) * 60)) + if minute == 60: + hour += 1 + minute = 0 + suffix = 'am' if hour < 12 or hour == 24 else 'pm' + display_hour = hour % 12 + if display_hour == 0: + display_hour = 12 + return f"{display_hour}:{minute:02d} {suffix}" + + def fclk_display_value(self): + self.ensure_one() + if self.is_off: + return 'OFF' + return ( + f"{self.env['fusion.clock.schedule'].fclk_float_to_display(self.start_time)} - " + f"{self.env['fusion.clock.schedule'].fclk_float_to_display(self.end_time)}" + ) + + @api.model + def fclk_hours_display(self, hours): + hours = float(hours or 0.0) + whole = int(hours) + minutes = int(round((hours - whole) * 60)) + if minutes == 60: + whole += 1 + minutes = 0 + return f"{whole}:{minutes:02d}" + + @api.model + def _fclk_parse_time_part(self, raw): + text = (raw or '').strip().lower().replace('.', '') + match = re.match(r'^(\d{1,2})(?::(\d{1,2}))?\s*(am|pm)?$', text) + if not match: + raise ValidationError(_("Could not understand time '%s'.") % raw) + hour = int(match.group(1)) + minute = int(match.group(2) or 0) + meridiem = match.group(3) + if minute < 0 or minute > 59: + raise ValidationError(_("Minutes must be between 00 and 59.")) + if meridiem: + if hour < 1 or hour > 12: + raise ValidationError(_("12-hour times must use hours from 1 to 12.")) + if meridiem == 'am': + hour = 0 if hour == 12 else hour + else: + hour = 12 if hour == 12 else hour + 12 + elif hour > 24: + raise ValidationError(_("Hours must be between 0 and 24.")) + return hour + (minute / 60.0) + + @api.model + def fclk_parse_planner_input(self, input_value, default_break_minutes=30.0): + text = (input_value or '').strip() + if not text: + return {'clear': True} + if text.upper() == 'OFF': + return { + 'clear': False, + 'is_off': True, + 'shift_id': False, + 'start_time': 0.0, + 'end_time': 0.0, + 'break_minutes': 0.0, + } + + normalized = ( + text.replace('–', '-') + .replace('—', '-') + .replace(' to ', '-') + .replace(' TO ', '-') + ) + parts = [p.strip() for p in normalized.split('-', 1)] + if len(parts) != 2 or not parts[0] or not parts[1]: + raise ValidationError(_("Enter a shift as '9-5', '9:00-5:30', '9:00 am - 5:30 pm', or OFF.")) + start = self._fclk_parse_time_part(parts[0]) + end = self._fclk_parse_time_part(parts[1]) + if end <= start and end + 12 <= 24: + end += 12 + if end <= start: + raise ValidationError(_("End time must be after start time. Overnight shifts are not supported yet.")) + return { + 'clear': False, + 'is_off': False, + 'shift_id': False, + 'start_time': start, + 'end_time': end, + 'break_minutes': float(default_break_minutes or 0.0), + } + + @api.model + def fclk_values_from_planner_payload(self, payload, employee): + payload = payload or {} + if 'start_time' in payload and 'end_time' in payload and not payload.get('shift_id'): + if payload.get('is_off'): + return { + 'clear': False, + 'is_off': True, + 'shift_id': False, + 'start_time': 0.0, + 'end_time': 0.0, + 'break_minutes': 0.0, + } + return { + 'clear': False, + 'is_off': False, + 'shift_id': False, + 'start_time': float(payload.get('start_time') or 0.0), + 'end_time': float(payload.get('end_time') or 0.0), + 'break_minutes': float(payload.get('break_minutes') or 0.0), + } + shift_id = int(payload.get('shift_id') or 0) + if shift_id: + shift = self.env['fusion.clock.shift'].sudo().browse(shift_id) + if not shift.exists(): + raise ValidationError(_("Selected shift template no longer exists.")) + return { + 'clear': False, + 'shift_id': shift.id, + 'is_off': False, + 'start_time': shift.start_time, + 'end_time': shift.end_time, + 'break_minutes': shift.break_minutes, + } + + default_break = employee._get_fclk_break_minutes() if employee else 30.0 + return self.fclk_parse_planner_input(payload.get('input', ''), default_break) + + @api.model + def fclk_snapshot(self, schedule): + if not schedule: + return '' + return schedule.fclk_display_value() + + @api.model + def fclk_apply_planner_cell(self, employee, schedule_date, payload, user=None): + self = self.sudo() + employee = employee.sudo() + date_obj = fields.Date.to_date(schedule_date) + if not employee.exists() or not date_obj: + raise ValidationError(_("Invalid employee or schedule date.")) + + existing = self.search([ + ('employee_id', '=', employee.id), + ('schedule_date', '=', date_obj), + ], limit=1) + old_value = self.fclk_snapshot(existing) + parsed = self.fclk_values_from_planner_payload(payload, employee) + + if parsed.get('clear'): + if existing: + existing.unlink() + new_schedule = self.browse() + new_value = '' + else: + vals = { + 'employee_id': employee.id, + 'schedule_date': date_obj, + 'shift_id': parsed.get('shift_id') or False, + 'is_off': bool(parsed.get('is_off')), + 'start_time': parsed.get('start_time') or 0.0, + 'end_time': parsed.get('end_time') or 0.0, + 'break_minutes': parsed.get('break_minutes') or 0.0, + 'note': payload.get('note') or False, + } + if existing: + existing.write(vals) + new_schedule = existing + else: + new_schedule = self.create(vals) + new_value = new_schedule.fclk_display_value() + + if old_value != new_value: + self.env['fusion.clock.schedule.audit'].sudo().create({ + 'schedule_id': new_schedule.id if new_schedule else False, + 'employee_id': employee.id, + 'schedule_date': date_obj, + 'old_value': old_value, + 'new_value': new_value, + 'changed_by_id': (user or self.env.user).id, + 'changed_at': fields.Datetime.now(), + 'company_id': employee.company_id.id, + 'department_id': employee.department_id.id, + }) + return new_schedule + + @api.model + def fclk_cell_payload(self, employee, date_obj, schedule=None): + schedule = schedule or self.search([ + ('employee_id', '=', employee.id), + ('schedule_date', '=', date_obj), + ], limit=1) + Schedule = self.env['fusion.clock.schedule'] + if schedule: + return { + 'schedule_id': schedule.id, + 'source': 'schedule', + 'input': schedule.fclk_display_value(), + 'label': schedule.fclk_display_value(), + 'is_off': schedule.is_off, + 'shift_id': schedule.shift_id.id or False, + 'start_time': schedule.start_time, + 'end_time': schedule.end_time, + 'break_minutes': schedule.break_minutes, + 'hours': schedule.planned_hours, + 'hours_display': Schedule.fclk_hours_display(schedule.planned_hours), + 'note': schedule.note or '', + } + + plan = employee._get_fclk_day_plan(date_obj) + return { + 'schedule_id': False, + 'source': plan.get('source') or 'fallback', + 'input': plan.get('label') or '', + 'label': plan.get('label') or '', + 'is_off': plan.get('is_off', False), + 'shift_id': False, + 'start_time': plan.get('start_time') or 0.0, + 'end_time': plan.get('end_time') or 0.0, + 'break_minutes': plan.get('break_minutes') or 0.0, + 'hours': plan.get('hours') or 0.0, + 'hours_display': Schedule.fclk_hours_display(plan.get('hours') or 0.0), + 'note': '', + } + + +class FusionClockScheduleAudit(models.Model): + _name = 'fusion.clock.schedule.audit' + _description = 'Clock Schedule Change Audit' + _order = 'changed_at desc, id desc' + _rec_name = 'display_name' + + schedule_id = fields.Many2one( + 'fusion.clock.schedule', + string='Schedule', + ondelete='set null', + index=True, + ) + employee_id = fields.Many2one( + 'hr.employee', + string='Employee', + required=True, + index=True, + ondelete='cascade', + ) + schedule_date = fields.Date( + string='Schedule Date', + required=True, + index=True, + ) + old_value = fields.Char(string='Old Value') + new_value = fields.Char(string='New Value') + changed_by_id = fields.Many2one( + 'res.users', + string='Changed By', + required=True, + ondelete='restrict', + ) + changed_at = fields.Datetime( + string='Changed At', + default=fields.Datetime.now, + required=True, + index=True, + ) + company_id = fields.Many2one( + 'res.company', + string='Company', + index=True, + ) + department_id = fields.Many2one( + 'hr.department', + string='Department', + index=True, + ) + display_name = fields.Char( + compute='_compute_display_name', + store=True, + ) + + @api.depends('employee_id', 'schedule_date', 'old_value', 'new_value') + def _compute_display_name(self): + for rec in self: + rec.display_name = "%s - %s: %s -> %s" % ( + rec.employee_id.name or '', + rec.schedule_date or '', + rec.old_value or 'blank', + rec.new_value or 'blank', + ) diff --git a/fusion_clock/models/hr_attendance.py b/fusion_clock/models/hr_attendance.py index 6883fc83..2419bee0 100644 --- a/fusion_clock/models/hr_attendance.py +++ b/fusion_clock/models/hr_attendance.py @@ -227,7 +227,18 @@ class HrAttendance(models.Model): continue employee = att.employee_id - scheduled_hours = employee._get_fclk_scheduled_hours() if employee else daily_threshold + scheduled_hours = daily_threshold + if employee: + local_date = get_local_today(self.env, employee) + if att.check_in: + tz_name = ( + employee.resource_id.tz + or (employee.user_id.partner_id.tz if employee.user_id else False) + or employee.company_id.partner_id.tz + or 'UTC' + ) + local_date = pytz.UTC.localize(att.check_in).astimezone(pytz.timezone(tz_name)).date() + scheduled_hours = employee._get_fclk_scheduled_hours(local_date) net = att.x_fclk_net_hours or 0.0 if net > scheduled_hours: @@ -264,11 +275,14 @@ class HrAttendance(models.Model): employee = att.employee_id emp_tz = pytz.timezone(employee.tz or self.env.company.tz or 'UTC') check_in_date = pytz.UTC.localize(check_in).astimezone(emp_tz).date() - _, scheduled_out = employee._get_fclk_scheduled_times(check_in_date) - - deadline = scheduled_out + timedelta(minutes=grace_min) max_deadline = check_in + timedelta(hours=max_shift) - effective_deadline = min(deadline, max_deadline) + day_plan = employee._get_fclk_day_plan(check_in_date) + if day_plan.get('source') == 'schedule' and day_plan.get('is_off'): + effective_deadline = max_deadline + else: + _, scheduled_out = employee._get_fclk_scheduled_times(check_in_date) + deadline = scheduled_out + timedelta(minutes=grace_min) + effective_deadline = min(deadline, max_deadline) if now > effective_deadline: clock_out_time = min(effective_deadline, now) @@ -283,7 +297,7 @@ class HrAttendance(models.Model): # Apply break deduction 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() + break_min = employee._get_fclk_break_minutes(check_in_date) att.sudo().write({'x_fclk_break_minutes': break_min}) att.sudo().message_post( @@ -346,6 +360,9 @@ class HrAttendance(models.Model): if yesterday.weekday() >= 5: continue + day_plan = emp._get_fclk_day_plan(yesterday) + if day_plan.get('source') == 'schedule' and day_plan.get('is_off'): + continue day_start, day_end = get_local_day_boundaries(self.env, yesterday, emp) @@ -423,6 +440,9 @@ class HrAttendance(models.Model): if today.weekday() >= 5: continue + day_plan = emp._get_fclk_day_plan(today) + if day_plan.get('source') == 'schedule' and day_plan.get('is_off'): + continue if emp.x_fclk_last_reminder_date == today: continue diff --git a/fusion_clock/models/hr_employee.py b/fusion_clock/models/hr_employee.py index a347dd1f..fc808c9a 100644 --- a/fusion_clock/models/hr_employee.py +++ b/fusion_clock/models/hr_employee.py @@ -120,11 +120,82 @@ class HrEmployee(models.Model): 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. - Priority: employee override > shift > global setting. + def _get_fclk_schedule_for_date(self, date): + """Return this employee's dated Fusion Clock schedule for a local date.""" + self.ensure_one() + date_obj = fields.Date.to_date(date) + if not date_obj: + return self.env['fusion.clock.schedule'] + return self.env['fusion.clock.schedule'].sudo().search([ + ('employee_id', '=', self.id), + ('schedule_date', '=', date_obj), + ], limit=1) + + def _get_fclk_day_plan(self, date): + """Return the effective plan for a local date. + + Dated schedules are the source of truth. If none exists, the legacy + employee shift/global settings remain the fallback. """ self.ensure_one() + Schedule = self.env['fusion.clock.schedule'].sudo() + schedule = self._get_fclk_schedule_for_date(date) + if schedule: + return { + 'source': 'schedule', + 'schedule_id': schedule.id, + 'is_off': schedule.is_off, + 'start_time': schedule.start_time, + 'end_time': schedule.end_time, + 'break_minutes': schedule.break_minutes, + 'hours': schedule.planned_hours, + 'label': schedule.fclk_display_value(), + } + if self.x_fclk_shift_id: + shift = self.x_fclk_shift_id + hours = max((shift.end_time - shift.start_time) - (shift.break_minutes / 60.0), 0.0) + return { + 'source': 'fallback', + 'schedule_id': False, + 'is_off': False, + 'start_time': shift.start_time, + 'end_time': shift.end_time, + 'break_minutes': shift.break_minutes, + 'hours': hours, + 'label': '%s - %s' % ( + Schedule.fclk_float_to_display(shift.start_time), + Schedule.fclk_float_to_display(shift.end_time), + ), + } + + ICP = self.env['ir.config_parameter'].sudo() + start_time = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0')) + end_time = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0')) + break_minutes = float(ICP.get_param('fusion_clock.default_break_minutes', '30')) + hours = max((end_time - start_time) - (break_minutes / 60.0), 0.0) + return { + 'source': 'fallback', + 'schedule_id': False, + 'is_off': False, + 'start_time': start_time, + 'end_time': end_time, + 'break_minutes': break_minutes, + 'hours': hours, + 'label': '%s - %s' % ( + Schedule.fclk_float_to_display(start_time), + Schedule.fclk_float_to_display(end_time), + ), + } + + def _get_fclk_break_minutes(self, date=None): + """Return effective break minutes for this employee. + Priority: dated schedule > employee override > shift > global setting. + """ + self.ensure_one() + if date: + plan = self._get_fclk_day_plan(date) + if plan.get('source') == 'schedule' and not plan.get('is_off'): + return plan.get('break_minutes') or 0.0 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: @@ -138,7 +209,7 @@ class HrEmployee(models.Model): 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. + Uses dated schedule first, employee shift second, then global settings. The configured hours are interpreted in the employee's local timezone and converted to naive-UTC datetimes so they can be compared with Odoo's UTC-based ``fields.Datetime.now()``. @@ -146,13 +217,9 @@ class HrEmployee(models.Model): import pytz 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')) + plan = self._get_fclk_day_plan(date) + in_hour = plan.get('start_time') or 0.0 + out_hour = plan.get('end_time') or 0.0 in_h = int(in_hour) in_m = int((in_hour - in_h) * 60) @@ -179,16 +246,13 @@ class HrEmployee(models.Model): scheduled_out = local_out.astimezone(utc).replace(tzinfo=None) return scheduled_in, scheduled_out - def _get_fclk_scheduled_hours(self): + def _get_fclk_scheduled_hours(self, date=None): """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) + plan = self._get_fclk_day_plan(date or get_local_today(self.env, self)) + if plan.get('is_off'): + return 0.0 + return plan.get('hours') or 0.0 def _compute_absence_counts(self): ActivityLog = self.env['fusion.clock.activity.log'].sudo() diff --git a/fusion_clock/security/ir.model.access.csv b/fusion_clock/security/ir.model.access.csv index 44608586..f36b307c 100644 --- a/fusion_clock/security/ir.model.access.csv +++ b/fusion_clock/security/ir.model.access.csv @@ -11,6 +11,9 @@ access_fusion_clock_leave_request_user,fusion.clock.leave.request.user,model_fus 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_schedule_user,fusion.clock.schedule.user,model_fusion_clock_schedule,group_fusion_clock_user,1,0,0,0 +access_fusion_clock_schedule_manager,fusion.clock.schedule.manager,model_fusion_clock_schedule,group_fusion_clock_manager,1,1,1,1 +access_fusion_clock_schedule_audit_manager,fusion.clock.schedule.audit.manager,model_fusion_clock_schedule_audit,group_fusion_clock_manager,1,0,0,0 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 @@ -22,4 +25,5 @@ access_fusion_clock_correction_portal,fusion.clock.correction.portal,model_fusio 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 +access_fusion_clock_schedule_portal,fusion.clock.schedule.portal,model_fusion_clock_schedule,base.group_portal,1,0,0,0 access_fusion_clock_nfc_enrollment_wizard_manager,fusion.clock.nfc.enrollment.wizard.manager,model_fusion_clock_nfc_enrollment_wizard,group_fusion_clock_manager,1,1,1,1 diff --git a/fusion_clock/security/security.xml b/fusion_clock/security/security.xml index 3eb293e7..017aa1e2 100644 --- a/fusion_clock/security/security.xml +++ b/fusion_clock/security/security.xml @@ -174,6 +174,49 @@ + + + Schedule: User sees own + + [('employee_id.user_id', '=', user.id)] + + + + + + + + + Schedule: Team Lead sees direct reports + + ['|', ('employee_id.user_id', '=', user.id), ('employee_id.parent_id.user_id', '=', user.id)] + + + + + + + + + Schedule: Manager full access + + [('company_id', 'in', company_ids)] + + + + + Schedule Audit: Manager reads all + + [('company_id', 'in', company_ids)] + + + + + + + @@ -286,4 +329,15 @@ + + Schedule: 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 52850bb9..dc4c377f 100644 --- a/fusion_clock/static/src/css/portal_clock.css +++ b/fusion_clock/static/src/css/portal_clock.css @@ -219,6 +219,63 @@ body:has(.fclk-app) .o_footer { opacity: 0.5; } +/* ---- Scheduled Shift Card ---- */ +.fclk-schedule-card { + display: flex; + align-items: center; + gap: 12px; + background: var(--fclk-card); + border: 1px solid var(--fclk-card-border); + border-radius: 14px; + padding: 14px 16px; + margin: -14px 0 28px; + box-shadow: var(--fclk-shadow); +} + +.fclk-schedule-icon { + width: 38px; + height: 38px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 10px; + background: rgba(59, 130, 246, 0.12); + color: var(--fclk-blue); + font-size: 16px; +} + +.fclk-schedule-info { + min-width: 0; + flex: 1; +} + +.fclk-schedule-label { + color: var(--fclk-text-muted); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.fclk-schedule-value { + color: var(--fclk-text); + font-size: 14px; + font-weight: 650; + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.fclk-schedule-hours { + color: var(--fclk-text); + font-size: 18px; + font-weight: 700; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + /* ---- Timer Section ---- */ .fclk-timer-section { text-align: center; diff --git a/fusion_clock/static/src/js/fusion_clock_shift_planner.js b/fusion_clock/static/src/js/fusion_clock_shift_planner.js new file mode 100644 index 00000000..dae74005 --- /dev/null +++ b/fusion_clock/static/src/js/fusion_clock_shift_planner.js @@ -0,0 +1,741 @@ +/** @odoo-module **/ + +import { Component, onPatched, onWillStart, useExternalListener, useRef, useState } 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 FusionClockShiftPlanner extends Component { + static template = "fusion_clock.ShiftPlanner"; + static props = []; + + setup() { + this.notification = useService("notification"); + this.dirtyCells = {}; + this.root = useRef("root"); + this.editorRef = useRef("shiftEditor"); + this.activeCellAnchor = null; + this.activeEditorEmployee = null; + this.activeEditorDay = null; + this.timeOptions = this._buildTimeOptions(); + this.state = useState({ + loading: true, + saving: false, + weekStart: "", + weekEnd: "", + days: [], + departments: [], + employees: [], + shifts: [], + error: "", + dirtyCount: 0, + invalidCount: 0, + collapsed: {}, + editor: { + open: false, + employeeId: false, + employeeName: "", + date: "", + dayLabel: "", + startValue: "9.00", + endValue: "17.00", + breakMinutes: 30, + hoursDisplay: "7:30", + error: "", + top: 0, + left: 0, + }, + }); + + onWillStart(async () => { + await this.loadWeek(); + }); + useExternalListener( + window, + "click", + (ev) => this.onGlobalClick(ev), + { capture: true } + ); + useExternalListener(window, "resize", () => this._positionActiveEditor()); + useExternalListener(window, "scroll", () => this._positionActiveEditor(), true); + onPatched(() => { + this._positionActiveEditor(); + }); + } + + async loadWeek(weekStart = null) { + this.state.loading = true; + this.state.error = ""; + try { + const data = await rpc("/fusion_clock/shift_planner/load", { week_start: weekStart }); + if (data.error) { + this.state.error = data.error; + } else { + this._applyData(data); + } + } catch (error) { + this.state.error = error.message || "Failed to load shift planner."; + } + this.state.loading = false; + } + + _applyData(data) { + this.dirtyCells = {}; + this.state.weekStart = data.week_start; + this.state.weekEnd = data.week_end; + this.state.days = data.days || []; + this.state.departments = data.departments || []; + this.state.employees = data.employees || []; + this.state.shifts = data.shifts || []; + this.state.dirtyCount = 0; + this.state.invalidCount = 0; + this.state.error = ""; + this.closeCellEditor(); + } + + get weekTitle() { + if (!this.state.weekStart || !this.state.weekEnd) { + return ""; + } + return `${this.state.weekStart} to ${this.state.weekEnd}`; + } + + getDepartmentEmployees(department) { + const ids = new Set(department.employee_ids || []); + return this.state.employees.filter((employee) => ids.has(employee.id)); + } + + isCollapsed(department) { + return !!this.state.collapsed[department.id]; + } + + toggleDepartment(department) { + this.state.collapsed[department.id] = !this.state.collapsed[department.id]; + this.closeCellEditor(); + } + + async previousWeek() { + await this.loadWeek(this._dateAdd(this.state.weekStart, -7)); + } + + async nextWeek() { + await this.loadWeek(this._dateAdd(this.state.weekStart, 7)); + } + + async currentWeek() { + await this.loadWeek(); + } + + async copyPreviousWeek() { + if (!window.confirm("Copy the previous week into this week? Current saved cells for the week may be replaced.")) { + return; + } + this.state.saving = true; + try { + const result = await rpc("/fusion_clock/shift_planner/copy_previous_week", { + week_start: this.state.weekStart, + }); + if (result.error) { + this.notification.add(result.error, { type: "danger" }); + } else { + this._applyData(result.data); + this.notification.add(`Copied previous week (${result.changed || 0} changes).`, { type: "success" }); + } + } catch (error) { + this.notification.add(error.message || "Could not copy previous week.", { type: "danger" }); + } + this.state.saving = false; + } + + async save() { + this._recountInvalid(); + if (this.state.invalidCount) { + this.notification.add("Fix invalid shift cells before saving.", { type: "danger" }); + return; + } + const changes = Object.values(this.dirtyCells); + if (!changes.length) { + this.notification.add("No shift changes to save.", { type: "info" }); + return; + } + this.state.saving = true; + try { + const result = await rpc("/fusion_clock/shift_planner/save", { + week_start: this.state.weekStart, + changes, + }); + if (result.error) { + this.notification.add(result.error, { type: "danger" }); + } else if (!result.success) { + this._markServerErrors(result.errors || []); + this.notification.add("Some shift cells could not be saved.", { type: "danger" }); + } else { + this._applyData(result.data); + this.notification.add(`Saved ${result.saved || 0} shift changes.`, { type: "success" }); + } + } catch (error) { + this.notification.add(error.message || "Could not save shift planner.", { type: "danger" }); + } + this.state.saving = false; + } + + async exportXlsx() { + try { + const result = await rpc("/fusion_clock/shift_planner/export_xlsx", { + week_start: this.state.weekStart, + }); + if (result.error) { + this.notification.add(result.error, { type: "danger" }); + return; + } + window.location = result.url; + } catch (error) { + this.notification.add(error.message || "Could not export shift planner.", { type: "danger" }); + } + } + + openCellEditor(employee, day, ev) { + if (this.state.loading || this.state.saving) { + return; + } + const anchor = ev.currentTarget.closest(".fclk-planner__shift-cell") || ev.currentTarget; + this.activeCellAnchor = anchor; + this.activeEditorEmployee = employee; + this.activeEditorDay = day; + + const cell = employee.cells[day.date] || {}; + const fallback = this._defaultTimes(employee, day); + const start = cell.is_off ? fallback.start : (cell.start_time || fallback.start); + const end = cell.is_off ? fallback.end : (cell.end_time || fallback.end); + const breakMinutes = cell.is_off ? 0 : (cell.break_minutes || fallback.breakMinutes || 30); + const hours = cell.is_off ? 0 : Math.max(end - start - breakMinutes / 60, 0); + + this.state.editor.open = true; + this.state.editor.employeeId = employee.id; + this.state.editor.employeeName = employee.name; + this.state.editor.date = day.date; + this.state.editor.dayLabel = `${day.weekday} ${day.label}`; + this.state.editor.startValue = this._timeValue(start); + this.state.editor.endValue = this._timeValue(end); + this.state.editor.breakMinutes = breakMinutes; + this.state.editor.hoursDisplay = cell.hours_display || this._formatHours(hours); + this.state.editor.error = cell.error || ""; + this._positionActiveEditor(anchor); + } + + closeCellEditor() { + this.state.editor.open = false; + this.activeCellAnchor = null; + this.activeEditorEmployee = null; + this.activeEditorDay = null; + } + + onGlobalClick(ev) { + if (!this.state.editor.open) { + return; + } + const target = ev.target; + const clickedEditor = this.editorRef.el && this.editorRef.el.contains(target); + const clickedCell = this.activeCellAnchor && this.activeCellAnchor.contains(target); + if (!clickedEditor && !clickedCell) { + this.closeCellEditor(); + } + } + + isActiveCell(employee, day) { + return this.state.editor.open + && this.state.editor.employeeId === employee.id + && this.state.editor.date === day.date; + } + + onCellInput(employee, day, ev) { + this._setCellFromInput(employee, day, ev.target.value, ev.target); + } + + onCellKeydown(employee, day, ev) { + if (ev.key === "Escape") { + ev.preventDefault(); + this.closeCellEditor(); + return; + } + if (ev.key === "Tab") { + this._setCellFromInput(employee, day, ev.currentTarget.value, ev.currentTarget); + this.closeCellEditor(); + return; + } + if (ev.key === "Enter") { + ev.preventDefault(); + this._setCellFromInput(employee, day, ev.currentTarget.value, ev.currentTarget); + if (!employee.cells[day.date]?.error) { + this.closeCellEditor(); + this._focusRelativeCell(ev.currentTarget, ev.shiftKey ? -this.state.days.length : this.state.days.length); + } + } + } + + selectQuickShift(option) { + const context = this._activeEditorContext(); + if (!context) { + return; + } + let parsed; + if (option.type === "template") { + parsed = { + is_off: false, + shift_id: option.shiftId, + start_time: option.start, + end_time: option.end, + break_minutes: option.breakMinutes, + hours: option.hours, + hours_display: option.hoursDisplay, + label: option.input, + normalized_input: option.input, + }; + } else { + parsed = this._parseInput(option.input, context.cell); + } + this._applyParsedToCell(context.employee, context.day, parsed, option.input); + this._syncEditorFromCell(context.employee, context.day); + this.closeCellEditor(); + } + + clearActiveCell() { + const context = this._activeEditorContext(); + if (!context) { + return; + } + this._setCellFromInput(context.employee, context.day, ""); + this.closeCellEditor(); + } + + onEditorStartChange(ev) { + this.state.editor.startValue = ev.target.value; + this.applyEditorRange(false); + } + + onEditorEndChange(ev) { + this.state.editor.endValue = ev.target.value; + this.applyEditorRange(false); + } + + applyEditorRange(close = true) { + const context = this._activeEditorContext(); + if (!context) { + return; + } + const start = Number(this.state.editor.startValue); + let end = Number(this.state.editor.endValue); + if (end <= start) { + end = Math.min(start + 0.5, 24); + this.state.editor.endValue = this._timeValue(end); + } + const parsed = this._rangeToParsed(start, end, this.state.editor.breakMinutes || 0); + if (parsed.error) { + context.cell.error = parsed.error; + this.state.editor.error = parsed.error; + } else { + this._applyParsedToCell(context.employee, context.day, parsed, parsed.label); + this._syncEditorFromCell(context.employee, context.day); + } + this._recountInvalid(); + if (close && !parsed.error) { + this.closeCellEditor(); + } + } + + _setCellFromInput(employee, day, input, target = null) { + const cell = employee.cells[day.date]; + cell.input = input; + + const parsed = this._parseInput(input, cell); + this._applyParsedToCell(employee, day, parsed, input); + if (!parsed.error && target && parsed.normalized_input !== undefined) { + target.value = parsed.normalized_input; + } + this._syncEditorFromCell(employee, day); + } + + _applyParsedToCell(employee, day, parsed, input) { + const cell = employee.cells[day.date]; + cell.error = parsed.error || ""; + if (parsed.error) { + cell.input = input; + this.state.editor.error = parsed.error; + this._markDirty(employee, day); + this._recountInvalid(); + return; + } + + cell.is_off = parsed.is_off || false; + cell.shift_id = parsed.shift_id || false; + cell.start_time = parsed.start_time || 0; + cell.end_time = parsed.end_time || 0; + cell.break_minutes = parsed.break_minutes || 0; + cell.hours = parsed.hours || 0; + cell.hours_display = parsed.hours_display || "0:00"; + cell.label = parsed.label || ""; + cell.input = parsed.normalized_input !== undefined ? parsed.normalized_input : input; + this.state.editor.error = ""; + this._markDirty(employee, day); + this._recountInvalid(); + } + + _markDirty(employee, day) { + const cell = employee.cells[day.date]; + const key = `${employee.id}:${day.date}`; + const payload = { + employee_id: employee.id, + date: day.date, + input: cell.input, + shift_id: cell.shift_id || false, + note: cell.note || "", + }; + if ((cell.input || "").trim()) { + payload.is_off = !!cell.is_off; + payload.start_time = cell.start_time || 0; + payload.end_time = cell.end_time || 0; + payload.break_minutes = cell.break_minutes || 0; + } + this.dirtyCells[key] = payload; + this.state.dirtyCount = Object.keys(this.dirtyCells).length; + } + + _markServerErrors(errors) { + for (const error of errors) { + const employee = this.state.employees.find((emp) => emp.id === error.employee_id); + const cell = employee && employee.cells[error.date]; + if (cell) { + cell.error = error.message; + } + } + this._recountInvalid(); + } + + _recountInvalid() { + let invalid = 0; + for (const employee of this.state.employees) { + for (const day of this.state.days) { + if (employee.cells[day.date]?.error) { + invalid++; + } + } + } + this.state.invalidCount = invalid; + } + + _parseInput(value, currentCell = {}) { + const text = (value || "").trim(); + if (!text) { + return { + is_off: false, + shift_id: false, + start_time: 0, + end_time: 0, + break_minutes: 0, + label: "", + hours: 0, + hours_display: "0:00", + normalized_input: "", + }; + } + if (text.toUpperCase() === "OFF") { + return { + is_off: true, + shift_id: false, + start_time: 0, + end_time: 0, + break_minutes: 0, + hours: 0, + hours_display: "0:00", + label: "OFF", + normalized_input: "OFF", + }; + } + + const lowerText = text.toLowerCase(); + const template = this.state.shifts.find((shift) => + [shift.option_label, shift.label, shift.name].some((value) => (value || "").toLowerCase() === lowerText) + ); + if (template) { + return { + is_off: false, + shift_id: template.id, + start_time: template.start_time, + end_time: template.end_time, + break_minutes: template.break_minutes, + hours: template.hours, + hours_display: template.hours_display, + label: template.label, + normalized_input: template.label, + }; + } + + try { + const parsed = this._parseTypedShift(text, currentCell); + return parsed; + } catch (error) { + return { error: error.message }; + } + } + + _parseTypedShift(value, currentCell = {}) { + const normalized = value.replaceAll("–", "-").replaceAll("—", "-").replace(/\s+to\s+/i, "-"); + const parts = normalized.split("-"); + if (parts.length !== 2 || !parts[0].trim() || !parts[1].trim()) { + throw new Error("Use 9-5, 9:00-5:30, 9:00 am - 5:30 pm, or OFF."); + } + const start = this._parseTimePart(parts[0]); + let end = this._parseTimePart(parts[1]); + if (end <= start && end + 12 <= 24) { + end += 12; + } + if (end <= start) { + throw new Error("End must be after start."); + } + const breakMinutes = currentCell.break_minutes || 30; + const hours = Math.max(end - start - breakMinutes / 60, 0); + const label = `${this._formatFloatTime(start)} - ${this._formatFloatTime(end)}`; + return { + is_off: false, + shift_id: false, + start_time: start, + end_time: end, + break_minutes: breakMinutes, + hours, + hours_display: this._formatHours(hours), + label, + normalized_input: label, + }; + } + + _rangeToParsed(start, end, breakMinutes) { + if (Number.isNaN(start) || Number.isNaN(end)) { + return { error: "Choose a start and end time." }; + } + if (end <= start) { + return { error: "End must be after start." }; + } + const hours = Math.max(end - start - breakMinutes / 60, 0); + const label = `${this._formatFloatTime(start)} - ${this._formatFloatTime(end)}`; + return { + is_off: false, + shift_id: false, + start_time: start, + end_time: end, + break_minutes: breakMinutes, + hours, + hours_display: this._formatHours(hours), + label, + normalized_input: label, + }; + } + + _parseTimePart(raw) { + const text = raw.trim().toLowerCase().replaceAll(".", ""); + const match = text.match(/^(\d{1,2})(?::(\d{1,2}))?\s*(am|pm)?$/); + if (!match) { + throw new Error(`Could not read "${raw.trim()}".`); + } + let hour = Number(match[1]); + const minute = Number(match[2] || 0); + const meridiem = match[3]; + if (minute < 0 || minute > 59) { + throw new Error("Minutes must be 00-59."); + } + if (meridiem) { + if (hour < 1 || hour > 12) { + throw new Error("Use 1-12 with am/pm."); + } + if (meridiem === "am") { + hour = hour === 12 ? 0 : hour; + } else { + hour = hour === 12 ? 12 : hour + 12; + } + } + if (hour < 0 || hour > 24) { + throw new Error("Hours must be 0-24."); + } + return hour + minute / 60; + } + + _formatFloatTime(value) { + let hour = Math.floor(value); + let minute = Math.round((value - hour) * 60); + if (minute === 60) { + hour += 1; + minute = 0; + } + const suffix = hour < 12 || hour === 24 ? "am" : "pm"; + let displayHour = hour % 12; + if (displayHour === 0) { + displayHour = 12; + } + return `${displayHour}:${String(minute).padStart(2, "0")} ${suffix}`; + } + + _formatHours(value) { + let hour = Math.floor(value); + let minute = Math.round((value - hour) * 60); + if (minute === 60) { + hour += 1; + minute = 0; + } + return `${hour}:${String(minute).padStart(2, "0")}`; + } + + _timeValue(value) { + const rounded = Math.round(Number(value || 0) * 4) / 4; + return rounded.toFixed(2); + } + + _buildTimeOptions() { + const options = []; + for (let minutes = 0; minutes <= 24 * 60; minutes += 15) { + const value = minutes / 60; + options.push({ + value: this._timeValue(value), + label: this._formatFloatTime(value), + }); + } + return options; + } + + _defaultTimes(employee, day) { + const dayIndex = this.state.days.findIndex((item) => item.date === day.date); + if (dayIndex > 0) { + const previousDay = this.state.days[dayIndex - 1]; + const previousCell = employee.cells[previousDay.date]; + if (previousCell && !previousCell.is_off && previousCell.start_time && previousCell.end_time) { + return { + start: previousCell.start_time, + end: previousCell.end_time, + breakMinutes: previousCell.break_minutes || 30, + }; + } + } + const firstShift = this.state.shifts[0]; + if (firstShift) { + return { + start: firstShift.start_time, + end: firstShift.end_time, + breakMinutes: firstShift.break_minutes || 30, + }; + } + return { start: 9, end: 17, breakMinutes: 30 }; + } + + get quickShiftOptions() { + const options = [{ + key: "off", + type: "input", + input: "OFF", + label: "OFF", + detail: "0:00", + }]; + const seen = new Set(["OFF"]); + for (const shift of this.state.shifts) { + if (seen.has(shift.label)) { + continue; + } + seen.add(shift.label); + options.push({ + key: `shift-${shift.id}`, + type: "template", + shiftId: shift.id, + input: shift.label, + label: shift.name || shift.label, + detail: `${shift.label} - ${shift.hours_display}`, + start: shift.start_time, + end: shift.end_time, + breakMinutes: shift.break_minutes, + hours: shift.hours, + hoursDisplay: shift.hours_display, + }); + } + for (const input of ["9:00 am - 5:00 pm", "7:00 am - 3:30 pm", "8:00 am - 4:30 pm", "11:00 am - 7:30 pm", "12:00 pm - 8:30 pm"]) { + if (seen.has(input)) { + continue; + } + const parsed = this._parseInput(input, { break_minutes: 30 }); + seen.add(input); + options.push({ + key: `common-${input}`, + type: "input", + input, + label: input, + detail: parsed.hours_display || "0:00", + }); + } + return options.slice(0, 10); + } + + _activeEditorContext() { + if (!this.state.editor.open || !this.activeEditorEmployee || !this.activeEditorDay) { + return null; + } + return { + employee: this.activeEditorEmployee, + day: this.activeEditorDay, + cell: this.activeEditorEmployee.cells[this.activeEditorDay.date], + }; + } + + _syncEditorFromCell(employee, day) { + if (!this.isActiveCell(employee, day)) { + return; + } + const cell = employee.cells[day.date] || {}; + if (!cell.is_off && cell.start_time && cell.end_time) { + this.state.editor.startValue = this._timeValue(cell.start_time); + this.state.editor.endValue = this._timeValue(cell.end_time); + } + this.state.editor.breakMinutes = cell.break_minutes || 0; + this.state.editor.hoursDisplay = cell.hours_display || "0:00"; + this.state.editor.error = cell.error || ""; + } + + _focusRelativeCell(input, offset) { + const inputs = Array.from(document.querySelectorAll(".fclk-planner__shift-input")); + const index = inputs.indexOf(input); + const next = inputs[index + offset]; + if (next) { + next.focus(); + next.select(); + } + } + + _positionActiveEditor(anchor = null) { + if (!this.state.editor.open) { + return; + } + const target = anchor || this.activeCellAnchor; + if (!target || !target.isConnected) { + this.closeCellEditor(); + return; + } + const rect = target.getBoundingClientRect(); + const editorWidth = Math.min(380, window.innerWidth - 16); + const editorHeight = this.editorRef.el?.offsetHeight || 300; + let left = Math.max(8, Math.min(rect.left, window.innerWidth - editorWidth - 8)); + let top = rect.bottom + 8; + if (top + editorHeight > window.innerHeight - 8) { + top = Math.max(8, rect.top - editorHeight - 8); + } + left = Math.round(left); + top = Math.round(top); + if (this.state.editor.left !== left) { + this.state.editor.left = left; + } + if (this.state.editor.top !== top) { + this.state.editor.top = top; + } + } + + _dateAdd(dateString, days) { + const date = new Date(`${dateString}T12:00:00`); + date.setDate(date.getDate() + days); + return date.toISOString().slice(0, 10); + } +} + +registry.category("actions").add("fusion_clock.ShiftPlanner", FusionClockShiftPlanner); diff --git a/fusion_clock/static/src/scss/_fusion_clock_shift_planner_tokens.scss b/fusion_clock/static/src/scss/_fusion_clock_shift_planner_tokens.scss new file mode 100644 index 00000000..9f9017a2 --- /dev/null +++ b/fusion_clock/static/src/scss/_fusion_clock_shift_planner_tokens.scss @@ -0,0 +1,77 @@ +$o-webclient-color-scheme: bright !default; + +$_fclk-planner-page: #f3f4f6; +$_fclk-planner-panel: #eef1f4; +$_fclk-planner-card: #ffffff; +$_fclk-planner-text: #1f2937; +$_fclk-planner-muted: #6b7280; +$_fclk-planner-border: #d8dadd; +$_fclk-planner-border-strong: #9ca3af; +$_fclk-planner-day: #b7dff5; +$_fclk-planner-subhead: #d8e9bd; +$_fclk-planner-hours: #f5d39b; +$_fclk-planner-fallback: #fff8e5; +$_fclk-planner-row-hover: #f9fafb; +$_fclk-planner-error: #dc2626; +$_fclk-planner-focus: #2563eb; +$_fclk-planner-shadow: rgba(15, 23, 42, 0.08); +$_fclk-planner-editor: #111827; +$_fclk-planner-editor-text: #f9fafb; +$_fclk-planner-editor-muted: #cbd5e1; +$_fclk-planner-editor-border: #374151; +$_fclk-planner-editor-control: #ffffff; +$_fclk-planner-editor-control-text: #111827; +$_fclk-planner-editor-chip: #1f2937; +$_fclk-planner-editor-chip-hover: #334155; + +@if $o-webclient-color-scheme == dark { + $_fclk-planner-page: #171a1f !global; + $_fclk-planner-panel: #20242b !global; + $_fclk-planner-card: #262b33 !global; + $_fclk-planner-text: #f3f4f6 !global; + $_fclk-planner-muted: #a3aab8 !global; + $_fclk-planner-border: #3b424c !global; + $_fclk-planner-border-strong: #647082 !global; + $_fclk-planner-day: #21465f !global; + $_fclk-planner-subhead: #394b2d !global; + $_fclk-planner-hours: #6f4f22 !global; + $_fclk-planner-fallback: #393326 !global; + $_fclk-planner-row-hover: #2b313a !global; + $_fclk-planner-error: #f87171 !global; + $_fclk-planner-focus: #60a5fa !global; + $_fclk-planner-shadow: rgba(0, 0, 0, 0.32) !global; + $_fclk-planner-editor: #0f172a !global; + $_fclk-planner-editor-text: #f9fafb !global; + $_fclk-planner-editor-muted: #cbd5e1 !global; + $_fclk-planner-editor-border: #475569 !global; + $_fclk-planner-editor-control: #1f2937 !global; + $_fclk-planner-editor-control-text: #f9fafb !global; + $_fclk-planner-editor-chip: #1e293b !global; + $_fclk-planner-editor-chip-hover: #334155 !global; +} + +:root { + --fclk-planner-page: #{$_fclk-planner-page}; + --fclk-planner-panel: #{$_fclk-planner-panel}; + --fclk-planner-card: #{$_fclk-planner-card}; + --fclk-planner-text: #{$_fclk-planner-text}; + --fclk-planner-muted: #{$_fclk-planner-muted}; + --fclk-planner-border: #{$_fclk-planner-border}; + --fclk-planner-border-strong: #{$_fclk-planner-border-strong}; + --fclk-planner-day: #{$_fclk-planner-day}; + --fclk-planner-subhead: #{$_fclk-planner-subhead}; + --fclk-planner-hours: #{$_fclk-planner-hours}; + --fclk-planner-fallback: #{$_fclk-planner-fallback}; + --fclk-planner-row-hover: #{$_fclk-planner-row-hover}; + --fclk-planner-error: #{$_fclk-planner-error}; + --fclk-planner-focus: #{$_fclk-planner-focus}; + --fclk-planner-shadow: #{$_fclk-planner-shadow}; + --fclk-planner-editor: #{$_fclk-planner-editor}; + --fclk-planner-editor-text: #{$_fclk-planner-editor-text}; + --fclk-planner-editor-muted: #{$_fclk-planner-editor-muted}; + --fclk-planner-editor-border: #{$_fclk-planner-editor-border}; + --fclk-planner-editor-control: #{$_fclk-planner-editor-control}; + --fclk-planner-editor-control-text: #{$_fclk-planner-editor-control-text}; + --fclk-planner-editor-chip: #{$_fclk-planner-editor-chip}; + --fclk-planner-editor-chip-hover: #{$_fclk-planner-editor-chip-hover}; +} diff --git a/fusion_clock/static/src/scss/fusion_clock_shift_planner.dark.scss b/fusion_clock/static/src/scss/fusion_clock_shift_planner.dark.scss new file mode 100644 index 00000000..092c8f70 --- /dev/null +++ b/fusion_clock/static/src/scss/fusion_clock_shift_planner.dark.scss @@ -0,0 +1,25 @@ +:root { + --fclk-planner-page: #171a1f; + --fclk-planner-panel: #20242b; + --fclk-planner-card: #262b33; + --fclk-planner-text: #f3f4f6; + --fclk-planner-muted: #a3aab8; + --fclk-planner-border: #3b424c; + --fclk-planner-border-strong: #647082; + --fclk-planner-day: #21465f; + --fclk-planner-subhead: #394b2d; + --fclk-planner-hours: #6f4f22; + --fclk-planner-fallback: #393326; + --fclk-planner-row-hover: #2b313a; + --fclk-planner-error: #f87171; + --fclk-planner-focus: #60a5fa; + --fclk-planner-shadow: rgba(0, 0, 0, 0.32); + --fclk-planner-editor: #0f172a; + --fclk-planner-editor-text: #f9fafb; + --fclk-planner-editor-muted: #cbd5e1; + --fclk-planner-editor-border: #475569; + --fclk-planner-editor-control: #1f2937; + --fclk-planner-editor-control-text: #f9fafb; + --fclk-planner-editor-chip: #1e293b; + --fclk-planner-editor-chip-hover: #334155; +} diff --git a/fusion_clock/static/src/scss/fusion_clock_shift_planner.scss b/fusion_clock/static/src/scss/fusion_clock_shift_planner.scss new file mode 100644 index 00000000..735c45c0 --- /dev/null +++ b/fusion_clock/static/src/scss/fusion_clock_shift_planner.scss @@ -0,0 +1,447 @@ +.fclk-planner { + min-height: 100%; + background: var(--fclk-planner-page, #f3f4f6); + color: var(--fclk-planner-text, #1f2937); + display: flex; + flex-direction: column; +} + +.fclk-planner__toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px 20px; + background: var(--fclk-planner-card, #ffffff); + border-bottom: 1px solid var(--fclk-planner-border, #d8dadd); + box-shadow: 0 1px 3px var(--fclk-planner-shadow, rgba(15, 23, 42, 0.08)); +} + +.fclk-planner__title { + margin: 0; + font-size: 20px; + font-weight: 650; + line-height: 1.2; +} + +.fclk-planner__subtitle { + color: var(--fclk-planner-muted, #6b7280); + font-size: 13px; + margin-top: 3px; +} + +.fclk-planner__actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 8px; +} + +.fclk-planner__warning { + margin: 12px 16px 0; + padding: 10px 12px; + background: #fff7ed; + border: 1px solid #fed7aa; + border-radius: 6px; + color: #9a3412; + font-size: 13px; +} + +.fclk-planner__loading { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + gap: 12px; + min-height: 340px; + color: var(--fclk-planner-muted, #6b7280); +} + +.fclk-planner__table-wrap { + flex: 1; + margin: 16px; + overflow: auto; + background: var(--fclk-planner-panel, #eef1f4); + border: 1px solid var(--fclk-planner-border, #d8dadd); + border-radius: 6px; + box-shadow: 0 6px 20px var(--fclk-planner-shadow, rgba(15, 23, 42, 0.08)); +} + +.fclk-planner__table { + --fclk-planner-shift-width: 135px; + --fclk-planner-hours-width: 55px; + --fclk-planner-days-width: 1330px; + width: 100%; + min-width: 1600px; + border-collapse: separate; + border-spacing: 0; + table-layout: fixed; + background: var(--fclk-planner-card, #ffffff); + font-size: 13px; +} + +.fclk-planner__employee-col { + width: calc(100% - var(--fclk-planner-days-width)); +} + +.fclk-planner__shift-col { + width: var(--fclk-planner-shift-width); +} + +.fclk-planner__hours-col { + width: var(--fclk-planner-hours-width); +} + +.fclk-planner__table th, +.fclk-planner__table td { + border-right: 1px solid var(--fclk-planner-border-strong, #9ca3af); + border-bottom: 1px solid var(--fclk-planner-border-strong, #9ca3af); +} + +.fclk-planner__employee-head, +.fclk-planner__day-head, +.fclk-planner__sub-head { + position: sticky; + top: 0; + z-index: 6; + color: var(--fclk-planner-text, #1f2937); +} + +.fclk-planner__employee-head { + left: 0; + z-index: 8; + width: calc(100% - var(--fclk-planner-days-width)); + background: var(--fclk-planner-day, #b7dff5); + text-align: left; + padding: 10px 12px; + border-left: 1px solid var(--fclk-planner-border-strong, #9ca3af); +} + +.fclk-planner__day-head { + background: var(--fclk-planner-day, #b7dff5); + text-align: center; + padding: 6px 8px; + font-weight: 700; +} + +.fclk-planner__sub-head { + top: 47px; + background: var(--fclk-planner-subhead, #d8e9bd); + text-align: left; + padding: 5px 8px; + font-weight: 650; +} + +.fclk-planner__hours-head { + width: var(--fclk-planner-hours-width); + text-align: center; + padding-left: 2px; + padding-right: 2px; +} + +.fclk-planner__weekday { + font-size: 14px; + line-height: 1.1; +} + +.fclk-planner__date { + font-size: 12px; + font-weight: 500; + margin-top: 2px; +} + +.fclk-planner__department-row td { + background: var(--fclk-planner-panel, #eef1f4); + padding: 0; + position: sticky; + left: 0; + z-index: 5; +} + +.fclk-planner__department-toggle { + width: 100%; + min-height: 34px; + display: flex; + align-items: center; + gap: 8px; + border: 0; + background: transparent; + color: var(--fclk-planner-text, #1f2937); + font-weight: 650; + padding: 7px 12px; + text-align: left; +} + +.fclk-planner__department-count { + color: var(--fclk-planner-muted, #6b7280); + font-weight: 500; + font-size: 12px; +} + +.fclk-planner__employee-row { + background: var(--fclk-planner-card, #ffffff); +} + +.fclk-planner__employee-row:hover { + background: var(--fclk-planner-row-hover, #f9fafb); +} + +.fclk-planner__employee-cell { + position: sticky; + left: 0; + z-index: 4; + width: calc(100% - var(--fclk-planner-days-width)); + background: inherit; + padding: 8px 12px; + border-left: 1px solid var(--fclk-planner-border-strong, #9ca3af); +} + +.fclk-planner__employee-name { + font-weight: 650; + line-height: 1.2; +} + +.fclk-planner__employee-role { + margin-top: 2px; + color: var(--fclk-planner-muted, #6b7280); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.fclk-planner__shift-cell { + width: var(--fclk-planner-shift-width); + min-height: 42px; + padding: 4px; + vertical-align: top; + background: var(--fclk-planner-card, #ffffff); +} + +.fclk-planner__shift-cell--fallback { + background: var(--fclk-planner-fallback, #fff8e5); +} + +.fclk-planner__shift-cell--error { + background: #fef2f2; +} + +.fclk-planner__shift-cell--active { + box-shadow: inset 0 0 0 2px var(--fclk-planner-focus, #2563eb); +} + +.fclk-planner__shift-input { + width: 100%; + height: 32px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: var(--fclk-planner-text, #1f2937); + padding: 4px 6px; + font-size: 13px; + line-height: 1.2; + outline: none; + white-space: nowrap; +} + +.fclk-planner__shift-input:focus { + background: var(--fclk-planner-card, #ffffff); + border-color: var(--fclk-planner-focus, #2563eb); + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.16); +} + +.fclk-planner__cell-error { + color: var(--fclk-planner-error, #dc2626); + font-size: 11px; + line-height: 1.2; + padding: 3px 5px 0; +} + +.fclk-planner__hours-cell { + width: var(--fclk-planner-hours-width); + background: var(--fclk-planner-hours, #f5d39b); + text-align: center; + font-variant-numeric: tabular-nums; + font-weight: 650; + vertical-align: middle; + padding: 6px 2px; +} + +.fclk-planner__cell-editor { + position: fixed; + z-index: 1080; + width: calc(100vw - 16px); + max-width: 380px; + padding: 14px; + color: var(--fclk-planner-editor-text, #f9fafb); + background: var(--fclk-planner-editor, #111827); + border: 1px solid var(--fclk-planner-editor-border, #374151); + border-radius: 8px; + box-shadow: 0 18px 45px rgba(0, 0, 0, 0.32); +} + +.fclk-planner__cell-editor::before { + content: ""; + position: absolute; + top: -7px; + left: 28px; + width: 14px; + height: 14px; + background: var(--fclk-planner-editor, #111827); + border-left: 1px solid var(--fclk-planner-editor-border, #374151); + border-top: 1px solid var(--fclk-planner-editor-border, #374151); + transform: rotate(45deg); +} + +.fclk-planner__editor-head { + position: relative; + z-index: 1; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.fclk-planner__editor-name { + font-size: 14px; + font-weight: 700; + line-height: 1.2; +} + +.fclk-planner__editor-day { + margin-top: 2px; + color: var(--fclk-planner-editor-muted, #cbd5e1); + font-size: 12px; +} + +.fclk-planner__editor-hours { + min-width: 56px; + padding: 5px 8px; + text-align: center; + color: #111827; + background: var(--fclk-planner-hours, #f5d39b); + border-radius: 6px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.fclk-planner__quick-grid { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.fclk-planner__quick-chip { + min-height: 46px; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 2px; + padding: 7px 9px; + color: var(--fclk-planner-editor-text, #f9fafb); + background: var(--fclk-planner-editor-chip, #1f2937); + border: 1px solid var(--fclk-planner-editor-border, #374151); + border-radius: 6px; + text-align: left; +} + +.fclk-planner__quick-chip:hover, +.fclk-planner__quick-chip:focus { + background: var(--fclk-planner-editor-chip-hover, #334155); + outline: none; +} + +.fclk-planner__quick-label { + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 650; + line-height: 1.15; +} + +.fclk-planner__quick-detail { + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--fclk-planner-editor-muted, #cbd5e1); + font-size: 11px; + line-height: 1.15; +} + +.fclk-planner__time-row { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-top: 12px; +} + +.fclk-planner__time-field { + display: flex; + flex-direction: column; + gap: 5px; + margin: 0; + color: var(--fclk-planner-editor-muted, #cbd5e1); + font-size: 12px; + font-weight: 650; +} + +.fclk-planner__time-field select { + width: 100%; + height: 34px; + color: var(--fclk-planner-editor-control-text, #111827); + background: var(--fclk-planner-editor-control, #ffffff); + border: 1px solid var(--fclk-planner-editor-border, #374151); + border-radius: 6px; + padding: 4px 8px; + font-size: 13px; +} + +.fclk-planner__editor-error { + position: relative; + z-index: 1; + margin-top: 10px; + padding: 7px 8px; + color: #991b1b; + background: #fee2e2; + border-radius: 6px; + font-size: 12px; + line-height: 1.25; +} + +.fclk-planner__editor-actions { + position: relative; + z-index: 1; + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 12px; +} + +@media (max-width: 900px) { + .fclk-planner__toolbar { + align-items: flex-start; + flex-direction: column; + } + + .fclk-planner__actions { + justify-content: flex-start; + } + + .fclk-planner__table-wrap { + margin: 10px; + } + + .fclk-planner__cell-editor { + width: calc(100vw - 16px); + } +} diff --git a/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml b/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml new file mode 100644 index 00000000..1e34684b --- /dev/null +++ b/fusion_clock/static/src/xml/fusion_clock_shift_planner.xml @@ -0,0 +1,198 @@ + + + + +
+
+
+

Shift Planner

+
+
+
+ + + + + + +
+
+ + +
+
+ + +
+ + invalid cells need attention. +
+
+ + +
+ + Loading shift planner... +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Employee +
+
+
ShiftHours
+ +
+
+
+ +
+
+ +
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+ +
+
+ +
+ + + +
+ +
+ + +
+ +
+ +
+ +
+ + +
+
+
+
+
+ +
diff --git a/fusion_clock/tests/__init__.py b/fusion_clock/tests/__init__.py index 2426bdb7..dffa6faa 100644 --- a/fusion_clock/tests/__init__.py +++ b/fusion_clock/tests/__init__.py @@ -2,3 +2,4 @@ from . import test_nfc_models from . import test_clock_nfc_kiosk +from . import test_shift_planner diff --git a/fusion_clock/tests/test_shift_planner.py b/fusion_clock/tests/test_shift_planner.py new file mode 100644 index 00000000..1a86aaaf --- /dev/null +++ b/fusion_clock/tests/test_shift_planner.py @@ -0,0 +1,254 @@ +# -*- coding: utf-8 -*- + +import json +from datetime import date, timedelta + +from psycopg2 import IntegrityError + +from odoo import fields +from odoo.exceptions import ValidationError +from odoo.tests.common import HttpCase, TransactionCase, tagged +from odoo.tools.misc import mute_logger + + +@tagged('-at_install', 'post_install', 'fusion_clock') +class TestShiftPlannerModels(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Schedule = cls.env['fusion.clock.schedule'].sudo() + cls.Shift = cls.env['fusion.clock.shift'].sudo() + cls.employee = cls.env['hr.employee'].sudo().create({ + 'name': 'Planner Model Employee', + 'company_id': cls.env.company.id, + 'x_fclk_enable_clock': True, + }) + cls.default_shift = cls.Shift.create({ + 'name': 'Default Planner Shift', + 'start_time': 8.0, + 'end_time': 16.5, + 'break_minutes': 30, + 'company_id': cls.env.company.id, + }) + cls.employee.x_fclk_shift_id = cls.default_shift.id + cls.schedule_date = date(2026, 1, 5) + + def test_unique_employee_date_schedule(self): + self.Schedule.create({ + 'employee_id': self.employee.id, + 'schedule_date': self.schedule_date, + 'is_off': True, + }) + with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'): + with self.env.cr.savepoint(): + self.Schedule.create({ + 'employee_id': self.employee.id, + 'schedule_date': self.schedule_date, + 'is_off': True, + }) + + def test_off_schedule_has_zero_hours(self): + schedule = self.Schedule.create({ + 'employee_id': self.employee.id, + 'schedule_date': date(2026, 1, 6), + 'is_off': True, + }) + self.assertEqual(schedule.planned_hours, 0) + self.assertEqual(schedule.fclk_display_value(), 'OFF') + + def test_working_schedule_computes_hours_minus_break(self): + schedule = self.Schedule.create({ + 'employee_id': self.employee.id, + 'schedule_date': date(2026, 1, 7), + 'start_time': 9.0, + 'end_time': 17.5, + 'break_minutes': 30, + }) + self.assertEqual(schedule.planned_hours, 8.0) + self.assertEqual(self.Schedule.fclk_hours_display(schedule.planned_hours), '8:00') + + def test_invalid_same_day_range_is_rejected(self): + with self.assertRaises(ValidationError): + self.Schedule.create({ + 'employee_id': self.employee.id, + 'schedule_date': date(2026, 1, 8), + 'start_time': 17.0, + 'end_time': 9.0, + 'break_minutes': 30, + }) + + def test_apply_planner_cell_creates_audit(self): + schedule_date = date(2026, 1, 9) + self.Schedule.fclk_apply_planner_cell( + self.employee, + schedule_date, + {'input': '9:00 am - 5:30 pm'}, + self.env.user, + ) + audit = self.env['fusion.clock.schedule.audit'].sudo().search([ + ('employee_id', '=', self.employee.id), + ('schedule_date', '=', schedule_date), + ], limit=1) + self.assertTrue(audit) + self.assertFalse(audit.old_value) + self.assertEqual(audit.new_value, '9:00 am - 5:30 pm') + + def test_dated_schedule_overrides_employee_shift_and_fallback_remains(self): + planned_date = date(2026, 1, 12) + self.Schedule.create({ + 'employee_id': self.employee.id, + 'schedule_date': planned_date, + 'start_time': 10.0, + 'end_time': 18.0, + 'break_minutes': 60, + }) + + planned = self.employee._get_fclk_day_plan(planned_date) + fallback = self.employee._get_fclk_day_plan(planned_date + timedelta(days=1)) + + self.assertEqual(planned['source'], 'schedule') + self.assertEqual(planned['start_time'], 10.0) + self.assertEqual(planned['hours'], 7.0) + self.assertEqual(fallback['source'], 'fallback') + self.assertEqual(fallback['start_time'], 8.0) + + +@tagged('-at_install', 'post_install', 'fusion_clock') +class TestShiftPlannerApi(HttpCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + manager_group = cls.env.ref('fusion_clock.group_fusion_clock_manager') + user_group = cls.env.ref('fusion_clock.group_fusion_clock_user') + cls.manager_user = cls.env['res.users'].sudo().create({ + 'name': 'Planner Manager', + 'login': 'planner-manager', + 'password': 'plannerpass', + 'company_id': cls.env.company.id, + 'company_ids': [(6, 0, [cls.env.company.id])], + 'group_ids': [(6, 0, [manager_group.id])], + }) + cls.employee_user = cls.env['res.users'].sudo().create({ + 'name': 'Planner Employee User', + 'login': 'planner-employee-user', + 'password': 'plannerpass', + 'company_id': cls.env.company.id, + 'company_ids': [(6, 0, [cls.env.company.id])], + 'group_ids': [(6, 0, [user_group.id])], + 'tz': 'UTC', + }) + cls.employee = cls.env['hr.employee'].sudo().create({ + 'name': 'Planner API Employee', + 'user_id': cls.employee_user.id, + 'company_id': cls.env.company.id, + 'x_fclk_enable_clock': True, + }) + cls.shift = cls.env['fusion.clock.shift'].sudo().create({ + 'name': 'API Morning', + 'start_time': 7.0, + 'end_time': 15.5, + 'break_minutes': 30, + 'company_id': cls.env.company.id, + }) + cls.week_start = '2026-01-19' + + def _json_call(self, route, payload, login='planner-manager'): + self.authenticate(login, 'plannerpass') + response = self.url_open( + route, + data=json.dumps({'jsonrpc': '2.0', 'method': 'call', 'params': payload}), + headers={'Content-Type': 'application/json'}, + ) + return response.json().get('result', {}) + + def test_manager_can_load_save_and_export_planner(self): + load_result = self._json_call('/fusion_clock/shift_planner/load', { + 'week_start': self.week_start, + }) + self.assertIn(self.employee.id, [row['id'] for row in load_result['employees']]) + + save_result = self._json_call('/fusion_clock/shift_planner/save', { + 'week_start': self.week_start, + 'changes': [{ + 'employee_id': self.employee.id, + 'date': self.week_start, + 'input': '9-5', + 'shift_id': False, + }], + }) + self.assertTrue(save_result.get('success')) + + schedule = self.env['fusion.clock.schedule'].sudo().search([ + ('employee_id', '=', self.employee.id), + ('schedule_date', '=', fields.Date.to_date(self.week_start)), + ], limit=1) + self.assertTrue(schedule) + self.assertEqual(schedule.start_time, 9.0) + self.assertEqual(schedule.end_time, 17.0) + + export_result = self._json_call('/fusion_clock/shift_planner/export_xlsx', { + 'week_start': self.week_start, + }) + self.assertTrue(export_result.get('success')) + self.assertTrue(export_result.get('url', '').startswith('/web/content/')) + self.assertTrue(self.env['ir.attachment'].sudo().browse(export_result['attachment_id']).exists()) + + def test_copy_previous_week(self): + previous_monday = fields.Date.to_date(self.week_start) - timedelta(days=7) + self.env['fusion.clock.schedule'].sudo().create({ + 'employee_id': self.employee.id, + 'schedule_date': previous_monday, + 'shift_id': self.shift.id, + 'start_time': self.shift.start_time, + 'end_time': self.shift.end_time, + 'break_minutes': self.shift.break_minutes, + }) + result = self._json_call('/fusion_clock/shift_planner/copy_previous_week', { + 'week_start': self.week_start, + }) + self.assertTrue(result.get('success')) + copied = self.env['fusion.clock.schedule'].sudo().search([ + ('employee_id', '=', self.employee.id), + ('schedule_date', '=', fields.Date.to_date(self.week_start)), + ], limit=1) + self.assertEqual(copied.shift_id, self.shift) + + def test_non_manager_cannot_mutate_planner(self): + result = self._json_call('/fusion_clock/shift_planner/save', { + 'week_start': self.week_start, + 'changes': [], + }, login='planner-employee-user') + self.assertEqual(result.get('error'), 'Access denied.') + + def test_off_day_clock_in_succeeds_and_logs_unscheduled_shift(self): + today = fields.Date.today() + location = self.env['fusion.clock.location'].sudo().create({ + 'name': 'Planner Test Location', + 'latitude': 43.65, + 'longitude': -79.38, + 'radius': 100, + 'company_id': self.env.company.id, + 'all_employees': True, + }) + self.env['fusion.clock.schedule'].sudo().create({ + 'employee_id': self.employee.id, + 'schedule_date': today, + 'is_off': True, + }) + + result = self._json_call('/fusion_clock/clock_action', { + 'latitude': location.latitude, + 'longitude': location.longitude, + 'source': 'portal', + }, login='planner-employee-user') + + self.assertTrue(result.get('success')) + self.assertEqual(result.get('action'), 'clock_in') + self.assertIn('unscheduled', result.get('message', '')) + log = self.env['fusion.clock.activity.log'].sudo().search([ + ('employee_id', '=', self.employee.id), + ('log_type', '=', 'unscheduled_shift'), + ], limit=1) + self.assertTrue(log) diff --git a/fusion_clock/views/clock_menus.xml b/fusion_clock/views/clock_menus.xml index 675e2152..7f487d13 100644 --- a/fusion_clock/views/clock_menus.xml +++ b/fusion_clock/views/clock_menus.xml @@ -16,6 +16,34 @@ sequence="5" groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/> + + + + + + + + + + + + + Shift Planner + fusion_clock.ShiftPlanner + + + + fusion.clock.schedule.list + fusion.clock.schedule + + + + + + + + + + + + + + + + + + fusion.clock.schedule.form + fusion.clock.schedule + +
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + fusion.clock.schedule.search + fusion.clock.schedule + + + + + + + + + + + + + + + Scheduled Shifts + fusion.clock.schedule + list,form + + + + fusion.clock.schedule.audit.list + fusion.clock.schedule.audit + + + + + + + + + + + + + + + + fusion.clock.schedule.audit.form + fusion.clock.schedule.audit + +
+ + + + + + + + + + + + + + + + +
+
+
+ + + Schedule Audit + fusion.clock.schedule.audit + list,form + + +
diff --git a/fusion_clock/views/portal_clock_templates.xml b/fusion_clock/views/portal_clock_templates.xml index a5060f2f..08389391 100644 --- a/fusion_clock/views/portal_clock_templates.xml +++ b/fusion_clock/views/portal_clock_templates.xml @@ -142,6 +142,28 @@ + +
+
+ +
+
+
Today's Shift
+
+ OFF + + + +
+
+
+ 0:00 + + h + +
+
+
diff --git a/fusion_clock/wizard/__pycache__/__init__.cpython-312.pyc b/fusion_clock/wizard/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..f807b514 Binary files /dev/null and b/fusion_clock/wizard/__pycache__/__init__.cpython-312.pyc differ diff --git a/fusion_clock/wizard/__pycache__/clock_nfc_enrollment_wizard.cpython-312.pyc b/fusion_clock/wizard/__pycache__/clock_nfc_enrollment_wizard.cpython-312.pyc new file mode 100644 index 00000000..708f8061 Binary files /dev/null and b/fusion_clock/wizard/__pycache__/clock_nfc_enrollment_wizard.cpython-312.pyc differ