From 005daade55b3e37a9e760ba7b9fec463db811157 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 23 May 2026 07:53:41 -0400 Subject: [PATCH] changes --- fusion-plating/.claude/settings.local.json | 7 + fusion_clock/CLAUDE.md | 358 +++++++++ fusion_clock/__manifest__.py | 10 +- .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 239 bytes fusion_clock/controllers/__init__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 336 bytes .../__pycache__/clock_api.cpython-312.pyc | Bin 33097 -> 35923 bytes .../__pycache__/clock_kiosk.cpython-312.pyc | Bin 0 -> 8346 bytes .../clock_nfc_kiosk.cpython-312.pyc | Bin 0 -> 13248 bytes .../__pycache__/portal_clock.cpython-312.pyc | Bin 15266 -> 15316 bytes .../__pycache__/shift_planner.cpython-312.pyc | Bin 0 -> 14511 bytes fusion_clock/controllers/clock_api.py | 58 +- fusion_clock/controllers/clock_kiosk.py | 23 +- fusion_clock/controllers/clock_nfc_kiosk.py | 23 +- fusion_clock/controllers/portal_clock.py | 6 +- fusion_clock/controllers/shift_planner.py | 269 +++++++ fusion_clock/models/__init__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 610 bytes .../clock_activity_log.cpython-312.pyc | Bin 0 -> 4944 bytes .../clock_correction.cpython-312.pyc | Bin 0 -> 8355 bytes .../clock_leave_request.cpython-312.pyc | Bin 0 -> 5656 bytes .../clock_location.cpython-312.pyc | Bin 0 -> 12513 bytes .../__pycache__/clock_penalty.cpython-312.pyc | Bin 0 -> 3370 bytes .../__pycache__/clock_report.cpython-312.pyc | Bin 28032 -> 28107 bytes .../clock_schedule.cpython-312.pyc | Bin 0 -> 18068 bytes .../__pycache__/clock_shift.cpython-312.pyc | Bin 0 -> 2774 bytes .../__pycache__/hr_attendance.cpython-312.pyc | Bin 26412 -> 28154 bytes .../__pycache__/hr_employee.cpython-312.pyc | Bin 10148 -> 13212 bytes .../__pycache__/res_company.cpython-312.pyc | Bin 0 -> 779 bytes .../res_config_settings.cpython-312.pyc | Bin 0 -> 12433 bytes .../__pycache__/tz_utils.cpython-312.pyc | Bin 3746 -> 3763 bytes fusion_clock/models/clock_activity_log.py | 2 + fusion_clock/models/clock_schedule.py | 414 ++++++++++ fusion_clock/models/hr_attendance.py | 32 +- fusion_clock/models/hr_employee.py | 102 ++- fusion_clock/security/ir.model.access.csv | 4 + fusion_clock/security/security.xml | 54 ++ fusion_clock/static/src/css/portal_clock.css | 57 ++ .../src/js/fusion_clock_shift_planner.js | 741 ++++++++++++++++++ .../_fusion_clock_shift_planner_tokens.scss | 77 ++ .../scss/fusion_clock_shift_planner.dark.scss | 25 + .../src/scss/fusion_clock_shift_planner.scss | 447 +++++++++++ .../src/xml/fusion_clock_shift_planner.xml | 198 +++++ fusion_clock/tests/__init__.py | 1 + fusion_clock/tests/test_shift_planner.py | 254 ++++++ fusion_clock/views/clock_menus.xml | 28 + fusion_clock/views/clock_schedule_views.xml | 128 +++ fusion_clock/views/portal_clock_templates.xml | 22 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 202 bytes ...lock_nfc_enrollment_wizard.cpython-312.pyc | Bin 0 -> 6945 bytes 50 files changed, 3300 insertions(+), 42 deletions(-) create mode 100644 fusion-plating/.claude/settings.local.json create mode 100644 fusion_clock/CLAUDE.md create mode 100644 fusion_clock/__pycache__/__init__.cpython-312.pyc create mode 100644 fusion_clock/controllers/__pycache__/__init__.cpython-312.pyc create mode 100644 fusion_clock/controllers/__pycache__/clock_kiosk.cpython-312.pyc create mode 100644 fusion_clock/controllers/__pycache__/clock_nfc_kiosk.cpython-312.pyc create mode 100644 fusion_clock/controllers/__pycache__/shift_planner.cpython-312.pyc create mode 100644 fusion_clock/controllers/shift_planner.py create mode 100644 fusion_clock/models/__pycache__/__init__.cpython-312.pyc create mode 100644 fusion_clock/models/__pycache__/clock_activity_log.cpython-312.pyc create mode 100644 fusion_clock/models/__pycache__/clock_correction.cpython-312.pyc create mode 100644 fusion_clock/models/__pycache__/clock_leave_request.cpython-312.pyc create mode 100644 fusion_clock/models/__pycache__/clock_location.cpython-312.pyc create mode 100644 fusion_clock/models/__pycache__/clock_penalty.cpython-312.pyc create mode 100644 fusion_clock/models/__pycache__/clock_schedule.cpython-312.pyc create mode 100644 fusion_clock/models/__pycache__/clock_shift.cpython-312.pyc create mode 100644 fusion_clock/models/__pycache__/res_company.cpython-312.pyc create mode 100644 fusion_clock/models/__pycache__/res_config_settings.cpython-312.pyc create mode 100644 fusion_clock/models/clock_schedule.py create mode 100644 fusion_clock/static/src/js/fusion_clock_shift_planner.js create mode 100644 fusion_clock/static/src/scss/_fusion_clock_shift_planner_tokens.scss create mode 100644 fusion_clock/static/src/scss/fusion_clock_shift_planner.dark.scss create mode 100644 fusion_clock/static/src/scss/fusion_clock_shift_planner.scss create mode 100644 fusion_clock/static/src/xml/fusion_clock_shift_planner.xml create mode 100644 fusion_clock/tests/test_shift_planner.py create mode 100644 fusion_clock/views/clock_schedule_views.xml create mode 100644 fusion_clock/wizard/__pycache__/__init__.cpython-312.pyc create mode 100644 fusion_clock/wizard/__pycache__/clock_nfc_enrollment_wizard.cpython-312.pyc 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 0000000000000000000000000000000000000000..b9c37cd920bd6c829aee737b1807cf40fc58a87a GIT binary patch literal 239 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 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 0000000000000000000000000000000000000000..29a88a2176837e73acf032a6ea131df32c05ca3c GIT binary patch literal 8346 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9400206e8555c8919319c9e8eac04f529a28bf66 GIT binary patch literal 13248 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 literal 0 HcmV?d00001 diff --git a/fusion_clock/controllers/__pycache__/portal_clock.cpython-312.pyc b/fusion_clock/controllers/__pycache__/portal_clock.cpython-312.pyc index 9d5f15ad726af34ddb8243c37ec6a6a90cfad9a7..9092d41de01e545540b42ee2e82964198b5cfdc6 100644 GIT binary patch delta 1975 zcmZ`)drXs86#u@Kwm?fC^zrq6EiDCkwxtvh2lxmRQ8PCc*|H5CcC^3>QvCWce0)W= zn5-BxohQpMTV`}VSTI>#m&jbUuvfOsJuJo15Zy?&nC*`x=+p>VvfT?5)VTifJLjJB zyXV|<&-rdo^^W%{FK1+=Q~3A0RO3HVdO?{bp=YBG$x%A9%C)PDck#X;@AP=Rfv%t{ z`lBIQLmx5D1q|RFr{Hr4xs@AN_QvJDL$55nuk;7ZIAF}{hv>Tyq2U-(EAHvTB`?pe!7_G5v?2FYMss|w{3S`+lEls_oesJSW$qNrC|mL*HEgX&>Pr$^D?zEZ z9xyDni5WnGRFDa&d{vSsD7~8G+}vl)T(S!3f=&<%DFWlo@#emx2pRc>0iq~>bu!M9 z^ez*O$%xxufh|fq&TzEcuQn zGn?Cdd~<6{XGb%kb$5EYI5|9NYN=s42O)?$dMCvj*KXmu@rv6`OsSg!Egt@*Ko{pH zHWz^o;*iYc;=15hlNBW9jcSg#oCHYQgDkvYHo+cqwd4}M;Cry9ya0YN|KrkRpO&4I zpOgj)#Io>Zm18- zr=^s^1g#ccJW@0c*DcSaCbU_%C|Q~cDy{t{EN|;5)^dV9&Q2>SgY~XTmY0n+RVKu( zm7-e|sI#4`ERIA@VZvIH$SO`?9cAA{8JX!}>1{KmW{&g?^j)#69+k#b>#wA)pSk72 zA%gf}qkhP%Rc~G%?ae+W!_7=Dyit6WRGNcfp_vU4+o zjG+cF6k^t|3p{=!!9oGu+!ySp1u2;=w&^ z6IkpmgmiQxbN(r2!pz!~__VX(Z4)k#HtPY{nw zgReWl@!TomeUHE~5IODYlSDmB;2eP=Smo@I4PiAX62q$UB0M6-=&bW1{di>mIBJL+ z8p85xItJgzh-sW@jO!Z1Y1bGQmp>{WXPe?oQ&=&DOkrh0YndB^@Y;leolw|f3LC7k znjo!oyBJSJw7v9enm!CAWmWVbw3p@5K{#CIrg`|R%*f1@Cz&sAsj`pZud7YJM;@Evqj zd3Ddxk`46VVoJJ>o)*n5ru*PV)gkE@gtH%Bu0CJ@N6H delta 1830 zcmZ`)X-rgC6n=M>H^75gm^bs58JGcNnT2IQLBLXth)bKQXl)xbN|+Iuq62TnqVNW_ zY2v^iZBN>uY2s2B8cc*tTb*bcY0}izwk*#CGTO9BlcqoV1FB7G(OO(n(~EpIhZVtCd=ZLMy-9kOv^esB~rDRL{9gc z7Tf=X*7R5_n`O1Ek*)q*f8K{ol#48k2t@@~lA*FJlextcwqcS8zfnv4Q_C@gl2FC1 zMn!OlD^id1Ti`r5$|O@)wFV9Z4s}R%uvw@m+>fb*9T&Vr!hXcon0 zKV&H}`;2%>oYhlS#{+{d-#I76T0rZp)Xfm>tN~td7T4q!O*=}av)t3!-f6)(%cWa$pxRmd_YmA#~2RxRiheOGq)IaB z3(aU8=>eb1s=~`A*AHcPi8+7C%xzEDhS%IiW6T|908R1k3En-)mku^7d}+e=NIC^O z+&_cUrS&|H5t~Mt>%QBaV?B3z27FUC--OLKX{#LGs@N(=84|P4bA^_0j_zET6o8DbUg0+!Bz080LCRa!CaQ-XcI|MoK48NWe;YHk-#Mg79Z4I!eaOz6@)5WaYM-%Dj(@lghs{M7-R0U+yuEyh1;&M z+heKsIdOtqlp;1MTvIIlC&3oe-@~6`OWE6MigmW zhM97nn=4E^U)bZCu7RUsBYF^TtJtQd`w6XK_@Z);J&AH%D5={QX!ZxBuuCsFi4Ff` zC@BneRZ$d$n^j#jjsGU$M{uCR9dB7#jEu*L!&L&;3EYOW)qeIhq}_lXs403Kno-Sd zX1al|>8EcIpTjU%bH)7)7L&T&?V;AzfF$Yh?qfr6t);YI-0iuT90{{R3 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 0000000000000000000000000000000000000000..4dbce0765e416028d8f6516000af23c3e4539e0d GIT binary patch literal 14511 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 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 0000000000000000000000000000000000000000..fc9a5395b27683e0d94886dce236269ba9c8201d GIT binary patch literal 610 zcmXw$L5|Zf6o#LhHcgwRVwlyyfM8i62nQH(1PT>L+?vpjr*e5Z;k(NHU-5bQJf zOwNKw@JL3%=kU3l2VcM!auGa+$1)DSgfHbXcmhvk5_|<;$yM+*d@a|(Q+O)V;2Zcx zZUhb4ZhMcNX(#TsWz&_P6;SxPGf1+dRfYH3RD~%uf)s&n`=%Q-m)Z10uT9bT0l{Vp ztnNGOQD0i^RN0w*eNaw&Uz>wN{B&w7N?+gBe!$2BH=pP0;#MoGznrw=JJKn3$9m7@ zc}rIwb#-cZizib<*;%V`W7s^M2Dg1-hL5}Kj%!^jw0 z;0HZ`^!Pq+jn6&SdRtUgXI#ENIm}aGfxPW1-MCz-+SFdDcm42~+#tl3AM7PA;P33- k0DmGO#NU|2i;

