Compare commits

...

13 Commits

Author SHA1 Message Date
gsinghpal
005daade55 changes 2026-05-23 07:53:41 -04:00
gsinghpal
27e12dd544 chore(shopfloor): register fp_rpc.js asset + bump to 19.0.30.2.0 (P6.3.6)
Some checks are pending
fusion_accounting CI / test (fusion_accounting_ai) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_core) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_migration) (push) Waiting to run
Adds the Phase 6.3 fpRpc wrapper to the web.assets_backend bundle.
Placed before its consumers so the `import { fpRpc } from "./services/fp_rpc"`
calls in job_workspace, shopfloor_landing, manager_dashboard, and
hold_composer resolve.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:47:51 -04:00
gsinghpal
5f03080374 feat(shopfloor): switch action-path RPCs to fpRpc + wire plant_overview/move_card (P6.3.5)
JobWorkspace, ShopfloorLanding, ManagerDashboard, and the embedded
FpHoldComposer now call fpRpc() for write-path endpoints (start/finish
step, hold create, sign-off, milestone advance, work-centre move,
assign-worker, assign-tank, manager takeover). fpRpc auto-injects
tablet_tech_id from the tech_store so the server can rebind env via
env_for_tablet_tech() and credit the right user.

Read-path RPCs (workspace/load, landing/kanban, manager/overview,
manager/funnel, manager/approval_inbox, manager/at_risk, shopfloor/scan)
stay as plain rpc() — no audit benefit, no need for the extra plumbing.

Also wires tablet_tech_id into /fp/shopfloor/plant_overview/move_card
which I missed in P6.3.3 — surfaced when grepping JS for write callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:47:20 -04:00
gsinghpal
efaf16dffb feat(shopfloor): propagate tablet_tech_id to shopfloor + manager action endpoints (P6.3.3 + P6.3.4)
10 endpoints in shopfloor_controller (log_chemistry, start_bake, end_bake,
start_wo, stop_wo, bump_qty_done, bump_qty_scrapped, log_thickness_reading,
quality_hold, mark_gate) and 3 in manager_controller (assign_worker,
assign_tank, take_over) now accept a `tablet_tech_id` kwarg. Each rebinds
env via env_for_tablet_tech() so writes carry the correct uid even when
the OS session belongs to the persistent tablet user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:43:44 -04:00
gsinghpal
e4000374ca feat(fusion_plating_shopfloor): wire tablet_tech_id into workspace endpoints (P6.3.2)
hold, sign_off, advance_milestone each accept tablet_tech_id and
rebind env via env_for_tablet_tech. Writes (Hold.create, button_finish,
action_advance_next_milestone) now carry the tech-of-record's uid.
load endpoint is read-only and untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:37:58 -04:00
gsinghpal
fee4219703 feat(fusion_plating_shopfloor): fpRpc wrapper + env_for_tablet_tech helper (P6.3.1)
Client-side fpRpc() is a drop-in for rpc() that automatically injects
tablet_tech_id from the tech_store into every action call. Read-only
endpoints can keep using plain rpc().

Server-side env_for_tablet_tech(env, tablet_tech_id) returns an env
scoped via with_user() when the id is a valid active user; otherwise
returns the original env unchanged. Controllers call this at the top
of action methods so all subsequent writes carry the right uid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:37:02 -04:00
gsinghpal
6ca9a58a8c chore(fusion_plating_shopfloor): bump 19.0.30.1.0 for Phase 6.2 — lock screen
Some checks are pending
fusion_accounting CI / test (fusion_accounting_ai) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_core) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_migration) (push) Waiting to run
Frontend lock screen ships:
- tech_store + activity_tracker shared OWL services
- FpPinPad, FpIdleWarning, FpPinSetup components
- FpTabletLock outer wrapper
- Wired into Landing/Workspace/Manager + Hand-Off button in each header
- fp_tablet_pin_setup client action for Preferences self-service
2026-05-23 00:33:42 -04:00
gsinghpal
d86c120969 feat(fusion_plating_shopfloor): FpPinSetup client action for self-service PIN (P6.2.6)
Registers fp_tablet_pin_setup as an ir.actions.client tag. Triggered
from res.users preferences via action_open_tablet_pin_setup (added
to res_users.py in P6.1.1). Three-stage flow:

  loading → check if user has existing PIN via search_count
  old     → enter current PIN (skipped if first-time)
  new     → choose new PIN
  confirm → enter new PIN again
  done    → success toast + auto-close 1.5s later

Each stage reuses FpPinPad with a different onSubmit + title. On
mismatch / server error, resets to the first stage with a notification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:33:28 -04:00
gsinghpal
85609f99cd feat(fusion_plating_shopfloor): wire FpTabletLock + Hand-Off into Landing/Workspace/Manager (P6.2.5)
Three OWL client actions all wrap their root in <FpTabletLock>:

  ShopfloorLanding   wraps o_fp_landing
  JobWorkspace       wraps o_fp_ws
  ManagerDashboard   wraps o_fp_manager

Each adds FpTabletLock to static components, imports tech_store, and
gains a handOff() method that calls techStore.lock(). The Hand-Off
button (yellow, lock icon) lands next to the scan/QR controls in each
header — pressing it instantly returns the tablet to the tile grid
without waiting for the idle timer.

Component composition (per spec §6.5):
  FpTabletLock
    if isLocked → tile grid + FpPinPad
    else → existing client action (via <t t-slot="default"/>) + FpIdleWarning

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:32:52 -04:00
gsinghpal
29821bd541 feat(fusion_plating_shopfloor): FpTabletLock outer wrapper component (P6.2.4)
Top-level wrapper that renders lock screen (tile grid + PIN pad) when
no tech is signed in, and renders <t t-slot="default"/> otherwise.
Drives the auto-lock countdown via the activity_tracker service +
sends a /fp/tablet/ping heartbeat every 60s while a tech is signed in.

Tiles fetch from /fp/tablet/tiles using the localStorage station id
(set by ShopfloorLanding on QR pair / station picker selection).

State machine for the lock screen body:
  loadingTiles → tiles list → tile tapped → PinPad → unlock RPC
                                          ↑
                                          onPinCancel → back to tiles

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:29:24 -04:00
gsinghpal
1fdafd34d1 feat(fusion_plating_shopfloor): FpIdleWarning overlay (P6.2.3)
Fixed-position yellow-border overlay + countdown toast shown during
the last N (default 30) seconds before auto-lock. Pure props-driven —
secondsRemaining is the only input; parent (FpTabletLock) decides
when to mount and unmount. Box-shadow pulse animation runs CSS-only
so OWL doesn't need to re-render every tick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:28:30 -04:00
gsinghpal
9584953467 feat(fusion_plating_shopfloor): FpPinPad numeric keypad component (P6.2.2)
Reusable 4-digit PIN pad. Auto-submits on the 4th digit via the
onSubmit prop. On wrong PIN, shake animation + dots clear + error
banner (caller controls the message via the returned {ok:false, error}).

Used by FpTabletLock (unlock flow) and FpPinSetup (set/change flow).

Dark-mode SCSS branch follows the same $o-webclient-color-scheme
pattern as the rest of the shopfloor components.

Also registers tech_store + activity_tracker services in the asset
bundle (assets/web.assets_backend) before the pin_pad files, since
the pin_pad/tablet_lock components consume them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:28:01 -04:00
gsinghpal
52097ca59b feat(fusion_plating_shopfloor): tech_store + activity_tracker OWL services (P6.2.1)
Two registry-level services:

tech_store    Shared reactive state holding currentTechId after a
              successful PIN unlock. Other components subscribe via
              useService("fp_shopfloor_tech_store") and read
              currentTechId to inject into action RPCs. setTech(id, name)
              on unlock; lock() on auto-lock / Hand-Off.

activity_tracker  Document-level event tracker for pointerdown / touchstart
              / keydown / visibilitychange. Mouse-move alone deliberately
              EXCLUDED — a tool resting on a tablet would otherwise keep
              the session alive indefinitely. Public API:
                bump(), getSecondsUntilLock(), getWarnThresholdSec()
              Reads thresholds from ir.config_parameter at start +
              every 5 min (so manager edits propagate within a shift).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:27:13 -04:00
77 changed files with 4354 additions and 103 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(ls /k/Github/Odoo-Modules/ | grep -i -E \"shopfloor|tablet|fusion_plating\")"
]
}
}

358
fusion_clock/CLAUDE.md Normal file
View File