bQSn)q}L<83Pf@}(#v0TWiQbG5Bkob@c;k- literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..10082a545932f4f5b40c8696d4b61aa5289ccdfb GIT binary patch literal 4944 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 XUOLa(h-qSNa^u+gu|FZbqO<-V$}(*5 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d9dbc3b2c826c3a82ea1942f1dabdffc9399b7ce GIT binary patch literal 5656 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..951eed601e14e7594cb056f73279f1085ce973d4 GIT binary patch literal 3370 zcmaJ^O>7&-6`tMYE=h?LDN>|tSwECzDWapF)=eE3{;3s9E@Dfv6B$WCBiO8Wh8AU# ztIV!Yi;z(kFi;mrQR*ZIMG&EvDv%HAsXi9H^w5hGDbQk&gMbz&a+7Np1$^q8S(2g? zx&vYOX6BnWGw*%xz182t;T8n#=fB*{%?1(r2Ord$S{0tZ1Hu}@2upcXkTOz1&d3EN zqew_bKSNl4h_FJWg+OiN(eI|>$r*LomsY>!gS5i5f`v)mW?D8!^4MmIv6wTDHX)={ z2hXQvelkHB38RdRrHq2*j1Mar75g%pjEH|B+PKk^v5Gb9$AKroO`kWD3BDHFf?II_ zu%SA(MPQRn^oDWUT^UCJ9j(7CB+y+=(CtrRn^N`sw%4NK4XB7frJCsKzzH}tSwFS? zwa89<3?F~eRY%4Ia;S;U6V-RueAFS3&FtBYdvGtD-&eO$LSRRlXiYV|r&FMQ&;->F z+D>kQcX`Vk2VB>}S#Q+X;{klChQfmn(Pniq)BV~VL--9mTzfrEH|*(IKAnD(#jY;d zIjeXjZ<+UQlA@7!mP;L3F*j!N$+5-iY*=7YHBby24oP8Rn32T`+lKrM(n+7JgIQ?yh=8AV&z$!u*5Qi`|2h#mgE9@tQ#k{pl z2n$SArL;m@;n^%e$tO!WN-zz>FfB8G9xkNkily*0znQr0=(|N&2@-?mmvw8&DMhBZ zSY5mU6QSyRQn!{DNm>@VX#_Nu+UCr8f|v3Hr*7p6BrS=xQtO22EE#z*SBg&NvROh2 zye~Ce$Q9EpuA5e2aS3jM>r1sO`>RV#wH=F+QrjfxHyt=Y?EP2Oo2WE4lPiJ>8?0Id zWT{n1{lcR606cFkQ9EVWsj6qj>iRLs8cTVH`Q}M}k@+UU4y9H~>@j|@QCw#JE1pnF z)(orXaBUUDx+Y4vH8JhrruNNR;FUzf%Z|JZCW`~c1Gd>Ulj#najI|oVR$^dQV_!Uy z=TaVL&lz-UXa!B8kFp3okXEDx0eH2e@4kv2F6y=9{1u{7jZo9Rhe-y&}-CLT&6MDUK;v-8W?Nvca7vf0j&KIbY*l9 z@BBAXgQJhGxRKa?r2nf(|GN1{>krm9$4)$+`$GRr->~<`CwAbkXJWtm=1%v`-R}2y zkIhxoZ_;BMOIyyDD^K6sP5-o_d>tJ+NRNo$XRV;}IUImkYXe1DsCr@DfiPu0o_k&) z6nCZ|R5zG;4LNnPt)OPa!P0Tquuv_B{J2`HyIvBcBtS}eMP8_t;2I2mn-%!`k}XR> zkq1DA1)iq9(?C<`J5w+`qSON)AlklJdFw}iKKG|{Tc@8+ z?DSpQp8H_?{SUW4%xvHOcspZk-@Nna%GaUpXMTPVO~Z|5)4eR9i&CfStVP!$z@S=y z9MbhqphR$s!AKPlnB=vR<`%h4nWkG56Uu@*1hN27PDU1(fi!)_DiRhxDxHv+JtqX` z>P`_p4DbxkGi^gcQLpAL!=b0Rwn!~%s_BCsgIVD^)>17Apqu$_k^4|Iiag8vuFM-$ zIF@q9F+V1t1luA_2Mt5TYmST zW1w<%?Z)a2H_+)uPPpw|Zd>d{P)%s%nZpo@pR5dV$GL%o8#(T_C%o0TR=y6a9j7X1 z_u~UQ@qvTTup1gVRFz=MVE{#Y0r?{A@6gKAhf$OqtbDMhuj<<)7xoet%d>8*-_yF$ zvln~2eBC`h_&E7R_h;R1q~DDW@+17dE^XVFJZwRo1C=Z%mmBGGqj5KU5!O1j@(oxD zbv`=#yYs&}|2VthJbi1g^>TUAZBJE_YkjMI;=;$==&*O;m{z_97e3mt*E(39Jc##J z&aRzbJ-_|NrN7|4`1SJiff_B({rdK=Zg02uuV36%fA~z+)Y0b((#{-m37(_1dx%QU zdfS$^tG@kAR#j8tm~)=!`GH%1HmxzAg)NKnBq*rlxlf(p|62$T(QAB(Ggr9oW#EEG o*h_Q<=6J%i`=L8jBuV;j3z7zR(5V;NWl1t6&usreVjxuf4-IWE-2eap literal 0 HcmV?d00001 diff --git a/fusion_clock/models/__pycache__/clock_report.cpython-312.pyc b/fusion_clock/models/__pycache__/clock_report.cpython-312.pyc index fe27c898faa1def586b197309cf88a42ab0f495f..a0b8b9a5ff6c7bf0f3352a70d15b2fc214a1cf79 100644 GIT binary patch delta 753 zcmYjOT}V@582-MU89VOJojEsi+u7MvY%!0M=^PR{=YE=q&Y!fP3(*;Y8llleU5qYl zM27VRSvO56k}NXL&7@!>FETCkY@-eIq92log07(0{PHh<)c!a146P3qMzbk;T~y|EXykY7a`ezN~CH6BWISfLfrVY z!cA+j;v4uK0tvfU~F`06sMf6(|CvV z)@A{`kHqdfRap(7^f@3PEn#OyUNYmoekEf(GSAGJXC$+M+wyD?E}@v)FG?!Cc|ehJ zPU1NVjhJL>fKr{NYIv$9O|d)>SNJu&A^Y#!Jxu@Q%Bz%n6z&kRdHspbo z9DryyU=?R2Kv$guD(NUhw(bSt9{JooBP=Mmi#=Zya21^jUMt4Ja#J4Ekq+)_a2FB< NvVGQu?L-x0!M`rN{jmT5 delta 667 zcmX?oo3Y_GBj0IWUM>b8$PL<_c_(TkUpWV(^5p3p%8a)r@8-}F;sZ)GFw6*4S)IsP$m#(>2i3yV)?;|XDA5RsT1$8(rd9xkfSD-IIf?8&Ro0yL^p&{aVe zDEWfnM+3tJMYn?@I~*4%F4tSAcS*^7gY#u2^MfLj?+Tt$1(^U*B=2$1WJlrx#_I}3 z7Zr>yDOheuzN}z*(Bx-P=;V*$B9re6ZB|04R`Nfoav*Sl;dN!pi^`UllpS^iUsiTF zsWN%9up;BF%~yrJ7+ozGL4Ntd0d!Y~^9^CK>%yuRg;hIT?#L-#1Uc!lqT6LTw+`nX z|2s1B7eV%4miM?U;{g=$y(6!55oE{Z$(f?P%(q1)C;t<5U}g}Ln(Qd12=-~JSQ?YK zFf0Io7D&iHzb9UuS&T;Amy5X0TH4|g+WHZ+( z3ZEqygycFLZ}1CDaPO+Qp`^J&aiP`(o*99Y_&=}$r6#|3^I(dF-JL?x721(`325J8onL!N03=?4BOis&91pr~b=?(w@ 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 0000000000000000000000000000000000000000..1f37eb6edff9f2ed6b41e1255bb857afc3e2f99e GIT binary patch literal 18068 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 literal 0 HcmV?d00001 diff --git a/fusion_clock/models/__pycache__/hr_attendance.cpython-312.pyc b/fusion_clock/models/__pycache__/hr_attendance.cpython-312.pyc index 033404884ff4608d78ada0157b164dd669cfb9ba..99ad6695f21a68fc83b749ff8c020af6d234d378 100644 GIT binary patch delta 6133 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 diff --git a/fusion_clock/models/__pycache__/hr_employee.cpython-312.pyc b/fusion_clock/models/__pycache__/hr_employee.cpython-312.pyc index be868b360ff0a071649fc5f580e5c6890420d8b5..115ef34c026ed8c3d4d5e8e81e700ffdc2fe8c55 100644 GIT binary patch delta 5325 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 X|5gzur}i?oKU|;IKi2;sfO+mel+V$% literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0e699f1af2f9499e61f214cfa811c20557b3cf34 GIT binary patch literal 12433 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 literal 0 HcmV?d00001 diff --git a/fusion_clock/models/__pycache__/tz_utils.cpython-312.pyc b/fusion_clock/models/__pycache__/tz_utils.cpython-312.pyc index 5bcbef22fbd6114ad8796795c0c4afb26555fa76..f23e55a16bcdb14fa4973e3b799decd6bc78788f 100644 GIT binary patch delta 52 zcmZ1^yIGd^G%qg~0}zA-?9M#2kvE7%$Ur|guSCBjwYWq#F(oBGuULO`0?RK>#$S`8 G`P>1YP!R|K delta 35 pcmdliyGWMzG%qg~0}yNt+@4v!kvE8iQGRnF%P&sGTa$D6+yS= 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 0000000000000000000000000000000000000000..f807b51427d3faba52d210f6fda229b5d859d4b8 GIT binary patch literal 202 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 literal 0 HcmV?d00001