@@ -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/<report_id>/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
```

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Clock', 'name': 'Fusion Clock',
'version': '19.0.3.3.0', 'version': '19.0.3.5.6',
'category': 'Human Resources/Attendances', 'category': 'Human Resources/Attendances',
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export', 'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
'description': """ 'description': """
@@ -70,6 +70,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
'views/clock_correction_views.xml', 'views/clock_correction_views.xml',
'views/clock_dashboard_views.xml', 'views/clock_dashboard_views.xml',
'views/hr_employee_views.xml', 'views/hr_employee_views.xml',
'views/clock_schedule_views.xml',
# Wizards (must load before clock_menus.xml since menu references wizard action) # Wizards (must load before clock_menus.xml since menu references wizard action)
'wizard/clock_nfc_enrollment_views.xml', 'wizard/clock_nfc_enrollment_views.xml',
'views/clock_menus.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', 'fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js',
], ],
'web.assets_backend': [ '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/scss/fusion_clock.scss',
'fusion_clock/static/src/js/fusion_clock_systray.js', 'fusion_clock/static/src/js/fusion_clock_systray.js',
'fusion_clock/static/src/xml/systray_clock.xml', 'fusion_clock/static/src/xml/systray_clock.xml',
'fusion_clock/static/src/js/fusion_clock_dashboard.js', 'fusion_clock/static/src/js/fusion_clock_dashboard.js',
'fusion_clock/static/src/xml/fusion_clock_dashboard.xml', '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_map.js',
'fusion_clock/static/src/js/fusion_clock_location_places.js', 'fusion_clock/static/src/js/fusion_clock_location_places.js',
'fusion_clock/static/src/xml/fusion_clock_location.xml', '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, 'installable': True,
'auto_install': False, 'auto_install': False,

Binary file not shown.

View File

@@ -4,3 +4,4 @@ from . import portal_clock
from . import clock_api from . import clock_api
from . import clock_kiosk from . import clock_kiosk
from . import clock_nfc_kiosk from . import clock_nfc_kiosk
from . import shift_planner

View File

@@ -5,6 +5,7 @@
import base64 import base64
import math import math
import logging import logging
import pytz
from datetime import datetime, timedelta from datetime import datetime, timedelta
from odoo import http, fields, _ from odoo import http, fields, _
from odoo.http import request from odoo.http import request
@@ -108,6 +109,9 @@ class FusionClockAPI(http.Controller):
ICP = request.env['ir.config_parameter'].sudo() ICP = request.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock.enable_penalties', 'True') != 'True': if ICP.get_param('fusion_clock.enable_penalties', 'True') != 'True':
return return
day_plan = employee._get_fclk_day_plan(get_local_today(request.env, employee))
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
return
grace = float(ICP.get_param('fusion_clock.penalty_grace_minutes', '5')) grace = float(ICP.get_param('fusion_clock.penalty_grace_minutes', '5'))
deduction = float(ICP.get_param('fusion_clock.penalty_deduction_minutes', '15')) deduction = float(ICP.get_param('fusion_clock.penalty_deduction_minutes', '15'))
@@ -161,7 +165,16 @@ class FusionClockAPI(http.Controller):
worked = attendance.worked_hours or 0.0 worked = attendance.worked_hours or 0.0
if worked >= threshold: if worked >= threshold:
break_min = employee._get_fclk_break_minutes() local_date = get_local_today(request.env, employee)
if attendance.check_in:
tz_name = (
employee.resource_id.tz
or (employee.user_id.partner_id.tz if employee.user_id else False)
or employee.company_id.partner_id.tz
or 'UTC'
)
local_date = pytz.UTC.localize(attendance.check_in).astimezone(pytz.timezone(tz_name)).date()
break_min = employee._get_fclk_break_minutes(local_date)
current = attendance.x_fclk_break_minutes or 0.0 current = attendance.x_fclk_break_minutes or 0.0
# Set to whichever is higher: configured break or existing (penalty-inflated) value # Set to whichever is higher: configured break or existing (penalty-inflated) value
new_val = max(break_min, current) new_val = max(break_min, current)
@@ -268,6 +281,8 @@ class FusionClockAPI(http.Controller):
now = fields.Datetime.now() now = fields.Datetime.now()
today = get_local_today(request.env, employee) 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 = { geo_info = {
'latitude': latitude, 'latitude': latitude,
@@ -307,6 +322,34 @@ class FusionClockAPI(http.Controller):
source=source, 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 # Check for late clock-in penalty
scheduled_in, _ = self._get_scheduled_times(employee, today) scheduled_in, _ = self._get_scheduled_times(employee, today)
self._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now) 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) self._apply_break_deduction(attendance, employee)
# Check for early clock-out penalty # Check for early clock-out penalty
_, scheduled_out = self._get_scheduled_times(employee, today) if not is_scheduled_off:
self._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now) _, scheduled_out = self._get_scheduled_times(employee, today)
self._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
# Log clock-out # Log clock-out
self._log_activity( self._log_activity(
@@ -518,6 +562,13 @@ class FusionClockAPI(http.Controller):
'pending_reason': employee.x_fclk_pending_reason, 'pending_reason': employee.x_fclk_pending_reason,
'ontime_streak': employee.x_fclk_ontime_streak, '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: if is_checked_in:
att = request.env['hr.attendance'].sudo().search([ 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, '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_start_utc, today_end_utc = get_local_day_boundaries(request.env, local_today, employee)
today_atts = request.env['hr.attendance'].sudo().search([ today_atts = request.env['hr.attendance'].sudo().search([
('employee_id', '=', employee.id), ('employee_id', '=', employee.id),

View File

@@ -5,6 +5,7 @@
import logging import logging
from odoo import http, fields, _ from odoo import http, fields, _
from odoo.http import request from odoo.http import request
from odoo.addons.fusion_clock.models.tz_utils import get_local_today
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -93,7 +94,9 @@ class FusionClockKiosk(http.Controller):
is_checked_in = employee.attendance_state == 'checked_in' is_checked_in = employee.attendance_state == 'checked_in'
now = fields.Datetime.now() 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 = { geo_info = {
'latitude': latitude, 'latitude': latitude,
@@ -120,8 +123,17 @@ class FusionClockKiosk(http.Controller):
source='kiosk', source='kiosk',
) )
scheduled_in, _ = api._get_scheduled_times(employee, today) if is_scheduled_off:
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now) 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 { return {
'success': True, 'success': True,
@@ -135,8 +147,9 @@ class FusionClockKiosk(http.Controller):
}) })
api._apply_break_deduction(attendance, employee) api._apply_break_deduction(attendance, employee)
_, scheduled_out = api._get_scheduled_times(employee, today) if not is_scheduled_off:
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now) _, scheduled_out = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
api._log_activity( api._log_activity(
employee, 'clock_out', employee, 'clock_out',

View File

@@ -8,6 +8,7 @@ import time
import threading import threading
from odoo import fields, http from odoo import fields, http
from odoo.http import request from odoo.http import request
from odoo.addons.fusion_clock.models.tz_utils import get_local_today
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
_UID_HEX_PATTERN = re.compile(r'^[0-9A-F]+$') _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' is_checked_in = employee.attendance_state == 'checked_in'
now = fields.Datetime.now() 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 = { geo_info = {
'latitude': 0, 'latitude': 0,
@@ -208,8 +211,17 @@ class FusionClockNfcKiosk(http.Controller):
latitude=0, longitude=0, distance=0, latitude=0, longitude=0, distance=0,
source='nfc_kiosk', source='nfc_kiosk',
) )
scheduled_in, _ = api._get_scheduled_times(employee, today) if is_scheduled_off:
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now) 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 { return {
'success': True, 'success': True,
'action': 'clock_in', 'action': 'clock_in',
@@ -224,8 +236,9 @@ class FusionClockNfcKiosk(http.Controller):
'x_fclk_check_out_photo': photo_bytes if photo_bytes else False, 'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
}) })
api._apply_break_deduction(attendance, employee) api._apply_break_deduction(attendance, employee)
_, scheduled_out = api._get_scheduled_times(employee, today) if not is_scheduled_off:
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now) _, scheduled_out = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
api._log_activity( api._log_activity(
employee, 'clock_out', employee, 'clock_out',
f"NFC kiosk clock-out from {location.name}. Net: {attendance.x_fclk_net_hours:.1f}h", f"NFC kiosk clock-out from {location.name}. Net: {attendance.x_fclk_net_hours:.1f}h",

View File

@@ -100,7 +100,9 @@ class FusionClockPortal(CustomerPortal):
], limit=1) ], limit=1)
# Today stats # 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([ today_atts = request.env['hr.attendance'].sudo().search([
('employee_id', '=', employee.id), ('employee_id', '=', employee.id),
('check_in', '>=', today_start), ('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) today_hours = sum(a.x_fclk_net_hours or 0 for a in today_atts)
# Week stats # Week stats
today = get_local_today(request.env, employee)
week_start = today - timedelta(days=today.weekday()) week_start = today - timedelta(days=today.weekday())
week_start_dt, _ = get_local_day_boundaries(request.env, week_start, employee) week_start_dt, _ = get_local_day_boundaries(request.env, week_start, employee)
week_atts = request.env['hr.attendance'].sudo().search([ week_atts = request.env['hr.attendance'].sudo().search([
@@ -151,6 +152,7 @@ class FusionClockPortal(CustomerPortal):
'current_attendance': current_attendance, 'current_attendance': current_attendance,
'today_hours': round(today_hours, 1), 'today_hours': round(today_hours, 1),
'week_hours': round(week_hours, 1), 'week_hours': round(week_hours, 1),
'today_schedule': today_schedule,
'recent_attendances': recent, 'recent_attendances': recent,
'google_maps_key': google_maps_key, 'google_maps_key': google_maps_key,
'enable_sounds': enable_sounds, 'enable_sounds': enable_sounds,

View File

@@ -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,
}

View File

@@ -9,5 +9,6 @@ from . import res_config_settings
from . import clock_activity_log from . import clock_activity_log
from . import clock_leave_request from . import clock_leave_request
from . import clock_shift from . import clock_shift
from . import clock_schedule
from . import clock_correction from . import clock_correction
from . import res_company from . import res_company

View File

@@ -34,6 +34,7 @@ class FusionClockActivityLog(models.Model):
('correction_request', 'Correction Request'), ('correction_request', 'Correction Request'),
('ip_fallback', 'IP Fallback Used'), ('ip_fallback', 'IP Fallback Used'),
('streak_milestone', 'Streak Milestone'), ('streak_milestone', 'Streak Milestone'),
('unscheduled_shift', 'Unscheduled Shift'),
('card_enrollment', 'Card Enrollment'), ('card_enrollment', 'Card Enrollment'),
('unknown_card_tap', 'Unknown Card Tap'), ('unknown_card_tap', 'Unknown Card Tap'),
], ],
@@ -108,6 +109,7 @@ class FusionClockActivityLog(models.Model):
'correction_request': 'Correction Request', 'correction_request': 'Correction Request',
'ip_fallback': 'IP Fallback Used', 'ip_fallback': 'IP Fallback Used',
'streak_milestone': 'Streak Milestone', 'streak_milestone': 'Streak Milestone',
'unscheduled_shift': 'Unscheduled Shift',
} }
@api.depends('latitude', 'longitude') @api.depends('latitude', 'longitude')

View File

@@ -0,0 +1,414 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import re
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class FusionClockSchedule(models.Model):
_name = 'fusion.clock.schedule'
_description = 'Clock Shift Schedule Entry'
_order = 'schedule_date, employee_id'
_rec_name = 'display_name'
employee_id = fields.Many2one(
'hr.employee',
string='Employee',
required=True,
index=True,
ondelete='cascade',
)
schedule_date = fields.Date(
string='Date',
required=True,
index=True,
)
shift_id = fields.Many2one(
'fusion.clock.shift',
string='Shift Template',
ondelete='set null',
)
is_off = fields.Boolean(
string='Off',
default=False,
index=True,
)
start_time = fields.Float(
string='Start Time',
default=9.0,
)
end_time = fields.Float(
string='End Time',
default=17.0,
)
break_minutes = fields.Float(
string='Break (min)',
default=30.0,
)
planned_hours = fields.Float(
string='Hours',
compute='_compute_planned_hours',
store=True,
)
note = fields.Char(string='Note')
company_id = fields.Many2one(
'res.company',
string='Company',
related='employee_id.company_id',
store=True,
readonly=True,
)
department_id = fields.Many2one(
'hr.department',
string='Department',
related='employee_id.department_id',
store=True,
readonly=True,
)
display_name = fields.Char(
compute='_compute_display_name',
store=True,
)
_employee_date_unique = models.Constraint(
'UNIQUE(employee_id, schedule_date)',
'Only one shift schedule is allowed per employee per day.',
)
@api.depends('is_off', 'start_time', 'end_time', 'break_minutes')
def _compute_planned_hours(self):
for rec in self:
if rec.is_off:
rec.planned_hours = 0.0
continue
raw_hours = (rec.end_time or 0.0) - (rec.start_time or 0.0)
rec.planned_hours = round(max(raw_hours - ((rec.break_minutes or 0.0) / 60.0), 0.0), 2)
@api.depends('employee_id', 'schedule_date', 'is_off', 'start_time', 'end_time')
def _compute_display_name(self):
for rec in self:
emp = rec.employee_id.name or ''
date_str = str(rec.schedule_date) if rec.schedule_date else ''
rec.display_name = f"{emp} - {date_str} - {rec.fclk_display_value()}"
@api.constrains('is_off', 'start_time', 'end_time', 'break_minutes')
def _check_schedule_times(self):
for rec in self:
if rec.break_minutes < 0:
raise ValidationError(_("Break minutes cannot be negative."))
if rec.is_off:
continue
if rec.start_time < 0 or rec.start_time >= 24:
raise ValidationError(_("Start time must be between 00:00 and 23:59."))
if rec.end_time <= 0 or rec.end_time > 24:
raise ValidationError(_("End time must be between 00:01 and 24:00."))
if rec.end_time <= rec.start_time:
raise ValidationError(_("End time must be after start time. Overnight shifts are not supported yet."))
shift_minutes = (rec.end_time - rec.start_time) * 60.0
if rec.break_minutes >= shift_minutes:
raise ValidationError(_("Break duration must be shorter than the scheduled shift."))
@api.onchange('shift_id')
def _onchange_shift_id(self):
for rec in self:
if rec.shift_id:
rec.is_off = False
rec.start_time = rec.shift_id.start_time
rec.end_time = rec.shift_id.end_time
rec.break_minutes = rec.shift_id.break_minutes
@api.model
def fclk_float_to_display(self, value):
value = float(value or 0.0)
hour = int(value)
minute = int(round((value - hour) * 60))
if minute == 60:
hour += 1
minute = 0
suffix = 'am' if hour < 12 or hour == 24 else 'pm'
display_hour = hour % 12
if display_hour == 0:
display_hour = 12
return f"{display_hour}:{minute:02d} {suffix}"
def fclk_display_value(self):
self.ensure_one()
if self.is_off:
return 'OFF'
return (
f"{self.env['fusion.clock.schedule'].fclk_float_to_display(self.start_time)} - "
f"{self.env['fusion.clock.schedule'].fclk_float_to_display(self.end_time)}"
)
@api.model
def fclk_hours_display(self, hours):
hours = float(hours or 0.0)
whole = int(hours)
minutes = int(round((hours - whole) * 60))
if minutes == 60:
whole += 1
minutes = 0
return f"{whole}:{minutes:02d}"
@api.model
def _fclk_parse_time_part(self, raw):
text = (raw or '').strip().lower().replace('.', '')
match = re.match(r'^(\d{1,2})(?::(\d{1,2}))?\s*(am|pm)?$', text)
if not match:
raise ValidationError(_("Could not understand time '%s'.") % raw)
hour = int(match.group(1))
minute = int(match.group(2) or 0)
meridiem = match.group(3)
if minute < 0 or minute > 59:
raise ValidationError(_("Minutes must be between 00 and 59."))
if meridiem:
if hour < 1 or hour > 12:
raise ValidationError(_("12-hour times must use hours from 1 to 12."))
if meridiem == 'am':
hour = 0 if hour == 12 else hour
else:
hour = 12 if hour == 12 else hour + 12
elif hour > 24:
raise ValidationError(_("Hours must be between 0 and 24."))
return hour + (minute / 60.0)
@api.model
def fclk_parse_planner_input(self, input_value, default_break_minutes=30.0):
text = (input_value or '').strip()
if not text:
return {'clear': True}
if text.upper() == 'OFF':
return {
'clear': False,
'is_off': True,
'shift_id': False,
'start_time': 0.0,
'end_time': 0.0,
'break_minutes': 0.0,
}
normalized = (
text.replace('', '-')
.replace('', '-')
.replace(' to ', '-')
.replace(' TO ', '-')
)
parts = [p.strip() for p in normalized.split('-', 1)]
if len(parts) != 2 or not parts[0] or not parts[1]:
raise ValidationError(_("Enter a shift as '9-5', '9:00-5:30', '9:00 am - 5:30 pm', or OFF."))
start = self._fclk_parse_time_part(parts[0])
end = self._fclk_parse_time_part(parts[1])
if end <= start and end + 12 <= 24:
end += 12
if end <= start:
raise ValidationError(_("End time must be after start time. Overnight shifts are not supported yet."))
return {
'clear': False,
'is_off': False,
'shift_id': False,
'start_time': start,
'end_time': end,
'break_minutes': float(default_break_minutes or 0.0),
}
@api.model
def fclk_values_from_planner_payload(self, payload, employee):
payload = payload or {}
if 'start_time' in payload and 'end_time' in payload and not payload.get('shift_id'):
if payload.get('is_off'):
return {
'clear': False,
'is_off': True,
'shift_id': False,
'start_time': 0.0,
'end_time': 0.0,
'break_minutes': 0.0,
}
return {
'clear': False,
'is_off': False,
'shift_id': False,
'start_time': float(payload.get('start_time') or 0.0),
'end_time': float(payload.get('end_time') or 0.0),
'break_minutes': float(payload.get('break_minutes') or 0.0),
}
shift_id = int(payload.get('shift_id') or 0)
if shift_id:
shift = self.env['fusion.clock.shift'].sudo().browse(shift_id)
if not shift.exists():
raise ValidationError(_("Selected shift template no longer exists."))
return {
'clear': False,
'shift_id': shift.id,
'is_off': False,
'start_time': shift.start_time,
'end_time': shift.end_time,
'break_minutes': shift.break_minutes,
}
default_break = employee._get_fclk_break_minutes() if employee else 30.0
return self.fclk_parse_planner_input(payload.get('input', ''), default_break)
@api.model
def fclk_snapshot(self, schedule):
if not schedule:
return ''
return schedule.fclk_display_value()
@api.model
def fclk_apply_planner_cell(self, employee, schedule_date, payload, user=None):
self = self.sudo()
employee = employee.sudo()
date_obj = fields.Date.to_date(schedule_date)
if not employee.exists() or not date_obj:
raise ValidationError(_("Invalid employee or schedule date."))
existing = self.search([
('employee_id', '=', employee.id),
('schedule_date', '=', date_obj),
], limit=1)
old_value = self.fclk_snapshot(existing)
parsed = self.fclk_values_from_planner_payload(payload, employee)
if parsed.get('clear'):
if existing:
existing.unlink()
new_schedule = self.browse()
new_value = ''
else:
vals = {
'employee_id': employee.id,
'schedule_date': date_obj,
'shift_id': parsed.get('shift_id') or False,
'is_off': bool(parsed.get('is_off')),
'start_time': parsed.get('start_time') or 0.0,
'end_time': parsed.get('end_time') or 0.0,
'break_minutes': parsed.get('break_minutes') or 0.0,
'note': payload.get('note') or False,
}
if existing:
existing.write(vals)
new_schedule = existing
else:
new_schedule = self.create(vals)
new_value = new_schedule.fclk_display_value()
if old_value != new_value:
self.env['fusion.clock.schedule.audit'].sudo().create({
'schedule_id': new_schedule.id if new_schedule else False,
'employee_id': employee.id,
'schedule_date': date_obj,
'old_value': old_value,
'new_value': new_value,
'changed_by_id': (user or self.env.user).id,
'changed_at': fields.Datetime.now(),
'company_id': employee.company_id.id,
'department_id': employee.department_id.id,
})
return new_schedule
@api.model
def fclk_cell_payload(self, employee, date_obj, schedule=None):
schedule = schedule or self.search([
('employee_id', '=', employee.id),
('schedule_date', '=', date_obj),
], limit=1)
Schedule = self.env['fusion.clock.schedule']
if schedule:
return {
'schedule_id': schedule.id,
'source': 'schedule',
'input': schedule.fclk_display_value(),
'label': schedule.fclk_display_value(),
'is_off': schedule.is_off,
'shift_id': schedule.shift_id.id or False,
'start_time': schedule.start_time,
'end_time': schedule.end_time,
'break_minutes': schedule.break_minutes,
'hours': schedule.planned_hours,
'hours_display': Schedule.fclk_hours_display(schedule.planned_hours),
'note': schedule.note or '',
}
plan = employee._get_fclk_day_plan(date_obj)
return {
'schedule_id': False,
'source': plan.get('source') or 'fallback',
'input': plan.get('label') or '',
'label': plan.get('label') or '',
'is_off': plan.get('is_off', False),
'shift_id': False,
'start_time': plan.get('start_time') or 0.0,
'end_time': plan.get('end_time') or 0.0,
'break_minutes': plan.get('break_minutes') or 0.0,
'hours': plan.get('hours') or 0.0,
'hours_display': Schedule.fclk_hours_display(plan.get('hours') or 0.0),
'note': '',
}
class FusionClockScheduleAudit(models.Model):
_name = 'fusion.clock.schedule.audit'
_description = 'Clock Schedule Change Audit'
_order = 'changed_at desc, id desc'
_rec_name = 'display_name'
schedule_id = fields.Many2one(
'fusion.clock.schedule',
string='Schedule',
ondelete='set null',
index=True,
)
employee_id = fields.Many2one(
'hr.employee',
string='Employee',
required=True,
index=True,
ondelete='cascade',
)
schedule_date = fields.Date(
string='Schedule Date',
required=True,
index=True,
)
old_value = fields.Char(string='Old Value')
new_value = fields.Char(string='New Value')
changed_by_id = fields.Many2one(
'res.users',
string='Changed By',
required=True,
ondelete='restrict',
)
changed_at = fields.Datetime(
string='Changed At',
default=fields.Datetime.now,
required=True,
index=True,
)
company_id = fields.Many2one(
'res.company',
string='Company',
index=True,
)
department_id = fields.Many2one(
'hr.department',
string='Department',
index=True,
)
display_name = fields.Char(
compute='_compute_display_name',
store=True,
)
@api.depends('employee_id', 'schedule_date', 'old_value', 'new_value')
def _compute_display_name(self):
for rec in self:
rec.display_name = "%s - %s: %s -> %s" % (
rec.employee_id.name or '',
rec.schedule_date or '',
rec.old_value or 'blank',
rec.new_value or 'blank',
)

View File

@@ -227,7 +227,18 @@ class HrAttendance(models.Model):
continue continue
employee = att.employee_id 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 net = att.x_fclk_net_hours or 0.0
if net > scheduled_hours: if net > scheduled_hours:
@@ -264,11 +275,14 @@ class HrAttendance(models.Model):
employee = att.employee_id employee = att.employee_id
emp_tz = pytz.timezone(employee.tz or self.env.company.tz or 'UTC') 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() 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) 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: if now > effective_deadline:
clock_out_time = min(effective_deadline, now) clock_out_time = min(effective_deadline, now)
@@ -283,7 +297,7 @@ class HrAttendance(models.Model):
# Apply break deduction # Apply break deduction
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0')) threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
if (att.worked_hours or 0) >= threshold: 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().write({'x_fclk_break_minutes': break_min})
att.sudo().message_post( att.sudo().message_post(
@@ -346,6 +360,9 @@ class HrAttendance(models.Model):
if yesterday.weekday() >= 5: if yesterday.weekday() >= 5:
continue 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) day_start, day_end = get_local_day_boundaries(self.env, yesterday, emp)
@@ -423,6 +440,9 @@ class HrAttendance(models.Model):
if today.weekday() >= 5: if today.weekday() >= 5:
continue 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: if emp.x_fclk_last_reminder_date == today:
continue continue

View File

@@ -120,11 +120,82 @@ class HrEmployee(models.Model):
help="Tracks the last date a reminder was sent to avoid duplicates.", help="Tracks the last date a reminder was sent to avoid duplicates.",
) )
def _get_fclk_break_minutes(self): def _get_fclk_schedule_for_date(self, date):
"""Return effective break minutes for this employee. """Return this employee's dated Fusion Clock schedule for a local date."""
Priority: employee override > shift > global setting. 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() 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: if self.x_fclk_break_minutes > 0:
return self.x_fclk_break_minutes return self.x_fclk_break_minutes
if self.x_fclk_shift_id and self.x_fclk_shift_id.break_minutes > 0: 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): def _get_fclk_scheduled_times(self, date):
"""Return (scheduled_in_dt, scheduled_out_dt) for a given 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 The configured hours are interpreted in the employee's local
timezone and converted to naive-UTC datetimes so they can be timezone and converted to naive-UTC datetimes so they can be
compared with Odoo's UTC-based ``fields.Datetime.now()``. compared with Odoo's UTC-based ``fields.Datetime.now()``.
@@ -146,13 +217,9 @@ class HrEmployee(models.Model):
import pytz import pytz
self.ensure_one() self.ensure_one()
if self.x_fclk_shift_id: plan = self._get_fclk_day_plan(date)
in_hour = self.x_fclk_shift_id.start_time in_hour = plan.get('start_time') or 0.0
out_hour = self.x_fclk_shift_id.end_time out_hour = plan.get('end_time') or 0.0
else:
ICP = self.env['ir.config_parameter'].sudo()
in_hour = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
in_h = int(in_hour) in_h = int(in_hour)
in_m = int((in_hour - in_h) * 60) 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) scheduled_out = local_out.astimezone(utc).replace(tzinfo=None)
return scheduled_in, scheduled_out 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.""" """Return the expected work hours for this employee's shift."""
self.ensure_one() self.ensure_one()
if self.x_fclk_shift_id: plan = self._get_fclk_day_plan(date or get_local_today(self.env, self))
return self.x_fclk_shift_id.scheduled_hours if plan.get('is_off'):
ICP = self.env['ir.config_parameter'].sudo() return 0.0
in_hour = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0')) return plan.get('hours') or 0.0
out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
break_hrs = self._get_fclk_break_minutes() / 60.0
return max((out_hour - in_hour) - break_hrs, 0.0)
def _compute_absence_counts(self): def _compute_absence_counts(self):
ActivityLog = self.env['fusion.clock.activity.log'].sudo() ActivityLog = self.env['fusion.clock.activity.log'].sudo()

View File

@@ -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_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_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_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_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_correction_manager,fusion.clock.correction.manager,model_fusion_clock_correction,group_fusion_clock_manager,1,1,1,1
access_fusion_clock_location_portal,fusion.clock.location.portal,model_fusion_clock_location,base.group_portal,1,0,0,0 access_fusion_clock_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_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_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_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 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
11 access_fusion_clock_leave_request_manager fusion.clock.leave.request.manager model_fusion_clock_leave_request group_fusion_clock_manager 1 1 1 1
12 access_fusion_clock_shift_user fusion.clock.shift.user model_fusion_clock_shift group_fusion_clock_user 1 0 0 0
13 access_fusion_clock_shift_manager fusion.clock.shift.manager model_fusion_clock_shift group_fusion_clock_manager 1 1 1 1
14 access_fusion_clock_schedule_user fusion.clock.schedule.user model_fusion_clock_schedule group_fusion_clock_user 1 0 0 0
15 access_fusion_clock_schedule_manager fusion.clock.schedule.manager model_fusion_clock_schedule group_fusion_clock_manager 1 1 1 1
16 access_fusion_clock_schedule_audit_manager fusion.clock.schedule.audit.manager model_fusion_clock_schedule_audit group_fusion_clock_manager 1 0 0 0
17 access_fusion_clock_correction_user fusion.clock.correction.user model_fusion_clock_correction group_fusion_clock_user 1 0 0 0
18 access_fusion_clock_correction_manager fusion.clock.correction.manager model_fusion_clock_correction group_fusion_clock_manager 1 1 1 1
19 access_fusion_clock_location_portal fusion.clock.location.portal model_fusion_clock_location base.group_portal 1 0 0 0
25 access_hr_attendance_portal hr.attendance.portal hr_attendance.model_hr_attendance base.group_portal 1 0 0 0
26 access_hr_employee_portal_clock hr.employee.portal.clock hr.model_hr_employee base.group_portal 1 0 0 0
27 access_fusion_clock_shift_portal fusion.clock.shift.portal model_fusion_clock_shift base.group_portal 1 0 0 0
28 access_fusion_clock_schedule_portal fusion.clock.schedule.portal model_fusion_clock_schedule base.group_portal 1 0 0 0
29 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

View File

@@ -174,6 +174,49 @@
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/> <field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
</record> </record>
<!-- ================================================================
Record Rules - Dated Schedules
================================================================ -->
<record id="rule_schedule_user" model="ir.rule">
<field name="name">Schedule: User sees own</field>
<field name="model_id" ref="model_fusion_clock_schedule"/>
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_fusion_clock_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_schedule_team_lead" model="ir.rule">
<field name="name">Schedule: Team Lead sees direct reports</field>
<field name="model_id" ref="model_fusion_clock_schedule"/>
<field name="domain_force">['|', ('employee_id.user_id', '=', user.id), ('employee_id.parent_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_fusion_clock_team_lead'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_schedule_manager" model="ir.rule">
<field name="name">Schedule: Manager full access</field>
<field name="model_id" ref="model_fusion_clock_schedule"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
</record>
<record id="rule_schedule_audit_manager" model="ir.rule">
<field name="name">Schedule Audit: Manager reads all</field>
<field name="model_id" ref="model_fusion_clock_schedule_audit"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- ================================================================ <!-- ================================================================
Record Rules - Correction Request Record Rules - Correction Request
================================================================ --> ================================================================ -->
@@ -286,4 +329,15 @@
<field name="perm_unlink" eval="False"/> <field name="perm_unlink" eval="False"/>
</record> </record>
<record id="rule_schedule_portal" model="ir.rule">
<field name="name">Schedule: Portal user sees own</field>
<field name="model_id" ref="model_fusion_clock_schedule"/>
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
</odoo> </odoo>

View File

@@ -219,6 +219,63 @@ body:has(.fclk-app) .o_footer {
opacity: 0.5; 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 ---- */ /* ---- Timer Section ---- */
.fclk-timer-section { .fclk-timer-section {
text-align: center; text-align: center;

View File

@@ -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);

View File

@@ -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};
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,198 @@
<?xml version="1.0" encoding="utf-8"?>
<templates xml:space="preserve">
<t t-name="fusion_clock.ShiftPlanner">
<div class="o_action fclk-planner" t-ref="root">
<div class="fclk-planner__toolbar">
<div>
<h2 class="fclk-planner__title">Shift Planner</h2>
<div class="fclk-planner__subtitle"><t t-esc="weekTitle"/></div>
</div>
<div class="fclk-planner__actions">
<button class="btn btn-light" t-on-click="() => this.previousWeek()" t-att-disabled="state.loading or state.saving">
<i class="fa fa-chevron-left"/>
</button>
<button class="btn btn-light" t-on-click="() => this.currentWeek()" t-att-disabled="state.loading or state.saving">This Week</button>
<button class="btn btn-light" t-on-click="() => this.nextWeek()" t-att-disabled="state.loading or state.saving">
<i class="fa fa-chevron-right"/>
</button>
<button class="btn btn-outline-secondary" t-on-click="() => this.copyPreviousWeek()" t-att-disabled="state.loading or state.saving">
<i class="fa fa-copy me-1"/> Copy Previous Week
</button>
<button class="btn btn-outline-secondary" t-on-click="() => this.exportXlsx()" t-att-disabled="state.loading or state.saving">
<i class="fa fa-file-excel-o me-1"/> Export XLSX
</button>
<button class="btn btn-primary" t-on-click="() => this.save()" t-att-disabled="state.loading or state.saving or !state.dirtyCount">
<t t-if="state.saving"><i class="fa fa-spinner fa-spin me-1"/></t>
<t t-else=""><i class="fa fa-save me-1"/></t>
Save
<t t-if="state.dirtyCount">(<t t-esc="state.dirtyCount"/>)</t>
</button>
</div>
</div>
<t t-if="state.error">
<div class="alert alert-danger mx-3 mt-3"><t t-esc="state.error"/></div>
</t>
<t t-if="state.invalidCount">
<div class="fclk-planner__warning">
<i class="fa fa-exclamation-triangle me-1"/>
<t t-esc="state.invalidCount"/> invalid cells need attention.
</div>
</t>
<t t-if="state.loading">
<div class="fclk-planner__loading">
<i class="fa fa-spinner fa-spin fa-2x"/>
<span>Loading shift planner...</span>
</div>
</t>
<t t-if="!state.loading and !state.error">
<div class="fclk-planner__table-wrap">
<table class="fclk-planner__table">
<colgroup>
<col class="fclk-planner__employee-col"/>
<t t-foreach="state.days" t-as="day" t-key="'col_' + day.date">
<col class="fclk-planner__shift-col"/>
<col class="fclk-planner__hours-col"/>
</t>
</colgroup>
<thead>
<tr>
<th class="fclk-planner__employee-head" rowspan="2">Employee</th>
<t t-foreach="state.days" t-as="day" t-key="day.date">
<th class="fclk-planner__day-head" colspan="2">
<div class="fclk-planner__weekday"><t t-esc="day.weekday"/></div>
<div class="fclk-planner__date"><t t-esc="day.label"/></div>
</th>
</t>
</tr>
<tr>
<t t-foreach="state.days" t-as="day" t-key="'sub_' + day.date">
<th class="fclk-planner__sub-head">Shift</th>
<th class="fclk-planner__sub-head fclk-planner__hours-head">Hours</th>
</t>
</tr>
</thead>
<tbody>
<t t-foreach="state.departments" t-as="department" t-key="department.id">
<tr class="fclk-planner__department-row">
<td t-att-colspan="1 + state.days.length * 2">
<button class="fclk-planner__department-toggle" t-on-click="() => this.toggleDepartment(department)">
<i t-att-class="isCollapsed(department) ? 'fa fa-chevron-right' : 'fa fa-chevron-down'"/>
<span><t t-esc="department.name"/></span>
<span class="fclk-planner__department-count">
<t t-esc="department.employee_ids.length"/> employees
</span>
</button>
</td>
</tr>
<t t-if="!isCollapsed(department)">
<t t-foreach="getDepartmentEmployees(department)" t-as="employee" t-key="employee.id">
<tr class="fclk-planner__employee-row">
<td class="fclk-planner__employee-cell">
<div class="fclk-planner__employee-name"><t t-esc="employee.name"/></div>
<div class="fclk-planner__employee-role" t-if="employee.job_title">
<t t-esc="employee.job_title"/>
</div>
</td>
<t t-foreach="state.days" t-as="day" t-key="employee.id + '_' + day.date">
<t t-set="cell" t-value="employee.cells[day.date]"/>
<td t-att-class="'fclk-planner__shift-cell ' + (cell.error ? 'fclk-planner__shift-cell--error ' : '') + (cell.source === 'fallback' ? 'fclk-planner__shift-cell--fallback ' : '') + (this.isActiveCell(employee, day) ? 'fclk-planner__shift-cell--active' : '')"
t-on-click="(ev) => this.openCellEditor(employee, day, ev)">
<input class="fclk-planner__shift-input"
t-att-value="cell.input"
t-att-title="cell.error || cell.label"
t-on-focus="(ev) => this.openCellEditor(employee, day, ev)"
t-on-change="(ev) => this.onCellInput(employee, day, ev)"
t-on-keydown="(ev) => this.onCellKeydown(employee, day, ev)"/>
<div class="fclk-planner__cell-error" t-if="cell.error">
<t t-esc="cell.error"/>
</div>
</td>
<td class="fclk-planner__hours-cell">
<t t-esc="cell.hours_display || '0:00'"/>
</td>
</t>
</tr>
</t>
</t>
</t>
</tbody>
</table>
</div>
<div t-if="state.editor.open"
t-ref="shiftEditor"
class="fclk-planner__cell-editor"
t-att-style="'top: ' + state.editor.top + 'px; left: ' + state.editor.left + 'px;'">
<div class="fclk-planner__editor-head">
<div class="fclk-planner__editor-person">
<div class="fclk-planner__editor-name"><t t-esc="state.editor.employeeName"/></div>
<div class="fclk-planner__editor-day"><t t-esc="state.editor.dayLabel"/></div>
</div>
<div class="fclk-planner__editor-hours">
<span><t t-esc="state.editor.hoursDisplay"/></span>
</div>
</div>
<div class="fclk-planner__quick-grid">
<t t-foreach="quickShiftOptions" t-as="option" t-key="option.key">
<button type="button"
class="fclk-planner__quick-chip"
t-on-click="() => this.selectQuickShift(option)">
<span class="fclk-planner__quick-label"><t t-esc="option.label"/></span>
<span class="fclk-planner__quick-detail"><t t-esc="option.detail"/></span>
</button>
</t>
</div>
<div class="fclk-planner__time-row">
<label class="fclk-planner__time-field">
<span>Start</span>
<select t-on-change="(ev) => this.onEditorStartChange(ev)">
<t t-foreach="timeOptions" t-as="option" t-key="'start_' + option.value">
<option t-att-value="option.value"
t-att-selected="option.value === state.editor.startValue">
<t t-esc="option.label"/>
</option>
</t>
</select>
</label>
<label class="fclk-planner__time-field">
<span>End</span>
<select t-on-change="(ev) => this.onEditorEndChange(ev)">
<t t-foreach="timeOptions" t-as="option" t-key="'end_' + option.value">
<option t-att-value="option.value"
t-att-selected="option.value === state.editor.endValue">
<t t-esc="option.label"/>
</option>
</t>
</select>
</label>
</div>
<div class="fclk-planner__editor-error" t-if="state.editor.error">
<t t-esc="state.editor.error"/>
</div>
<div class="fclk-planner__editor-actions">
<button type="button"
class="btn btn-sm btn-light"
t-on-click="() => this.clearActiveCell()">
<i class="fa fa-eraser me-1"/> Clear
</button>
<button type="button"
class="btn btn-sm btn-primary"
t-on-click="() => this.applyEditorRange(true)">
<i class="fa fa-check me-1"/> Done
</button>
</div>
</div>
</t>
</div>
</t>
</templates>

View File

@@ -2,3 +2,4 @@
from . import test_nfc_models from . import test_nfc_models
from . import test_clock_nfc_kiosk from . import test_clock_nfc_kiosk
from . import test_shift_planner

View File

@@ -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)

View File

@@ -16,6 +16,34 @@
sequence="5" sequence="5"
groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/> groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/>
<!-- Scheduling -->
<menuitem id="menu_fusion_clock_scheduling"
name="Scheduling"
parent="menu_fusion_clock_root"
sequence="8"
groups="group_fusion_clock_manager"/>
<menuitem id="menu_fusion_clock_shift_planner"
name="Shift Planner"
parent="menu_fusion_clock_scheduling"
action="action_fusion_clock_shift_planner"
sequence="5"
groups="group_fusion_clock_manager"/>
<menuitem id="menu_fusion_clock_scheduled_shifts"
name="Scheduled Shifts"
parent="menu_fusion_clock_scheduling"
action="action_fusion_clock_schedule"
sequence="10"
groups="group_fusion_clock_manager"/>
<menuitem id="menu_fusion_clock_schedule_audit"
name="Schedule Audit"
parent="menu_fusion_clock_scheduling"
action="action_fusion_clock_schedule_audit"
sequence="20"
groups="group_fusion_clock_manager"/>
<!-- Attendance Sub-Menu --> <!-- Attendance Sub-Menu -->
<menuitem id="menu_fusion_clock_attendance" <menuitem id="menu_fusion_clock_attendance"
name="Attendance" name="Attendance"

View File

@@ -0,0 +1,128 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_fusion_clock_shift_planner" model="ir.actions.client">
<field name="name">Shift Planner</field>
<field name="tag">fusion_clock.ShiftPlanner</field>
</record>
<record id="view_fusion_clock_schedule_list" model="ir.ui.view">
<field name="name">fusion.clock.schedule.list</field>
<field name="model">fusion.clock.schedule</field>
<field name="arch" type="xml">
<list>
<field name="schedule_date"/>
<field name="employee_id"/>
<field name="department_id"/>
<field name="is_off"/>
<field name="shift_id"/>
<field name="start_time" widget="float_time"/>
<field name="end_time" widget="float_time"/>
<field name="break_minutes"/>
<field name="planned_hours"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
</record>
<record id="view_fusion_clock_schedule_form" model="ir.ui.view">
<field name="name">fusion.clock.schedule.form</field>
<field name="model">fusion.clock.schedule</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group>
<field name="employee_id"/>
<field name="schedule_date"/>
<field name="is_off"/>
<field name="shift_id"/>
</group>
<group>
<field name="start_time" widget="float_time"/>
<field name="end_time" widget="float_time"/>
<field name="break_minutes"/>
<field name="planned_hours" readonly="1"/>
</group>
</group>
<group>
<field name="note"/>
<field name="department_id" readonly="1"/>
<field name="company_id" readonly="1" groups="base.group_multi_company"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_fusion_clock_schedule_search" model="ir.ui.view">
<field name="name">fusion.clock.schedule.search</field>
<field name="model">fusion.clock.schedule</field>
<field name="arch" type="xml">
<search>
<field name="employee_id"/>
<field name="department_id"/>
<field name="schedule_date"/>
<filter name="off" string="OFF" domain="[('is_off', '=', True)]"/>
<filter name="working" string="Working" domain="[('is_off', '=', False)]"/>
<filter name="group_department" string="Department" context="{'group_by': 'department_id'}"/>
<filter name="group_date" string="Date" context="{'group_by': 'schedule_date'}"/>
</search>
</field>
</record>
<record id="action_fusion_clock_schedule" model="ir.actions.act_window">
<field name="name">Scheduled Shifts</field>
<field name="res_model">fusion.clock.schedule</field>
<field name="view_mode">list,form</field>
</record>
<record id="view_fusion_clock_schedule_audit_list" model="ir.ui.view">
<field name="name">fusion.clock.schedule.audit.list</field>
<field name="model">fusion.clock.schedule.audit</field>
<field name="arch" type="xml">
<list create="0" edit="0" delete="0">
<field name="changed_at"/>
<field name="employee_id"/>
<field name="schedule_date"/>
<field name="old_value"/>
<field name="new_value"/>
<field name="changed_by_id"/>
<field name="department_id"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
</record>
<record id="view_fusion_clock_schedule_audit_form" model="ir.ui.view">
<field name="name">fusion.clock.schedule.audit.form</field>
<field name="model">fusion.clock.schedule.audit</field>
<field name="arch" type="xml">
<form create="0" edit="0" delete="0">
<sheet>
<group>
<group>
<field name="changed_at"/>
<field name="changed_by_id"/>
<field name="employee_id"/>
<field name="schedule_date"/>
</group>
<group>
<field name="old_value"/>
<field name="new_value"/>
<field name="department_id"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_fusion_clock_schedule_audit" model="ir.actions.act_window">
<field name="name">Schedule Audit</field>
<field name="res_model">fusion.clock.schedule.audit</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -142,6 +142,28 @@
</div> </div>
</div> </div>
<!-- Scheduled Shift -->
<div class="fclk-schedule-card">
<div class="fclk-schedule-icon">
<i class="fa fa-calendar-check-o"/>
</div>
<div class="fclk-schedule-info">
<div class="fclk-schedule-label">Today's Shift</div>
<div class="fclk-schedule-value">
<t t-if="today_schedule.get('is_off')">OFF</t>
<t t-else="">
<t t-esc="today_schedule.get('label') or 'Not scheduled'"/>
</t>
</div>
</div>
<div class="fclk-schedule-hours">
<t t-if="today_schedule.get('is_off')">0:00</t>
<t t-else="">
<t t-esc="'%.1f' % (today_schedule.get('hours') or 0.0)"/>h
</t>
</div>
</div>
<!-- Timer Section --> <!-- Timer Section -->
<div class="fclk-timer-section"> <div class="fclk-timer-section">
<div class="fclk-timer-label" id="fclk-timer-label"> <div class="fclk-timer-label" id="fclk-timer-label">

View File

@@ -166,6 +166,19 @@ These modules have **source code in this repo** but are **intentionally NOT inst
| `fusion_plating_culture` | `state=uninstalled`, dir removed from entech disk | Soft people-ops feature (peer kudos / "Fundamental of the Week"); zero data entered; not a client priority. Top-level "Culture" menu confused operators. | Ask the client whether they want it before reinstalling. If yes: re-sync folder + `-i fusion_plating_culture` + seed a value set. | | `fusion_plating_culture` | `state=uninstalled`, dir removed from entech disk | Soft people-ops feature (peer kudos / "Fundamental of the Week"); zero data entered; not a client priority. Top-level "Culture" menu confused operators. | Ask the client whether they want it before reinstalling. If yes: re-sync folder + `-i fusion_plating_culture` + seed a value set. |
| `fusion_plating_sensors` | deleted entirely (not in repo anymore) | Duplicated `fusion_plating_iot`'s scope but with no working alerting logic. Its valuables (sensor_type taxonomy, dashboard, location flexibility) were ported into `fusion_iot/fusion_plating_iot/`. | N/A — gone. Any new sensor work goes in `fusion_iot/fusion_plating_iot/`. | | `fusion_plating_sensors` | deleted entirely (not in repo anymore) | Duplicated `fusion_plating_iot`'s scope but with no working alerting logic. Its valuables (sensor_type taxonomy, dashboard, location flexibility) were ported into `fusion_iot/fusion_plating_iot/`. | N/A — gone. Any new sensor work goes in `fusion_iot/fusion_plating_iot/`. |
## Shop-floor action endpoints — credit the correct tech via `tablet_tech_id`
The tablet sits on a long-lived "shopfloor service" Odoo session shared by many techs. The actual tech-of-record is established via the PIN unlock (Phase 6); their id lives in the OWL `fp_shopfloor_tech_store` service and is sent as `tablet_tech_id` on every action RPC.
When writing a NEW shop-floor controller endpoint that **writes** (creates a record, calls a `button_*` method, posts to chatter):
1. Add `tablet_tech_id=None` as a kwarg on the route handler.
2. At the top, call: `env = env_for_tablet_tech(request.env, tablet_tech_id)` (from `fusion_plating_shopfloor/controllers/_tablet_audit.py`).
3. Use `env` (not `request.env`) for all subsequent writes. `env.with_user(...)` is applied internally so `create_uid` / `write_uid` / chatter authorship carry the right uid.
4. Read-only endpoints (load / kanban / funnel / overview) don't need this — leave them as `request.env`.
On the client side: use `fpRpc()` from `services/fp_rpc.js` (drop-in for `rpc()`) for action calls. It auto-injects `tablet_tech_id`. Read calls can keep using plain `rpc()`.
If `tablet_tech_id` is missing or invalid, `env_for_tablet_tech` falls back to the session uid — old callers and pre-Phase-6.3 endpoints continue working.
## Removing menus/records — Odoo does NOT auto-delete orphans ## Removing menus/records — Odoo does NOT auto-delete orphans
Deleting a `<menuitem>` (or any `<record>`) from a data XML file does NOT remove the corresponding database row. The XML loader only updates records it sees; orphans persist in `ir.ui.menu` / `ir.model.data` until you delete them explicitly. Symptom: the menu still appears in the UI after `-u`. Fix — add a `<delete>` directive in a data file with `noupdate="0"`: Deleting a `<menuitem>` (or any `<record>`) from a data XML file does NOT remove the corresponding database row. The XML loader only updates records it sees; orphans persist in `ir.ui.menu` / `ir.model.data` until you delete them explicitly. Symptom: the menu still appears in the UI after `-u`. Fix — add a `<delete>` directive in a data file with `noupdate="0"`:
```xml ```xml

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Shop Floor', 'name': 'Fusion Plating — Shop Floor',
'version': '19.0.30.0.0', 'version': '19.0.30.2.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, ' 'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.', 'first-piece inspection gates.',
@@ -82,6 +82,24 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating_shopfloor/static/src/scss/components/_kanban_card.scss', 'fusion_plating_shopfloor/static/src/scss/components/_kanban_card.scss',
'fusion_plating_shopfloor/static/src/xml/components/kanban_card.xml', 'fusion_plating_shopfloor/static/src/xml/components/kanban_card.xml',
'fusion_plating_shopfloor/static/src/js/components/kanban_card.js', 'fusion_plating_shopfloor/static/src/js/components/kanban_card.js',
# ---- Phase 6.2 tablet PIN gate ----
'fusion_plating_shopfloor/static/src/js/services/tech_store.js',
'fusion_plating_shopfloor/static/src/js/services/activity_tracker.js',
# Phase 6.3 — fpRpc wrapper. MUST load before any consumer
# (job_workspace, shopfloor_landing, manager_dashboard,
# hold_composer) so `import { fpRpc }` resolves.
'fusion_plating_shopfloor/static/src/js/services/fp_rpc.js',
'fusion_plating_shopfloor/static/src/scss/components/_pin_pad.scss',
'fusion_plating_shopfloor/static/src/xml/components/pin_pad.xml',
'fusion_plating_shopfloor/static/src/js/components/pin_pad.js',
'fusion_plating_shopfloor/static/src/scss/components/_idle_warning.scss',
'fusion_plating_shopfloor/static/src/xml/components/idle_warning.xml',
'fusion_plating_shopfloor/static/src/js/components/idle_warning.js',
'fusion_plating_shopfloor/static/src/scss/tablet_lock.scss',
'fusion_plating_shopfloor/static/src/xml/tablet_lock.xml',
'fusion_plating_shopfloor/static/src/js/tablet_lock.js',
'fusion_plating_shopfloor/static/src/xml/components/pin_setup.xml',
'fusion_plating_shopfloor/static/src/js/components/pin_setup.js',
# ---- Job Workspace (Phase 1 — tablet redesign) ---- # ---- Job Workspace (Phase 1 — tablet redesign) ----
'fusion_plating_shopfloor/static/src/scss/job_workspace.scss', 'fusion_plating_shopfloor/static/src/scss/job_workspace.scss',
'fusion_plating_shopfloor/static/src/xml/job_workspace.xml', 'fusion_plating_shopfloor/static/src/xml/job_workspace.xml',

View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Helper for audit-credit propagation (Phase 6.3 tablet redesign).
Controllers that accept an optional `tablet_tech_id` kwarg use this
helper to switch their `env` to the tech-of-record before performing
writes. The result: chatter posts + create_uid/write_uid carry the
unlocked tech's identity, not the tablet's persistent session user.
"""
import logging
_logger = logging.getLogger(__name__)
def env_for_tablet_tech(env, tablet_tech_id):
"""Return an env scoped to `tablet_tech_id` if it's a valid user;
otherwise return the original env unchanged.
Validation: the user must exist and be active. We deliberately do
NOT cross-check that they actually unlocked recently — the OWL
component is the source of truth for "who's at the tablet right
now", and the only path that produces a tablet_tech_id is a
successful /fp/tablet/unlock followed by an active session in the
OWL tech_store.
"""
if not tablet_tech_id:
return env
try:
tech_id = int(tablet_tech_id)
except (TypeError, ValueError):
return env
User = env['res.users'].sudo()
tech = User.browse(tech_id)
if not tech.exists() or not tech.active:
_logger.warning(
"tablet_tech_id %s invalid (not found or inactive); "
"falling back to session uid %s",
tablet_tech_id, env.uid,
)
return env
return env(user=tech_id)

View File

@@ -25,6 +25,8 @@ from odoo import fields, http
from odoo.addons.fusion_plating.models.fp_tz import fp_format from odoo.addons.fusion_plating.models.fp_tz import fp_format
from odoo.http import request from odoo.http import request
from ._tablet_audit import env_for_tablet_tech
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -387,7 +389,8 @@ class FpManagerDashboardController(http.Controller):
# Assign a worker to a step # Assign a worker to a step
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@http.route('/fp/manager/assign_worker', type='jsonrpc', auth='user') @http.route('/fp/manager/assign_worker', type='jsonrpc', auth='user')
def assign_worker(self, step_id=None, user_id=None, workorder_id=None, **kwargs): def assign_worker(self, step_id=None, user_id=None, workorder_id=None,
tablet_tech_id=None, **kwargs):
"""Assign an operator to a step. ``step_id`` is the canonical """Assign an operator to a step. ``step_id`` is the canonical
kwarg; ``workorder_id`` is accepted as a deprecated alias for kwarg; ``workorder_id`` is accepted as a deprecated alias for
one release so any caller we missed doesn't break. one release so any caller we missed doesn't break.
@@ -400,7 +403,8 @@ class FpManagerDashboardController(http.Controller):
step_id = workorder_id step_id = workorder_id
if not step_id: if not step_id:
return {'ok': False, 'error': 'step_id required'} return {'ok': False, 'error': 'step_id required'}
step = request.env['fp.job.step'].browse(int(step_id)) env = env_for_tablet_tech(request.env, tablet_tech_id)
step = env['fp.job.step'].browse(int(step_id))
if not step.exists(): if not step.exists():
return {'ok': False, 'error': 'Step not found.'} return {'ok': False, 'error': 'Step not found.'}
step.assigned_user_id = int(user_id) if user_id else False step.assigned_user_id = int(user_id) if user_id else False
@@ -415,7 +419,8 @@ class FpManagerDashboardController(http.Controller):
# Reassign or swap tank on a step # Reassign or swap tank on a step
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@http.route('/fp/manager/assign_tank', type='jsonrpc', auth='user') @http.route('/fp/manager/assign_tank', type='jsonrpc', auth='user')
def assign_tank(self, step_id=None, tank_id=None, workorder_id=None, **kwargs): def assign_tank(self, step_id=None, tank_id=None, workorder_id=None,
tablet_tech_id=None, **kwargs):
"""Swap the tank on a step. ``step_id`` is the canonical kwarg; """Swap the tank on a step. ``step_id`` is the canonical kwarg;
``workorder_id`` is accepted as a deprecated alias. ``workorder_id`` is accepted as a deprecated alias.
""" """
@@ -427,7 +432,8 @@ class FpManagerDashboardController(http.Controller):
step_id = workorder_id step_id = workorder_id
if not step_id: if not step_id:
return {'ok': False, 'error': 'step_id required'} return {'ok': False, 'error': 'step_id required'}
step = request.env['fp.job.step'].browse(int(step_id)) env = env_for_tablet_tech(request.env, tablet_tech_id)
step = env['fp.job.step'].browse(int(step_id))
if not step.exists(): if not step.exists():
return {'ok': False, 'error': 'Step not found.'} return {'ok': False, 'error': 'Step not found.'}
step.tank_id = int(tank_id) if tank_id else False step.tank_id = int(tank_id) if tank_id else False
@@ -442,7 +448,8 @@ class FpManagerDashboardController(http.Controller):
# Manager takes over a step (no-show coverage) # Manager takes over a step (no-show coverage)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@http.route('/fp/manager/take_over', type='jsonrpc', auth='user') @http.route('/fp/manager/take_over', type='jsonrpc', auth='user')
def take_over(self, step_id=None, workorder_id=None, **kwargs): def take_over(self, step_id=None, workorder_id=None,
tablet_tech_id=None, **kwargs):
"""Manager takes over a step. ``step_id`` is the canonical kwarg; """Manager takes over a step. ``step_id`` is the canonical kwarg;
``workorder_id`` is accepted as a deprecated alias. ``workorder_id`` is accepted as a deprecated alias.
""" """
@@ -454,10 +461,11 @@ class FpManagerDashboardController(http.Controller):
step_id = workorder_id step_id = workorder_id
if not step_id: if not step_id:
return {'ok': False, 'error': 'step_id required'} return {'ok': False, 'error': 'step_id required'}
step = request.env['fp.job.step'].browse(int(step_id)) env = env_for_tablet_tech(request.env, tablet_tech_id)
step = env['fp.job.step'].browse(int(step_id))
if not step.exists(): if not step.exists():
return {'ok': False, 'error': 'Step not found.'} return {'ok': False, 'error': 'Step not found.'}
user = request.env.user user = env.user
previous = step.assigned_user_id.name or '' previous = step.assigned_user_id.name or ''
step.assigned_user_id = user.id step.assigned_user_id = user.id
step.message_post( step.message_post(

View File

@@ -20,6 +20,8 @@ from odoo.addons.fusion_plating.models.fp_tz import (
from odoo.exceptions import UserError from odoo.exceptions import UserError
from odoo.http import request from odoo.http import request
from ._tablet_audit import env_for_tablet_tech
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -255,11 +257,13 @@ class FpShopfloorController(http.Controller):
# Quick chemistry log from the tablet # Quick chemistry log from the tablet
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@http.route('/fp/shopfloor/log_chemistry', type='jsonrpc', auth='user') @http.route('/fp/shopfloor/log_chemistry', type='jsonrpc', auth='user')
def log_chemistry(self, bath_id, readings, shift=None, notes=None): def log_chemistry(self, bath_id, readings, shift=None, notes=None,
tablet_tech_id=None):
"""Create a fusion.plating.bath.log with one line per reading.""" """Create a fusion.plating.bath.log with one line per reading."""
env = env_for_tablet_tech(request.env, tablet_tech_id)
if not bath_id: if not bath_id:
raise UserError("bath_id required") raise UserError("bath_id required")
bath = request.env['fusion.plating.bath'].browse(int(bath_id)) bath = env['fusion.plating.bath'].browse(int(bath_id))
if not bath.exists(): if not bath.exists():
raise UserError(f"Bath {bath_id} not found") raise UserError(f"Bath {bath_id} not found")
@@ -274,7 +278,7 @@ class FpShopfloorController(http.Controller):
'value': float(value) if value not in (None, '') else 0.0, 'value': float(value) if value not in (None, '') else 0.0,
})) }))
log = request.env['fusion.plating.bath.log'].create({ log = env['fusion.plating.bath.log'].create({
'bath_id': bath.id, 'bath_id': bath.id,
'shift': shift or False, 'shift': shift or False,
'notes': notes or False, 'notes': notes or False,
@@ -291,10 +295,11 @@ class FpShopfloorController(http.Controller):
# Bake window controls # Bake window controls
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@http.route('/fp/shopfloor/start_bake', type='jsonrpc', auth='user') @http.route('/fp/shopfloor/start_bake', type='jsonrpc', auth='user')
def start_bake(self, bake_window_id, oven_id=None): def start_bake(self, bake_window_id, oven_id=None, tablet_tech_id=None):
# action_start_bake raises UserError for S6 missed_window. Wrap # action_start_bake raises UserError for S6 missed_window. Wrap
# the same way as start_wo so operator gets a clean flash. # the same way as start_wo so operator gets a clean flash.
bw = request.env['fusion.plating.bake.window'].browse(int(bake_window_id)) env = env_for_tablet_tech(request.env, tablet_tech_id)
bw = env['fusion.plating.bake.window'].browse(int(bake_window_id))
if not bw.exists(): if not bw.exists():
return {'ok': False, 'error': f'Bake window {bake_window_id} not found'} return {'ok': False, 'error': f'Bake window {bake_window_id} not found'}
if oven_id: if oven_id:
@@ -306,12 +311,13 @@ class FpShopfloorController(http.Controller):
return { return {
'ok': True, 'ok': True,
'state': bw.state, 'state': bw.state,
'bake_start_time': fp_format(request.env, bw.bake_start_time), 'bake_start_time': fp_format(env, bw.bake_start_time),
} }
@http.route('/fp/shopfloor/end_bake', type='jsonrpc', auth='user') @http.route('/fp/shopfloor/end_bake', type='jsonrpc', auth='user')
def end_bake(self, bake_window_id): def end_bake(self, bake_window_id, tablet_tech_id=None):
bw = request.env['fusion.plating.bake.window'].browse(int(bake_window_id)) env = env_for_tablet_tech(request.env, tablet_tech_id)
bw = env['fusion.plating.bake.window'].browse(int(bake_window_id))
if not bw.exists(): if not bw.exists():
return {'ok': False, 'error': f'Bake window {bake_window_id} not found'} return {'ok': False, 'error': f'Bake window {bake_window_id} not found'}
try: try:
@@ -321,7 +327,7 @@ class FpShopfloorController(http.Controller):
return { return {
'ok': True, 'ok': True,
'state': bw.state, 'state': bw.state,
'bake_end_time': fp_format(request.env, bw.bake_end_time), 'bake_end_time': fp_format(env, bw.bake_end_time),
'bake_duration_hours': bw.bake_duration_hours, 'bake_duration_hours': bw.bake_duration_hours,
} }
@@ -340,8 +346,15 @@ class FpShopfloorController(http.Controller):
step = request.env['fp.job.step'].browse(int(sid)) step = request.env['fp.job.step'].browse(int(sid))
return step if step.exists() else False return step if step.exists() else False
def _resolve_step_in_env(self, env, step_id=None, workorder_id=None):
sid = step_id if step_id else workorder_id
if not sid:
return False
step = env['fp.job.step'].browse(int(sid))
return step if step.exists() else False
@http.route('/fp/shopfloor/start_wo', type='jsonrpc', auth='user') @http.route('/fp/shopfloor/start_wo', type='jsonrpc', auth='user')
def start_wo(self, workorder_id=None, step_id=None): def start_wo(self, workorder_id=None, step_id=None, tablet_tech_id=None):
"""Start the timer on a fp.job.step (called from the tablet). """Start the timer on a fp.job.step (called from the tablet).
button_start() can raise UserError for any guarded condition button_start() can raise UserError for any guarded condition
@@ -350,7 +363,8 @@ class FpShopfloorController(http.Controller):
the explicit state check, so the tablet flashes a clean toast the explicit state check, so the tablet flashes a clean toast
instead of popping a stack-trace dialog at the operator. instead of popping a stack-trace dialog at the operator.
""" """
step = self._resolve_step(step_id, workorder_id) env = env_for_tablet_tech(request.env, tablet_tech_id)
step = self._resolve_step_in_env(env, step_id, workorder_id)
if not step: if not step:
return {'ok': False, 'error': 'Step not found'} return {'ok': False, 'error': 'Step not found'}
if not _step_can_start(step): if not _step_can_start(step):
@@ -369,7 +383,8 @@ class FpShopfloorController(http.Controller):
} }
@http.route('/fp/shopfloor/stop_wo', type='jsonrpc', auth='user') @http.route('/fp/shopfloor/stop_wo', type='jsonrpc', auth='user')
def stop_wo(self, workorder_id=None, step_id=None, finish=False): def stop_wo(self, workorder_id=None, step_id=None, finish=False,
tablet_tech_id=None):
"""Finish the timer on a fp.job.step. """Finish the timer on a fp.job.step.
finish=True calls button_finish(); other values are no-ops for finish=True calls button_finish(); other values are no-ops for
@@ -380,7 +395,8 @@ class FpShopfloorController(http.Controller):
not provided). Wrapped same as start_wo so the operator gets a not provided). Wrapped same as start_wo so the operator gets a
clean flash, not a stack-trace dialog. clean flash, not a stack-trace dialog.
""" """
step = self._resolve_step(step_id, workorder_id) env = env_for_tablet_tech(request.env, tablet_tech_id)
step = self._resolve_step_in_env(env, step_id, workorder_id)
if not step: if not step:
return {'ok': False, 'error': 'Step not found'} return {'ok': False, 'error': 'Step not found'}
if finish: if finish:
@@ -409,11 +425,12 @@ class FpShopfloorController(http.Controller):
# both with a single tap. Scrap auto-spawns a hold via fp.job.write # both with a single tap. Scrap auto-spawns a hold via fp.job.write
# (S17 hook, no extra wiring needed here). # (S17 hook, no extra wiring needed here).
@http.route('/fp/shopfloor/bump_qty_done', type='jsonrpc', auth='user') @http.route('/fp/shopfloor/bump_qty_done', type='jsonrpc', auth='user')
def bump_qty_done(self, job_id, delta=1): def bump_qty_done(self, job_id, delta=1, tablet_tech_id=None):
"""Increment job.qty_done by `delta` (defaults to +1). """Increment job.qty_done by `delta` (defaults to +1).
Returns the new totals so the tablet can update without a full refresh. Returns the new totals so the tablet can update without a full refresh.
""" """
job = request.env['fp.job'].browse(int(job_id)) env = env_for_tablet_tech(request.env, tablet_tech_id)
job = env['fp.job'].browse(int(job_id))
if not job.exists(): if not job.exists():
return {'ok': False, 'error': 'Job not found'} return {'ok': False, 'error': 'Job not found'}
try: try:
@@ -433,13 +450,15 @@ class FpShopfloorController(http.Controller):
} }
@http.route('/fp/shopfloor/bump_qty_scrapped', type='jsonrpc', auth='user') @http.route('/fp/shopfloor/bump_qty_scrapped', type='jsonrpc', auth='user')
def bump_qty_scrapped(self, job_id, delta=1, reason=None): def bump_qty_scrapped(self, job_id, delta=1, reason=None,
tablet_tech_id=None):
"""Increment job.qty_scrapped by `delta`. The S17 write-hook on """Increment job.qty_scrapped by `delta`. The S17 write-hook on
fp.job auto-spawns a fusion.plating.quality.hold for the delta; fp.job auto-spawns a fusion.plating.quality.hold for the delta;
the operator can edit the description on that hold later. the operator can edit the description on that hold later.
`reason` is optional — passed through to the hold's description. `reason` is optional — passed through to the hold's description.
""" """
job = request.env['fp.job'].browse(int(job_id)) env = env_for_tablet_tech(request.env, tablet_tech_id)
job = env['fp.job'].browse(int(job_id))
if not job.exists(): if not job.exists():
return {'ok': False, 'error': 'Job not found'} return {'ok': False, 'error': 'Job not found'}
try: try:
@@ -470,20 +489,22 @@ class FpShopfloorController(http.Controller):
position_label=None, reading_number=None, position_label=None, reading_number=None,
equipment_model=None, calibration_std_ref=None, equipment_model=None, calibration_std_ref=None,
microscope_image=None, microscope_image=None,
microscope_image_filename=None): microscope_image_filename=None,
tablet_tech_id=None):
"""Record a single Fischerscope reading against a job. """Record a single Fischerscope reading against a job.
`job_id` is the canonical kwarg; `production_id` is accepted as an `job_id` is the canonical kwarg; `production_id` is accepted as an
alias for older clients. The reading auto-links to an existing alias for older clients. The reading auto-links to an existing
CoC certificate for the job when one exists. CoC certificate for the job when one exists.
""" """
Reading = request.env.get('fp.thickness.reading') env = env_for_tablet_tech(request.env, tablet_tech_id)
Reading = env.get('fp.thickness.reading')
if Reading is None: if Reading is None:
return {'ok': False, 'error': 'Certificates module not installed'} return {'ok': False, 'error': 'Certificates module not installed'}
target_id = job_id or production_id target_id = job_id or production_id
if not target_id: if not target_id:
return {'ok': False, 'error': 'job_id required'} return {'ok': False, 'error': 'job_id required'}
job = request.env['fp.job'].browse(int(target_id)) job = env['fp.job'].browse(int(target_id))
if not job.exists(): if not job.exists():
return {'ok': False, 'error': f'Job {target_id} not found'} return {'ok': False, 'error': f'Job {target_id} not found'}
@@ -508,7 +529,7 @@ class FpShopfloorController(http.Controller):
'ni_percent': float(ni_percent or 0.0), 'ni_percent': float(ni_percent or 0.0),
'p_percent': float(p_percent or 0.0), 'p_percent': float(p_percent or 0.0),
'position_label': position_label or '', 'position_label': position_label or '',
'operator_id': request.env.user.id, 'operator_id': env.user.id,
} }
if equipment_model: if equipment_model:
@@ -516,7 +537,7 @@ class FpShopfloorController(http.Controller):
if calibration_std_ref: if calibration_std_ref:
vals['calibration_std_ref'] = calibration_std_ref vals['calibration_std_ref'] = calibration_std_ref
if microscope_image: if microscope_image:
att = request.env['ir.attachment'].create({ att = env['ir.attachment'].create({
'name': microscope_image_filename or f'thickness_{reading_number}.jpg', 'name': microscope_image_filename or f'thickness_{reading_number}.jpg',
'datas': microscope_image, 'datas': microscope_image,
'res_model': 'fp.thickness.reading', 'res_model': 'fp.thickness.reading',
@@ -525,7 +546,7 @@ class FpShopfloorController(http.Controller):
vals['microscope_image_id'] = att.id vals['microscope_image_id'] = att.id
# Auto-link to an existing CoC if there is one for this job. # Auto-link to an existing CoC if there is one for this job.
Cert = request.env.get('fp.certificate') Cert = env.get('fp.certificate')
if Cert is not None: if Cert is not None:
if 'x_fc_job_id' in Cert._fields: if 'x_fc_job_id' in Cert._fields:
cert_field = 'x_fc_job_id' cert_field = 'x_fc_job_id'
@@ -557,7 +578,8 @@ class FpShopfloorController(http.Controller):
part_ref=None, qty_on_hold=0, qty_original=0, part_ref=None, qty_on_hold=0, qty_original=0,
hold_reason='other', description=None, hold_reason='other', description=None,
mark_for_scrap=False, facility_id=None, mark_for_scrap=False, facility_id=None,
work_center_id=None, current_process_node=None): work_center_id=None, current_process_node=None,
tablet_tech_id=None):
"""Create a quality hold record, splitting qty from the original lot. """Create a quality hold record, splitting qty from the original lot.
The hold is linked to the fp.job and (when provided) the The hold is linked to the fp.job and (when provided) the
@@ -566,7 +588,8 @@ class FpShopfloorController(http.Controller):
if not qty_on_hold or int(qty_on_hold) <= 0: if not qty_on_hold or int(qty_on_hold) <= 0:
raise UserError("qty_on_hold must be a positive integer.") raise UserError("qty_on_hold must be a positive integer.")
Hold = request.env['fusion.plating.quality.hold'] env = env_for_tablet_tech(request.env, tablet_tech_id)
Hold = env['fusion.plating.quality.hold']
vals = { vals = {
'part_ref': part_ref or '', 'part_ref': part_ref or '',
@@ -583,7 +606,7 @@ class FpShopfloorController(http.Controller):
if work_center_id: if work_center_id:
vals['work_center_id'] = int(work_center_id) vals['work_center_id'] = int(work_center_id)
if portal_job_id: if portal_job_id:
pj = request.env['fusion.plating.portal.job'].browse( pj = env['fusion.plating.portal.job'].browse(
int(portal_job_id), int(portal_job_id),
) )
if pj.exists(): if pj.exists():
@@ -594,7 +617,7 @@ class FpShopfloorController(http.Controller):
# via fusion_plating_jobs (Phase 3) as `x_fc_job_id` / `x_fc_step_id`. # via fusion_plating_jobs (Phase 3) as `x_fc_job_id` / `x_fc_step_id`.
step_target_id = step_id or workorder_id step_target_id = step_id or workorder_id
if step_target_id: if step_target_id:
step = request.env['fp.job.step'].browse(int(step_target_id)) step = env['fp.job.step'].browse(int(step_target_id))
if step.exists(): if step.exists():
if 'x_fc_step_id' in Hold._fields: if 'x_fc_step_id' in Hold._fields:
vals['x_fc_step_id'] = step.id vals['x_fc_step_id'] = step.id
@@ -605,7 +628,7 @@ class FpShopfloorController(http.Controller):
# set it through the step. # set it through the step.
if (job_id and 'x_fc_job_id' in Hold._fields if (job_id and 'x_fc_job_id' in Hold._fields
and not vals.get('x_fc_job_id')): and not vals.get('x_fc_job_id')):
j = request.env['fp.job'].browse(int(job_id)) j = env['fp.job'].browse(int(job_id))
if j.exists(): if j.exists():
vals['x_fc_job_id'] = j.id vals['x_fc_job_id'] = j.id
@@ -995,8 +1018,9 @@ class FpShopfloorController(http.Controller):
# Mark a first-piece gate result from the tablet # Mark a first-piece gate result from the tablet
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@http.route('/fp/shopfloor/mark_gate', type='jsonrpc', auth='user') @http.route('/fp/shopfloor/mark_gate', type='jsonrpc', auth='user')
def mark_gate(self, gate_id, result): def mark_gate(self, gate_id, result, tablet_tech_id=None):
gate = request.env['fusion.plating.first.piece.gate'].browse(int(gate_id)) env = env_for_tablet_tech(request.env, tablet_tech_id)
gate = env['fusion.plating.first.piece.gate'].browse(int(gate_id))
if not gate.exists(): if not gate.exists():
return {'ok': False, 'error': 'Gate not found.'} return {'ok': False, 'error': 'Gate not found.'}
try: try:
@@ -1084,21 +1108,23 @@ class FpShopfloorController(http.Controller):
@http.route('/fp/shopfloor/plant_overview/move_card', @http.route('/fp/shopfloor/plant_overview/move_card',
type='jsonrpc', auth='user') type='jsonrpc', auth='user')
def plant_overview_move_card(self, card_id, source_model=None, def plant_overview_move_card(self, card_id, source_model=None,
target_workcenter_id=None): target_workcenter_id=None,
tablet_tech_id=None):
"""Move a step card to a different work centre (drag & drop). """Move a step card to a different work centre (drag & drop).
`source_model` is accepted for backward compatibility but ignored — `source_model` is accepted for backward compatibility but ignored —
Plant Overview now only ever serves fp.job.step cards. A target Plant Overview now only ever serves fp.job.step cards. A target
of 0 / falsy clears the work centre. of 0 / falsy clears the work centre.
""" """
Step = request.env['fp.job.step'] env = env_for_tablet_tech(request.env, tablet_tech_id)
Step = env['fp.job.step']
step = Step.browse(int(card_id)) step = Step.browse(int(card_id))
if not step.exists(): if not step.exists():
return {'ok': False, 'error': f'Step {card_id} not found.'} return {'ok': False, 'error': f'Step {card_id} not found.'}
wc_id = int(target_workcenter_id) if target_workcenter_id else False wc_id = int(target_workcenter_id) if target_workcenter_id else False
if wc_id: if wc_id:
wc = request.env['fp.work.centre'].browse(wc_id) wc = env['fp.work.centre'].browse(wc_id)
if not wc.exists(): if not wc.exists():
return {'ok': False, return {'ok': False,
'error': f'Work centre {target_workcenter_id} not found.'} 'error': f'Work centre {target_workcenter_id} not found.'}
@@ -1108,7 +1134,7 @@ class FpShopfloorController(http.Controller):
_logger.info( _logger.info(
'Plant Overview: moved step %s (%s) → WC %s by uid %s', 'Plant Overview: moved step %s (%s) → WC %s by uid %s',
step.id, step.name, wc_id or 'unassigned', step.id, step.name, wc_id or 'unassigned',
request.env.uid, env.uid,
) )
except Exception as exc: except Exception as exc:
_logger.exception('Plant Overview move_card failed') _logger.exception('Plant Overview move_card failed')

View File

@@ -23,6 +23,8 @@ from odoo import fields, http
from odoo.addons.fusion_plating.models.fp_tz import fp_format from odoo.addons.fusion_plating.models.fp_tz import fp_format
from odoo.http import request from odoo.http import request
from ._tablet_audit import env_for_tablet_tech
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -190,8 +192,8 @@ class FpWorkspaceController(http.Controller):
@http.route('/fp/workspace/hold', type='jsonrpc', auth='user') @http.route('/fp/workspace/hold', type='jsonrpc', auth='user')
def hold(self, job_id, reason='other', qty_on_hold=1, description='', def hold(self, job_id, reason='other', qty_on_hold=1, description='',
part_ref='', step_id=None, mark_for_scrap=False, part_ref='', step_id=None, mark_for_scrap=False,
photo_data=None, photo_filename=None): photo_data=None, photo_filename=None, tablet_tech_id=None):
env = request.env env = env_for_tablet_tech(request.env, tablet_tech_id)
job = env['fp.job'].browse(int(job_id)) job = env['fp.job'].browse(int(job_id))
if not job.exists(): if not job.exists():
return {'ok': False, 'error': f'Job {job_id} not found'} return {'ok': False, 'error': f'Job {job_id} not found'}
@@ -253,8 +255,8 @@ class FpWorkspaceController(http.Controller):
# /fp/workspace/sign_off — capture signature + finish step atomically # /fp/workspace/sign_off — capture signature + finish step atomically
# ====================================================================== # ======================================================================
@http.route('/fp/workspace/sign_off', type='jsonrpc', auth='user') @http.route('/fp/workspace/sign_off', type='jsonrpc', auth='user')
def sign_off(self, step_id, signature_data_uri): def sign_off(self, step_id, signature_data_uri, tablet_tech_id=None):
env = request.env env = env_for_tablet_tech(request.env, tablet_tech_id)
sig = (signature_data_uri or '').strip() sig = (signature_data_uri or '').strip()
if not sig: if not sig:
_logger.warning("workspace/sign_off: empty signature for step %s", step_id) _logger.warning("workspace/sign_off: empty signature for step %s", step_id)
@@ -302,8 +304,8 @@ class FpWorkspaceController(http.Controller):
# /fp/workspace/advance_milestone — fire next_milestone_action # /fp/workspace/advance_milestone — fire next_milestone_action
# ====================================================================== # ======================================================================
@http.route('/fp/workspace/advance_milestone', type='jsonrpc', auth='user') @http.route('/fp/workspace/advance_milestone', type='jsonrpc', auth='user')
def advance_milestone(self, job_id): def advance_milestone(self, job_id, tablet_tech_id=None):
env = request.env env = env_for_tablet_tech(request.env, tablet_tech_id)
job = env['fp.job'].browse(int(job_id)) job = env['fp.job'].browse(int(job_id))
if not job.exists(): if not job.exists():
return {'ok': False, 'error': f'Job {job_id} not found'} return {'ok': False, 'error': f'Job {job_id} not found'}

View File

@@ -14,7 +14,7 @@
import { Component, useState } from "@odoo/owl"; import { Component, useState } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog"; import { Dialog } from "@web/core/dialog/dialog";
import { rpc } from "@web/core/network/rpc"; import { fpRpc } from "../services/fp_rpc";
import { useService } from "@web/core/utils/hooks"; import { useService } from "@web/core/utils/hooks";
// Hold reasons kept here so the picker doesn't need a server roundtrip. // Hold reasons kept here so the picker doesn't need a server roundtrip.
@@ -75,7 +75,7 @@ export class FpHoldComposer extends Component {
} }
this.state.submitting = true; this.state.submitting = true;
try { try {
const res = await rpc("/fp/workspace/hold", { const res = await fpRpc("/fp/workspace/hold", {
job_id: this.props.jobId, job_id: this.props.jobId,
step_id: this.props.stepId || null, step_id: this.props.stepId || null,
part_ref: this.props.partRef || "", part_ref: this.props.partRef || "",

View File

@@ -0,0 +1,18 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — FpIdleWarning (shared OWL service)
//
// Yellow-border overlay + countdown toast shown during the last
// (default 30) seconds before auto-lock. Any pointer/touch event on
// the document elsewhere resets the activity tracker, which causes
// this component's parent (FpTabletLock) to hide the warning.
// =============================================================================
import { Component } from "@odoo/owl";
export class FpIdleWarning extends Component {
static template = "fusion_plating_shopfloor.IdleWarning";
static props = {
secondsRemaining: { type: Number },
};
}

View File

@@ -0,0 +1,72 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — FpPinPad (shared OWL service)
//
// Numeric 4-digit PIN pad. Auto-submits on the 4th digit via onSubmit
// callback. Used by FpTabletLock unlock flow AND FpPinSetup change flow.
//
// Props:
// onSubmit : (pin: string) => Promise<{ok: boolean, error?: string}>
// title : optional header text
// subtitle : optional smaller text
// onCancel : optional cancel callback (e.g. close modal)
// =============================================================================
import { Component, useState } from "@odoo/owl";
export class FpPinPad extends Component {
static template = "fusion_plating_shopfloor.PinPad";
static props = {
onSubmit: { type: Function },
title: { type: String, optional: true },
subtitle: { type: String, optional: true },
onCancel: { type: Function, optional: true },
};
setup() {
this.state = useState({
pin: "",
submitting: false,
error: "",
shake: false,
});
}
async _press(digit) {
if (this.state.submitting) return;
if (this.state.pin.length >= 4) return;
this.state.pin = this.state.pin + digit;
this.state.error = "";
if (this.state.pin.length === 4) {
await this._submit();
}
}
_clear() {
this.state.pin = "";
this.state.error = "";
}
async _submit() {
this.state.submitting = true;
try {
const result = await this.props.onSubmit(this.state.pin);
if (result && !result.ok) {
this.state.error = result.error || "Incorrect PIN";
this.state.shake = true;
setTimeout(() => { this.state.shake = false; }, 400);
this.state.pin = "";
}
} catch (err) {
this.state.error = err.message || String(err);
this.state.pin = "";
} finally {
this.state.submitting = false;
}
}
get dots() {
// Render 4 dot slots: filled if typed, empty otherwise
return [0, 1, 2, 3].map((i) => this.state.pin.length > i);
}
}

View File

@@ -0,0 +1,97 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — FpPinSetup (client action `fp_tablet_pin_setup`)
//
// Modal flow for setting OR changing the user's tablet PIN. Triggered
// from res.users preferences via action_open_tablet_pin_setup. Three
// stages: (1) old PIN (only if has_pin), (2) new PIN, (3) confirm new.
// =============================================================================
import { Component, useState, onMounted } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { user } from "@web/core/user";
import { FpPinPad } from "./pin_pad";
export class FpPinSetup extends Component {
static template = "fusion_plating_shopfloor.PinSetup";
static components = { FpPinPad };
static props = ["*"];
setup() {
this.notification = useService("notification");
this.action = useService("action");
this.state = useState({
stage: "loading", // 'loading' | 'old' | 'new' | 'confirm' | 'done'
newPin: "",
hasExistingPin: false,
});
onMounted(() => this._init());
}
async _init() {
// Cheap probe: search_count on the user's own record filtered
// by pin_set_date. Non-manager users can read their own set_date
// (not the hash). If the count is 1, they have a PIN; 0 = no PIN.
try {
const has = await rpc("/web/dataset/call_kw", {
model: "res.users",
method: "search_count",
args: [[
["id", "=", user.userId],
["x_fc_tablet_pin_set_date", "!=", false],
]],
kwargs: {},
});
this.state.hasExistingPin = has > 0;
} catch (e) {
this.state.hasExistingPin = false;
}
this.state.stage = this.state.hasExistingPin ? "old" : "new";
}
async onOldPinSubmit(pin) {
// Stash for the final call; set_pin verifies it server-side
this._oldPin = pin;
this.state.stage = "new";
return { ok: true };
}
async onNewPinSubmit(pin) {
this.state.newPin = pin;
this.state.stage = "confirm";
return { ok: true };
}
async onConfirmPinSubmit(pin) {
if (pin !== this.state.newPin) {
return { ok: false, error: "PINs don't match. Try again." };
}
const params = { new_pin: this.state.newPin };
if (this._oldPin) params.old_pin = this._oldPin;
const res = await rpc("/fp/tablet/set_pin", params);
if (res && res.ok) {
this.notification.add("Tablet PIN updated.", { type: "success" });
this.state.stage = "done";
setTimeout(() => this._close(), 1500);
return { ok: true };
}
// Reset back to start on hard error so user can retry cleanly
this.notification.add((res && res.error) || "Failed to set PIN", { type: "danger" });
this._oldPin = null;
this.state.newPin = "";
this.state.stage = this.state.hasExistingPin ? "old" : "new";
return { ok: false, error: (res && res.error) || "Failed" };
}
_close() {
this.action.doAction({ type: "ir.actions.act_window_close" });
}
onCancel() {
this._close();
}
}
registry.category("actions").add("fp_tablet_pin_setup", FpPinSetup);

View File

@@ -20,21 +20,24 @@
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl"; import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
import { registry } from "@web/core/registry"; import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc"; import { rpc } from "@web/core/network/rpc";
import { fpRpc } from "./services/fp_rpc";
import { useService } from "@web/core/utils/hooks"; import { useService } from "@web/core/utils/hooks";
import { WorkflowChip } from "./components/workflow_chip"; import { WorkflowChip } from "./components/workflow_chip";
import { GateViz } from "./components/gate_viz"; import { GateViz } from "./components/gate_viz";
import { FpSignaturePad } from "./components/signature_pad"; import { FpSignaturePad } from "./components/signature_pad";
import { FpHoldComposer } from "./components/hold_composer"; import { FpHoldComposer } from "./components/hold_composer";
import { FpTabletLock } from "./tablet_lock";
export class FpJobWorkspace extends Component { export class FpJobWorkspace extends Component {
static template = "fusion_plating_shopfloor.JobWorkspace"; static template = "fusion_plating_shopfloor.JobWorkspace";
static props = ["*"]; static props = ["*"];
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer }; static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock };
setup() { setup() {
this.notification = useService("notification"); this.notification = useService("notification");
this.action = useService("action"); this.action = useService("action");
this.dialog = useService("dialog"); this.dialog = useService("dialog");
this.techStore = useService("fp_shopfloor_tech_store");
this.state = useState({ this.state = useState({
data: null, data: null,
@@ -76,6 +79,11 @@ export class FpJobWorkspace extends Component {
this.action.doAction({ type: "ir.actions.act_window_close" }); this.action.doAction({ type: "ir.actions.act_window_close" });
} }
// ---- Hand-Off (Phase 6.2) ---------------------------------------------
handOff() {
this.techStore.lock();
}
onJumpToBlocker({ model, id }) { onJumpToBlocker({ model, id }) {
// If the predecessor is in this same workspace, just scroll to it // If the predecessor is in this same workspace, just scroll to it
const inThisJob = (this.state.data.steps || []).find((s) => s.id === id); const inThisJob = (this.state.data.steps || []).find((s) => s.id === id);
@@ -109,7 +117,7 @@ export class FpJobWorkspace extends Component {
// ---- Step actions ------------------------------------------------------ // ---- Step actions ------------------------------------------------------
async onStartStep(stepId) { async onStartStep(stepId) {
try { try {
const res = await rpc("/fp/shopfloor/start_wo", { workorder_id: stepId }); const res = await fpRpc("/fp/shopfloor/start_wo", { workorder_id: stepId });
if (res && res.ok) { if (res && res.ok) {
this.notification.add("Step started.", { type: "success" }); this.notification.add("Step started.", { type: "success" });
await this.refresh(); await this.refresh();
@@ -128,7 +136,7 @@ export class FpJobWorkspace extends Component {
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`, contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
onSubmit: async (dataUri) => { onSubmit: async (dataUri) => {
try { try {
const res = await rpc("/fp/workspace/sign_off", { const res = await fpRpc("/fp/workspace/sign_off", {
step_id: step.id, step_id: step.id,
signature_data_uri: dataUri, signature_data_uri: dataUri,
}); });
@@ -147,7 +155,7 @@ export class FpJobWorkspace extends Component {
} }
// Plain finish — no signature required // Plain finish — no signature required
try { try {
const res = await rpc("/fp/shopfloor/stop_wo", { const res = await fpRpc("/fp/shopfloor/stop_wo", {
workorder_id: step.id, finish: true, workorder_id: step.id, finish: true,
}); });
if (res && res.ok) { if (res && res.ok) {
@@ -194,7 +202,7 @@ export class FpJobWorkspace extends Component {
async onAdvanceMilestone() { async onAdvanceMilestone() {
try { try {
const res = await rpc("/fp/workspace/advance_milestone", { const res = await fpRpc("/fp/workspace/advance_milestone", {
job_id: this.state.jobId, job_id: this.state.jobId,
}); });
if (res && res.ok) { if (res && res.ok) {

View File

@@ -15,17 +15,20 @@
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl"; import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
import { registry } from "@web/core/registry"; import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc"; import { rpc } from "@web/core/network/rpc";
import { fpRpc } from "./services/fp_rpc";
import { useService } from "@web/core/utils/hooks"; import { useService } from "@web/core/utils/hooks";
import { QrScanner } from "./qr_scanner"; import { QrScanner } from "./qr_scanner";
import { FpTabletLock } from "./tablet_lock";
export class ManagerDashboard extends Component { export class ManagerDashboard extends Component {
static template = "fusion_plating_shopfloor.ManagerDashboard"; static template = "fusion_plating_shopfloor.ManagerDashboard";
static props = ["*"]; static props = ["*"];
static components = { QrScanner }; static components = { QrScanner, FpTabletLock };
setup() { setup() {
this.notification = useService("notification"); this.notification = useService("notification");
this.action = useService("action"); this.action = useService("action");
this.techStore = useService("fp_shopfloor_tech_store");
this.state = useState({ this.state = useState({
overview: null, overview: null,
@@ -148,6 +151,11 @@ export class ManagerDashboard extends Component {
this.state.mode = this.state.mode === "quick" ? "detailed" : "quick"; this.state.mode = this.state.mode === "quick" ? "detailed" : "quick";
} }
// ---- Hand-Off (Phase 6.2) ---------------------------------------------
handOff() {
this.techStore.lock();
}
toggleCard(jobId) { toggleCard(jobId) {
this.state.expandedJobId = this.state.expandedJobId === jobId ? null : jobId; this.state.expandedJobId = this.state.expandedJobId === jobId ? null : jobId;
} }
@@ -201,7 +209,7 @@ export class ManagerDashboard extends Component {
async onAssignWorker(step, userIdRaw) { async onAssignWorker(step, userIdRaw) {
const userId = parseInt(userIdRaw) || null; const userId = parseInt(userIdRaw) || null;
try { try {
const res = await rpc("/fp/manager/assign_worker", { const res = await fpRpc("/fp/manager/assign_worker", {
step_id: step.id, user_id: userId, step_id: step.id, user_id: userId,
}); });
if (res && res.ok) { if (res && res.ok) {
@@ -219,7 +227,7 @@ export class ManagerDashboard extends Component {
async onAssignTank(step, tankIdRaw) { async onAssignTank(step, tankIdRaw) {
const tankId = parseInt(tankIdRaw) || null; const tankId = parseInt(tankIdRaw) || null;
try { try {
const res = await rpc("/fp/manager/assign_tank", { const res = await fpRpc("/fp/manager/assign_tank", {
step_id: step.id, tank_id: tankId, step_id: step.id, tank_id: tankId,
}); });
if (res && res.ok) { if (res && res.ok) {
@@ -236,7 +244,7 @@ export class ManagerDashboard extends Component {
async onTakeOver(step) { async onTakeOver(step) {
try { try {
const res = await rpc("/fp/manager/take_over", { const res = await fpRpc("/fp/manager/take_over", {
step_id: step.id, step_id: step.id,
}); });
if (res && res.ok) { if (res && res.ok) {

View File

@@ -0,0 +1,73 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — Activity Tracker (shared OWL service)
//
// Watches the document for pointer/touch/keydown/visibility events and
// tracks lastActiveAt. FpTabletLock reads getSecondsUntilLock() once per
// second to drive the idle warning + auto-lock transitions.
//
// Threshold reads from ir.config_parameter at service start; refreshes
// every 5 min in case the manager changed it.
// =============================================================================
import { rpc } from "@web/core/network/rpc";
import { registry } from "@web/core/registry";
const DEFAULT_IDLE_MIN = 5;
const DEFAULT_WARN_SEC = 30;
export const fpShopfloorActivityTracker = {
async start() {
let lastActiveAt = Date.now();
let idleThresholdMs = DEFAULT_IDLE_MIN * 60 * 1000;
let warnThresholdSec = DEFAULT_WARN_SEC;
async function refreshThreshold() {
try {
const minutes = await rpc("/web/dataset/call_kw", {
model: "ir.config_parameter",
method: "get_param",
args: ["fp.shopfloor.tablet_idle_lock_minutes", String(DEFAULT_IDLE_MIN)],
kwargs: {},
});
idleThresholdMs = (parseInt(minutes, 10) || DEFAULT_IDLE_MIN) * 60 * 1000;
const warn = await rpc("/web/dataset/call_kw", {
model: "ir.config_parameter",
method: "get_param",
args: ["fp.shopfloor.tablet_warn_seconds_before_lock", String(DEFAULT_WARN_SEC)],
kwargs: {},
});
warnThresholdSec = parseInt(warn, 10) || DEFAULT_WARN_SEC;
} catch (e) {
// keep defaults if RPC fails (e.g. no session yet)
}
}
await refreshThreshold();
setInterval(refreshThreshold, 5 * 60 * 1000);
// Activity = explicit user input. Mouse-move alone DOES NOT count
// because something brushing the screen (a stray glove, a tool
// resting on the tablet) could otherwise keep the session alive.
const bump = () => { lastActiveAt = Date.now(); };
document.addEventListener("pointerdown", bump, { capture: true });
document.addEventListener("touchstart", bump, { capture: true, passive: true });
document.addEventListener("keydown", bump, { capture: true });
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") bump();
});
return {
bump,
getSecondsUntilLock() {
return Math.max(0, Math.floor((lastActiveAt + idleThresholdMs - Date.now()) / 1000));
},
getWarnThresholdSec() { return warnThresholdSec; },
getIdleThresholdMs() { return idleThresholdMs; },
getLastActiveAt() { return lastActiveAt; },
};
},
};
registry
.category("services")
.add("fp_shopfloor_activity", fpShopfloorActivityTracker);

View File

@@ -0,0 +1,42 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — fpRpc() wrapper
//
// Drop-in replacement for the standard `rpc()` import. Automatically
// injects the current tablet_tech_id from the tech_store into every
// call, so server-side endpoints can attribute the action to the right
// user via env.with_user() (see env_for_tablet_tech in
// controllers/_tablet_audit.py).
//
// USE for any RPC that WRITES (start step, finish step, hold create,
// sign-off, milestone advance). For read-only loads (kanban, workspace
// load, manager funnel), plain rpc() is fine.
//
// Example:
// import { fpRpc } from "../services/fp_rpc";
// await fpRpc("/fp/shopfloor/start_wo", { workorder_id: stepId });
//
// =============================================================================
import { rpc as baseRpc } from "@web/core/network/rpc";
function _getTechStore() {
// Lazy-resolve via the global debug API — avoids circular service init
try {
const env = odoo.__WOWL_DEBUG__?.root?.env;
if (env && env.services && env.services.fp_shopfloor_tech_store) {
return env.services.fp_shopfloor_tech_store;
}
} catch (e) {
// ignore
}
return null;
}
export function fpRpc(url, params = {}) {
const techStore = _getTechStore();
if (techStore && techStore.currentTechId) {
params = { ...params, tablet_tech_id: techStore.currentTechId };
}
return baseRpc(url, params);
}

View File

@@ -0,0 +1,42 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — Tech Store (shared OWL service)
//
// Holds the "current tech of record" for the locked tablet. Set by
// FpTabletLock on successful PIN unlock; cleared on auto-lock / Hand-Off.
// Other components read currentTechId via useService("fp_shopfloor_tech_store")
// and pass it through fpRpc() so server actions credit the right user.
// =============================================================================
import { reactive } from "@odoo/owl";
import { registry } from "@web/core/registry";
export const fpShopfloorTechStore = {
start() {
const state = reactive({
currentTechId: null,
currentTechName: "",
lockedAt: null,
});
return {
get currentTechId() { return state.currentTechId; },
get currentTechName() { return state.currentTechName; },
get isLocked() { return !state.currentTechId; },
setTech(id, name) {
state.currentTechId = id;
state.currentTechName = name;
state.lockedAt = null;
},
lock() {
state.currentTechId = null;
state.currentTechName = "";
state.lockedAt = Date.now();
},
state, // exposed for OWL reactive subscriptions
};
},
};
registry
.category("services")
.add("fp_shopfloor_tech_store", fpShopfloorTechStore);

View File

@@ -20,9 +20,11 @@
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl"; import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
import { registry } from "@web/core/registry"; import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc"; import { rpc } from "@web/core/network/rpc";
import { fpRpc } from "./services/fp_rpc";
import { useService } from "@web/core/utils/hooks"; import { useService } from "@web/core/utils/hooks";
import { QrScanner } from "./qr_scanner"; import { QrScanner } from "./qr_scanner";
import { FpKanbanCard } from "./components/kanban_card"; import { FpKanbanCard } from "./components/kanban_card";
import { FpTabletLock } from "./tablet_lock";
const LS_STATION_ID = "fp_landing_station_id"; const LS_STATION_ID = "fp_landing_station_id";
const LS_MODE = "fp_landing_mode"; const LS_MODE = "fp_landing_mode";
@@ -31,11 +33,12 @@ const REFRESH_MS = 15000;
export class FpShopfloorLanding extends Component { export class FpShopfloorLanding extends Component {
static template = "fusion_plating_shopfloor.ShopfloorLanding"; static template = "fusion_plating_shopfloor.ShopfloorLanding";
static props = ["*"]; static props = ["*"];
static components = { QrScanner, FpKanbanCard }; static components = { QrScanner, FpKanbanCard, FpTabletLock };
setup() { setup() {
this.notification = useService("notification"); this.notification = useService("notification");
this.action = useService("action"); this.action = useService("action");
this.techStore = useService("fp_shopfloor_tech_store");
this.state = useState({ this.state = useState({
mode: localStorage.getItem(LS_MODE) || "all_plant", mode: localStorage.getItem(LS_MODE) || "all_plant",
@@ -120,6 +123,12 @@ export class FpShopfloorLanding extends Component {
this.refresh(); this.refresh();
} }
// ---- Hand-Off (Phase 6.2) ---------------------------------------------
handOff() {
// Tech walking away: lock the tablet so the next operator must PIN in
this.techStore.lock();
}
// ---- Search ------------------------------------------------------------ // ---- Search ------------------------------------------------------------
onSearchInput(ev) { onSearchInput(ev) {
this.state.search = ev.target.value; this.state.search = ev.target.value;
@@ -246,7 +255,7 @@ export class FpShopfloorLanding extends Component {
this._movesInFlight += 1; this._movesInFlight += 1;
this._lastDropAt = Date.now(); this._lastDropAt = Date.now();
try { try {
const res = await rpc("/fp/shopfloor/plant_overview/move_card", { const res = await fpRpc("/fp/shopfloor/plant_overview/move_card", {
card_id: dragged.id, card_id: dragged.id,
target_workcenter_id: col.work_center_id, target_workcenter_id: col.work_center_id,
}); });

View File

@@ -0,0 +1,135 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — FpTabletLock (top-level wrapper)
//
// Mounted by Landing / Workspace / Manager Dashboard as their outermost
// element. Renders the lock screen (tile grid + PIN pad) when no tech
// is signed in; renders <t t-slot="default"/> (the wrapped client
// action) otherwise. Also drives the auto-lock countdown + idle warning.
//
// Usage in a parent template:
//
// <FpTabletLock>
// <t t-set-slot="default">
// <div class="o_fp_landing"> ...your existing tree... </div>
// </t>
// </FpTabletLock>
//
// =============================================================================
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { FpPinPad } from "./components/pin_pad";
import { FpIdleWarning } from "./components/idle_warning";
export class FpTabletLock extends Component {
static template = "fusion_plating_shopfloor.TabletLock";
static components = { FpPinPad, FpIdleWarning };
static props = {
slots: { type: Object, optional: true },
};
setup() {
this.techStore = useService("fp_shopfloor_tech_store");
this.activity = useService("fp_shopfloor_activity");
this.notification = useService("notification");
this.state = useState({
tiles: [],
selectedTileUserId: null,
idleSecondsRemaining: null,
loadingTiles: false,
});
onMounted(async () => {
await this._loadTiles();
this._tick = setInterval(() => this._checkIdle(), 1000);
// Heartbeat ping every 60s — for forensic visibility
this._ping = setInterval(() => {
if (this.techStore.currentTechId) {
rpc("/fp/tablet/ping", { current_tech_id: this.techStore.currentTechId })
.catch(() => {});
}
}, 60000);
});
onWillUnmount(() => {
if (this._tick) clearInterval(this._tick);
if (this._ping) clearInterval(this._ping);
});
}
get isLocked() {
return this.techStore.isLocked;
}
async _loadTiles() {
this.state.loadingTiles = true;
try {
const stationId = parseInt(localStorage.getItem("fp_landing_station_id")) || null;
const res = await rpc("/fp/tablet/tiles", { station_id: stationId });
if (res && res.ok) {
this.state.tiles = res.tiles;
}
} catch (err) {
// Quiet fail — tile grid stays empty; user gets prompted
} finally {
this.state.loadingTiles = false;
}
}
_checkIdle() {
if (!this.techStore.currentTechId) {
this.state.idleSecondsRemaining = null;
return;
}
const remaining = this.activity.getSecondsUntilLock();
const warnThreshold = this.activity.getWarnThresholdSec();
if (remaining <= 0) {
this.handOff();
} else if (remaining <= warnThreshold) {
this.state.idleSecondsRemaining = remaining;
} else if (this.state.idleSecondsRemaining !== null) {
this.state.idleSecondsRemaining = null;
}
}
onTileClick(userId) {
this.state.selectedTileUserId = userId;
}
_selectedTileName() {
const tile = this.state.tiles.find(t => t.user_id === this.state.selectedTileUserId);
return tile ? tile.name : "";
}
async unlock(pin) {
try {
const res = await rpc("/fp/tablet/unlock", {
user_id: this.state.selectedTileUserId,
pin,
});
if (res && res.ok) {
this.techStore.setTech(res.current_tech_id, res.current_tech_name);
this.activity.bump();
this.state.selectedTileUserId = null;
return { ok: true };
}
return { ok: false, error: (res && res.error) || "Unlock failed" };
} catch (err) {
return { ok: false, error: err.message || String(err) };
}
}
onPinCancel() {
this.state.selectedTileUserId = null;
}
handOff() {
this.techStore.lock();
this.state.selectedTileUserId = null;
this.state.idleSecondsRemaining = null;
this._loadTiles();
}
}

View File

@@ -0,0 +1,35 @@
// =============================================================================
// FpIdleWarning — yellow-border countdown overlay before auto-lock
// =============================================================================
.o_fp_idle_warning_overlay {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9998;
box-shadow: inset 0 0 0 4px #ff9f0a;
animation: o_fp_idle_pulse 1s ease-in-out infinite alternate;
}
@keyframes o_fp_idle_pulse {
from { box-shadow: inset 0 0 0 4px rgba(255, 159, 10, 0.6); }
to { box-shadow: inset 0 0 0 4px rgba(255, 159, 10, 1); }
}
.o_fp_idle_warning_toast {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
background: #1d1d1f;
color: #ffd585;
padding: 0.6rem 1.2rem;
border-radius: 8px;
font-size: 0.9rem;
z-index: 9999;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
strong { color: #ffb84d; margin: 0 0.2rem; }
> i { margin-right: 0.4rem; }
}

View File

@@ -0,0 +1,89 @@
// =============================================================================
// FpPinPad — numeric keypad for tablet lock screen + PIN setup
// Dark-mode aware via $o-webclient-color-scheme branch.
// =============================================================================
$o-webclient-color-scheme: bright !default;
$_pin-bg-hex: #ffffff;
$_pin-key-bg-hex: #f3f4f6;
$_pin-key-hover-hex: #e5e7eb;
$_pin-border-hex: #d8dadd;
$_pin-dot-hex: #d8dadd;
$_pin-dot-fill-hex: #1d1d1f;
@if $o-webclient-color-scheme == dark {
$_pin-bg-hex: #22262d !global;
$_pin-key-bg-hex: #2d3138 !global;
$_pin-key-hover-hex: #3a3f48 !global;
$_pin-border-hex: #424245 !global;
$_pin-dot-fill-hex: #f5f5f7 !global;
}
.o_fp_pin_pad {
background: $_pin-bg-hex;
border-radius: 12px;
padding: 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.8rem;
min-width: 280px;
}
.o_fp_pin_title { font-size: 1.1rem; font-weight: 600; }
.o_fp_pin_subtitle { font-size: 0.85rem; color: var(--text-secondary, #666); text-align: center; }
.o_fp_pin_dots {
display: flex;
gap: 0.8rem;
margin: 0.5rem 0;
}
.o_fp_pin_dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: $_pin-dot-hex;
transition: background 0.1s ease;
&.filled { background: $_pin-dot-fill-hex; }
}
.o_fp_pin_error {
color: #ff3b30;
font-size: 0.85rem;
min-height: 1.2rem;
}
.o_fp_pin_grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
width: 100%;
}
.o_fp_pin_key {
background: $_pin-key-bg-hex;
border: 1px solid $_pin-border-hex;
border-radius: 10px;
padding: 1rem 0;
font-size: 1.5rem;
font-weight: 500;
cursor: pointer;
transition: background 0.1s ease, transform 0.05s ease;
&:hover { background: $_pin-key-hover-hex; }
&:active { transform: scale(0.97); }
&:disabled { opacity: 0.5; cursor: wait; }
}
.o_fp_pin_key_clear { font-size: 0.95rem; color: var(--text-secondary, #666); }
.o_fp_pin_key_cancel { font-size: 0.95rem; color: var(--text-secondary, #666); }
@keyframes o_fp_pin_shake_kf {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-8px); }
50% { transform: translateX(8px); }
75% { transform: translateX(-4px); }
}
.o_fp_pin_shake { animation: o_fp_pin_shake_kf 0.4s ease; }

View File

@@ -0,0 +1,96 @@
// =============================================================================
// FpTabletLock — lock screen with tile grid + PIN pad overlay
// =============================================================================
$o-webclient-color-scheme: bright !default;
$_lock-bg-hex: #f3f4f6;
$_lock-card-hex: #ffffff;
$_lock-border-hex: #d8dadd;
$_lock-ink-hex: #1d1d1f;
@if $o-webclient-color-scheme == dark {
$_lock-bg-hex: #1a1d21 !global;
$_lock-card-hex: #22262d !global;
$_lock-border-hex: #424245 !global;
$_lock-ink-hex: #f5f5f7 !global;
}
.o_fp_tablet_lock {
position: fixed;
inset: 0;
background: $_lock-bg-hex;
color: $_lock-ink-hex;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
z-index: 9000;
overflow-y: auto;
}
.o_fp_tablet_lock_header {
h1 {
font-size: 1.4rem;
font-weight: 600;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.6rem;
}
}
.o_fp_tablet_lock_loading, .o_fp_tablet_lock_empty {
margin: 2rem auto;
color: var(--text-secondary, #666);
}
.o_fp_tablet_lock_tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
max-width: 900px;
width: 100%;
}
.o_fp_tablet_lock_tile {
background: $_lock-card-hex;
border: 2px solid $_lock-border-hex;
border-radius: 12px;
padding: 1rem;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
transition: border-color 0.1s ease, transform 0.05s ease;
&:hover { border-color: #0071e3; }
&:active { transform: scale(0.98); }
}
.o_fp_tablet_lock_tile_avatar {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
}
.o_fp_tablet_lock_tile_name {
font-weight: 600;
text-align: center;
}
.o_fp_tablet_lock_tile_clocked {
color: #34c759;
font-size: 0.75rem;
}
.o_fp_tablet_lock_tile_nopin {
color: #ff9f0a;
font-size: 0.75rem;
}
.o_fp_tablet_lock_pinwrap {
margin-top: 2rem;
}

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.IdleWarning">
<div class="o_fp_idle_warning_overlay">
<div class="o_fp_idle_warning_toast">
<i class="fa fa-clock-o"/>
Locking in <strong t-esc="props.secondsRemaining"/>s · tap anywhere to stay
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.PinPad">
<div t-att-class="'o_fp_pin_pad' + (state.shake ? ' o_fp_pin_shake' : '')">
<div t-if="props.title" class="o_fp_pin_title" t-esc="props.title"/>
<div t-if="props.subtitle" class="o_fp_pin_subtitle" t-esc="props.subtitle"/>
<div class="o_fp_pin_dots">
<t t-foreach="dots" t-as="filled" t-key="filled_index">
<span t-att-class="'o_fp_pin_dot' + (filled ? ' filled' : '')"/>
</t>
</div>
<div t-if="state.error" class="o_fp_pin_error" t-esc="state.error"/>
<div class="o_fp_pin_grid">
<t t-foreach="[1,2,3,4,5,6,7,8,9]" t-as="d" t-key="d">
<button class="o_fp_pin_key"
t-on-click="() => this._press(String(d))"
t-att-disabled="state.submitting">
<t t-esc="d"/>
</button>
</t>
<button class="o_fp_pin_key o_fp_pin_key_clear"
t-on-click="_clear">Clear</button>
<button class="o_fp_pin_key"
t-on-click="() => this._press('0')"
t-att-disabled="state.submitting">0</button>
<button t-if="props.onCancel"
class="o_fp_pin_key o_fp_pin_key_cancel"
t-on-click="() => this.props.onCancel()">Cancel</button>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.PinSetup">
<div class="o_fp_pin_setup">
<div t-if="state.stage === 'loading'" class="o_fp_pin_setup_loading">
<i class="fa fa-spinner fa-spin"/> Loading…
</div>
<FpPinPad t-if="state.stage === 'old'"
onSubmit.bind="onOldPinSubmit"
title="'Enter your current PIN'"
onCancel.bind="onCancel"/>
<FpPinPad t-if="state.stage === 'new'"
onSubmit.bind="onNewPinSubmit"
title="'Choose a new 4-digit PIN'"
onCancel.bind="onCancel"/>
<FpPinPad t-if="state.stage === 'confirm'"
onSubmit.bind="onConfirmPinSubmit"
title="'Confirm your new PIN'"
subtitle="'Enter it again to confirm'"
onCancel.bind="onCancel"/>
<div t-if="state.stage === 'done'" class="o_fp_pin_setup_done">
<i class="fa fa-check-circle text-success fa-3x"/>
<h3>PIN updated</h3>
</div>
</div>
</t>
</templates>

View File

@@ -2,6 +2,8 @@
<templates xml:space="preserve"> <templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.JobWorkspace"> <t t-name="fusion_plating_shopfloor.JobWorkspace">
<FpTabletLock>
<t t-set-slot="default">
<div class="o_fp_ws"> <div class="o_fp_ws">
<!-- Loading state --> <!-- Loading state -->
@@ -20,6 +22,12 @@
<button class="btn btn-link o_fp_ws_back" t-on-click="onBack"> <button class="btn btn-link o_fp_ws_back" t-on-click="onBack">
<i class="fa fa-arrow-left"/> Back <i class="fa fa-arrow-left"/> Back
</button> </button>
<!-- Phase 6.2 — Hand-Off: lock the tablet -->
<button class="btn btn-sm btn-warning ms-2"
t-on-click="handOff"
title="Lock the tablet for the next operator">
<i class="fa fa-lock"/> Hand Off
</button>
<span class="o_fp_ws_wo"><t t-esc="state.data.job.display_wo_name"/></span> <span class="o_fp_ws_wo"><t t-esc="state.data.job.display_wo_name"/></span>
<span class="o_fp_ws_dot"> · </span> <span class="o_fp_ws_dot"> · </span>
<span class="o_fp_ws_cust"><t t-esc="state.data.job.partner_name"/></span> <span class="o_fp_ws_cust"><t t-esc="state.data.job.partner_name"/></span>
@@ -225,6 +233,8 @@
</t> </t>
</div> </div>
</t>
</FpTabletLock>
</t> </t>
</templates> </templates>

View File

@@ -7,6 +7,8 @@
<templates xml:space="preserve"> <templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.ManagerDashboard"> <t t-name="fusion_plating_shopfloor.ManagerDashboard">
<FpTabletLock>
<t t-set-slot="default">
<div class="o_fp_manager"> <div class="o_fp_manager">
<!-- ============ Hero ============ --> <!-- ============ Hero ============ -->
@@ -45,6 +47,12 @@
<i t-att-class="'fa fa-refresh' + (state.isFetching ? ' fa-spin' : '')"/> <i t-att-class="'fa fa-refresh' + (state.isFetching ? ' fa-spin' : '')"/>
</button> </button>
<QrScanner cssClass="'btn'"/> <QrScanner cssClass="'btn'"/>
<!-- Phase 6.2 — Hand-Off: lock the tablet -->
<button class="btn btn-warning"
t-on-click="handOff"
title="Lock the tablet for the next operator">
<i class="fa fa-lock"/> Hand Off
</button>
<button t-att-class="'btn ' + (state.mode === 'quick' ? 'btn-primary' : '')" <button t-att-class="'btn ' + (state.mode === 'quick' ? 'btn-primary' : '')"
t-on-click="toggleMode"> t-on-click="toggleMode">
<t t-if="state.mode === 'quick'">Quick View</t> <t t-if="state.mode === 'quick'">Quick View</t>
@@ -583,6 +591,8 @@
<div>Loading manager data…</div> <div>Loading manager data…</div>
</div> </div>
</div> </div>
</t>
</FpTabletLock>
</t> </t>
</templates> </templates>

View File

@@ -2,6 +2,8 @@
<templates xml:space="preserve"> <templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.ShopfloorLanding"> <t t-name="fusion_plating_shopfloor.ShopfloorLanding">
<FpTabletLock>
<t t-set-slot="default">
<div class="o_fp_landing"> <div class="o_fp_landing">
<!-- Loading state --> <!-- Loading state -->
@@ -62,6 +64,13 @@
</button> </button>
<QrScanner cssClass="'btn btn-sm btn-outline-secondary'" label="'Camera'"/> <QrScanner cssClass="'btn btn-sm btn-outline-secondary'" label="'Camera'"/>
<!-- Phase 6.2 — Hand-Off: lock the tablet for the next operator -->
<button class="btn btn-sm btn-warning"
t-on-click="handOff"
title="Lock the tablet for the next operator">
<i class="fa fa-lock"/> Hand Off
</button>
<!-- Refresh indicator --> <!-- Refresh indicator -->
<span class="o_fp_landing_refresh text-muted"> <span class="o_fp_landing_refresh text-muted">
<i class="fa fa-clock-o"/> <t t-esc="state.lastRefresh"/> <i class="fa fa-clock-o"/> <t t-esc="state.lastRefresh"/>
@@ -158,6 +167,8 @@
</t> </t>
</div> </div>
</t>
</FpTabletLock>
</t> </t>
</templates> </templates>

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.TabletLock">
<t t-if="isLocked">
<div class="o_fp_tablet_lock">
<div class="o_fp_tablet_lock_header">
<h1><i class="fa fa-lock"/> Tap your name to unlock</h1>
</div>
<div t-if="state.loadingTiles" class="o_fp_tablet_lock_loading">
<i class="fa fa-spinner fa-spin"/> Loading…
</div>
<div t-elif="!state.selectedTileUserId" class="o_fp_tablet_lock_tiles">
<t t-if="!state.tiles.length">
<div class="o_fp_tablet_lock_empty">
No operators configured.
</div>
</t>
<t t-foreach="state.tiles" t-as="tile" t-key="tile.user_id">
<button class="o_fp_tablet_lock_tile"
t-on-click="() => this.onTileClick(tile.user_id)">
<img class="o_fp_tablet_lock_tile_avatar"
t-att-src="tile.avatar_url"
t-att-alt="tile.name"/>
<div class="o_fp_tablet_lock_tile_name" t-esc="tile.name"/>
<span t-if="tile.is_clocked_in" class="o_fp_tablet_lock_tile_clocked">
● Clocked in
</span>
<span t-if="!tile.has_pin" class="o_fp_tablet_lock_tile_nopin">
PIN required
</span>
</button>
</t>
</div>
<div t-else="" class="o_fp_tablet_lock_pinwrap">
<FpPinPad onSubmit.bind="unlock"
title="_selectedTileName()"
subtitle="'Enter your 4-digit PIN'"
onCancel.bind="onPinCancel"/>
</div>
</div>
</t>
<t t-else="">
<t t-slot="default"/>
<FpIdleWarning t-if="state.idleSecondsRemaining !== null"
secondsRemaining="state.idleSecondsRemaining"/>
</t>
</t>
</templates>