Compare commits
25 Commits
cecc699a70
...
11108dfea3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11108dfea3 | ||
|
|
85cdecddea | ||
|
|
2aaa1a57e7 | ||
|
|
b5d5a9acba | ||
|
|
0d94af6532 | ||
|
|
95abd2e337 | ||
|
|
b1db851e29 | ||
|
|
f18c59fe89 | ||
|
|
2fb774e4fa | ||
|
|
60c25f8241 | ||
|
|
47a6523e24 | ||
|
|
4a9f31cef5 | ||
|
|
dd908c3861 | ||
|
|
5c1f60b3b8 | ||
|
|
55da42e91f | ||
|
|
ab3e6fa1e2 | ||
|
|
e2f7fa6d19 | ||
|
|
2c8ad83d43 | ||
|
|
3fd074ff6d | ||
|
|
e26a7cd9e8 | ||
|
|
09cea73e50 | ||
|
|
3235d4ceca | ||
|
|
5a488ae86e | ||
|
|
55898dd1d4 | ||
|
|
2a16f80d8d |
@@ -13,7 +13,7 @@
|
||||
4. **HTTP routes**: `type="jsonrpc"` — NOT `type="json"` (deprecated).
|
||||
5. **res.config.settings**: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields.
|
||||
**`config_parameter=` Boolean fields don't round-trip `False` as a string.** Odoo's `set_values()` calls `IrConfigParameter.set_param(key, value)`, and `set_param` deletes the row when `value` is falsy (False / None / empty). So writing `False` to a Boolean config field means the param no longer exists in `ir_config_parameter`; a subsequent `get_param(key)` returns the *default* (Python `False`), not `'False'`. Test like `self.assertFalse(ICP.get_param('...'))` — never `assertEqual(..., 'False')`. (Integer/Float/Char go through `repr(value)` / strip, so they DO persist as strings — `'90'`, `'0'`, etc.) Source: `odoo/addons/base/models/res_config.py::set_values` and `ir_config_parameter.py::set_param`.
|
||||
6. **res.groups**: NO `users` field, NO `category_id` field.
|
||||
6. **res.groups**: NO `users` field, NO `category_id` field. **The Odoo 19 replacement for `category_id` is `res.groups.privilege`.** To make a module's groups appear as application-access dropdowns on the user form (Settings → Users → *Application Accesses*) instead of only in developer mode: define an `ir.module.category`, a `res.groups.privilege` (with `category_id` → that category), and set each group's `privilege_id` → that privilege. Groups under one privilege that form an `implied_ids` chain render as a single role dropdown; a standalone group in its own privilege renders as a separate row under the same category header. Verified in `fusion_clock/security/security.xml`; mirrors `fusion_plating`/`fusion_tasks`.
|
||||
**res.users**: field was renamed `groups_id` → `group_ids` (also `all_group_ids` for implied). The plural form is gone; using `groups_id` raises `ValueError: Invalid field 'groups_id' in 'res.users'`.
|
||||
**`ir.ui.view`**: same rename — view-level visibility gating uses `group_ids`, not `groups_id`. A record like `<field name="groups_id" eval="[(4, ref('base.group_system'))]"/>` on an `ir.ui.view` raises `ValueError: Invalid field 'groups_id' in 'ir.ui.view'` at module install. (The XML *attribute* `groups="base.group_system"` on form elements like `<page>`, `<button>`, `<field>` is unrelated and still works.)
|
||||
**`ir.rule` `groups` field is additive, not restrictive.** A rule with `groups=[some_group]` applies ONLY to users in that group — it does NOT restrict non-members. So `domain_force=[(1,'=',1)]` + `groups=[base.group_system]` does NOT mean "only admins see rows"; it means "admins see all rows (and the rule is silent on everyone else)". Non-admins are gated by the ACL (`ir.model.access.csv`), not the rule. To truly restrict by group at the rule layer, pair a global rule (`groups=[]`, `domain_force=[(0,'=',1)]` = block-all baseline) with a group-scoped allow rule. Default to letting the ACL do the gating; use rules for row-level filters that ACLs cannot express.
|
||||
@@ -92,8 +92,8 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
|
||||
- Canadian English for all user-facing text
|
||||
- Currency: `$` sign with Monetary fields + currency_id
|
||||
|
||||
## Cursor-Managed Modules
|
||||
- **fusion_clock** is currently being modified in Cursor — always read files fresh before editing, don't assume you know the current state
|
||||
## Module-Specific Notes
|
||||
- **fusion_clock** — developed in **Claude Code** (no longer Cursor; no concurrent-editing conflicts). Changed a lot recently (NFC kiosk: tap-to-clock, enrollment + program-from-unknown-tap, manager page, sounds, screen lock, guided profile-photo capture, faster animations). Still read files fresh before editing rather than assuming the layout. Live on entech (`odoo-entech` / LXC 111 on `pve-worker5`).
|
||||
- **fusion_repairs** — read [`fusion_repairs/cloud.md`](fusion_repairs/cloud.md) before feature work. **Version `19.0.2.2.4`.** Bundles 1–11 shipped in repo (intake, portals, dashboard, pricing, flowcharts, parts/PO). **Not production-deployed** to Westin as of 2026-05-27. Local: `docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_repairs --stop-after-init`. Outstanding: RingCentral SMS, C2 history sidebar UI, office follow-up crons (config keys only), `tests/`, more flowchart content, sales-rep dashboard tile in `fusion_authorizer_portal`.
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
# Schedule-Driven Attendance Automation — Design
|
||||
|
||||
**Date:** 2026-05-30
|
||||
**Module:** `fusion_clock`
|
||||
**Status:** Approved design → ready for implementation plan
|
||||
|
||||
## Goal
|
||||
|
||||
Drive every attendance automation (clock-in/out reminders, absence detection,
|
||||
late/early penalties, auto-clock-out) from each employee's **real schedule** —
|
||||
the team lead's **posted** planner entry first, then the employee's **recurring
|
||||
shift** — never the global 9–5 default. Employees who aren't scheduled get no
|
||||
reminders or absence flags. Overtime past the scheduled end is normal and is
|
||||
never cut off.
|
||||
|
||||
## Problem & root cause
|
||||
|
||||
The machinery already exists: `fusion.clock.shift` (recurring templates,
|
||||
assigned via `hr.employee.x_fclk_shift_id`), `fusion.clock.schedule` (dated
|
||||
per-employee entries built in the backend **shift planner** client action), and
|
||||
`hr.employee._get_fclk_day_plan(date)` which resolves per-day times. The crons
|
||||
already call these.
|
||||
|
||||
The bug: in `_get_fclk_day_plan()`, when an employee has **no dated entry and no
|
||||
assigned shift**, it silently falls back to the **global 9–5 default with
|
||||
`is_off = False`**. So everyone is treated as a 9–5 worker, and the reminder /
|
||||
absence crons fire off that global time. The crons also **hardcode-skip Sat/Sun**
|
||||
(`weekday() >= 5`), which is wrong for a production floor that runs weekends.
|
||||
Net effect: reminders are not actually schedule-driven for anyone who isn't on a
|
||||
fixed weekday 9–5 — exactly the spurious-email problem reported.
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
1. **"Expected to work" source:** posted planner entry → else recurring shift
|
||||
(if it covers that weekday) → else **not scheduled** (silent). The global
|
||||
default never makes someone "expected."
|
||||
2. **Overtime:** time past the scheduled end is overtime and is never cut off.
|
||||
Auto-clock-out fires **only** at a generous safety cap (forgot-to-clock-out).
|
||||
3. **Posting:** draft → post gate. Team leads build the week in draft;
|
||||
automation ignores draft days. "Post" publishes the week and emails each
|
||||
employee their shifts. Only posted entries drive automation.
|
||||
4. **Employee schedule view:** reuse the **existing "Today's Shift" card** on
|
||||
`/my/clock` — no new portal view. (See Coordination.)
|
||||
|
||||
## Non-goals / constraints
|
||||
|
||||
- **No edits to the employee `/my` portal shell.** A concurrent session
|
||||
("Internal employee portal design", `fusion_plating`) owns `/my` + `/my/home`
|
||||
routing and the `/my/clock` bottom-nav tabs (it is adding a Payslips tab).
|
||||
This feature makes **zero** edits to `controllers/portal_clock.py` routing,
|
||||
`views/portal_clock_templates.xml`, or `/my` routing. The existing "Today's
|
||||
Shift" card already renders `today_schedule.get('label') or 'Not scheduled'`,
|
||||
so once the resolver is schedule-driven the card updates itself. Employees get
|
||||
their full posted week via the Post notification email. A dedicated "My
|
||||
Schedule" nav tab, if ever wanted, belongs to the portal-shell session.
|
||||
- The backend **shift planner** client action (manager/team-lead facing) is
|
||||
*not* the `/my` portal and **is** in scope to edit (Post button, draft/posted
|
||||
visuals).
|
||||
- No change to how attendance hours / overtime are computed.
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Schedule resolver — `hr.employee._get_fclk_day_plan(date)`
|
||||
|
||||
Rewrite to return an explicit `scheduled` flag and a precise `source`, keeping
|
||||
all existing keys for backward compatibility (`is_off`, `label`, `hours`,
|
||||
`start_time`, `end_time`, `break_minutes`).
|
||||
|
||||
Return shape:
|
||||
```python
|
||||
{
|
||||
'scheduled': bool, # is the employee expected to work this day?
|
||||
'source': 'schedule' | 'shift' | 'none',
|
||||
'is_off': bool,
|
||||
'start_time': float, 'end_time': float, 'break_minutes': float,
|
||||
'hours': float,
|
||||
'label': str, # '' when not scheduled → card shows 'Not scheduled'
|
||||
'schedule_id': int | False,
|
||||
}
|
||||
```
|
||||
|
||||
Resolution order:
|
||||
1. **Posted planner entry** (`fusion.clock.schedule`, `state == 'posted'`) for
|
||||
(employee, date) — *draft entries are ignored, treated as absent*:
|
||||
- `is_off` → `scheduled=False`, `is_off=True`, `source='schedule'`, `hours=0`,
|
||||
`label='OFF'`.
|
||||
- else → `scheduled=True`, times from entry, `source='schedule'`.
|
||||
2. Else **recurring shift** `x_fclk_shift_id` **and** the shift covers
|
||||
`date`'s weekday → `scheduled=True`, times from shift, `source='shift'`.
|
||||
3. Else → `scheduled=False`, `source='none'`, `is_off=False`, `label=''`,
|
||||
`hours=0`. (Global default may fill `start_time`/`end_time` as a display
|
||||
hint only; it never sets `scheduled=True`.)
|
||||
|
||||
`_get_fclk_scheduled_times()` and `_get_fclk_break_minutes()` keep working off
|
||||
this structure unchanged.
|
||||
|
||||
### 2. Data model changes
|
||||
|
||||
- **`fusion.clock.schedule`**: add
|
||||
- `state = Selection([('draft','Draft'),('posted','Posted')], default='draft')`
|
||||
- `posted_date = Datetime`
|
||||
- Automation reads only `state == 'posted'`.
|
||||
- **`fusion.clock.shift`**: add a weekday pattern —
|
||||
`day_mon … day_sun = Boolean` (default Mon–Fri True, Sat–Sun False) plus a
|
||||
helper `covers_weekday(date) -> bool`. This replaces the hardcoded weekend
|
||||
skip and lets weekend shifts exist. (Judgment call: pattern lives on the
|
||||
shared shift template, e.g. "Mon–Fri Day", "Sat–Sun Weekend"; unique patterns
|
||||
→ own template or a posted planner override.)
|
||||
|
||||
### 3. Posting workflow
|
||||
|
||||
- New jsonrpc route `POST /fusion_clock/shift_planner/post_week` in
|
||||
`controllers/shift_planner.py`:
|
||||
- Gate: manager OR team lead.
|
||||
- Scope: managers → all in-scope employees for the viewed week; team leads →
|
||||
their direct reports (`parent_id` == the team lead's employee). Reuse the
|
||||
existing dashboard scoping helper.
|
||||
- Set `state='posted'`, `posted_date=now` on those week entries.
|
||||
- Queue **one email per affected employee** summarizing their posted shifts
|
||||
for the week (reuse `_fclk_email_wrap`). Failures logged, never block the
|
||||
post.
|
||||
- New planner entries default to `draft`. Re-posting after edits re-publishes
|
||||
(and re-notifies, flagged as an update).
|
||||
- Planner client action (`static/src/js/fusion_clock_shift_planner.js` + its
|
||||
template) gains a **Post** button and a draft-vs-posted visual cue. (Backend
|
||||
client action — not the `/my` portal.)
|
||||
|
||||
### 4. Reminder cron — `hr.attendance._cron_fusion_employee_reminders`
|
||||
|
||||
- Remove the `weekday() >= 5` hardcode.
|
||||
- Per enabled employee: `plan = emp._get_fclk_day_plan(today)`; **if not
|
||||
`plan['scheduled']` → skip** (silent).
|
||||
- Missed clock-in: if scheduled, not checked in, no attendance today, and
|
||||
`now > scheduled_in + reminder_before_shift_minutes` → remind. Uses the
|
||||
employee's real start, so a 14:00 shift is never pinged at 09:30.
|
||||
- Clock-out reminder: **reframed** (judgment call). Drop the "your shift ends at
|
||||
X" nudge (noise when OT is the norm). Instead, if still checked in and
|
||||
approaching the safety cap (`check_in + max_shift_hours -
|
||||
reminder_before_end_minutes`), send "you're still clocked in — remember to
|
||||
clock out."
|
||||
|
||||
### 5. Absence cron — `hr.attendance._cron_fusion_check_absences`
|
||||
|
||||
- Remove the `weekday() >= 5` hardcode.
|
||||
- Per enabled employee: `plan = emp._get_fclk_day_plan(yesterday)`; **only flag
|
||||
absent if `plan['scheduled']`** AND no attendance AND no leave request AND no
|
||||
global holiday. Off/unscheduled → never flagged.
|
||||
|
||||
### 6. Auto-clock-out — `hr.attendance._cron_fusion_auto_clock_out`
|
||||
|
||||
- Stop closing at `scheduled_out + grace`. Close **only** at the safety cap
|
||||
`check_in + max_shift_hours`. Everything between the scheduled end and the cap
|
||||
is captured as overtime by the existing fields.
|
||||
- Bump default `max_shift_hours` **12 → 16** (still configurable).
|
||||
- Keep `x_fclk_pending_reason=True`, break deduction, and office notify on
|
||||
auto-close.
|
||||
|
||||
### 7. Penalties — `controllers/clock_api.py::_check_and_create_penalty`
|
||||
|
||||
- Skip when the day is not scheduled (`not plan['scheduled']`), in addition to
|
||||
the existing posted-OFF skip. Late-in / early-out stay keyed off the resolved
|
||||
scheduled start/end. Overtime is never penalized.
|
||||
|
||||
### 8. Kiosk callers — `clock_kiosk.py`, `clock_nfc_kiosk.py`
|
||||
|
||||
- The existing `is_scheduled_off = source == 'schedule' and is_off` checks keep
|
||||
working for posted-OFF days. Extend the "unscheduled shift" log + penalty-skip
|
||||
to also cover `source == 'none'` (clocked in on a day with no schedule) so a
|
||||
not-scheduled clock-in is logged as `unscheduled_shift` and creates no penalty.
|
||||
|
||||
### 9. Settings
|
||||
|
||||
- `res_config_settings`: change `fclk_max_shift_hours` default 12 → 16 (and the
|
||||
resolver/cron `get_param` fallback). Optionally surface the shift weekday
|
||||
pattern on the shift form. No other new settings required.
|
||||
|
||||
### 10. Frontend
|
||||
|
||||
- **No file edits.** The existing "Today's Shift" card auto-reflects the new
|
||||
resolver: scheduled → times + hours; posted OFF → "OFF"; not scheduled →
|
||||
"Not scheduled" (already coded as `label or 'Not scheduled'`).
|
||||
|
||||
## Data flow
|
||||
|
||||
posted planner entry / recurring shift → `_get_fclk_day_plan(date)` →
|
||||
`scheduled` flag → consumed by: reminder cron, absence cron, penalty helper,
|
||||
kiosk unscheduled-log, and (read-only) the portal "Today's Shift" card. Posting
|
||||
flips `state` to `posted` (making entries visible to the resolver) and emails
|
||||
employees.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Crons: wrap each employee's body in `with self.env.cr.savepoint():` so one bad
|
||||
record can't abort the batch (savepoints, not `cr.commit()` — works in prod and
|
||||
tests).
|
||||
- Posting: state writes + email queueing in one transaction; email creation in
|
||||
try/except with logging so a bad address never blocks the post.
|
||||
- Notifications: `mail.mail` with `auto_delete=True`; send failures logged.
|
||||
|
||||
## Testing (`tests/test_schedule_driven.py`, post_install)
|
||||
|
||||
- **Resolver matrix:** posted-working / posted-off / draft-ignored /
|
||||
recurring-covers-weekday / recurring-skips-weekday / nothing → not-scheduled.
|
||||
Assert `scheduled`, times, and `label`.
|
||||
- **Reminder cron:** scheduled + late + no attendance → reminder; not scheduled →
|
||||
none; 14:00 shift not pinged at 09:30; already clocked in → no clock-in
|
||||
reminder.
|
||||
- **Absence cron:** scheduled no-show → absent logged; not scheduled → not
|
||||
flagged; leave/holiday → not flagged.
|
||||
- **Auto-clock-out:** open past scheduled end but under cap → stays open; past
|
||||
cap → closed + `x_fclk_pending_reason`.
|
||||
- **Posting:** draft entry → resolver `scheduled=False` (ignored by crons); post
|
||||
→ `state='posted'`, resolver picks it up, email queued; team lead can post only
|
||||
direct reports.
|
||||
- **Penalties:** not-scheduled clock-in → no penalty; scheduled late → `late_in`.
|
||||
|
||||
## Files expected to change (for the plan)
|
||||
|
||||
- `models/hr_employee.py` — resolver refactor.
|
||||
- `models/clock_shift.py` — weekday booleans + `covers_weekday`.
|
||||
- `models/clock_schedule.py` — `state` + `posted_date`.
|
||||
- `models/hr_attendance.py` — reminders, absences, auto-clock-out + savepoints.
|
||||
- `controllers/clock_api.py` — penalty skip when not scheduled.
|
||||
- `controllers/clock_kiosk.py`, `controllers/clock_nfc_kiosk.py` — unscheduled
|
||||
log/penalty for `source == 'none'`.
|
||||
- `controllers/shift_planner.py` — `post_week` route + scope + notifications;
|
||||
default new entries to draft.
|
||||
- `static/src/js/fusion_clock_shift_planner.js` + planner template — Post button,
|
||||
draft/posted visuals.
|
||||
- `models/res_config_settings.py` + `views/res_config_settings_views.xml` —
|
||||
`max_shift_hours` default 16; optional weekday-pattern surfacing.
|
||||
- `views/clock_shift_views.xml` — weekday checkboxes on the shift form.
|
||||
- `views/clock_schedule_views.xml` — show `state`.
|
||||
- `tests/test_schedule_driven.py` (+ `tests/__init__.py`).
|
||||
- **Not touched:** `controllers/portal_clock.py` routing,
|
||||
`views/portal_clock_templates.xml`, `/my` routing (owned by the concurrent
|
||||
portal-shell session).
|
||||
|
||||
## Coordination
|
||||
|
||||
Concurrent session "Internal employee portal design" (`fusion_plating`) owns the
|
||||
employee `/my` portal shell: `/my` + `/my/home` redirect to the clock page and
|
||||
new bottom-nav tabs (Payslips). This feature is **backend-only on the frontend
|
||||
side** — it edits no `/my` portal files — so the two land without conflict
|
||||
regardless of order. Shared touchpoint to watch: both evolve the employee
|
||||
experience; if a "My Schedule" nav tab is desired, it is the portal-shell
|
||||
session's responsibility, fed by this feature's resolver.
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Clock',
|
||||
'version': '19.0.3.5.6',
|
||||
'version': '19.0.3.12.1',
|
||||
'category': 'Human Resources/Attendances',
|
||||
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
||||
'description': """
|
||||
@@ -78,6 +78,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
|
||||
'views/portal_clock_templates.xml',
|
||||
'views/portal_timesheet_templates.xml',
|
||||
'views/portal_report_templates.xml',
|
||||
'views/portal_payslip_templates.xml',
|
||||
'views/kiosk_templates.xml',
|
||||
'views/kiosk_nfc_templates.xml',
|
||||
],
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -110,7 +110,8 @@ class FusionClockAPI(http.Controller):
|
||||
if ICP.get_param('fusion_clock.enable_penalties', 'True') != 'True':
|
||||
return
|
||||
day_plan = employee._get_fclk_day_plan(get_local_today(request.env, employee))
|
||||
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
|
||||
if not day_plan.get('scheduled'):
|
||||
# No late/early penalties on days the employee isn't scheduled to work.
|
||||
return
|
||||
|
||||
grace = float(ICP.get_param('fusion_clock.penalty_grace_minutes', '5'))
|
||||
@@ -282,7 +283,8 @@ class FusionClockAPI(http.Controller):
|
||||
now = fields.Datetime.now()
|
||||
today = get_local_today(request.env, employee)
|
||||
day_plan = employee._get_fclk_day_plan(today)
|
||||
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
|
||||
# "Unscheduled" = a posted OFF day OR a day with no schedule at all.
|
||||
is_scheduled_off = not day_plan.get('scheduled')
|
||||
|
||||
geo_info = {
|
||||
'latitude': latitude,
|
||||
@@ -325,7 +327,7 @@ class FusionClockAPI(http.Controller):
|
||||
if is_scheduled_off:
|
||||
self._log_activity(
|
||||
employee, 'unscheduled_shift',
|
||||
f"Clocked in on a scheduled OFF day at {location.name}.",
|
||||
f"Clocked in on an unscheduled day at {location.name}.",
|
||||
attendance=attendance, location=location,
|
||||
latitude=latitude, longitude=longitude, distance=distance,
|
||||
source=source,
|
||||
@@ -335,7 +337,7 @@ class FusionClockAPI(http.Controller):
|
||||
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.",
|
||||
f"{employee.name} clocked in on an unscheduled day.",
|
||||
'hr.attendance',
|
||||
attendance.id,
|
||||
)
|
||||
|
||||
@@ -10,6 +10,12 @@ from odoo.addons.fusion_clock.models.tz_utils import get_local_today
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_kiosk_operator(user):
|
||||
"""Kiosk surfaces accept a full Clock Manager OR a dedicated Kiosk Operator."""
|
||||
return (user.has_group('fusion_clock.group_fusion_clock_manager')
|
||||
or user.has_group('fusion_clock.group_fusion_clock_kiosk_app'))
|
||||
|
||||
|
||||
class FusionClockKiosk(http.Controller):
|
||||
"""Kiosk mode controller for shared-device clock-in/out."""
|
||||
|
||||
@@ -17,7 +23,7 @@ class FusionClockKiosk(http.Controller):
|
||||
def kiosk_page(self, **kw):
|
||||
"""Kiosk clock-in/out page for shared tablets."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
if not _is_kiosk_operator(user):
|
||||
return request.redirect('/my')
|
||||
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
@@ -34,7 +40,7 @@ class FusionClockKiosk(http.Controller):
|
||||
def kiosk_search(self, query='', **kw):
|
||||
"""Search employees for kiosk identification."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
if not _is_kiosk_operator(user):
|
||||
return {'error': 'Access denied.'}
|
||||
|
||||
employees = request.env['hr.employee'].sudo().search([
|
||||
@@ -48,6 +54,7 @@ class FusionClockKiosk(http.Controller):
|
||||
'name': emp.name,
|
||||
'department': emp.department_id.name or '',
|
||||
'is_checked_in': emp.attendance_state == 'checked_in',
|
||||
'card_uid': emp.x_fclk_nfc_card_uid or '',
|
||||
} for emp in employees],
|
||||
}
|
||||
|
||||
@@ -55,7 +62,7 @@ class FusionClockKiosk(http.Controller):
|
||||
def kiosk_verify_pin(self, employee_id=0, pin='', **kw):
|
||||
"""Verify employee PIN for kiosk mode."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
if not _is_kiosk_operator(user):
|
||||
return {'error': 'Access denied.'}
|
||||
|
||||
employee = request.env['hr.employee'].sudo().browse(employee_id)
|
||||
@@ -75,7 +82,7 @@ class FusionClockKiosk(http.Controller):
|
||||
def kiosk_clock(self, employee_id=0, latitude=0, longitude=0, **kw):
|
||||
"""Perform clock action from kiosk on behalf of an employee."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
if not _is_kiosk_operator(user):
|
||||
return {'error': 'Access denied.'}
|
||||
|
||||
employee = request.env['hr.employee'].sudo().browse(employee_id)
|
||||
@@ -96,7 +103,7 @@ class FusionClockKiosk(http.Controller):
|
||||
now = fields.Datetime.now()
|
||||
today = get_local_today(request.env, employee)
|
||||
day_plan = employee._get_fclk_day_plan(today)
|
||||
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
|
||||
is_scheduled_off = not day_plan.get('scheduled')
|
||||
|
||||
geo_info = {
|
||||
'latitude': latitude,
|
||||
@@ -126,7 +133,7 @@ class FusionClockKiosk(http.Controller):
|
||||
if is_scheduled_off:
|
||||
api._log_activity(
|
||||
employee, 'unscheduled_shift',
|
||||
f"Kiosk clock-in on a scheduled OFF day at {location.name}",
|
||||
f"Kiosk clock-in on an unscheduled day at {location.name}",
|
||||
attendance=attendance, location=location,
|
||||
latitude=latitude, longitude=longitude, distance=distance,
|
||||
source='kiosk',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
@@ -44,6 +45,12 @@ def _strip_data_url_prefix(b64):
|
||||
return b64.encode('ascii', errors='ignore') if isinstance(b64, str) else b64
|
||||
|
||||
|
||||
def _is_kiosk_operator(user):
|
||||
"""Kiosk surfaces accept a full Clock Manager OR a dedicated Kiosk Operator."""
|
||||
return (user.has_group('fusion_clock.group_fusion_clock_manager')
|
||||
or user.has_group('fusion_clock.group_fusion_clock_kiosk_app'))
|
||||
|
||||
|
||||
class FusionClockNfcKiosk(http.Controller):
|
||||
"""NFC tap-to-clock kiosk controller. Reuses FusionClockAPI helpers."""
|
||||
|
||||
@@ -66,14 +73,14 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
def nfc_kiosk_page(self, **kw):
|
||||
"""Render the NFC kiosk page for a wall-mounted tablet."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
if not _is_kiosk_operator(user):
|
||||
return request.redirect('/my')
|
||||
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.enable_nfc_kiosk', 'False') != 'True':
|
||||
return request.redirect('/my')
|
||||
|
||||
company = request.env.company
|
||||
company = request.env.company.sudo()
|
||||
location = company.x_fclk_nfc_kiosk_location_id
|
||||
company_logo_url = (
|
||||
'/web/image/res.company/%s/logo' % company.id if company.logo else ''
|
||||
@@ -86,9 +93,46 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
'location_configured': bool(location),
|
||||
'photo_required': ICP.get_param('fusion_clock.nfc_photo_required', 'True') == 'True',
|
||||
'debug_enabled': ICP.get_param('fusion_clock.nfc_kiosk_debug', 'False') == 'True',
|
||||
'sounds_enabled': ICP.get_param('fusion_clock.enable_sounds', 'True') == 'True',
|
||||
}
|
||||
return request.render('fusion_clock.nfc_kiosk_page', values)
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/manifest.webmanifest', type='http', auth='public')
|
||||
def nfc_kiosk_manifest(self, **kw):
|
||||
"""Web App Manifest so the NFC kiosk installs as a full-screen home-screen app.
|
||||
|
||||
On a wall tablet, 'Install' (Chrome) / 'Add to Home Screen' (Safari) then
|
||||
launches the kiosk standalone -- no address bar or browser tabs, like Odoo's
|
||||
own PWA. Public so the icon/splash can load without a session.
|
||||
"""
|
||||
company = request.env.company.sudo()
|
||||
# Square icons via Odoo's on-the-fly resizer (placeholder if the company has no logo).
|
||||
icon_192 = '/web/image/res.company/%s/logo/192x192' % company.id
|
||||
icon_512 = '/web/image/res.company/%s/logo/512x512' % company.id
|
||||
manifest = {
|
||||
'name': 'Fusion Clock Kiosk',
|
||||
'short_name': 'Clock Kiosk',
|
||||
'description': 'Tap-to-clock NFC kiosk',
|
||||
'start_url': '/fusion_clock/kiosk/nfc',
|
||||
'scope': '/',
|
||||
'display': 'fullscreen',
|
||||
'display_override': ['fullscreen', 'standalone'],
|
||||
'background_color': '#0e1116',
|
||||
'theme_color': '#0e1116',
|
||||
'orientation': 'any',
|
||||
'icons': [
|
||||
{'src': icon_192, 'sizes': '192x192', 'type': 'image/png'},
|
||||
{'src': icon_512, 'sizes': '512x512', 'type': 'image/png'},
|
||||
],
|
||||
}
|
||||
return request.make_response(
|
||||
json.dumps(manifest),
|
||||
headers=[
|
||||
('Content-Type', 'application/manifest+json; charset=utf-8'),
|
||||
('Cache-Control', 'public, max-age=3600'),
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _check_enroll_password(env, supplied):
|
||||
"""Verify the enroll-mode password. Empty config = always-allow for managers."""
|
||||
@@ -98,10 +142,11 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
return (supplied or '') == configured
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/enroll', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def nfc_enroll(self, employee_id=0, card_uid='', enroll_password='', **kw):
|
||||
"""Bind an NFC card UID to an employee. Manager-gated, password-gated."""
|
||||
def nfc_enroll(self, employee_id=0, card_uid='', enroll_password='', force=False, **kw):
|
||||
"""Bind an NFC card UID to an employee. Manager-gated, password-gated.
|
||||
With force=True, a card already held by another employee is moved (reassigned)."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
if not _is_kiosk_operator(user):
|
||||
return {'error': 'access_denied'}
|
||||
|
||||
if not self._check_enroll_password(request.env, enroll_password):
|
||||
@@ -121,10 +166,12 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
('id', '!=', target.id),
|
||||
], limit=1)
|
||||
if existing:
|
||||
return {
|
||||
'error': 'card_already_assigned',
|
||||
'existing_employee': existing.name,
|
||||
}
|
||||
if not force:
|
||||
return {
|
||||
'error': 'card_already_assigned',
|
||||
'existing_employee': existing.name,
|
||||
}
|
||||
existing.x_fclk_nfc_card_uid = False # reassign: clear the previous holder
|
||||
|
||||
target.x_fclk_nfc_card_uid = normalized
|
||||
|
||||
@@ -138,15 +185,97 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'employee_id': target.id,
|
||||
'employee_name': target.name,
|
||||
'card_uid': normalized,
|
||||
'needs_photo': not target.image_1920,
|
||||
}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/create_employee', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def nfc_create_employee(self, name='', enroll_password='', **kw):
|
||||
"""Create a minimal hr.employee from the kiosk; the caller then enrolls the card.
|
||||
|
||||
Manager/Kiosk-Operator gated + enroll-password gated. Creates the employee via
|
||||
sudo with just a name, clock enabled, and the current company — HR fills in the
|
||||
rest (department, contract, etc.) later.
|
||||
"""
|
||||
user = request.env.user
|
||||
if not _is_kiosk_operator(user):
|
||||
return {'error': 'access_denied'}
|
||||
if not self._check_enroll_password(request.env, enroll_password):
|
||||
return {'error': 'invalid_password'}
|
||||
clean = (name or '').strip()
|
||||
if len(clean) < 2:
|
||||
return {'error': 'invalid_name'}
|
||||
employee = request.env['hr.employee'].sudo().create({
|
||||
'name': clean,
|
||||
'x_fclk_enable_clock': True,
|
||||
'company_id': request.env.company.id,
|
||||
})
|
||||
return {'employee_id': employee.id, 'employee_name': employee.name}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/clear_tag', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def nfc_clear_tag(self, employee_id=0, enroll_password='', **kw):
|
||||
"""Unbind the NFC card from an employee. Manager/operator + password gated."""
|
||||
if not _is_kiosk_operator(request.env.user):
|
||||
return {'error': 'access_denied'}
|
||||
if not self._check_enroll_password(request.env, enroll_password):
|
||||
return {'error': 'invalid_password'}
|
||||
emp = request.env['hr.employee'].sudo().browse(int(employee_id or 0))
|
||||
if not emp.exists():
|
||||
return {'error': 'employee_not_found'}
|
||||
emp.x_fclk_nfc_card_uid = False
|
||||
return {'success': True, 'employee_name': emp.name}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/delete_employee', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def nfc_delete_employee(self, employee_id=0, enroll_password='', **kw):
|
||||
"""Archive an employee (active=False) and clear their tag — a safe 'delete' that
|
||||
preserves attendance history. Manager/operator + password gated."""
|
||||
if not _is_kiosk_operator(request.env.user):
|
||||
return {'error': 'access_denied'}
|
||||
if not self._check_enroll_password(request.env, enroll_password):
|
||||
return {'error': 'invalid_password'}
|
||||
emp = request.env['hr.employee'].sudo().browse(int(employee_id or 0))
|
||||
if not emp.exists():
|
||||
return {'error': 'employee_not_found'}
|
||||
name = emp.name
|
||||
emp.x_fclk_nfc_card_uid = False
|
||||
emp.active = False
|
||||
return {'success': True, 'employee_name': name}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/save_profile_photo', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def nfc_save_profile_photo(self, employee_id=0, photo_b64='', **kw):
|
||||
"""Save a captured photo to the employee's profile image. Operator-gated (the
|
||||
trusted kiosk device); no separate PIN, so it also works on self clock-in."""
|
||||
if not _is_kiosk_operator(request.env.user):
|
||||
return {'error': 'access_denied'}
|
||||
photo = _strip_data_url_prefix(photo_b64)
|
||||
if not photo:
|
||||
return {'error': 'no_photo'}
|
||||
emp = request.env['hr.employee'].sudo().browse(int(employee_id or 0))
|
||||
if not emp.exists():
|
||||
return {'error': 'employee_not_found'}
|
||||
emp.image_1920 = photo
|
||||
# Also push to the linked user's partner image, which is the image Odoo
|
||||
# shows on the user's profile/preferences avatar (res.users delegates
|
||||
# image_1920 to res.partner). Employees with no user are HR-only photos.
|
||||
if emp.user_id and emp.user_id.partner_id:
|
||||
emp.user_id.partner_id.sudo().write({'image_1920': photo})
|
||||
return {'success': True}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/verify_pin', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def nfc_verify_pin(self, pin='', **kw):
|
||||
"""Verify the Manager PIN (enroll password) — used to unlock the kiosk screen.
|
||||
Returns only a boolean so the PIN itself never reaches the client."""
|
||||
if not _is_kiosk_operator(request.env.user):
|
||||
return {'ok': False}
|
||||
return {'ok': self._check_enroll_password(request.env, pin)}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/tap', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def nfc_tap(self, card_uid='', photo_b64='', **kw):
|
||||
"""Toggle attendance state for the employee owning this card UID."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
if not _is_kiosk_operator(user):
|
||||
return {'error': 'access_denied'}
|
||||
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
@@ -165,7 +294,7 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
return {'error': 'photo_required', 'message': 'Camera unavailable. Ask IT to check the kiosk.'}
|
||||
photo_bytes = _strip_data_url_prefix(photo_b64) if photo_b64 else b''
|
||||
|
||||
company = request.env.company
|
||||
company = request.env.company.sudo()
|
||||
location = company.x_fclk_nfc_kiosk_location_id
|
||||
if not location:
|
||||
return {'error': 'no_location_configured'}
|
||||
@@ -183,10 +312,19 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
api = FusionClockAPI()
|
||||
|
||||
is_checked_in = employee.attendance_state == 'checked_in'
|
||||
# Cache-buster: /web/image is browser-cached, so without a unique token a
|
||||
# freshly-saved profile photo never shows. write_date bumps on every
|
||||
# write (incl. saving image_1920), so it refreshes exactly when needed.
|
||||
avatar_unique = employee.write_date.strftime('%Y%m%d%H%M%S') if employee.write_date else ''
|
||||
# PUBLIC model: the kiosk runs as a non-HR operator who can't read
|
||||
# hr.employee images (ACL) — /web/image would serve a placeholder.
|
||||
# hr.employee.public exposes the same avatar to any internal user
|
||||
# (verified readable as the kiosk operator, uid 141).
|
||||
avatar_url = f'/web/image/hr.employee.public/{employee.id}/avatar_128?unique={avatar_unique}'
|
||||
now = fields.Datetime.now()
|
||||
today = get_local_today(request.env, employee)
|
||||
day_plan = employee._get_fclk_day_plan(today)
|
||||
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
|
||||
is_scheduled_off = not day_plan.get('scheduled')
|
||||
|
||||
geo_info = {
|
||||
'latitude': 0,
|
||||
@@ -214,7 +352,7 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
if is_scheduled_off:
|
||||
api._log_activity(
|
||||
employee, 'unscheduled_shift',
|
||||
f"NFC kiosk clock-in on a scheduled OFF day at {location.name}",
|
||||
f"NFC kiosk clock-in on an unscheduled day at {location.name}",
|
||||
attendance=attendance, location=location,
|
||||
latitude=0, longitude=0, distance=0,
|
||||
source='nfc_kiosk',
|
||||
@@ -225,10 +363,12 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
return {
|
||||
'success': True,
|
||||
'action': 'clock_in',
|
||||
'employee_id': employee.id,
|
||||
'employee_name': employee.name,
|
||||
'employee_avatar_url': f'/web/image/hr.employee/{employee.id}/avatar_128',
|
||||
'employee_avatar_url': avatar_url,
|
||||
'message': f'{employee.name} clocked in at {location.name}',
|
||||
'net_hours_today': 0.0,
|
||||
'worked_hours': 0.0,
|
||||
'needs_photo': not employee.image_1920,
|
||||
}
|
||||
else:
|
||||
attendance.sudo().write({
|
||||
@@ -249,10 +389,15 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
return {
|
||||
'success': True,
|
||||
'action': 'clock_out',
|
||||
'employee_id': employee.id,
|
||||
'employee_name': employee.name,
|
||||
'employee_avatar_url': f'/web/image/hr.employee/{employee.id}/avatar_128',
|
||||
'employee_avatar_url': avatar_url,
|
||||
'message': f'{employee.name} clocked out',
|
||||
'net_hours_today': round(attendance.x_fclk_net_hours or 0, 2),
|
||||
# GROSS time between clock-in and clock-out (what the employee
|
||||
# expects to see). x_fclk_net_hours subtracts break + early-out
|
||||
# penalty minutes, which zeroed short shifts — that's for payroll.
|
||||
'worked_hours': attendance.worked_hours or 0.0,
|
||||
'needs_photo': not employee.image_1920,
|
||||
}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/employee_search', type='jsonrpc', auth='user', methods=['POST'])
|
||||
|
||||
@@ -65,6 +65,20 @@ class FusionClockPortal(CustomerPortal):
|
||||
], limit=1)
|
||||
return employee
|
||||
|
||||
def _payroll_available(self):
|
||||
"""True when fusion_payroll (hr.payslip) is installed on this DB."""
|
||||
return 'hr.payslip' in request.env
|
||||
|
||||
def _get_my_payslips(self, employee):
|
||||
"""Finalized payslips for this employee, newest first.
|
||||
|
||||
Caller must ensure payroll is installed (see _payroll_available).
|
||||
"""
|
||||
return request.env['hr.payslip'].sudo().search(
|
||||
[('employee_id', '=', employee.id), ('state', 'in', ('done', 'paid'))],
|
||||
order='date_to desc, id desc',
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Clock Page
|
||||
# =========================================================================
|
||||
@@ -157,6 +171,7 @@ class FusionClockPortal(CustomerPortal):
|
||||
'google_maps_key': google_maps_key,
|
||||
'enable_sounds': enable_sounds,
|
||||
'locations_json': locations_json,
|
||||
'show_payslips': self._payroll_available(),
|
||||
'page_name': 'clock',
|
||||
}
|
||||
return request.render('fusion_clock.portal_clock_page', values)
|
||||
@@ -234,6 +249,7 @@ class FusionClockPortal(CustomerPortal):
|
||||
'total_hours': round(total_hours, 1),
|
||||
'net_hours': round(net_hours, 1),
|
||||
'total_breaks': round(total_breaks, 0),
|
||||
'show_payslips': self._payroll_available(),
|
||||
'page_name': 'timesheets',
|
||||
}
|
||||
return request.render('fusion_clock.portal_timesheet_page', values)
|
||||
@@ -257,6 +273,7 @@ class FusionClockPortal(CustomerPortal):
|
||||
values = {
|
||||
'employee': employee,
|
||||
'reports': reports,
|
||||
'show_payslips': self._payroll_available(),
|
||||
'page_name': 'clock_reports',
|
||||
}
|
||||
return request.render('fusion_clock.portal_report_page', values)
|
||||
@@ -285,3 +302,64 @@ class FusionClockPortal(CustomerPortal):
|
||||
('Content-Disposition', f'attachment; filename="{filename}"'),
|
||||
],
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Payslips
|
||||
# =========================================================================
|
||||
|
||||
@http.route('/my/clock/payslips', type='http', auth='user', website=True)
|
||||
def portal_payslips(self, **kw):
|
||||
"""List the employee's finalized pay slips."""
|
||||
employee = self._get_portal_employee()
|
||||
if not employee or not self._payroll_available():
|
||||
return request.redirect('/my/clock')
|
||||
values = {
|
||||
'employee': employee,
|
||||
'payslips': self._get_my_payslips(employee),
|
||||
'show_payslips': True,
|
||||
'page_name': 'payslips',
|
||||
}
|
||||
return request.render('fusion_clock.portal_payslip_list_page', values)
|
||||
|
||||
@http.route('/my/clock/payslips/<int:payslip_id>', type='http', auth='user', website=True)
|
||||
def portal_payslip_detail(self, payslip_id, **kw):
|
||||
"""Inline paystub for one finalized slip the employee owns."""
|
||||
employee = self._get_portal_employee()
|
||||
if not employee or not self._payroll_available():
|
||||
return request.redirect('/my/clock')
|
||||
payslip = request.env['hr.payslip'].sudo().browse(payslip_id)
|
||||
if not payslip.exists() or payslip.employee_id.id != employee.id \
|
||||
or payslip.state not in ('done', 'paid'):
|
||||
return request.redirect('/my/clock/payslips')
|
||||
pdf_report = request.env['ir.actions.report'].sudo().search(
|
||||
[('model', '=', 'hr.payslip'), ('report_type', '=', 'qweb-pdf')], limit=1)
|
||||
values = {
|
||||
'employee': employee,
|
||||
'payslip': payslip,
|
||||
'has_pdf': bool(pdf_report),
|
||||
'show_payslips': True,
|
||||
'page_name': 'payslips',
|
||||
}
|
||||
return request.render('fusion_clock.portal_payslip_detail_page', values)
|
||||
|
||||
@http.route('/my/clock/payslips/<int:payslip_id>/pdf', type='http', auth='user', website=True)
|
||||
def portal_payslip_pdf(self, payslip_id, **kw):
|
||||
"""Render the standard payslip PDF (sudo) for a slip the employee owns."""
|
||||
employee = self._get_portal_employee()
|
||||
if not employee or not self._payroll_available():
|
||||
return request.redirect('/my/clock')
|
||||
payslip = request.env['hr.payslip'].sudo().browse(payslip_id)
|
||||
if not payslip.exists() or payslip.employee_id.id != employee.id \
|
||||
or payslip.state not in ('done', 'paid'):
|
||||
return request.redirect('/my/clock/payslips')
|
||||
report = request.env['ir.actions.report'].sudo().search(
|
||||
[('model', '=', 'hr.payslip'), ('report_type', '=', 'qweb-pdf')], limit=1)
|
||||
if not report:
|
||||
return request.redirect('/my/clock/payslips/%s' % payslip_id)
|
||||
pdf_content, _ctype = report._render_qweb_pdf(report.id, [payslip.id])
|
||||
slip_ref = payslip.number if 'number' in payslip._fields else False
|
||||
filename = 'Payslip-%s.pdf' % (slip_ref or payslip.id)
|
||||
return request.make_response(pdf_content, headers=[
|
||||
('Content-Type', 'application/pdf'),
|
||||
('Content-Disposition', 'attachment; filename="%s"' % filename),
|
||||
])
|
||||
|
||||
@@ -155,6 +155,41 @@ class FusionClockShiftPlanner(http.Controller):
|
||||
'data': self._load_week_data(week_start),
|
||||
}
|
||||
|
||||
@http.route('/fusion_clock/shift_planner/post_week', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def post_week(self, week_start=None, **kw):
|
||||
"""Publish (post) the viewed week's draft entries so automation acts on
|
||||
them, and email each newly-affected employee their posted shifts."""
|
||||
if not self._check_manager():
|
||||
return {'error': 'Access denied.'}
|
||||
|
||||
start = self._week_start(week_start)
|
||||
end = start + timedelta(days=6)
|
||||
employees = self._manager_employees()
|
||||
Schedule = request.env['fusion.clock.schedule'].sudo()
|
||||
|
||||
entries = Schedule.search([
|
||||
('employee_id', 'in', employees.ids),
|
||||
('schedule_date', '>=', start),
|
||||
('schedule_date', '<=', end),
|
||||
('state', '!=', 'posted'),
|
||||
])
|
||||
posted_count = len(entries)
|
||||
affected = entries.mapped('employee_id')
|
||||
if entries:
|
||||
entries.write({'state': 'posted', 'posted_date': fields.Datetime.now()})
|
||||
|
||||
notified = 0
|
||||
for employee in affected:
|
||||
if Schedule.fclk_email_posted_week(employee, start, end):
|
||||
notified += 1
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'posted': posted_count,
|
||||
'notified': notified,
|
||||
'data': self._load_week_data(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():
|
||||
|
||||
@@ -61,4 +61,16 @@
|
||||
<field name="priority">80</field>
|
||||
</record>
|
||||
|
||||
<!-- Photo Wipe Cron: runs daily, deletes clock photos past the retention window -->
|
||||
<record id="cron_wipe_old_photos" model="ir.cron">
|
||||
<field name="name">Fusion Clock: Wipe Old Clock Photos</field>
|
||||
<field name="model_id" ref="hr_attendance.model_hr_attendance"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_fusion_wipe_old_photos()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">True</field>
|
||||
<field name="priority">65</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# NFC Kiosk — Enrollment UX / PIN fix / Speed / Clock-out Hours — Implementation Plan
|
||||
|
||||
> **For agentic workers:** Steps use checkbox (`- [ ]`) syntax. Executed inline this session.
|
||||
|
||||
**Goal:** Make NFC-tag enrollment programmable from an unknown tap (with create-new-employee), fix the per-digit PIN re-render, speed up clock-in/out for lines, and clearly show shift hours on clock-out.
|
||||
|
||||
**Architecture:** Extend the existing IIFE kiosk state machine (`fusion_clock_nfc_kiosk.js`) — no Interaction migration. Add one sudo controller endpoint for kiosk employee-create. SCSS-only changes for animation timing. Spec: `docs/superpowers/specs/2026-05-30-nfc-kiosk-enroll-speed-design.md`.
|
||||
|
||||
**Tech Stack:** Odoo 19 HTTP controller (jsonrpc), vanilla JS IIFE, SCSS. Verify: `pyflakes`, `xmllint`, manifest `ast.literal_eval`, on-device deploy on entech (LXC 111 / pve-worker5).
|
||||
|
||||
**XSS note:** the kiosk uses `innerHTML`; every dynamic value (employee names, the typed new-employee name, errors) MUST go through the existing `escapeHtml()`. The new-employee name is user input — escape it everywhere it renders.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — `nfc_create_employee` endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `controllers/clock_nfc_kiosk.py` (add route after `nfc_enroll`)
|
||||
- Test: `tests/test_clock_nfc_kiosk.py` (add a method)
|
||||
|
||||
- [ ] **Step 1: Add the endpoint.** Manager/Kiosk-Operator gated (`_is_kiosk_operator`) + password gated (`_check_enroll_password`). Create `hr.employee` via sudo with name + `x_fclk_enable_clock=True` + `company_id`. Return `{employee_id, employee_name}` or `{error}`.
|
||||
|
||||
```python
|
||||
@http.route('/fusion_clock/kiosk/nfc/create_employee', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def nfc_create_employee(self, name='', enroll_password='', **kw):
|
||||
"""Create a minimal hr.employee from the kiosk (manager+password gated)."""
|
||||
user = request.env.user
|
||||
if not _is_kiosk_operator(user):
|
||||
return {'error': 'access_denied'}
|
||||
if not self._check_enroll_password(request.env, enroll_password):
|
||||
return {'error': 'invalid_password'}
|
||||
clean = (name or '').strip()
|
||||
if len(clean) < 2:
|
||||
return {'error': 'invalid_name'}
|
||||
employee = request.env['hr.employee'].sudo().create({
|
||||
'name': clean,
|
||||
'x_fclk_enable_clock': True,
|
||||
'company_id': request.env.company.id,
|
||||
})
|
||||
return {'employee_id': employee.id, 'employee_name': employee.name}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add a unit test** (runs when a test env is available; mirrors existing tests in the file).
|
||||
|
||||
```python
|
||||
def test_nfc_create_employee_creates_clock_enabled(self):
|
||||
Ctrl = self._controller() # follow existing pattern in this file for instantiating
|
||||
# password gate: wrong password rejected
|
||||
bad = Ctrl.nfc_create_employee(name='Test Person', enroll_password='wrong')
|
||||
self.assertEqual(bad.get('error'), 'invalid_password')
|
||||
# happy path (set the configured password in the test env first)
|
||||
self.env['ir.config_parameter'].sudo().set_param('fusion_clock.nfc_enroll_password', '1120')
|
||||
res = Ctrl.nfc_create_employee(name='Test Person', enroll_password='1120')
|
||||
emp = self.env['hr.employee'].browse(res['employee_id'])
|
||||
self.assertTrue(emp.exists())
|
||||
self.assertTrue(emp.x_fclk_enable_clock)
|
||||
```
|
||||
> If the existing test file doesn't instantiate controllers directly, adapt to its harness (or assert via model behaviour). Keep parity with existing tests.
|
||||
|
||||
- [ ] **Step 3: Verify.** `docker exec ... pyflakes controllers/clock_nfc_kiosk.py` (locally: `python3 -m pyflakes`). Expected: clean. Unit test runs in the next test invocation / on a Community dev box.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: JS — reusable fixed PIN-pad component (fixes per-digit re-render)
|
||||
|
||||
**Files:** Modify `static/src/js/fusion_clock_nfc_kiosk.js`
|
||||
|
||||
- [ ] **Step 1:** Add a `mountPinPad({title, onOk, onCancel})` helper that sets `stateContainer.innerHTML` **once** (title, `.pin-display`, numpad, cancel), keeps a local `let pin = ""`, and on digit/back/ok updates **only** `displayEl.textContent = "•".repeat(pin.length)` — never re-renders the panel. `ok` calls `onOk(pin)`; cancel calls `onCancel()`. Resets the enroll idle timer on each press.
|
||||
- [ ] **Step 2:** Rewrite `renderEnroll(phase:"password")` to call `mountPinPad({title:"Enter Manager PIN", onOk:(pin)=>{enrollPassword=pin; renderEnroll({phase:"search"});}, onCancel:exitEnrollMode})`. Remove the old per-digit `renderEnroll(...)` rebuild.
|
||||
- [ ] **Step 3: Verify.** Manual on device: digits append with no flicker/screen refresh; backspace works; OK advances.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: JS+SCSS — program-a-tag from an unknown tap (with create-new-employee)
|
||||
|
||||
**Files:** Modify `fusion_clock_nfc_kiosk.js`, `static/src/scss/nfc_kiosk.scss`
|
||||
|
||||
- [ ] **Step 1:** Add module var `let pendingEnrollUid = null;`. In `handleTap`, when `result.error === "card_unknown"`, call `renderUnknownCard(uid)` instead of the generic error result.
|
||||
- [ ] **Step 2:** `renderUnknownCard(uid)` renders an **amber** panel: "This card isn't programmed yet" + buttons "Program this card" / "Cancel". Auto-cancel to IDLE after 8s. "Program this card" → `pendingEnrollUid = uid; enrollPassword=""; setState(STATE.ENROLL,{phase:"program_pin"})`.
|
||||
- [ ] **Step 3:** Add enroll phases:
|
||||
- `program_pin` → `mountPinPad({title:"Manager PIN", onOk:(pin)=>{enrollPassword=pin; renderEnroll({phase:"employee"});}, onCancel:exitEnrollMode})`.
|
||||
- `employee` → search box (reuse existing `employee_search` debounced fetch) + a **"+ New employee"** button. Picking an existing row → `assignPendingCard(emp)`. "+ New employee" → `renderEnroll({phase:"new_employee"})`.
|
||||
- `new_employee` → a name input + "Create & assign" / back. On submit → POST `create_employee` {name, enroll_password}; on success → `assignPendingCard({id, name})`; on error → inline message (escape).
|
||||
- [ ] **Step 4:** `assignPendingCard(emp)`: POST `nfc/enroll` {employee_id: emp.id, card_uid: pendingEnrollUid, enroll_password}. Render enroll `result` phase (reuse existing). On done/another → reset `pendingEnrollUid`, back to IDLE.
|
||||
- [ ] **Step 5:** SCSS — add `.nfc-kiosk__result--warn` (amber: `#e0a83e`-ish border/glow) and a `.employee-create` styling block (reuse `.nfc-kiosk__enroll-panel` patterns). Escape all dynamic strings.
|
||||
- [ ] **Step 6: Verify.** `xmllint`/sass compile via deploy; device: unknown tap → program existing + new employee, card binds with no re-tap.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Speed — "Fast" timers + animation durations
|
||||
|
||||
**Files:** Modify `fusion_clock_nfc_kiosk.js`, `static/src/scss/nfc_kiosk.scss`
|
||||
|
||||
- [ ] **Step 1 (JS):** In `renderResult`: success `setTimeout(... , 3000)` → `1800`; error `4000` → `3000`.
|
||||
- [ ] **Step 2 (SCSS):** `nfc-state-in` 400ms→200ms (the `#nfc_state_container > *` rule + keyframe usages); `.nfc-kiosk__result--success` `nfc-success-burst` 700ms→350ms; `.nfc-kiosk__avatar` `nfc-avatar-in` 600ms→300ms. Leave idle wave/chip + mesh drift unchanged. Keep `prefers-reduced-motion` block.
|
||||
- [ ] **Step 3: Verify.** Device: noticeably snappier; result clears ~1.8s.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Clock-out shift hours — prominent + correct label
|
||||
|
||||
**Files:** Modify `fusion_clock_nfc_kiosk.js`, `static/src/scss/nfc_kiosk.scss`
|
||||
|
||||
- [ ] **Step 1 (JS):** In `renderResult` success branch, for `action === "clock_out"`: compute `const mins = Math.round((payload.net_hours_today || 0) * 60); const h = Math.floor(mins/60); const m = mins%60;` and always render `<div class="hours">Worked ${h}h ${m}m this shift</div>` (show even at 0). Clock-in: no hours line.
|
||||
- [ ] **Step 2 (SCSS):** Bump `.nfc-kiosk__result-text .hours` prominence (e.g. `font-size: 1.35rem; opacity: 0.9; margin-top: 0.6rem;`).
|
||||
- [ ] **Step 3: Verify.** Device: clock-out shows "Worked Xh Ym this shift".
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Version bump + deploy + verify
|
||||
|
||||
- [ ] **Step 1:** Bump `__manifest__.py` `version` `19.0.3.6.0` → `19.0.3.7.0` (assets changed).
|
||||
- [ ] **Step 2:** Local pre-flight: `pyflakes` controller, `xmllint`? (JS has no linter here — read carefully), manifest `ast.literal_eval`.
|
||||
- [ ] **Step 3:** Deploy to entech (backup → push 4 files → `-u fusion_clock` stop/upgrade/start). Bump asset cache (version bump handles it; `DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%'` + restart if the bundle doesn't refresh).
|
||||
- [ ] **Step 4:** Verify: service active, version 19.0.3.7.0, manifest route 200. On tablet (hard refresh): PIN no flicker; unknown tap → program (existing + new); faster; clock-out hours.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
- **Spec coverage:** PIN fix (T2), unknown-tap+create-new (T1,T3), speed (T4), clock-out hours (T5), deploy (T6). All covered.
|
||||
- **Placeholders:** none (test harness instantiation noted as adapt-to-existing — acceptable, file-specific).
|
||||
- **Consistency:** `pendingEnrollUid`, `enrollPassword`, `mountPinPad`, `assignPendingCard`, `_is_kiosk_operator`, `_check_enroll_password`, `net_hours_today` used consistently with the existing code read.
|
||||
@@ -0,0 +1,86 @@
|
||||
# NFC Kiosk — Enrollment UX, PIN fix, Speed, Clock-out Hours
|
||||
|
||||
**Date:** 2026-05-30
|
||||
**Module:** `fusion_clock` (NFC tap kiosk at `/fusion_clock/kiosk/nfc`)
|
||||
**Status:** Approved design, ready for implementation plan.
|
||||
|
||||
## Context
|
||||
|
||||
The NFC kiosk (`static/src/js/fusion_clock_nfc_kiosk.js`, an IIFE state machine) handles
|
||||
tap-to-clock on a wall tablet at the entech client. Four issues to address, all driven by
|
||||
real shop-floor use (lines of 10–20 people).
|
||||
|
||||
**Implementation approach:** extend the existing IIFE in place. A migration to an Odoo 19
|
||||
`Interaction` (per repo CLAUDE.md guidance) is deliberately out of scope — the file is a
|
||||
large, working state machine on a live client device and the four changes here are
|
||||
surgical; a rewrite would be high-risk for no functional gain. Noted deviation.
|
||||
|
||||
## Requirements & Design
|
||||
|
||||
### 1. PIN entry: stop the per-digit full re-render
|
||||
**Problem:** in `renderEnroll(phase:"password")`, every numpad press calls
|
||||
`renderEnroll(...)` which rebuilds the whole panel via `stateContainer.innerHTML = ...` and
|
||||
replays the 400ms `nfc-state-in` entrance animation → the screen visibly "refreshes" on each
|
||||
digit (entry is preserved, but it flickers).
|
||||
**Design:** a reusable PIN-pad component that renders the panel **once**, then on
|
||||
digit/backspace mutates only the masked `.pin-display` text node + an in-memory buffer.
|
||||
No `innerHTML` rebuild, no re-animation. Used by both the ⚙ enroll PIN and the new
|
||||
Manager-PIN step (§2). OK/Cancel callbacks are parameters.
|
||||
|
||||
### 2. Program a tag from an unknown tap
|
||||
**Problem:** an unknown card tap returns `{error:"card_unknown"}` and shows a red error that
|
||||
auto-dismisses. Programming requires the separate ⚙ flow (enter password → search → **re-tap**).
|
||||
**Design:** the tapped UID is already captured, so program *that* card with no re-tap:
|
||||
1. Unknown tap → **amber** "This card isn't programmed yet" panel with **"Program this card"**
|
||||
and **"Cancel"** buttons. Auto-cancel to idle after ~8s of inactivity.
|
||||
2. **"Program this card"** → **Manager PIN** step (reuses §1 component; credential =
|
||||
`fusion_clock.nfc_enroll_password`, currently `1120`; labelled "Manager PIN" in UI).
|
||||
3. **Employee step**: search-and-pick an existing employee **or** "+ New employee" →
|
||||
enter a name → create a minimal `hr.employee`.
|
||||
4. **Assign**: bind the captured UID to that employee → success confirmation.
|
||||
- The ⚙ enroll mode stays as a proactive path, reusing the same fixed PIN component.
|
||||
|
||||
**Backend:**
|
||||
- Reuse `POST /fusion_clock/kiosk/nfc/enroll` (`employee_id`, `card_uid`, `enroll_password`)
|
||||
for the bind. Already manager/Kiosk-Operator + password gated, sudo data ops.
|
||||
- **New endpoint** `POST /fusion_clock/kiosk/nfc/create_employee` (`name`, `enroll_password`):
|
||||
Kiosk-Operator-gated + password-gated; creates `hr.employee` via **sudo** with
|
||||
`name`, `x_fclk_enable_clock=True`, `company_id = request.env.company.id`; returns
|
||||
`{employee_id, employee_name}` (or `{error}`). JS then calls `enroll` with the captured UID.
|
||||
Minimal fields only — department/contract/etc. are completed later in HR.
|
||||
|
||||
### 3. Faster clock-in/out ("Fast")
|
||||
**Problem:** result card lingers 3s (errors 4s) and entrance animations are 0.4–0.7s →
|
||||
slow throughput for long lines.
|
||||
**Design (JS timers):** success result display **3000 → 1800 ms**; error **4000 → 3000 ms**.
|
||||
**Design (SCSS durations):** `nfc-state-in` 400→200ms; `nfc-success-burst` 700→350ms;
|
||||
`nfc-avatar-in` 600→300ms. Ambient idle wave/chip loop unchanged (does not gate throughput).
|
||||
`prefers-reduced-motion` fallback preserved.
|
||||
|
||||
### 4. Clock-out shows shift hours, clearly
|
||||
**Problem:** clock-out shows `${net_hours_today.toFixed(1)}h today` — mislabelled "today",
|
||||
small, and hidden when it rounds to 0.
|
||||
**Design:** on clock-out always show a prominent **"Worked Xh Ym this shift"** computed from
|
||||
`net_hours_today` (the just-closed attendance's net hours = worked − break). Render h+m;
|
||||
show even when 0 (e.g. "Worked 0h 4m this shift"). Backend already returns the value; this is
|
||||
a JS label/format + SCSS prominence change. Clock-in unchanged.
|
||||
|
||||
## Files
|
||||
- `static/src/js/fusion_clock_nfc_kiosk.js` — PIN component; unknown-tap → program flow;
|
||||
create-employee call; result timers; clock-out hours formatting.
|
||||
- `static/src/scss/nfc_kiosk.scss` — animation durations; amber "unknown card" panel +
|
||||
create-employee styles; prominent clock-out hours.
|
||||
- `controllers/clock_nfc_kiosk.py` — new `nfc_create_employee` endpoint.
|
||||
- `__manifest__.py` — version bump (assets changed).
|
||||
|
||||
## Out of scope / non-goals
|
||||
- No migration of the kiosk JS to an `Interaction`.
|
||||
- No new employee fields beyond name/clock-enabled/company at kiosk-create time.
|
||||
- Classic PIN kiosk (`/fusion_clock/kiosk`) untouched (disabled at entech).
|
||||
|
||||
## Test / verify
|
||||
- Local: `pyflakes` the controller; `xmllint`/manifest parse; review the JS by hand
|
||||
(no local Odoo container available this session).
|
||||
- entech: deploy, upgrade, then on the tablet — PIN entry no longer flickers; unknown tap →
|
||||
program (existing + new employee) binds without re-tap; clock-in/out visibly faster;
|
||||
clock-out shows "Worked Xh Ym this shift".
|
||||
24
fusion_clock/migrations/19.0.3.12.1/post-migrate.py
Normal file
24
fusion_clock/migrations/19.0.3.12.1/post-migrate.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Backfill schedule state on upgrade to 19.0.3.12.0.
|
||||
|
||||
Before this version there was no draft/posted concept — every dated
|
||||
``fusion.clock.schedule`` entry was authoritative and drove reminders, absence
|
||||
checks and penalties. The new ``state`` field defaults to 'draft', and the
|
||||
schedule resolver now only acts on POSTED entries. Without this backfill, every
|
||||
pre-existing schedule entry would silently become draft on upgrade and stop
|
||||
driving automation. Mark all pre-existing entries 'posted' to preserve prior
|
||||
behaviour. (Runs only on upgrade, never on a fresh install.)
|
||||
"""
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
if not version:
|
||||
return
|
||||
cr.execute("""
|
||||
UPDATE fusion_clock_schedule
|
||||
SET state = 'posted',
|
||||
posted_date = COALESCE(posted_date, now())
|
||||
WHERE state IS NULL OR state = 'draft'
|
||||
""")
|
||||
Binary file not shown.
Binary file not shown.
@@ -2,11 +2,15 @@
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionClockSchedule(models.Model):
|
||||
_name = 'fusion.clock.schedule'
|
||||
@@ -72,6 +76,15 @@ class FusionClockSchedule(models.Model):
|
||||
compute='_compute_display_name',
|
||||
store=True,
|
||||
)
|
||||
state = fields.Selection(
|
||||
[('draft', 'Draft'), ('posted', 'Posted')],
|
||||
string='Status',
|
||||
default='draft',
|
||||
index=True,
|
||||
help="Only POSTED entries drive reminders, absence checks and penalties. "
|
||||
"Draft entries are ignored by automation until the team lead posts them.",
|
||||
)
|
||||
posted_date = fields.Datetime(string='Posted On', readonly=True)
|
||||
|
||||
_employee_date_unique = models.Constraint(
|
||||
'UNIQUE(employee_id, schedule_date)',
|
||||
@@ -288,6 +301,10 @@ class FusionClockSchedule(models.Model):
|
||||
'end_time': parsed.get('end_time') or 0.0,
|
||||
'break_minutes': parsed.get('break_minutes') or 0.0,
|
||||
'note': payload.get('note') or False,
|
||||
# Any planner edit returns the cell to draft; it must be re-posted
|
||||
# before automation acts on it.
|
||||
'state': 'draft',
|
||||
'posted_date': False,
|
||||
}
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
@@ -321,6 +338,7 @@ class FusionClockSchedule(models.Model):
|
||||
return {
|
||||
'schedule_id': schedule.id,
|
||||
'source': 'schedule',
|
||||
'state': schedule.state,
|
||||
'input': schedule.fclk_display_value(),
|
||||
'label': schedule.fclk_display_value(),
|
||||
'is_off': schedule.is_off,
|
||||
@@ -336,7 +354,8 @@ class FusionClockSchedule(models.Model):
|
||||
plan = employee._get_fclk_day_plan(date_obj)
|
||||
return {
|
||||
'schedule_id': False,
|
||||
'source': plan.get('source') or 'fallback',
|
||||
'source': plan.get('source') or 'none',
|
||||
'state': False,
|
||||
'input': plan.get('label') or '',
|
||||
'label': plan.get('label') or '',
|
||||
'is_off': plan.get('is_off', False),
|
||||
@@ -349,6 +368,57 @@ class FusionClockSchedule(models.Model):
|
||||
'note': '',
|
||||
}
|
||||
|
||||
@api.model
|
||||
def fclk_email_posted_week(self, employee, week_start, week_end):
|
||||
"""Email one employee a summary of their POSTED shifts for the week."""
|
||||
employee = employee.sudo()
|
||||
if not employee.work_email:
|
||||
return False
|
||||
from .hr_attendance import _fclk_email_wrap
|
||||
entries = self.sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('schedule_date', '>=', week_start),
|
||||
('schedule_date', '<=', week_end),
|
||||
('state', '=', 'posted'),
|
||||
])
|
||||
by_date = {entry.schedule_date: entry for entry in entries}
|
||||
rows = []
|
||||
day = week_start
|
||||
while day <= week_end:
|
||||
entry = by_date.get(day)
|
||||
rows.append((
|
||||
day.strftime('%a %b %d'),
|
||||
entry.fclk_display_value() if entry else 'Not scheduled',
|
||||
))
|
||||
day += timedelta(days=1)
|
||||
company = employee.company_id or self.env.company
|
||||
body = _fclk_email_wrap(
|
||||
company_name=company.name or '',
|
||||
title='Your Posted Schedule',
|
||||
summary=(
|
||||
f'Hello <strong>{employee.name}</strong>, your shifts for '
|
||||
f'<strong>{week_start.strftime("%b %d")} - {week_end.strftime("%b %d, %Y")}</strong> '
|
||||
f'have been posted.'
|
||||
),
|
||||
sections=[('This Week', rows)],
|
||||
note='Log in to <a href="/my/clock" style="color:#10B981;">your portal</a> for details.',
|
||||
)
|
||||
try:
|
||||
mail = self.env['mail.mail'].sudo().create({
|
||||
'subject': f'Your schedule: {week_start.strftime("%b %d")} - {week_end.strftime("%b %d")}',
|
||||
'email_from': company.email or '',
|
||||
'email_to': employee.work_email,
|
||||
'body_html': body,
|
||||
'auto_delete': True,
|
||||
})
|
||||
mail.send()
|
||||
return True
|
||||
except Exception as exc:
|
||||
_logger.error(
|
||||
"Fusion Clock: failed to email posted schedule to %s: %s", employee.name, exc
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
class FusionClockScheduleAudit(models.Model):
|
||||
_name = 'fusion.clock.schedule.audit'
|
||||
|
||||
@@ -42,6 +42,17 @@ class FusionClockShift(models.Model):
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
color = fields.Char(string='Color', default='#3B82F6')
|
||||
|
||||
# Weekday pattern — which days this recurring shift applies as the baseline
|
||||
# when there is no posted planner entry for the day. Default Mon-Fri.
|
||||
day_mon = fields.Boolean(string='Mon', default=True)
|
||||
day_tue = fields.Boolean(string='Tue', default=True)
|
||||
day_wed = fields.Boolean(string='Wed', default=True)
|
||||
day_thu = fields.Boolean(string='Thu', default=True)
|
||||
day_fri = fields.Boolean(string='Fri', default=True)
|
||||
day_sat = fields.Boolean(string='Sat', default=False)
|
||||
day_sun = fields.Boolean(string='Sun', default=False)
|
||||
|
||||
employee_ids = fields.One2many(
|
||||
'hr.employee',
|
||||
'x_fclk_shift_id',
|
||||
@@ -56,6 +67,17 @@ class FusionClockShift(models.Model):
|
||||
for rec in self:
|
||||
rec.employee_count = len(rec.employee_ids)
|
||||
|
||||
def covers_weekday(self, date):
|
||||
"""Return True if this recurring shift applies on the given date's
|
||||
weekday (Mon=0 .. Sun=6)."""
|
||||
self.ensure_one()
|
||||
date_obj = fields.Date.to_date(date)
|
||||
if not date_obj:
|
||||
return False
|
||||
days = (self.day_mon, self.day_tue, self.day_wed, self.day_thu,
|
||||
self.day_fri, self.day_sat, self.day_sun)
|
||||
return bool(days[date_obj.weekday()])
|
||||
|
||||
@property
|
||||
def scheduled_hours(self):
|
||||
"""Return the scheduled work hours for this shift (excluding break)."""
|
||||
|
||||
@@ -250,64 +250,55 @@ class HrAttendance(models.Model):
|
||||
|
||||
@api.model
|
||||
def _cron_fusion_auto_clock_out(self):
|
||||
"""Cron job: auto clock-out employees after shift + grace period."""
|
||||
"""Cron job: safety-net auto clock-out.
|
||||
|
||||
Overtime past the scheduled end is expected, so this NEVER closes a shift
|
||||
at the scheduled end. It only closes an attendance left open longer than
|
||||
the max-shift safety cap (someone forgot to clock out), and flags the
|
||||
employee to explain on their next clock-in.
|
||||
"""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.enable_auto_clockout', 'True') != 'True':
|
||||
return
|
||||
|
||||
max_shift = float(ICP.get_param('fusion_clock.max_shift_hours', '12.0'))
|
||||
grace_min = float(ICP.get_param('fusion_clock.grace_period_minutes', '15'))
|
||||
max_shift = float(ICP.get_param('fusion_clock.max_shift_hours', '16.0'))
|
||||
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
|
||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
|
||||
|
||||
now = fields.Datetime.now()
|
||||
|
||||
open_attendances = self.sudo().search([
|
||||
('check_out', '=', False),
|
||||
])
|
||||
|
||||
open_attendances = self.sudo().search([('check_out', '=', False)])
|
||||
ActivityLog = self.env['fusion.clock.activity.log'].sudo()
|
||||
|
||||
for att in open_attendances:
|
||||
check_in = att.check_in
|
||||
if not check_in:
|
||||
continue
|
||||
effective_deadline = check_in + timedelta(hours=max_shift)
|
||||
if now <= effective_deadline:
|
||||
continue
|
||||
|
||||
employee = att.employee_id
|
||||
emp_tz = pytz.timezone(employee.tz or self.env.company.tz or 'UTC')
|
||||
check_in_date = pytz.UTC.localize(check_in).astimezone(emp_tz).date()
|
||||
max_deadline = check_in + timedelta(hours=max_shift)
|
||||
day_plan = employee._get_fclk_day_plan(check_in_date)
|
||||
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
|
||||
effective_deadline = max_deadline
|
||||
else:
|
||||
_, scheduled_out = employee._get_fclk_scheduled_times(check_in_date)
|
||||
deadline = scheduled_out + timedelta(minutes=grace_min)
|
||||
effective_deadline = min(deadline, max_deadline)
|
||||
|
||||
if now > effective_deadline:
|
||||
clock_out_time = min(effective_deadline, now)
|
||||
try:
|
||||
clock_out_time = effective_deadline
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
att.sudo().write({
|
||||
'check_out': clock_out_time,
|
||||
'x_fclk_auto_clocked_out': True,
|
||||
'x_fclk_grace_used': True,
|
||||
'x_fclk_clock_source': 'auto',
|
||||
})
|
||||
|
||||
# Apply break deduction
|
||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
|
||||
if (att.worked_hours or 0) >= threshold:
|
||||
break_min = employee._get_fclk_break_minutes(check_in_date)
|
||||
att.sudo().write({'x_fclk_break_minutes': break_min})
|
||||
|
||||
att.sudo().write(
|
||||
{'x_fclk_break_minutes': employee._get_fclk_break_minutes(check_in_date)}
|
||||
)
|
||||
att.sudo().message_post(
|
||||
body=f"Auto clocked out at {_fclk_utc_to_local_str(clock_out_time, employee, '%H:%M')} "
|
||||
f"(grace period expired). Net hours: {att.x_fclk_net_hours:.1f}h",
|
||||
f"(max-shift cap reached). Net hours: {att.x_fclk_net_hours:.1f}h",
|
||||
message_type='comment',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
|
||||
# Log to activity log
|
||||
ActivityLog.create({
|
||||
'employee_id': employee.id,
|
||||
'log_type': 'auto_clock_out',
|
||||
@@ -317,11 +308,7 @@ class HrAttendance(models.Model):
|
||||
'location_id': att.x_fclk_location_id.id if att.x_fclk_location_id else False,
|
||||
'source': 'system',
|
||||
})
|
||||
|
||||
# Set pending reason
|
||||
employee.sudo().write({'x_fclk_pending_reason': True})
|
||||
|
||||
# Notify office user
|
||||
self._fclk_notify_office(
|
||||
office_user_id,
|
||||
f"Auto Clock-Out: {employee.name}",
|
||||
@@ -330,16 +317,66 @@ class HrAttendance(models.Model):
|
||||
'hr.attendance',
|
||||
att.id,
|
||||
)
|
||||
|
||||
_logger.info(
|
||||
"Fusion Clock: Auto clocked out %s (attendance %s)",
|
||||
employee.name, att.id,
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error(
|
||||
"Fusion Clock: Failed to auto clock-out attendance %s: %s",
|
||||
att.id, str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error(
|
||||
"Fusion Clock: Failed to auto clock-out attendance %s: %s",
|
||||
att.id, str(e),
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _cron_fusion_wipe_old_photos(self):
|
||||
"""Cron job: delete clock-in/out verification photos older than the
|
||||
configured retention window (``fusion_clock.photo_retention_days``).
|
||||
|
||||
Only the images are removed — the attendance records, worked hours and
|
||||
penalties are kept. The photos are attachment-backed binary fields, so we
|
||||
unlink the underlying ir.attachment rows directly, which reclaims the
|
||||
filestore space. Set the retention to 0 to disable the wipe entirely."""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
retention_days = int(ICP.get_param('fusion_clock.photo_retention_days', '60') or 0)
|
||||
if retention_days <= 0:
|
||||
return # 0 / unset → auto-wipe disabled
|
||||
|
||||
cutoff = fields.Datetime.now() - timedelta(days=retention_days)
|
||||
old_attendances = self.sudo().search([('check_in', '<', cutoff)])
|
||||
if not old_attendances:
|
||||
return
|
||||
|
||||
Attachment = self.env['ir.attachment'].sudo()
|
||||
photo_fields = [
|
||||
'x_fclk_check_in_photo', # NFC kiosk clock-in selfie
|
||||
'x_fclk_check_out_photo', # NFC kiosk clock-out selfie
|
||||
'x_fclk_checkin_photo', # legacy portal clock-in photo
|
||||
]
|
||||
wiped = 0
|
||||
# Batch the attendances so the res_id IN (...) list stays bounded, and
|
||||
# isolate each batch in a savepoint so one bad row can't abort the rest.
|
||||
for offset in range(0, len(old_attendances), 500):
|
||||
batch_ids = old_attendances[offset:offset + 500].ids
|
||||
photos = Attachment.search([
|
||||
('res_model', '=', 'hr.attendance'),
|
||||
('res_field', 'in', photo_fields),
|
||||
('res_id', 'in', batch_ids),
|
||||
])
|
||||
if not photos:
|
||||
continue
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
count = len(photos)
|
||||
photos.unlink()
|
||||
wiped += count
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock: Failed to wipe a photo batch: %s", e)
|
||||
|
||||
if wiped:
|
||||
_logger.info(
|
||||
"Fusion Clock: Wiped %s clock verification photo(s) older than %s days.",
|
||||
wiped, retention_days,
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _cron_fusion_check_absences(self):
|
||||
@@ -356,127 +393,144 @@ class HrAttendance(models.Model):
|
||||
LeaveRequest = self.env['fusion.clock.leave.request'].sudo()
|
||||
|
||||
for emp in employees:
|
||||
yesterday = get_local_today(self.env, emp) - timedelta(days=1)
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
yesterday = get_local_today(self.env, emp) - timedelta(days=1)
|
||||
|
||||
if yesterday.weekday() >= 5:
|
||||
continue
|
||||
day_plan = emp._get_fclk_day_plan(yesterday)
|
||||
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
|
||||
continue
|
||||
# Only days the employee was actually scheduled to work
|
||||
# (posted shift or covering recurring shift) can count as an
|
||||
# absence. Off days and unscheduled days are never flagged.
|
||||
if not emp._get_fclk_day_plan(yesterday).get('scheduled'):
|
||||
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)
|
||||
|
||||
holidays = self.env['resource.calendar.leaves'].sudo().search([
|
||||
('resource_id', '=', False),
|
||||
('date_from', '<=', day_end),
|
||||
('date_to', '>=', day_start),
|
||||
])
|
||||
if holidays:
|
||||
continue
|
||||
holidays = self.env['resource.calendar.leaves'].sudo().search([
|
||||
('resource_id', '=', False),
|
||||
('date_from', '<=', day_end),
|
||||
('date_to', '>=', day_start),
|
||||
])
|
||||
if holidays:
|
||||
continue
|
||||
|
||||
att_count = self.sudo().search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_in', '>=', day_start),
|
||||
('check_in', '<', day_end),
|
||||
])
|
||||
if att_count > 0:
|
||||
continue
|
||||
att_count = self.sudo().search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_in', '>=', day_start),
|
||||
('check_in', '<', day_end),
|
||||
])
|
||||
if att_count > 0:
|
||||
continue
|
||||
|
||||
leave = LeaveRequest.search([
|
||||
('employee_id', '=', emp.id),
|
||||
('leave_date', '=', yesterday),
|
||||
], limit=1)
|
||||
if leave:
|
||||
continue
|
||||
leave = LeaveRequest.search([
|
||||
('employee_id', '=', emp.id),
|
||||
('leave_date', '=', yesterday),
|
||||
], limit=1)
|
||||
if leave:
|
||||
continue
|
||||
|
||||
ActivityLog.create({
|
||||
'employee_id': emp.id,
|
||||
'log_type': 'absent',
|
||||
'log_date': day_start,
|
||||
'description': f"No attendance recorded for {yesterday}",
|
||||
'source': 'system',
|
||||
})
|
||||
ActivityLog.create({
|
||||
'employee_id': emp.id,
|
||||
'log_type': 'absent',
|
||||
'log_date': day_start,
|
||||
'description': f"No attendance recorded for {yesterday}",
|
||||
'source': 'system',
|
||||
})
|
||||
|
||||
emp.sudo().write({'x_fclk_pending_reason': True})
|
||||
emp.sudo().write({'x_fclk_pending_reason': True})
|
||||
|
||||
month_start = yesterday.replace(day=1)
|
||||
month_boundary_start, _ = get_local_day_boundaries(self.env, month_start, emp)
|
||||
absence_count = ActivityLog.search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('log_type', '=', 'absent'),
|
||||
('log_date', '>=', month_boundary_start),
|
||||
])
|
||||
month_start = yesterday.replace(day=1)
|
||||
month_boundary_start, _ = get_local_day_boundaries(self.env, month_start, emp)
|
||||
absence_count = ActivityLog.search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('log_type', '=', 'absent'),
|
||||
('log_date', '>=', month_boundary_start),
|
||||
])
|
||||
|
||||
if absence_count >= max_absences:
|
||||
self._fclk_notify_office(
|
||||
office_user_id,
|
||||
f"Excessive Absences: {emp.name}",
|
||||
f"{emp.name} has {absence_count} absences this month "
|
||||
f"(threshold: {max_absences}). Please review.",
|
||||
'hr.employee',
|
||||
emp.id,
|
||||
)
|
||||
if absence_count >= max_absences:
|
||||
self._fclk_notify_office(
|
||||
office_user_id,
|
||||
f"Excessive Absences: {emp.name}",
|
||||
f"{emp.name} has {absence_count} absences this month "
|
||||
f"(threshold: {max_absences}). Please review.",
|
||||
'hr.employee',
|
||||
emp.id,
|
||||
)
|
||||
|
||||
_logger.info("Fusion Clock: Marked %s as absent for %s", emp.name, yesterday)
|
||||
_logger.info("Fusion Clock: Marked %s as absent for %s", emp.name, yesterday)
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock: absence check failed for %s: %s", emp.name, e)
|
||||
|
||||
@api.model
|
||||
def _cron_fusion_employee_reminders(self):
|
||||
"""Cron job: send clock-in/out reminders to employees."""
|
||||
"""Cron job: schedule-driven clock-in / clock-out reminders.
|
||||
|
||||
Reminders only go to employees actually SCHEDULED to work today (posted
|
||||
shift or covering recurring shift). Someone not scheduled — or whose
|
||||
shift simply hasn't started yet — is never pinged.
|
||||
"""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.enable_employee_notifications', 'True') != 'True':
|
||||
return
|
||||
|
||||
reminder_in_min = float(ICP.get_param('fusion_clock.reminder_before_shift_minutes', '30'))
|
||||
reminder_out_min = float(ICP.get_param('fusion_clock.reminder_before_end_minutes', '15'))
|
||||
max_shift = float(ICP.get_param('fusion_clock.max_shift_hours', '16.0'))
|
||||
|
||||
now = fields.Datetime.now()
|
||||
|
||||
employees = self.env['hr.employee'].sudo().search([
|
||||
('x_fclk_enable_clock', '=', True),
|
||||
])
|
||||
|
||||
for emp in employees:
|
||||
today = get_local_today(self.env, emp)
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
today = get_local_today(self.env, emp)
|
||||
if not emp._get_fclk_day_plan(today).get('scheduled'):
|
||||
continue
|
||||
if emp.x_fclk_last_reminder_date == today:
|
||||
continue
|
||||
|
||||
if today.weekday() >= 5:
|
||||
continue
|
||||
day_plan = emp._get_fclk_day_plan(today)
|
||||
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
|
||||
continue
|
||||
is_checked_in = emp.attendance_state == 'checked_in'
|
||||
|
||||
if emp.x_fclk_last_reminder_date == today:
|
||||
continue
|
||||
|
||||
scheduled_in, scheduled_out = emp._get_fclk_scheduled_times(today)
|
||||
is_checked_in = emp.attendance_state == 'checked_in'
|
||||
|
||||
# Missed clock-in reminder
|
||||
reminder_deadline = scheduled_in + timedelta(minutes=reminder_in_min)
|
||||
if not is_checked_in and now > reminder_deadline:
|
||||
today_start, _ = get_local_day_boundaries(self.env, today, emp)
|
||||
has_attendance = self.sudo().search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_in', '>=', today_start),
|
||||
])
|
||||
if has_attendance == 0:
|
||||
self._fclk_send_employee_reminder(
|
||||
emp,
|
||||
"Clock-In Reminder",
|
||||
f"Hi {emp.name}, you haven't clocked in yet today. "
|
||||
f"Your shift started at {_fclk_utc_to_local_str(scheduled_in, emp)}.",
|
||||
)
|
||||
emp.sudo().write({'x_fclk_last_reminder_date': today})
|
||||
|
||||
# Clock-out reminder
|
||||
reminder_before_end = scheduled_out - timedelta(minutes=reminder_out_min)
|
||||
if is_checked_in and now > reminder_before_end and now < scheduled_out:
|
||||
self._fclk_send_employee_reminder(
|
||||
emp,
|
||||
"Clock-Out Reminder",
|
||||
f"Hi {emp.name}, your shift ends at {_fclk_utc_to_local_str(scheduled_out, emp)}. "
|
||||
f"Don't forget to clock out.",
|
||||
)
|
||||
emp.sudo().write({'x_fclk_last_reminder_date': today})
|
||||
if not is_checked_in:
|
||||
# Missed clock-in — only after THIS employee's own shift
|
||||
# start (+ threshold), so a late shift is never pinged early.
|
||||
scheduled_in, _scheduled_out = emp._get_fclk_scheduled_times(today)
|
||||
if now <= scheduled_in + timedelta(minutes=reminder_in_min):
|
||||
continue
|
||||
today_start, _ = get_local_day_boundaries(self.env, today, emp)
|
||||
has_attendance = self.sudo().search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_in', '>=', today_start),
|
||||
])
|
||||
if has_attendance == 0:
|
||||
self._fclk_send_employee_reminder(
|
||||
emp,
|
||||
"Clock-In Reminder",
|
||||
f"Hi {emp.name}, you haven't clocked in yet today. "
|
||||
f"Your shift started at {_fclk_utc_to_local_str(scheduled_in, emp)}.",
|
||||
)
|
||||
emp.sudo().write({'x_fclk_last_reminder_date': today})
|
||||
else:
|
||||
# Still-clocked-in nudge (OT-aware): only as the max-shift
|
||||
# safety cap approaches, never at the scheduled end.
|
||||
open_att = self.sudo().search([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_out', '=', False),
|
||||
], order='check_in desc', limit=1)
|
||||
if not open_att or not open_att.check_in:
|
||||
continue
|
||||
cap = open_att.check_in + timedelta(hours=max_shift)
|
||||
if cap - timedelta(minutes=reminder_out_min) < now < cap:
|
||||
self._fclk_send_employee_reminder(
|
||||
emp,
|
||||
"Clock-Out Reminder",
|
||||
f"Hi {emp.name}, you're still clocked in. "
|
||||
f"Remember to clock out when you leave.",
|
||||
)
|
||||
emp.sudo().write({'x_fclk_last_reminder_date': today})
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock: reminder failed for %s: %s", emp.name, e)
|
||||
|
||||
@api.model
|
||||
def _cron_fusion_weekly_summary(self):
|
||||
|
||||
@@ -132,18 +132,25 @@ class HrEmployee(models.Model):
|
||||
], limit=1)
|
||||
|
||||
def _get_fclk_day_plan(self, date):
|
||||
"""Return the effective plan for a local date.
|
||||
"""Return the effective plan for a local date, with an explicit
|
||||
``scheduled`` flag that ALL attendance automation keys off.
|
||||
|
||||
Dated schedules are the source of truth. If none exists, the legacy
|
||||
employee shift/global settings remain the fallback.
|
||||
Resolution order:
|
||||
1. POSTED planner entry (``fusion.clock.schedule`` state='posted').
|
||||
Draft entries are ignored, so the recurring baseline still applies
|
||||
until the team lead posts the schedule.
|
||||
2. The employee's recurring shift, IF it covers this weekday.
|
||||
3. Otherwise: not scheduled. The global default times are returned
|
||||
only as a display hint; ``scheduled`` stays False so nothing fires.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Schedule = self.env['fusion.clock.schedule'].sudo()
|
||||
schedule = self._get_fclk_schedule_for_date(date)
|
||||
if schedule:
|
||||
if schedule and schedule.state == 'posted':
|
||||
return {
|
||||
'source': 'schedule',
|
||||
'schedule_id': schedule.id,
|
||||
'scheduled': not schedule.is_off,
|
||||
'is_off': schedule.is_off,
|
||||
'start_time': schedule.start_time,
|
||||
'end_time': schedule.end_time,
|
||||
@@ -151,12 +158,14 @@ class HrEmployee(models.Model):
|
||||
'hours': schedule.planned_hours,
|
||||
'label': schedule.fclk_display_value(),
|
||||
}
|
||||
if self.x_fclk_shift_id:
|
||||
shift = self.x_fclk_shift_id
|
||||
|
||||
shift = self.x_fclk_shift_id
|
||||
if shift and shift.covers_weekday(date):
|
||||
hours = max((shift.end_time - shift.start_time) - (shift.break_minutes / 60.0), 0.0)
|
||||
return {
|
||||
'source': 'fallback',
|
||||
'source': 'shift',
|
||||
'schedule_id': False,
|
||||
'scheduled': True,
|
||||
'is_off': False,
|
||||
'start_time': shift.start_time,
|
||||
'end_time': shift.end_time,
|
||||
@@ -168,23 +177,21 @@ class HrEmployee(models.Model):
|
||||
),
|
||||
}
|
||||
|
||||
# Not scheduled — global default times are a display hint only.
|
||||
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',
|
||||
'source': 'none',
|
||||
'schedule_id': False,
|
||||
'scheduled': 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),
|
||||
),
|
||||
'hours': 0.0,
|
||||
'label': '',
|
||||
}
|
||||
|
||||
def _get_fclk_break_minutes(self, date=None):
|
||||
|
||||
@@ -56,8 +56,11 @@ class ResConfigSettings(models.TransientModel):
|
||||
fclk_max_shift_hours = fields.Float(
|
||||
string='Max Shift Length (hours)',
|
||||
config_parameter='fusion_clock.max_shift_hours',
|
||||
default=12.0,
|
||||
help="Maximum shift length before auto clock-out (safety net).",
|
||||
default=16.0,
|
||||
help="Safety-net cap: an attendance left open longer than this is "
|
||||
"auto-clocked-out (assumed forgot-to-clock-out). Overtime up to this "
|
||||
"cap is never cut off, so set it comfortably above your longest real "
|
||||
"shift + overtime.",
|
||||
)
|
||||
fclk_enable_penalties = fields.Boolean(
|
||||
string='Enable Penalty Tracking',
|
||||
@@ -268,6 +271,15 @@ class ResConfigSettings(models.TransientModel):
|
||||
help="Which clock location is bound to the NFC kiosk for this company. "
|
||||
"Required when the kiosk is enabled.",
|
||||
)
|
||||
fclk_photo_retention_days = fields.Integer(
|
||||
string='Auto-Wipe Photos After (days)',
|
||||
config_parameter='fusion_clock.photo_retention_days',
|
||||
default=60,
|
||||
help="Clock-in/out verification photos older than this many days are deleted "
|
||||
"automatically by a daily cron. The attendance record, worked hours and "
|
||||
"penalties are kept — only the images are removed, reclaiming storage. "
|
||||
"Set to 0 to disable the auto-wipe.",
|
||||
)
|
||||
|
||||
def set_values(self):
|
||||
super().set_values()
|
||||
|
||||
@@ -1,25 +1,66 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ================================================================
|
||||
App category + privileges (Odoo 19) so Fusion Clock roles appear
|
||||
as selectable application-access dropdowns on the user form,
|
||||
exactly like the other Fusion apps (no developer mode needed).
|
||||
Odoo 19 dropped res.groups.category_id; groups link to a
|
||||
res.groups.privilege, which carries the category_id.
|
||||
================================================================ -->
|
||||
<record id="module_category_fusion_clock" model="ir.module.category">
|
||||
<field name="name">Fusion Clock</field>
|
||||
<field name="sequence">45</field>
|
||||
</record>
|
||||
|
||||
<!-- Main role hierarchy (User < Team Lead < Manager) -> one dropdown -->
|
||||
<record id="res_groups_privilege_fusion_clock" model="res.groups.privilege">
|
||||
<field name="name">Fusion Clock</field>
|
||||
<field name="sequence">45</field>
|
||||
<field name="category_id" ref="module_category_fusion_clock"/>
|
||||
</record>
|
||||
|
||||
<!-- Standalone kiosk-operator role -> its own row under the same header -->
|
||||
<record id="res_groups_privilege_fusion_clock_kiosk" model="res.groups.privilege">
|
||||
<field name="name">Fusion Clock Kiosk</field>
|
||||
<field name="sequence">46</field>
|
||||
<field name="category_id" ref="module_category_fusion_clock"/>
|
||||
</record>
|
||||
|
||||
<!-- Groups -->
|
||||
<record id="group_fusion_clock_user" model="res.groups">
|
||||
<field name="name">Fusion Clock / User</field>
|
||||
<field name="name">User</field>
|
||||
<field name="privilege_id" ref="res_groups_privilege_fusion_clock"/>
|
||||
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||
<field name="comment">Can clock in/out and view own attendance</field>
|
||||
</record>
|
||||
|
||||
<record id="group_fusion_clock_team_lead" model="res.groups">
|
||||
<field name="name">Fusion Clock / Team Lead</field>
|
||||
<field name="name">Team Lead</field>
|
||||
<field name="privilege_id" ref="res_groups_privilege_fusion_clock"/>
|
||||
<field name="implied_ids" eval="[(4, ref('group_fusion_clock_user'))]"/>
|
||||
<field name="comment">Can view direct reports attendance (read-only)</field>
|
||||
</record>
|
||||
|
||||
<record id="group_fusion_clock_manager" model="res.groups">
|
||||
<field name="name">Fusion Clock / Manager</field>
|
||||
<field name="name">Manager</field>
|
||||
<field name="privilege_id" ref="res_groups_privilege_fusion_clock"/>
|
||||
<field name="implied_ids" eval="[(4, ref('group_fusion_clock_team_lead'))]"/>
|
||||
<field name="comment">Can manage locations, view all attendance, generate reports</field>
|
||||
</record>
|
||||
|
||||
<!-- Dedicated kiosk-operator permission: can run the shared clock kiosk
|
||||
(NFC tap / PIN) WITHOUT full Clock Manager access. Gates the
|
||||
"Fusion Clock Kiosk" app menu and is accepted by the kiosk controllers.
|
||||
Implies only base.group_user, so it does NOT reveal the full Fusion
|
||||
Clock app (which is gated to group_fusion_clock_user). -->
|
||||
<record id="group_fusion_clock_kiosk_app" model="res.groups">
|
||||
<field name="name">Kiosk Operator</field>
|
||||
<field name="privilege_id" ref="res_groups_privilege_fusion_clock_kiosk"/>
|
||||
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||
<field name="comment">Can open and operate the shared clock kiosk (NFC tap / PIN) without full Clock Manager access. Intended for shared wall-tablet accounts.</field>
|
||||
</record>
|
||||
|
||||
<!-- Auto-assign admin to Manager group -->
|
||||
<function model="res.users" name="write">
|
||||
<value eval="[ref('base.user_admin')]"/>
|
||||
|
||||
@@ -1661,3 +1661,91 @@ html.o_dark #fclk-portal-fab {
|
||||
width: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Employee portal — Payslips, 4-item nav, sign out
|
||||
(uses the --fclk-* palette above, so light/dark just works)
|
||||
============================================================ */
|
||||
|
||||
/* Keep 4 nav items comfortable on narrow phones */
|
||||
.fclk-nav-bar .fclk-nav-item { min-width: 64px; }
|
||||
|
||||
/* Sign out (clock header, top-right) */
|
||||
.fclk-header { position: relative; }
|
||||
.fclk-signout {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
color: var(--fclk-text-muted);
|
||||
background: var(--fclk-card);
|
||||
border: 1px solid var(--fclk-card-border);
|
||||
text-decoration: none;
|
||||
}
|
||||
.fclk-signout:hover { color: var(--fclk-text); }
|
||||
|
||||
/* Payslip list rows (extend .fclk-report-item) */
|
||||
.fclk-payslip-item { text-decoration: none; color: inherit; cursor: pointer; }
|
||||
.fclk-payslip-status {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fclk-payslip-status--paid { background: var(--fclk-green-glow); color: var(--fclk-green); }
|
||||
.fclk-payslip-status--done { background: var(--fclk-hover-bg); color: var(--fclk-text-muted); }
|
||||
|
||||
/* Payslip detail (inline paystub) */
|
||||
.fclk-payslip-detail-header .fclk-payslip-back {
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
color: var(--fclk-green);
|
||||
text-decoration: none;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.fclk-payslip-net {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.fclk-payslip-net-label { font-size: 13px; color: var(--fclk-text-muted); }
|
||||
.fclk-payslip-net-value { font-size: 26px; font-weight: 700; color: var(--fclk-green); }
|
||||
.fclk-payslip-section { margin-bottom: 16px; }
|
||||
.fclk-payslip-section-title {
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
color: var(--fclk-text-muted);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.fclk-payslip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
font-size: 14px;
|
||||
color: var(--fclk-text);
|
||||
border-bottom: 1px solid var(--fclk-card-border);
|
||||
}
|
||||
.fclk-payslip-row:last-child { border-bottom: none; }
|
||||
.fclk-payslip-row--total { font-weight: 700; }
|
||||
.fclk-payslip-pdf-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
margin-bottom: 90px; /* clear the fixed bottom nav */
|
||||
border-radius: 12px;
|
||||
background: var(--fclk-green);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,20 @@
|
||||
const photoRequired = root.dataset.photoRequired === "1";
|
||||
const debugEnabled = root.dataset.debugEnabled === "1";
|
||||
const locationConfigured = root.dataset.locationConfigured === "1";
|
||||
const soundsEnabled = root.dataset.soundsEnabled === "1";
|
||||
|
||||
// On a known device (set up before) the browser already remembers camera/NFC
|
||||
// permission, so slim the prompt to a simple resume tap.
|
||||
try {
|
||||
if (localStorage.getItem("nfc_setup_done") === "1") {
|
||||
const _h2 = document.querySelector(".nfc-kiosk__setup h2");
|
||||
const _p = document.querySelector(".nfc-kiosk__setup p");
|
||||
const _btn = document.getElementById("nfc_setup_start");
|
||||
if (_h2) _h2.textContent = "Fusion Clock Kiosk";
|
||||
if (_p) _p.textContent = "Tap to resume.";
|
||||
if (_btn) _btn.textContent = "Tap to resume";
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Debug overlay (visible only when fusion_clock.nfc_kiosk_debug = True)
|
||||
@@ -140,8 +154,10 @@
|
||||
stroke="currentColor" stroke-width="4" fill="none"/>
|
||||
<circle class="nfc-wave nfc-wave-1" cx="100" cy="100" r="58"
|
||||
stroke="currentColor" stroke-width="4" fill="none"/>
|
||||
<rect class="nfc-chip" x="68" y="68" width="64" height="64"
|
||||
rx="11" fill="currentColor"/>
|
||||
<g class="nfc-chip" fill="none" stroke="currentColor" stroke-width="8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="100" cy="100" r="34"/>
|
||||
<polyline points="100,80 100,100 116,108"/>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="nfc-kiosk__prompt">Tap your card to clock in or out</div>
|
||||
</div>
|
||||
@@ -157,11 +173,67 @@
|
||||
`;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Clock sounds (Web Audio — synthesized, loud + distinct in/out).
|
||||
// AudioContext is created/resumed on the setup tap (a user gesture),
|
||||
// after which it can play on each clock event.
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
let _audioCtx = null;
|
||||
function unlockAudio() {
|
||||
try {
|
||||
if (!_audioCtx) {
|
||||
const AC = window.AudioContext || window.webkitAudioContext;
|
||||
if (AC) _audioCtx = new AC();
|
||||
}
|
||||
if (_audioCtx && _audioCtx.state === "suspended") _audioCtx.resume();
|
||||
} catch (e) { debugLog("audio: unlock failed " + e.message); }
|
||||
}
|
||||
function _note(freq, startAt, dur, peak, type) {
|
||||
const osc = _audioCtx.createOscillator();
|
||||
const g = _audioCtx.createGain();
|
||||
osc.type = type || "sine";
|
||||
osc.frequency.setValueAtTime(freq, startAt);
|
||||
g.gain.setValueAtTime(0.0001, startAt);
|
||||
g.gain.exponentialRampToValueAtTime(peak, startAt + 0.015); // soft attack (no click)
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, startAt + dur); // smooth decay
|
||||
osc.connect(g); g.connect(_audioCtx.destination);
|
||||
osc.start(startAt); osc.stop(startAt + dur + 0.04);
|
||||
}
|
||||
function playClockSound(action) {
|
||||
if (!soundsEnabled || !_audioCtx) return;
|
||||
try {
|
||||
if (_audioCtx.state === "suspended") _audioCtx.resume();
|
||||
const t = _audioCtx.currentTime;
|
||||
if (action === "clock_out") {
|
||||
// warm descending major triad (G–E–C) — a pleasant "goodbye"
|
||||
_note(783.99, t, 0.20, 0.6, "sine"); // G5
|
||||
_note(659.25, t + 0.13, 0.20, 0.6, "sine"); // E5
|
||||
_note(523.25, t + 0.26, 0.42, 0.7, "sine"); // C5
|
||||
} else {
|
||||
// bright ascending major triad (C–E–G) — a cheerful "welcome"
|
||||
_note(523.25, t, 0.18, 0.6, "sine"); // C5
|
||||
_note(659.25, t + 0.13, 0.18, 0.6, "sine"); // E5
|
||||
_note(783.99, t + 0.26, 0.42, 0.72, "sine"); // G5
|
||||
}
|
||||
} catch (e) { debugLog("audio: play failed " + e.message); }
|
||||
}
|
||||
// Distinct low "denied" tone for wrong / unknown taps — clearly not a success chime.
|
||||
function playErrorSound() {
|
||||
if (!soundsEnabled || !_audioCtx) return;
|
||||
try {
|
||||
if (_audioCtx.state === "suspended") _audioCtx.resume();
|
||||
const t = _audioCtx.currentTime;
|
||||
_note(311.13, t, 0.20, 0.55, "triangle"); // Eb4
|
||||
_note(207.65, t + 0.18, 0.36, 0.6, "triangle"); // Ab3 (low → "wrong")
|
||||
} catch (e) { debugLog("audio: play failed " + e.message); }
|
||||
}
|
||||
|
||||
function renderResult(payload) {
|
||||
const isError = payload && payload.error;
|
||||
const cls = isError ? "nfc-kiosk__result--error" : "nfc-kiosk__result--success";
|
||||
|
||||
if (isError) {
|
||||
playErrorSound();
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__result ${cls}">
|
||||
<div class="nfc-kiosk__result-text">
|
||||
@@ -169,25 +241,43 @@
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
setTimeout(() => setState(STATE.IDLE), 4000);
|
||||
setTimeout(() => setState(STATE.IDLE), 3000);
|
||||
} else {
|
||||
playClockSound(payload.action);
|
||||
const avatar = payload.employee_avatar_url || "";
|
||||
const action = payload.action === "clock_in" ? "CLOCKED IN" : "CLOCKED OUT";
|
||||
const hours = payload.action === "clock_out" && payload.net_hours_today
|
||||
? `${payload.net_hours_today.toFixed(1)}h today`
|
||||
: "";
|
||||
const time = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
let hoursLine = "";
|
||||
if (payload.action === "clock_out") {
|
||||
// Gross clock-in → clock-out time. Adaptive units so short shifts
|
||||
// (and quick tests) don't all read "0h 0m".
|
||||
const totalSec = Math.round((payload.worked_hours || 0) * 3600);
|
||||
const h = Math.floor(totalSec / 3600);
|
||||
const m = Math.floor((totalSec % 3600) / 60);
|
||||
const s = totalSec % 60;
|
||||
let dur;
|
||||
if (h > 0) dur = `${h}h ${m}m`;
|
||||
else if (m > 0) dur = `${m}m`;
|
||||
else dur = `${s}s`;
|
||||
hoursLine = `<div class="hours">Worked ${dur} this shift</div>`;
|
||||
}
|
||||
const time = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: true });
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__result ${cls}">
|
||||
<div class="nfc-kiosk__avatar" style="background-image:url('${avatar}')"></div>
|
||||
<div class="nfc-kiosk__result-text">
|
||||
<div class="name">${escapeHtml(payload.employee_name)}</div>
|
||||
<div class="action">${action} at ${time}</div>
|
||||
${hours ? `<div class="hours">${hours}</div>` : ""}
|
||||
${hoursLine}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
setTimeout(() => setState(STATE.IDLE), 3000);
|
||||
setTimeout(() => {
|
||||
if (payload.action === "clock_in" && payload.needs_photo && payload.employee_id) {
|
||||
openPhotoCapture(payload.employee_id, payload.employee_name, () => setState(STATE.IDLE));
|
||||
} else {
|
||||
setState(STATE.IDLE);
|
||||
}
|
||||
}, 1800);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +286,7 @@
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
let enrollPassword = "";
|
||||
let enrollSelectedEmployee = null;
|
||||
let pendingEnrollUid = null; // set when programming a just-tapped unknown card
|
||||
let enrollIdleTimer = null;
|
||||
|
||||
function resetEnrollIdleTimer() {
|
||||
@@ -209,60 +300,178 @@
|
||||
function exitEnrollMode() {
|
||||
if (enrollIdleTimer) clearTimeout(enrollIdleTimer);
|
||||
enrollIdleTimer = null;
|
||||
enrollPassword = "";
|
||||
if (kioskLocked) enrollPassword = ""; // keep the PIN while unlocked (no re-prompt)
|
||||
enrollSelectedEmployee = null;
|
||||
pendingEnrollUid = null;
|
||||
setState(STATE.IDLE);
|
||||
}
|
||||
|
||||
// Fixed PIN pad: renders the panel ONCE, then mutates only the masked
|
||||
// display on each press — no innerHTML rebuild, no replayed entrance
|
||||
// animation. (Fixes the per-digit "screen refresh" bug.)
|
||||
function mountPinPad(opts) {
|
||||
let pin = "";
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__enroll-overlay">
|
||||
<div class="nfc-kiosk__enroll-panel nfc-kiosk__enroll-panel--pin">
|
||||
<h2>${escapeHtml(opts.title || "Enter PIN")}</h2>
|
||||
<div class="pin-display" id="nfc_pin_display"></div>
|
||||
<div class="numpad">
|
||||
${[1,2,3,4,5,6,7,8,9].map(n => `<button data-n="${n}">${n}</button>`).join("")}
|
||||
<button data-n="back">⌫</button>
|
||||
<button data-n="0">0</button>
|
||||
<button data-n="ok">OK</button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="cancel" id="nfc_pin_cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const displayEl = stateContainer.querySelector("#nfc_pin_display");
|
||||
const paint = () => { displayEl.textContent = "•".repeat(pin.length); };
|
||||
paint();
|
||||
stateContainer.querySelectorAll(".numpad button").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
resetEnrollIdleTimer();
|
||||
const n = btn.dataset.n;
|
||||
if (n === "back") { pin = pin.slice(0, -1); paint(); }
|
||||
else if (n === "ok") { if (pin.length) opts.onOk(pin); }
|
||||
else { pin += n; paint(); }
|
||||
});
|
||||
});
|
||||
stateContainer.querySelector("#nfc_pin_cancel").addEventListener("click", opts.onCancel);
|
||||
}
|
||||
|
||||
// Reactive flow: an unknown card was tapped — offer to program it now.
|
||||
function renderUnknownCard(uid) {
|
||||
playErrorSound();
|
||||
currentState = STATE.RESULT; // block taps while this prompt is up
|
||||
if (enrollIdleTimer) { clearTimeout(enrollIdleTimer); enrollIdleTimer = null; }
|
||||
const autoCancel = setTimeout(() => {
|
||||
if (currentState === STATE.RESULT) setState(STATE.IDLE);
|
||||
}, 8000);
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__enroll-overlay">
|
||||
<div class="nfc-kiosk__enroll-panel nfc-kiosk__unknown" style="text-align:center">
|
||||
<div class="unknown-icon">⚠</div>
|
||||
<h2>This card isn't programmed yet</h2>
|
||||
<p style="color:var(--nfc-text-muted)">Program it now, or ask a manager.</p>
|
||||
<div class="actions" style="justify-content:center">
|
||||
<button class="confirm" id="uc_program">Program this card</button>
|
||||
<button class="cancel" id="uc_cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
stateContainer.querySelector("#uc_program").addEventListener("click", () => {
|
||||
clearTimeout(autoCancel);
|
||||
pendingEnrollUid = uid;
|
||||
enrollSelectedEmployee = null;
|
||||
// If a manager already unlocked, skip the PIN; otherwise ask for it.
|
||||
setState(STATE.ENROLL, { phase: enrollPassword ? "employee" : "password" });
|
||||
});
|
||||
stateContainer.querySelector("#uc_cancel").addEventListener("click", () => {
|
||||
clearTimeout(autoCancel);
|
||||
setState(STATE.IDLE);
|
||||
});
|
||||
}
|
||||
|
||||
function renderEnroll(payload) {
|
||||
const phase = (payload && payload.phase) || "password";
|
||||
resetEnrollIdleTimer();
|
||||
|
||||
if (phase === "password") {
|
||||
const masked = "•".repeat(enrollPassword.length);
|
||||
mountPinPad({
|
||||
title: "Manager PIN",
|
||||
onOk: (pin) => { enrollPassword = pin; renderEnroll({ phase: "employee" }); },
|
||||
onCancel: exitEnrollMode,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase === "manager") {
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__enroll-overlay">
|
||||
<div class="nfc-kiosk__enroll-panel">
|
||||
<h2>Enter Enroll Mode Password</h2>
|
||||
<div class="pin-display">${masked}</div>
|
||||
<div class="numpad">
|
||||
${[1,2,3,4,5,6,7,8,9].map(n => `<button data-n="${n}">${n}</button>`).join("")}
|
||||
<button data-n="back">⌫</button>
|
||||
<button data-n="0">0</button>
|
||||
<button data-n="ok">OK</button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="cancel" id="enroll_cancel">Cancel</button>
|
||||
<h2>Manage employees</h2>
|
||||
<input class="employee-search" id="mgr_search" placeholder="Search by name…" autocomplete="off"/>
|
||||
<div class="employee-list" id="mgr_list"></div>
|
||||
<div class="actions" style="justify-content:space-between">
|
||||
<button class="confirm" id="mgr_new">+ New employee</button>
|
||||
<button class="cancel" id="mgr_close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
stateContainer.querySelectorAll(".numpad button").forEach(btn => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const searchEl = document.getElementById("mgr_search");
|
||||
const listEl = document.getElementById("mgr_list");
|
||||
let confirmDeleteId = null;
|
||||
let debounceTimer = null;
|
||||
async function refresh() {
|
||||
resetEnrollIdleTimer();
|
||||
let emps = [];
|
||||
try { emps = (await postJson("/fusion_clock/kiosk/nfc/employee_search", { query: searchEl.value })).employees || []; }
|
||||
catch (e) { listEl.innerHTML = `<div style="opacity:.6;padding:1rem">Connection error.</div>`; return; }
|
||||
if (!emps.length) { listEl.innerHTML = `<div style="opacity:.6;padding:1rem">No employees found.</div>`; return; }
|
||||
listEl.innerHTML = emps.map(e => {
|
||||
const tag = e.card_uid
|
||||
? `<span class="m-tag m-tag--on">● ${escapeHtml(e.card_uid)}</span>`
|
||||
: `<span class="m-tag">○ no tag</span>`;
|
||||
const actions = (confirmDeleteId === e.id)
|
||||
? `<button class="m-btn m-danger" data-act="delok" data-id="${e.id}">Confirm delete</button>
|
||||
<button class="m-btn" data-act="delno" data-id="${e.id}">Cancel</button>`
|
||||
: `<button class="m-btn" data-act="assign" data-id="${e.id}" data-name="${escapeHtml(e.name)}">${e.card_uid ? "Re-tag" : "Assign"}</button>
|
||||
<button class="m-btn" data-act="photo" data-id="${e.id}" data-name="${escapeHtml(e.name)}">📷 Photo</button>
|
||||
${e.card_uid ? `<button class="m-btn" data-act="clear" data-id="${e.id}">Clear tag</button>` : ""}
|
||||
<button class="m-btn m-danger" data-act="del" data-id="${e.id}">Delete</button>`;
|
||||
return `<div class="manager-row">
|
||||
<div class="m-info"><span class="m-name">${escapeHtml(e.name)}</span><small class="m-dept">${escapeHtml(e.department || "")}</small> ${tag}</div>
|
||||
<div class="m-actions">${actions}</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
listEl.querySelectorAll(".m-btn").forEach(btn => btn.addEventListener("click", async () => {
|
||||
resetEnrollIdleTimer();
|
||||
const n = btn.dataset.n;
|
||||
if (n === "back") enrollPassword = enrollPassword.slice(0, -1);
|
||||
else if (n === "ok") {
|
||||
if (enrollPassword.length === 0) return;
|
||||
renderEnroll({ phase: "search" });
|
||||
return;
|
||||
const id = parseInt(btn.dataset.id, 10);
|
||||
const act = btn.dataset.act;
|
||||
if (act === "assign") {
|
||||
enrollSelectedEmployee = { id, name: btn.dataset.name };
|
||||
pendingEnrollUid = null;
|
||||
renderEnroll({ phase: "tap" });
|
||||
} else if (act === "photo") {
|
||||
openPhotoCapture(id, btn.dataset.name, () => renderEnroll({ phase: "manager" }));
|
||||
} else if (act === "clear") {
|
||||
try { await postJson("/fusion_clock/kiosk/nfc/clear_tag", { employee_id: id, enroll_password: enrollPassword }); } catch (e) {}
|
||||
refresh();
|
||||
} else if (act === "del") {
|
||||
confirmDeleteId = id; refresh();
|
||||
} else if (act === "delno") {
|
||||
confirmDeleteId = null; refresh();
|
||||
} else if (act === "delok") {
|
||||
try { await postJson("/fusion_clock/kiosk/nfc/delete_employee", { employee_id: id, enroll_password: enrollPassword }); } catch (e) {}
|
||||
confirmDeleteId = null; refresh();
|
||||
}
|
||||
else enrollPassword += n;
|
||||
renderEnroll({ phase: "password" });
|
||||
});
|
||||
}));
|
||||
}
|
||||
searchEl.addEventListener("input", () => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(refresh, 200);
|
||||
});
|
||||
document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
|
||||
document.getElementById("mgr_new").addEventListener("click", () => renderEnroll({ phase: "new_employee" }));
|
||||
document.getElementById("mgr_close").addEventListener("click", exitEnrollMode);
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase === "search") {
|
||||
if (phase === "employee") {
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__enroll-overlay">
|
||||
<div class="nfc-kiosk__enroll-panel">
|
||||
<h2>Pick the employee to enroll</h2>
|
||||
<h2>Who is this card for?</h2>
|
||||
<input class="employee-search" id="enroll_search" placeholder="Search by name…" autocomplete="off"/>
|
||||
<div class="employee-list" id="enroll_list"></div>
|
||||
<div class="actions">
|
||||
<div class="actions" style="justify-content:space-between">
|
||||
<button class="confirm" id="enroll_new">+ New employee</button>
|
||||
<button class="cancel" id="enroll_cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -281,17 +490,61 @@
|
||||
).join("");
|
||||
listEl.querySelectorAll(".employee-row").forEach(row => {
|
||||
row.addEventListener("click", () => {
|
||||
enrollSelectedEmployee = { id: parseInt(row.dataset.id, 10), name: row.dataset.name };
|
||||
renderEnroll({ phase: "tap" });
|
||||
chooseEmployee({ id: parseInt(row.dataset.id, 10), name: row.dataset.name });
|
||||
});
|
||||
});
|
||||
}, 200);
|
||||
});
|
||||
searchEl.focus();
|
||||
document.getElementById("enroll_new").addEventListener("click", () => renderEnroll({ phase: "new_employee" }));
|
||||
document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase === "new_employee") {
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__enroll-overlay">
|
||||
<div class="nfc-kiosk__enroll-panel">
|
||||
<h2>New employee</h2>
|
||||
<input class="employee-search" id="new_emp_name" placeholder="Full name…" autocomplete="off"/>
|
||||
<div class="enroll-msg" id="new_emp_msg"></div>
|
||||
<div class="actions" style="justify-content:space-between">
|
||||
<button class="cancel" id="new_emp_back">Back</button>
|
||||
<button class="confirm" id="new_emp_create">Create & assign</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const nameEl = document.getElementById("new_emp_name");
|
||||
const msgEl = document.getElementById("new_emp_msg");
|
||||
nameEl.addEventListener("input", resetEnrollIdleTimer);
|
||||
nameEl.focus();
|
||||
const doCreate = async () => {
|
||||
resetEnrollIdleTimer();
|
||||
const nm = nameEl.value.trim();
|
||||
if (nm.length < 2) { msgEl.textContent = "Enter the employee's full name."; return; }
|
||||
msgEl.textContent = "Creating…";
|
||||
let res;
|
||||
try {
|
||||
res = await postJson("/fusion_clock/kiosk/nfc/create_employee", { name: nm, enroll_password: enrollPassword });
|
||||
} catch (e) {
|
||||
msgEl.textContent = "No connection. Try again.";
|
||||
return;
|
||||
}
|
||||
if (res.error) {
|
||||
msgEl.textContent = res.error === "invalid_password" ? "Wrong Manager PIN."
|
||||
: res.error === "invalid_name" ? "Enter a valid name."
|
||||
: "Could not create employee.";
|
||||
return;
|
||||
}
|
||||
chooseEmployee({ id: res.employee_id, name: res.employee_name });
|
||||
};
|
||||
document.getElementById("new_emp_create").addEventListener("click", doCreate);
|
||||
nameEl.addEventListener("keydown", (e) => { if (e.key === "Enter") doCreate(); });
|
||||
document.getElementById("new_emp_back").addEventListener("click", () => renderEnroll({ phase: "employee" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase === "tap") {
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__enroll-overlay">
|
||||
@@ -312,52 +565,163 @@
|
||||
if (phase === "result") {
|
||||
const ok = !payload.error;
|
||||
const msg = ok
|
||||
? `✓ Card ${escapeHtml(payload.card_uid)} enrolled to ${escapeHtml(payload.employee_name)}`
|
||||
? `✓ Card enrolled to ${escapeHtml(payload.employee_name)}`
|
||||
: (payload.error === "invalid_password"
|
||||
? "Wrong password. Try again."
|
||||
? "Wrong Manager PIN. Try again."
|
||||
: payload.error === "card_already_assigned"
|
||||
? `This card is already assigned to ${escapeHtml(payload.existing_employee || "another employee")}.`
|
||||
: `Enroll failed: ${escapeHtml(payload.error)}`);
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__enroll-overlay">
|
||||
<div class="nfc-kiosk__enroll-panel" style="text-align:center">
|
||||
<h2 style="color:${ok ? "#18a957" : "#d9374e"}">${msg}</h2>
|
||||
<h2 style="color:${ok ? "var(--nfc-success)" : "var(--nfc-error)"}">${msg}</h2>
|
||||
<div class="actions" style="justify-content:center">
|
||||
${ok && payload.employee_id ? `<button class="confirm" id="enroll_photo">📷 Take photo</button>` : ""}
|
||||
<button class="confirm" id="enroll_another">Enroll another</button>
|
||||
<button class="cancel" id="enroll_done">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
if (ok && payload.employee_id) {
|
||||
document.getElementById("enroll_photo").addEventListener("click", () => {
|
||||
openPhotoCapture(payload.employee_id, payload.employee_name, () => {
|
||||
if (enrollPassword) renderEnroll({ phase: "manager" }); else exitEnrollMode();
|
||||
});
|
||||
});
|
||||
}
|
||||
document.getElementById("enroll_another").addEventListener("click", () => {
|
||||
enrollSelectedEmployee = null;
|
||||
renderEnroll({ phase: ok ? "search" : "password" });
|
||||
pendingEnrollUid = null;
|
||||
renderEnroll({ phase: ok ? "employee" : "password" });
|
||||
});
|
||||
document.getElementById("enroll_done").addEventListener("click", exitEnrollMode);
|
||||
}
|
||||
}
|
||||
|
||||
async function _onEnrollTap(uid) {
|
||||
if (!enrollSelectedEmployee) return;
|
||||
const result = await postJson("/fusion_clock/kiosk/nfc/enroll", {
|
||||
employee_id: enrollSelectedEmployee.id,
|
||||
card_uid: uid,
|
||||
enroll_password: enrollPassword,
|
||||
});
|
||||
renderEnroll({ phase: "result", ...result });
|
||||
// Existing employee picked → if we already hold a tapped UID, bind it now
|
||||
// (no re-tap); otherwise fall back to the proactive ⚙ "tap the card" step.
|
||||
function chooseEmployee(emp) {
|
||||
if (pendingEnrollUid) {
|
||||
doEnroll(emp.id, emp.name, pendingEnrollUid, false);
|
||||
} else {
|
||||
enrollSelectedEmployee = emp;
|
||||
renderEnroll({ phase: "tap" });
|
||||
}
|
||||
}
|
||||
|
||||
// ⚙ button → enter Enroll Mode
|
||||
// Single enroll path (program flow, ⚙ tap flow, manager re-tag). A card already
|
||||
// held by someone else triggers a reassign confirm rather than a hard error.
|
||||
async function doEnroll(empId, empName, uid, force) {
|
||||
resetEnrollIdleTimer();
|
||||
let result;
|
||||
try {
|
||||
result = await postJson("/fusion_clock/kiosk/nfc/enroll", {
|
||||
employee_id: empId, card_uid: uid, enroll_password: enrollPassword, force: !!force,
|
||||
});
|
||||
} catch (e) {
|
||||
renderEnroll({ phase: "result", employee_name: empName, error: "network" });
|
||||
return;
|
||||
}
|
||||
if (result.error === "card_already_assigned" && !force) {
|
||||
renderReassignConfirm(empId, empName, uid, result.existing_employee);
|
||||
return;
|
||||
}
|
||||
renderEnroll({ phase: "result", employee_name: empName, ...result });
|
||||
}
|
||||
|
||||
function renderReassignConfirm(empId, empName, uid, existingName) {
|
||||
resetEnrollIdleTimer();
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__enroll-overlay">
|
||||
<div class="nfc-kiosk__enroll-panel" style="text-align:center">
|
||||
<h2>Reassign card?</h2>
|
||||
<p style="color:var(--nfc-text-muted)">This card belongs to <b>${escapeHtml(existingName || "another employee")}</b>. Move it to <b>${escapeHtml(empName)}</b>?</p>
|
||||
<div class="actions" style="justify-content:center">
|
||||
<button class="cancel" id="ra_cancel">Cancel</button>
|
||||
<button class="confirm" id="ra_move">Move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
stateContainer.querySelector("#ra_move").addEventListener("click", () => doEnroll(empId, empName, uid, true));
|
||||
stateContainer.querySelector("#ra_cancel").addEventListener("click", () => renderEnroll({ phase: "manager" }));
|
||||
}
|
||||
|
||||
async function _onEnrollTap(uid) {
|
||||
if (!enrollSelectedEmployee) return;
|
||||
doEnroll(enrollSelectedEmployee.id, enrollSelectedEmployee.name, uid, false);
|
||||
}
|
||||
|
||||
// ⚙ button → enter Enroll Mode (only when unlocked)
|
||||
const settingsBtn = document.getElementById("nfc_settings_btn");
|
||||
if (settingsBtn) {
|
||||
settingsBtn.addEventListener("click", () => {
|
||||
if (currentState !== STATE.IDLE) return;
|
||||
enrollPassword = "";
|
||||
if (kioskLocked || currentState !== STATE.IDLE) return;
|
||||
enrollSelectedEmployee = null;
|
||||
setState(STATE.ENROLL, { phase: "password" });
|
||||
pendingEnrollUid = null;
|
||||
// Already unlocked → reuse that PIN, open the manager page (no re-prompt).
|
||||
setState(STATE.ENROLL, { phase: "manager" });
|
||||
});
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Screen lock — the kiosk starts LOCKED: only card taps work, ⚙ hidden.
|
||||
// A manager long-presses the bottom-right corner and enters the Manager
|
||||
// PIN to unlock (revealing ⚙ + 🔒). Re-locks via 🔒, on reload, or after
|
||||
// inactivity. Unlock is in-memory only, so every reload starts locked.
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
let kioskLocked = true;
|
||||
let relockTimer = null;
|
||||
const lockBtn = document.getElementById("nfc_lock_btn");
|
||||
const unlockBtn = document.getElementById("nfc_unlock_btn");
|
||||
|
||||
function applyLockState() {
|
||||
if (settingsBtn) settingsBtn.style.display = kioskLocked ? "none" : "flex";
|
||||
if (lockBtn) lockBtn.style.display = kioskLocked ? "none" : "flex";
|
||||
if (unlockBtn) unlockBtn.style.display = kioskLocked ? "flex" : "none";
|
||||
}
|
||||
function armRelock() {
|
||||
if (relockTimer) clearTimeout(relockTimer);
|
||||
relockTimer = setTimeout(() => {
|
||||
relockTimer = null;
|
||||
if (currentState === STATE.IDLE) lockKiosk();
|
||||
else armRelock(); // don't re-lock mid-enroll
|
||||
}, 120000);
|
||||
}
|
||||
function lockKiosk() {
|
||||
kioskLocked = true;
|
||||
enrollPassword = ""; // forget the PIN → unlocking requires it again
|
||||
if (relockTimer) { clearTimeout(relockTimer); relockTimer = null; }
|
||||
applyLockState();
|
||||
}
|
||||
function unlockKiosk() {
|
||||
kioskLocked = false;
|
||||
applyLockState();
|
||||
armRelock();
|
||||
}
|
||||
if (lockBtn) lockBtn.addEventListener("click", lockKiosk);
|
||||
|
||||
function openUnlockPin() {
|
||||
currentState = STATE.RESULT; // block card taps during PIN entry
|
||||
mountPinPad({
|
||||
title: "Manager PIN — unlock",
|
||||
onOk: async (pin) => {
|
||||
let ok = false;
|
||||
try { ok = (await postJson("/fusion_clock/kiosk/nfc/verify_pin", { pin })).ok; } catch (e) {}
|
||||
if (ok) { enrollPassword = pin; unlockKiosk(); setState(STATE.IDLE); }
|
||||
else { const d = document.getElementById("nfc_pin_display"); if (d) d.textContent = "✕ wrong"; }
|
||||
},
|
||||
onCancel: () => setState(STATE.IDLE),
|
||||
});
|
||||
}
|
||||
|
||||
if (unlockBtn) unlockBtn.addEventListener("click", () => {
|
||||
if (kioskLocked && currentState === STATE.IDLE) openUnlockPin();
|
||||
});
|
||||
|
||||
applyLockState(); // start locked
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || "").replace(/[&<>"']/g, c => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||
@@ -387,6 +751,9 @@
|
||||
updateClock();
|
||||
setInterval(updateClock, 1000);
|
||||
|
||||
// Keep-alive: refresh the session every 4 min so the kiosk login never expires.
|
||||
setInterval(() => { postJson("/fusion_clock/get_settings", {}).catch(() => {}); }, 240000);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Setup wizard
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
@@ -577,6 +944,10 @@
|
||||
setState(STATE.IDLE);
|
||||
return;
|
||||
}
|
||||
if (result.error === "card_unknown") {
|
||||
renderUnknownCard(uid);
|
||||
return;
|
||||
}
|
||||
setState(STATE.RESULT, result);
|
||||
} catch (e) {
|
||||
debugLog("handleTap: POST failed: " + e.message);
|
||||
@@ -624,6 +995,118 @@
|
||||
return canvasEl.toDataURL("image/jpeg", 0.7);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Guided profile-photo capture (for employees with no picture).
|
||||
// Live camera + oval face guide → Capture → preview → Use/Retake →
|
||||
// saves a centered 512² square to the employee's profile image.
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
function _captureProfileSquare(video) {
|
||||
const vw = video.videoWidth, vh = video.videoHeight;
|
||||
if (!vw || !vh) return "";
|
||||
const side = Math.min(vw, vh);
|
||||
const sx = (vw - side) / 2, sy = (vh - side) / 2;
|
||||
const c = document.createElement("canvas");
|
||||
c.width = 512; c.height = 512;
|
||||
c.getContext("2d").drawImage(video, sx, sy, side, side, 0, 0, 512, 512);
|
||||
return c.toDataURL("image/jpeg", 0.85);
|
||||
}
|
||||
|
||||
function openPhotoCapture(employeeId, employeeName, onDone) {
|
||||
currentState = STATE.RESULT; // block card taps during capture
|
||||
if (enrollIdleTimer) { clearTimeout(enrollIdleTimer); enrollIdleTimer = null; }
|
||||
const finish = () => { if (onDone) onDone(); };
|
||||
if (!cameraStream) { finish(); return; } // no camera → skip silently
|
||||
let captured = "";
|
||||
const renderPreview = () => {
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__enroll-overlay">
|
||||
<div class="nfc-kiosk__photo-panel">
|
||||
<h2>Use this photo?</h2>
|
||||
<div class="nfc-photo-stage">
|
||||
<img class="nfc-photo-preview" src="${captured}"/>
|
||||
<div class="nfc-photo-guide"></div>
|
||||
</div>
|
||||
<div class="actions" style="justify-content:space-between">
|
||||
<button class="cancel" id="photo_retake">Retake</button>
|
||||
<button class="confirm" id="photo_use">Use photo</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById("photo_retake").addEventListener("click", renderLive);
|
||||
document.getElementById("photo_use").addEventListener("click", async () => {
|
||||
try { await postJson("/fusion_clock/kiosk/nfc/save_profile_photo", { employee_id: employeeId, photo_b64: captured }); } catch (e) {}
|
||||
finish();
|
||||
});
|
||||
};
|
||||
function renderLive() {
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__enroll-overlay">
|
||||
<div class="nfc-kiosk__photo-panel">
|
||||
<h2>Take ${escapeHtml(employeeName)}'s photo</h2>
|
||||
<div class="nfc-photo-stage" id="photo_stage">
|
||||
<div class="nfc-photo-guide"></div>
|
||||
<div class="nfc-photo-countdown" id="photo_countdown"></div>
|
||||
<div class="nfc-photo-hint">Center the face in the oval</div>
|
||||
</div>
|
||||
<div class="actions" style="justify-content:space-between">
|
||||
<button class="cancel" id="photo_skip">Skip</button>
|
||||
<button class="confirm" id="photo_capture">Capture</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const stage = document.getElementById("photo_stage");
|
||||
const v = document.createElement("video");
|
||||
v.autoplay = true; v.muted = true; v.playsInline = true;
|
||||
v.className = "nfc-photo-video";
|
||||
v.srcObject = cameraStream;
|
||||
stage.insertBefore(v, stage.firstChild);
|
||||
v.play().catch(() => {});
|
||||
|
||||
const captureBtn = document.getElementById("photo_capture");
|
||||
const skipBtn = document.getElementById("photo_skip");
|
||||
const cdEl = document.getElementById("photo_countdown");
|
||||
const hintEl = stage.querySelector(".nfc-photo-hint");
|
||||
let cdTimer = null;
|
||||
const doCapture = () => {
|
||||
captured = _captureProfileSquare(v);
|
||||
if (captured) renderPreview(); else finish();
|
||||
};
|
||||
// Click Capture → 10s countdown (time to get into frame) → auto-snap.
|
||||
// While counting, the button becomes Cancel (cdTimer also acts as the flag).
|
||||
captureBtn.addEventListener("click", () => {
|
||||
if (cdTimer) {
|
||||
clearInterval(cdTimer); cdTimer = null;
|
||||
cdEl.classList.remove("is-active"); cdEl.textContent = "";
|
||||
if (hintEl) hintEl.textContent = "Center the face in the oval";
|
||||
captureBtn.textContent = "Capture";
|
||||
return;
|
||||
}
|
||||
let n = 10;
|
||||
captureBtn.textContent = "Cancel";
|
||||
if (hintEl) hintEl.textContent = "Get ready…";
|
||||
cdEl.textContent = n;
|
||||
cdEl.classList.add("is-active");
|
||||
cdTimer = setInterval(() => {
|
||||
n -= 1;
|
||||
if (n <= 0) {
|
||||
clearInterval(cdTimer); cdTimer = null;
|
||||
cdEl.classList.remove("is-active"); cdEl.textContent = "";
|
||||
doCapture();
|
||||
return;
|
||||
}
|
||||
cdEl.textContent = n;
|
||||
}, 1000);
|
||||
});
|
||||
skipBtn.addEventListener("click", () => {
|
||||
if (cdTimer) { clearInterval(cdTimer); cdTimer = null; }
|
||||
finish();
|
||||
});
|
||||
}
|
||||
renderLive();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Wake Lock — keeps the screen on while the kiosk page is active.
|
||||
// Released automatically on tab close/navigation; re-acquired on
|
||||
@@ -668,6 +1151,7 @@
|
||||
if (setupBtn) {
|
||||
setupBtn.addEventListener("click", async () => {
|
||||
debugLog("setup button clicked");
|
||||
unlockAudio(); // user gesture → unlock Web Audio for clock sounds
|
||||
// Try Web NFC, but don't fail if absent — USB HID reader is a
|
||||
// first-class alternative (works on desktops/iOS too).
|
||||
let webNfcOk = false;
|
||||
@@ -704,6 +1188,7 @@
|
||||
console.warn("[nfc-kiosk] camera unavailable, continuing (photo not required)", camErr);
|
||||
}
|
||||
await acquireWakeLock();
|
||||
try { localStorage.setItem("nfc_setup_done", "1"); } catch (e) {}
|
||||
setState(STATE.IDLE);
|
||||
debugLog("setup: IDLE — Web NFC: " + (webNfcOk ? "✓" : "✗") + " · USB HID: ✓");
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ export class FusionClockShiftPlanner extends Component {
|
||||
error: "",
|
||||
dirtyCount: 0,
|
||||
invalidCount: 0,
|
||||
draftCount: 0,
|
||||
collapsed: {},
|
||||
editor: {
|
||||
open: false,
|
||||
@@ -89,6 +90,15 @@ export class FusionClockShiftPlanner extends Component {
|
||||
this.state.shifts = data.shifts || [];
|
||||
this.state.dirtyCount = 0;
|
||||
this.state.invalidCount = 0;
|
||||
let draft = 0;
|
||||
for (const emp of this.state.employees) {
|
||||
for (const key in emp.cells || {}) {
|
||||
if (emp.cells[key] && emp.cells[key].state === "draft") {
|
||||
draft += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.state.draftCount = draft;
|
||||
this.state.error = "";
|
||||
this.closeCellEditor();
|
||||
}
|
||||
@@ -194,6 +204,34 @@ export class FusionClockShiftPlanner extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
async postWeek() {
|
||||
if (this.state.dirtyCount) {
|
||||
this.notification.add("Save your changes before posting.", { type: "warning" });
|
||||
return;
|
||||
}
|
||||
if (!window.confirm("Post this week's schedule? Employees will be emailed their shifts, and reminders/absence checks will start using it.")) {
|
||||
return;
|
||||
}
|
||||
this.state.saving = true;
|
||||
try {
|
||||
const result = await rpc("/fusion_clock/shift_planner/post_week", {
|
||||
week_start: this.state.weekStart,
|
||||
});
|
||||
if (result.error) {
|
||||
this.notification.add(result.error, { type: "danger" });
|
||||
} else {
|
||||
this._applyData(result.data);
|
||||
this.notification.add(
|
||||
`Posted ${result.posted || 0} shift(s); notified ${result.notified || 0} employee(s).`,
|
||||
{ type: "success" },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.notification.add(error.message || "Could not post the schedule.", { type: "danger" });
|
||||
}
|
||||
this.state.saving = false;
|
||||
}
|
||||
|
||||
openCellEditor(employee, day, ev) {
|
||||
if (this.state.loading || this.state.saving) {
|
||||
return;
|
||||
|
||||
@@ -57,6 +57,7 @@ html:has(#nfc_kiosk_root) {
|
||||
padding: 2rem;
|
||||
box-sizing: border-box;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
overflow: hidden;
|
||||
background: var(--nfc-bg);
|
||||
@@ -106,18 +107,19 @@ html:has(#nfc_kiosk_root) {
|
||||
top: 1.25rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-height: 52px;
|
||||
max-width: 220px;
|
||||
max-height: 64px;
|
||||
max-width: 260px;
|
||||
object-fit: contain;
|
||||
background: rgba(255, 255, 255, 0.20);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(24px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
||||
padding: 0.5rem 0.85rem;
|
||||
border-radius: 0.85rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
padding: 0.6rem 1.1rem;
|
||||
border-radius: 1rem;
|
||||
border: 2px solid hsla(var(--nfc-h), 85%, 72%, 0.95);
|
||||
box-shadow:
|
||||
0 6px 24px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||
0 8px 28px rgba(0, 0, 0, 0.4),
|
||||
0 0 30px hsla(var(--nfc-h), 90%, 62%, 0.6),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||
box-sizing: content-box;
|
||||
animation: nfc-logo-in 1.2s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
@@ -190,6 +192,29 @@ html:has(#nfc_kiosk_root) {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.nfc-kiosk__lock {
|
||||
position: absolute;
|
||||
bottom: 1.5rem;
|
||||
right: 4.85rem; // sits just left of the ⚙
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.04);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
color: var(--nfc-text-muted);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
cursor: pointer;
|
||||
font-size: 1.05rem;
|
||||
display: none; // JS shows it only when unlocked
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
// Unlock button sits in the primary bottom-right corner (shown only while locked)
|
||||
#nfc_unlock_btn { right: 1.5rem; }
|
||||
|
||||
.nfc-kiosk__settings {
|
||||
position: absolute;
|
||||
bottom: 1.5rem;
|
||||
@@ -234,7 +259,7 @@ html:has(#nfc_kiosk_root) {
|
||||
// State container — base fade-in for whatever child renders
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
#nfc_state_container > * {
|
||||
animation: nfc-state-in 400ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
animation: nfc-state-in 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes nfc-state-in {
|
||||
@@ -251,6 +276,7 @@ html:has(#nfc_kiosk_root) {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
margin-top: 7rem; // push icon + prompt down so waves clear the clock/time
|
||||
}
|
||||
|
||||
.nfc-kiosk__icon-svg {
|
||||
@@ -263,6 +289,7 @@ html:has(#nfc_kiosk_root) {
|
||||
.nfc-chip {
|
||||
animation: nfc-chip-pulse 2.5s ease-in-out infinite;
|
||||
transform-origin: center;
|
||||
transform-box: fill-box;
|
||||
}
|
||||
|
||||
.nfc-wave {
|
||||
@@ -340,8 +367,10 @@ html:has(#nfc_kiosk_root) {
|
||||
max-width: 720px;
|
||||
padding: 2.5rem 3rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
text-align: center;
|
||||
gap: 1.25rem;
|
||||
position: relative;
|
||||
|
||||
&--success {
|
||||
@@ -350,7 +379,7 @@ html:has(#nfc_kiosk_root) {
|
||||
0 20px 60px rgba(0,0,0,0.5),
|
||||
0 0 80px rgba(24,169,87,0.35),
|
||||
inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
animation: nfc-success-burst 700ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
animation: nfc-success-burst 350ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
&--error {
|
||||
@@ -359,7 +388,7 @@ html:has(#nfc_kiosk_root) {
|
||||
0 20px 60px rgba(0,0,0,0.5),
|
||||
0 0 60px rgba(217,55,78,0.3),
|
||||
inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
animation: nfc-shake 350ms ease-in-out, nfc-state-in 400ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
animation: nfc-shake 350ms ease-in-out, nfc-state-in 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,7 +435,7 @@ html:has(#nfc_kiosk_root) {
|
||||
flex-shrink: 0;
|
||||
border: 2px solid rgba(255,255,255,0.2);
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||
animation: nfc-avatar-in 600ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
animation: nfc-avatar-in 300ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
}
|
||||
|
||||
@keyframes nfc-avatar-in {
|
||||
@@ -415,11 +444,11 @@ html:has(#nfc_kiosk_root) {
|
||||
}
|
||||
|
||||
.nfc-kiosk__result-text {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
|
||||
.name { font-size: 2.25rem; font-weight: 600; letter-spacing: -0.02em; }
|
||||
.action { font-size: 1.5rem; margin-top: 0.5rem; opacity: 0.95; font-weight: 400; }
|
||||
.hours { font-size: 1.05rem; opacity: 0.75; margin-top: 0.5rem; }
|
||||
.hours { font-size: 1.35rem; opacity: 0.9; margin-top: 0.6rem; font-weight: 500; }
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -477,9 +506,17 @@ html:has(#nfc_kiosk_root) {
|
||||
|
||||
.nfc-kiosk__enroll-panel {
|
||||
@extend %nfc-glass;
|
||||
padding: 2.5rem;
|
||||
padding: 2rem;
|
||||
width: 80vw;
|
||||
max-width: 720px;
|
||||
max-height: 92vh;
|
||||
overflow-y: auto;
|
||||
|
||||
// Compact PIN-pad variant — narrower than the wide list panels, but a
|
||||
// definite width so the centred flex overlay doesn't collapse it.
|
||||
// (Don't use CSS min() here — Odoo's Sass compiler evaluates the built-in
|
||||
// min() and errors on mixed vw/px units; width+max-width is equivalent.)
|
||||
&--pin { width: 86vw; max-width: 380px; padding: 1.75rem 1.75rem 1.5rem; }
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
@@ -491,12 +528,13 @@ html:has(#nfc_kiosk_root) {
|
||||
.numpad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0;
|
||||
gap: 0.6rem;
|
||||
margin: 0.5rem 0 0.75rem;
|
||||
|
||||
button {
|
||||
font-size: 2rem;
|
||||
padding: 1.5rem 0;
|
||||
font-size: 1.6rem;
|
||||
min-height: 60px;
|
||||
padding: 0.4rem 0;
|
||||
background: rgba(255,255,255,0.05);
|
||||
color: var(--nfc-text);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
@@ -511,12 +549,12 @@ html:has(#nfc_kiosk_root) {
|
||||
}
|
||||
|
||||
.pin-display {
|
||||
font-size: 2.5rem;
|
||||
font-size: 2.2rem;
|
||||
letter-spacing: 0.5rem;
|
||||
text-align: center;
|
||||
margin: 1rem 0 1.5rem;
|
||||
margin: 0.75rem 0 1rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-height: 3rem;
|
||||
min-height: 2.6rem;
|
||||
color: hsl(var(--nfc-h), 80%, 70%);
|
||||
}
|
||||
|
||||
@@ -578,6 +616,135 @@ html:has(#nfc_kiosk_root) {
|
||||
}
|
||||
}
|
||||
|
||||
// Amber accent for the "unknown card → program it" prompt + the inline
|
||||
// status line in the new-employee form.
|
||||
.nfc-kiosk__enroll-panel.nfc-kiosk__unknown {
|
||||
border-color: rgba(224, 168, 62, 0.55);
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0,0,0,0.5),
|
||||
0 0 60px rgba(224, 168, 62, 0.28),
|
||||
inset 0 1px 0 rgba(255,255,255,0.08);
|
||||
|
||||
.unknown-icon { font-size: 3.5rem; line-height: 1; margin-bottom: 0.5rem; color: #e0a83e; }
|
||||
h2 { color: #e0a83e; }
|
||||
}
|
||||
|
||||
.nfc-kiosk__enroll-panel .enroll-msg {
|
||||
min-height: 1.4rem;
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
color: var(--nfc-error);
|
||||
font-size: 0.95rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// Manager page rows (employee + tag status + per-row actions)
|
||||
.nfc-kiosk__enroll-panel .manager-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.7rem 0.4rem;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
flex-wrap: wrap;
|
||||
|
||||
.m-info { display: flex; align-items: baseline; gap: 0.5rem; flex: 1; min-width: 12rem; }
|
||||
.m-name { font-size: 1.05rem; }
|
||||
.m-dept { color: var(--nfc-text-muted); font-size: 0.8rem; }
|
||||
.m-tag { font-size: 0.78rem; color: var(--nfc-text-muted); white-space: nowrap; }
|
||||
.m-tag--on { color: hsl(var(--nfc-h), 70%, 66%); }
|
||||
.m-actions { display: flex; gap: 0.4rem; flex-shrink: 0; flex-wrap: wrap; }
|
||||
.m-btn {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: var(--nfc-text);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
cursor: pointer;
|
||||
|
||||
&.m-danger { color: #ff8b9a; border-color: rgba(217,55,78,0.45); }
|
||||
&:active { transform: scale(0.96); }
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Guided profile-photo capture — live camera with an oval face guide
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
.nfc-kiosk__photo-panel {
|
||||
@extend %nfc-glass;
|
||||
padding: 1.5rem;
|
||||
width: 80vw;
|
||||
max-width: 540px;
|
||||
max-height: 92vh;
|
||||
overflow-y: auto;
|
||||
text-align: center;
|
||||
|
||||
h2 { font-size: 1.4rem; margin: 0 0 1rem; font-weight: 400; }
|
||||
}
|
||||
.nfc-photo-stage {
|
||||
position: relative;
|
||||
aspect-ratio: 3 / 4; // portrait — width follows the (height-driven) box
|
||||
height: 62vh;
|
||||
max-height: 520px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
.nfc-photo-video,
|
||||
.nfc-photo-preview {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.nfc-photo-video { transform: scaleX(-1); } // mirror for a natural selfie view
|
||||
.nfc-photo-guide {
|
||||
position: absolute;
|
||||
top: 47%;
|
||||
left: 50%;
|
||||
width: 64%;
|
||||
aspect-ratio: 3 / 4; // VERTICAL oval — a face is taller than it is wide
|
||||
transform: translate(-50%, -50%);
|
||||
border: 3px dashed rgba(255, 255, 255, 0.92);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5); // dim everything outside the oval
|
||||
pointer-events: none;
|
||||
}
|
||||
.nfc-photo-countdown {
|
||||
position: absolute;
|
||||
top: 47%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 6rem;
|
||||
font-weight: 200;
|
||||
line-height: 1;
|
||||
color: #fff;
|
||||
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.85);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
|
||||
&.is-active { opacity: 1; animation: nfc-cd-pop 1s ease-out infinite; }
|
||||
}
|
||||
@keyframes nfc-cd-pop {
|
||||
0% { transform: translate(-50%, -50%) scale(1.25); opacity: 0.35; }
|
||||
40% { opacity: 1; }
|
||||
100% { transform: translate(-50%, -50%) scale(0.9); opacity: 0.7; }
|
||||
}
|
||||
.nfc-photo-hint {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0.75rem;
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
text-shadow: 0 1px 5px rgba(0, 0, 0, 0.9);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Reduced-motion fallback — respect users who prefer no animation
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
Save
|
||||
<t t-if="state.dirtyCount">(<t t-esc="state.dirtyCount"/>)</t>
|
||||
</button>
|
||||
<button class="btn btn-success" t-on-click="() => this.postWeek()" t-att-disabled="state.loading or state.saving or state.dirtyCount" t-att-title="state.dirtyCount ? 'Save your changes before posting' : 'Publish this week and email employees their shifts'">
|
||||
<i class="fa fa-paper-plane me-1"/> Post Schedule
|
||||
<t t-if="state.draftCount">(<t t-esc="state.draftCount"/> draft)</t>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -100,7 +104,7 @@
|
||||
</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' : '')"
|
||||
<td t-att-class="'fclk-planner__shift-cell ' + (cell.error ? 'fclk-planner__shift-cell--error ' : '') + (cell.source !== 'schedule' ? '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"
|
||||
|
||||
@@ -3,3 +3,5 @@
|
||||
from . import test_nfc_models
|
||||
from . import test_clock_nfc_kiosk
|
||||
from . import test_shift_planner
|
||||
from . import test_photo_retention
|
||||
from . import test_schedule_driven
|
||||
|
||||
@@ -411,3 +411,46 @@ class TestEmployeeSearch(HttpCase):
|
||||
self.assertIn('employees', result)
|
||||
names = [e['name'] for e in result['employees']]
|
||||
self.assertIn('Searchable Steve', names)
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestCreateEmployeeEndpoint(HttpCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.ICP = cls.env['ir.config_parameter'].sudo()
|
||||
cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
|
||||
cls.ICP.set_param('fusion_clock.nfc_enroll_password', '1234')
|
||||
cls.kiosk_user = cls.env['res.users'].create({
|
||||
'name': 'Create Kiosk User',
|
||||
'login': 'nfc-kiosk-create',
|
||||
'password': 'kioskpass123',
|
||||
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
|
||||
})
|
||||
|
||||
def _call(self, payload):
|
||||
self.authenticate('nfc-kiosk-create', 'kioskpass123')
|
||||
response = self.url_open(
|
||||
'/fusion_clock/kiosk/nfc/create_employee',
|
||||
data=json.dumps({'jsonrpc': '2.0', 'method': 'call', 'params': payload}),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
return response.json().get('result', {})
|
||||
|
||||
def test_create_success_clock_enabled(self):
|
||||
result = self._call({'name': 'Newhire Nancy', 'enroll_password': '1234'})
|
||||
self.assertIn('employee_id', result)
|
||||
emp = self.env['hr.employee'].browse(result['employee_id'])
|
||||
self.assertTrue(emp.exists())
|
||||
self.assertEqual(emp.name, 'Newhire Nancy')
|
||||
self.assertTrue(emp.x_fclk_enable_clock)
|
||||
|
||||
def test_create_wrong_password(self):
|
||||
result = self._call({'name': 'Should Not Exist', 'enroll_password': 'wrong'})
|
||||
self.assertEqual(result.get('error'), 'invalid_password')
|
||||
self.assertFalse(self.env['hr.employee'].search([('name', '=', 'Should Not Exist')]))
|
||||
|
||||
def test_create_invalid_name(self):
|
||||
result = self._call({'name': 'X', 'enroll_password': '1234'})
|
||||
self.assertEqual(result.get('error'), 'invalid_name')
|
||||
|
||||
64
fusion_clock/tests/test_photo_retention.py
Normal file
64
fusion_clock/tests/test_photo_retention.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import base64
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import fields
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestPhotoRetention(TransactionCase):
|
||||
"""The daily wipe cron must delete verification photos older than the
|
||||
retention window while keeping the attendance records (and recent photos)."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Attendance = self.env['hr.attendance']
|
||||
self.ICP = self.env['ir.config_parameter'].sudo()
|
||||
self.emp = self.env['hr.employee'].create({'name': 'Retention Test Emp'})
|
||||
# x_fclk_check_*_photo are plain Binary fields (no image validation),
|
||||
# so arbitrary base64 bytes are fine for the test.
|
||||
self.photo = base64.b64encode(b'pretend-jpeg-bytes')
|
||||
self.ICP.set_param('fusion_clock.photo_retention_days', '60')
|
||||
|
||||
def _make_attendance(self, days_ago):
|
||||
start = fields.Datetime.now() - timedelta(days=days_ago)
|
||||
att = self.Attendance.create({
|
||||
'employee_id': self.emp.id,
|
||||
'check_in': start,
|
||||
'check_out': start + timedelta(hours=8),
|
||||
})
|
||||
att.x_fclk_check_in_photo = self.photo
|
||||
att.x_fclk_check_out_photo = self.photo
|
||||
return att
|
||||
|
||||
def test_old_photos_wiped_recent_kept(self):
|
||||
old = self._make_attendance(days_ago=70)
|
||||
recent = self._make_attendance(days_ago=5)
|
||||
self.assertTrue(old.x_fclk_check_in_photo)
|
||||
self.assertTrue(recent.x_fclk_check_in_photo)
|
||||
|
||||
self.Attendance._cron_fusion_wipe_old_photos()
|
||||
self.env.invalidate_all() # binary fields are cached; re-read from DB
|
||||
|
||||
# Old shift: both photos gone, but the record + hours survive.
|
||||
self.assertFalse(old.x_fclk_check_in_photo, "Old check-in photo should be wiped")
|
||||
self.assertFalse(old.x_fclk_check_out_photo, "Old check-out photo should be wiped")
|
||||
self.assertTrue(old.exists(), "Attendance record itself must be kept")
|
||||
self.assertGreater(old.worked_hours, 0.0, "Worked hours must be preserved")
|
||||
|
||||
# Recent shift: photo untouched.
|
||||
self.assertTrue(recent.x_fclk_check_in_photo, "Recent photo should be kept")
|
||||
|
||||
def test_zero_retention_disables_wipe(self):
|
||||
self.ICP.set_param('fusion_clock.photo_retention_days', '0')
|
||||
old = self._make_attendance(days_ago=200)
|
||||
|
||||
self.Attendance._cron_fusion_wipe_old_photos()
|
||||
self.env.invalidate_all()
|
||||
|
||||
self.assertTrue(old.x_fclk_check_in_photo, "Retention=0 must disable the wipe")
|
||||
170
fusion_clock/tests/test_schedule_driven.py
Normal file
170
fusion_clock/tests/test_schedule_driven.py
Normal file
@@ -0,0 +1,170 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
from odoo import fields
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
try:
|
||||
from freezegun import freeze_time
|
||||
except ImportError: # freezegun may not be present on the runtime image
|
||||
freeze_time = None
|
||||
|
||||
# 2026-06-01 is a Monday, 2026-06-02 a Tuesday, 2026-06-06 a Saturday.
|
||||
MON = date(2026, 6, 1)
|
||||
TUE = date(2026, 6, 2)
|
||||
SAT = date(2026, 6, 6)
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestScheduleDriven(TransactionCase):
|
||||
"""Attendance automation must be driven by the employee's real schedule
|
||||
(posted planner entry -> recurring shift), never the global default."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.Employee = cls.env['hr.employee']
|
||||
cls.Schedule = cls.env['fusion.clock.schedule']
|
||||
cls.Shift = cls.env['fusion.clock.shift']
|
||||
cls.Attendance = cls.env['hr.attendance']
|
||||
cls.Log = cls.env['fusion.clock.activity.log']
|
||||
cls.ICP = cls.env['ir.config_parameter'].sudo()
|
||||
|
||||
cls.emp = cls.Employee.create({
|
||||
'name': 'Schedule Test',
|
||||
'x_fclk_enable_clock': True,
|
||||
'work_email': 'sched.test@example.com',
|
||||
'tz': 'UTC',
|
||||
})
|
||||
# Mon-Fri 09:00-17:00 recurring baseline (assigned per-test where needed).
|
||||
cls.shift = cls.Shift.create({
|
||||
'name': 'Test Day Shift',
|
||||
'start_time': 9.0, 'end_time': 17.0, 'break_minutes': 30.0,
|
||||
'day_mon': True, 'day_tue': True, 'day_wed': True,
|
||||
'day_thu': True, 'day_fri': True, 'day_sat': False, 'day_sun': False,
|
||||
})
|
||||
cls.ICP.set_param('fusion_clock.enable_employee_notifications', 'True')
|
||||
cls.ICP.set_param('fusion_clock.max_shift_hours', '16')
|
||||
|
||||
def _post(self, day, **vals):
|
||||
v = {
|
||||
'employee_id': self.emp.id, 'schedule_date': day, 'state': 'posted',
|
||||
'start_time': 9.0, 'end_time': 17.0, 'break_minutes': 30.0,
|
||||
}
|
||||
v.update(vals)
|
||||
return self.Schedule.create(v)
|
||||
|
||||
# ----- resolver matrix (time-independent) -----
|
||||
|
||||
def test_posted_working_is_scheduled(self):
|
||||
self._post(MON)
|
||||
plan = self.emp._get_fclk_day_plan(MON)
|
||||
self.assertTrue(plan['scheduled'])
|
||||
self.assertEqual(plan['source'], 'schedule')
|
||||
|
||||
def test_posted_off_is_not_scheduled(self):
|
||||
self._post(MON, is_off=True)
|
||||
plan = self.emp._get_fclk_day_plan(MON)
|
||||
self.assertFalse(plan['scheduled'])
|
||||
self.assertTrue(plan['is_off'])
|
||||
self.assertEqual(plan['source'], 'schedule')
|
||||
|
||||
def test_draft_entry_is_ignored(self):
|
||||
self.emp.x_fclk_shift_id = self.shift
|
||||
self._post(MON, state='draft') # draft on a Monday the shift covers
|
||||
plan = self.emp._get_fclk_day_plan(MON)
|
||||
# Draft ignored -> falls through to the recurring baseline.
|
||||
self.assertTrue(plan['scheduled'])
|
||||
self.assertEqual(plan['source'], 'shift')
|
||||
|
||||
def test_recurring_shift_covers_weekday(self):
|
||||
self.emp.x_fclk_shift_id = self.shift
|
||||
plan = self.emp._get_fclk_day_plan(MON)
|
||||
self.assertTrue(plan['scheduled'])
|
||||
self.assertEqual(plan['source'], 'shift')
|
||||
|
||||
def test_recurring_shift_skips_uncovered_weekday(self):
|
||||
self.emp.x_fclk_shift_id = self.shift
|
||||
plan = self.emp._get_fclk_day_plan(SAT) # Saturday not in the pattern
|
||||
self.assertFalse(plan['scheduled'])
|
||||
self.assertEqual(plan['source'], 'none')
|
||||
|
||||
def test_nothing_scheduled(self):
|
||||
plan = self.emp._get_fclk_day_plan(MON) # no posted entry, no shift
|
||||
self.assertFalse(plan['scheduled'])
|
||||
self.assertEqual(plan['source'], 'none')
|
||||
self.assertEqual(plan['label'], '') # portal card -> "Not scheduled"
|
||||
|
||||
def test_planner_edit_resets_to_draft(self):
|
||||
posted = self._post(MON)
|
||||
self.assertEqual(posted.state, 'posted')
|
||||
# Re-applying the cell via the planner path must drop it back to draft.
|
||||
self.Schedule.fclk_apply_planner_cell(self.emp, MON, {'input': '8:00 - 16:00'})
|
||||
self.assertEqual(posted.state, 'draft')
|
||||
|
||||
# ----- reminder cron -----
|
||||
|
||||
def test_no_reminder_when_not_scheduled(self):
|
||||
# Not scheduled today -> the cron must stay completely silent.
|
||||
self.Attendance._cron_fusion_employee_reminders()
|
||||
self.assertNotEqual(self.emp.x_fclk_last_reminder_date, fields.Date.context_today(self.emp))
|
||||
|
||||
def test_reminder_fires_for_scheduled_late(self):
|
||||
if freeze_time is None:
|
||||
self.skipTest("freezegun not available")
|
||||
with freeze_time("2026-06-01 12:00:00"): # Monday noon, shift started 09:00
|
||||
self._post(MON, start_time=9.0)
|
||||
self.Attendance._cron_fusion_employee_reminders()
|
||||
self.assertEqual(self.emp.x_fclk_last_reminder_date, MON)
|
||||
|
||||
def test_no_early_reminder_for_late_shift(self):
|
||||
if freeze_time is None:
|
||||
self.skipTest("freezegun not available")
|
||||
with freeze_time("2026-06-01 12:00:00"): # noon, but shift starts 14:00
|
||||
self._post(MON, start_time=14.0, end_time=22.0)
|
||||
self.Attendance._cron_fusion_employee_reminders()
|
||||
self.assertFalse(self.emp.x_fclk_last_reminder_date)
|
||||
|
||||
# ----- absence cron -----
|
||||
|
||||
def test_absence_for_scheduled_noshow(self):
|
||||
if freeze_time is None:
|
||||
self.skipTest("freezegun not available")
|
||||
with freeze_time("2026-06-02 09:00:00"): # Tuesday -> yesterday = Monday
|
||||
self._post(MON) # scheduled Monday, no attendance
|
||||
self.Attendance._cron_fusion_check_absences()
|
||||
self.assertEqual(self.Log.search_count([
|
||||
('employee_id', '=', self.emp.id), ('log_type', '=', 'absent'),
|
||||
]), 1)
|
||||
|
||||
def test_no_absence_when_not_scheduled(self):
|
||||
if freeze_time is None:
|
||||
self.skipTest("freezegun not available")
|
||||
with freeze_time("2026-06-02 09:00:00"): # yesterday Monday, nothing scheduled
|
||||
self.Attendance._cron_fusion_check_absences()
|
||||
self.assertEqual(self.Log.search_count([
|
||||
('employee_id', '=', self.emp.id), ('log_type', '=', 'absent'),
|
||||
]), 0)
|
||||
|
||||
# ----- auto clock-out (OT-aware safety cap) -----
|
||||
|
||||
def test_auto_clockout_only_past_cap(self):
|
||||
now = fields.Datetime.now()
|
||||
recent = self.Attendance.create({
|
||||
'employee_id': self.emp.id,
|
||||
'check_in': now - timedelta(hours=2),
|
||||
})
|
||||
emp2 = self.Employee.create({
|
||||
'name': 'Schedule Test 2', 'x_fclk_enable_clock': True, 'tz': 'UTC',
|
||||
})
|
||||
stale = self.Attendance.create({
|
||||
'employee_id': emp2.id,
|
||||
'check_in': now - timedelta(hours=17),
|
||||
})
|
||||
self.Attendance._cron_fusion_auto_clock_out()
|
||||
self.assertFalse(recent.check_out, "Under-cap shift must stay open (overtime).")
|
||||
self.assertTrue(stale.check_out, "Over-cap shift must be auto-closed.")
|
||||
@@ -102,15 +102,18 @@ class TestShiftPlannerModels(TransactionCase):
|
||||
'start_time': 10.0,
|
||||
'end_time': 18.0,
|
||||
'break_minutes': 60,
|
||||
'state': 'posted',
|
||||
})
|
||||
|
||||
planned = self.employee._get_fclk_day_plan(planned_date)
|
||||
fallback = self.employee._get_fclk_day_plan(planned_date + timedelta(days=1))
|
||||
|
||||
# Posted dated entry wins; the next day (no entry) falls back to the
|
||||
# employee's recurring shift, which now reports source 'shift'.
|
||||
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['source'], 'shift')
|
||||
self.assertEqual(fallback['start_time'], 8.0)
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,22 @@
|
||||
sequence="45"
|
||||
groups="group_fusion_clock_user"/>
|
||||
|
||||
<!-- Dedicated kiosk app: a separate top-level icon (same artwork) that opens
|
||||
the NFC kiosk. Gated to Kiosk Operator so shared tablet accounts see ONLY
|
||||
this app, not the full Fusion Clock backend. -->
|
||||
<record id="action_fusion_clock_kiosk_nfc" model="ir.actions.act_url">
|
||||
<field name="name">Fusion Clock Kiosk</field>
|
||||
<field name="url">/fusion_clock/kiosk/nfc</field>
|
||||
<field name="target">self</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fusion_clock_kiosk_app_root"
|
||||
name="Fusion Clock Kiosk"
|
||||
web_icon="fusion_clock,static/description/icon.png"
|
||||
action="action_fusion_clock_kiosk_nfc"
|
||||
sequence="46"
|
||||
groups="group_fusion_clock_kiosk_app"/>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<menuitem id="menu_fusion_clock_dashboard"
|
||||
name="Dashboard"
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<field name="end_time" widget="float_time"/>
|
||||
<field name="break_minutes"/>
|
||||
<field name="planned_hours"/>
|
||||
<field name="state" widget="badge" decoration-success="state == 'posted'" decoration-warning="state == 'draft'"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</list>
|
||||
</field>
|
||||
@@ -47,6 +48,8 @@
|
||||
</group>
|
||||
<group>
|
||||
<field name="note"/>
|
||||
<field name="state"/>
|
||||
<field name="posted_date" readonly="1"/>
|
||||
<field name="department_id" readonly="1"/>
|
||||
<field name="company_id" readonly="1" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
@@ -65,6 +68,9 @@
|
||||
<field name="schedule_date"/>
|
||||
<filter name="off" string="OFF" domain="[('is_off', '=', True)]"/>
|
||||
<filter name="working" string="Working" domain="[('is_off', '=', False)]"/>
|
||||
<separator/>
|
||||
<filter name="posted" string="Posted" domain="[('state', '=', 'posted')]"/>
|
||||
<filter name="draft" string="Draft" domain="[('state', '=', 'draft')]"/>
|
||||
<filter name="group_department" string="Department" context="{'group_by': 'department_id'}"/>
|
||||
<filter name="group_date" string="Date" context="{'group_by': 'schedule_date'}"/>
|
||||
</search>
|
||||
|
||||
@@ -39,6 +39,15 @@
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Working Days" col="7">
|
||||
<field name="day_mon"/>
|
||||
<field name="day_tue"/>
|
||||
<field name="day_wed"/>
|
||||
<field name="day_thu"/>
|
||||
<field name="day_fri"/>
|
||||
<field name="day_sat"/>
|
||||
<field name="day_sun"/>
|
||||
</group>
|
||||
<group string="Assigned Employees">
|
||||
<field name="employee_ids" nolabel="1" colspan="2">
|
||||
<list>
|
||||
|
||||
@@ -46,9 +46,14 @@
|
||||
<field name="x_fclk_is_overtime"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Check-In Photo" name="fusion_clock_photo"
|
||||
invisible="not x_fclk_checkin_photo">
|
||||
<field name="x_fclk_checkin_photo" widget="image" class="oe_avatar"/>
|
||||
<group string="Verification Photos" name="fusion_clock_photo"
|
||||
invisible="not x_fclk_check_in_photo and not x_fclk_check_out_photo and not x_fclk_checkin_photo">
|
||||
<field name="x_fclk_check_in_photo" widget="image" string="Clock-In Photo"
|
||||
options="{'size': [200, 200]}" invisible="not x_fclk_check_in_photo"/>
|
||||
<field name="x_fclk_check_out_photo" widget="image" string="Clock-Out Photo"
|
||||
options="{'size': [200, 200]}" invisible="not x_fclk_check_out_photo"/>
|
||||
<field name="x_fclk_checkin_photo" widget="image" string="Portal Check-In Photo"
|
||||
options="{'size': [200, 200]}" invisible="not x_fclk_checkin_photo"/>
|
||||
</group>
|
||||
<group string="Penalties" name="fusion_clock_penalties"
|
||||
invisible="not x_fclk_penalty_ids">
|
||||
|
||||
@@ -7,12 +7,25 @@
|
||||
<t t-set="no_footer" t-value="True"/>
|
||||
<t t-set="head">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"/>
|
||||
<!-- Installable full-screen kiosk app (Chrome "Install" / Safari "Add to Home Screen") -->
|
||||
<link rel="manifest" href="/fusion_clock/kiosk/nfc/manifest.webmanifest"/>
|
||||
<meta name="mobile-web-app-capable" content="yes"/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes"/>
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>
|
||||
<meta name="apple-mobile-web-app-title" content="Fusion Clock Kiosk"/>
|
||||
<link rel="apple-touch-icon" href="/fusion_clock/static/description/icon.png"/>
|
||||
<!-- Kiosk lockdown: hide Odoo's frontend->backend nav (the floating
|
||||
"Go to your Odoo Apps" / edit button the website module injects for
|
||||
logged-in internal users) so a kiosk user can't reach the backend.
|
||||
Page-scoped here so the real website keeps its nav. -->
|
||||
<style>.o_frontend_to_backend_nav { display: none !important; }</style>
|
||||
</t>
|
||||
|
||||
<div id="nfc_kiosk_root" class="nfc-kiosk"
|
||||
t-att-data-photo-required="'1' if photo_required else '0'"
|
||||
t-att-data-debug-enabled="'1' if debug_enabled else '0'"
|
||||
t-att-data-location-configured="'1' if location_configured else '0'"
|
||||
t-att-data-sounds-enabled="'1' if sounds_enabled else '0'"
|
||||
t-att-data-company-logo-url="company_logo_url or ''">
|
||||
|
||||
<!-- Company logo (also drives the dominant-hue palette via JS) -->
|
||||
@@ -31,6 +44,8 @@
|
||||
<span t-if="location_configured">Clock at: <t t-esc="location_name"/></span>
|
||||
<span t-else="" style="color:#d9374e">⚠ No location configured</span>
|
||||
</div>
|
||||
<button class="nfc-kiosk__lock" id="nfc_lock_btn" title="Lock screen">🔒</button>
|
||||
<button class="nfc-kiosk__lock" id="nfc_unlock_btn" title="Unlock (manager PIN)">🔓</button>
|
||||
<button class="nfc-kiosk__settings" id="nfc_settings_btn" title="Enroll Mode">⚙</button>
|
||||
|
||||
<!-- Dynamic state container (JS swaps inner HTML based on state) -->
|
||||
|
||||
@@ -95,6 +95,13 @@
|
||||
|
||||
<!-- Header -->
|
||||
<div class="fclk-header">
|
||||
<a href="/web/session/logout?redirect=/" class="fclk-signout" title="Sign Out">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
</a>
|
||||
<div class="fclk-date" id="fclk-date-display"></div>
|
||||
<h1 class="fclk-greeting">Hello, <t t-esc="employee.name.split(' ')[0]"/></h1>
|
||||
</div>
|
||||
@@ -305,6 +312,15 @@
|
||||
</svg>
|
||||
<span>Reports</span>
|
||||
</a>
|
||||
<t t-if="show_payslips">
|
||||
<a href="/my/clock/payslips" class="fclk-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="5" width="20" height="14" rx="2"/>
|
||||
<line x1="2" y1="10" x2="22" y2="10"/>
|
||||
</svg>
|
||||
<span>Payslips</span>
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
191
fusion_clock/views/portal_payslip_templates.xml
Normal file
191
fusion_clock/views/portal_payslip_templates.xml
Normal file
@@ -0,0 +1,191 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Payslip List -->
|
||||
<template id="portal_payslip_list_page" name="Fusion Clock Payslips">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="False"/>
|
||||
<t t-set="no_breadcrumbs" t-value="True"/>
|
||||
<t t-set="no_header" t-value="True"/>
|
||||
|
||||
<div class="fclk-app">
|
||||
<div class="fclk-reports-container">
|
||||
<div class="fclk-ts-header" style="margin-bottom:24px;">
|
||||
<h2>Payslips</h2>
|
||||
</div>
|
||||
|
||||
<t t-if="payslips">
|
||||
<t t-foreach="payslips" t-as="payslip">
|
||||
<a t-attf-href="/my/clock/payslips/#{payslip.id}"
|
||||
class="fclk-report-item fclk-payslip-item">
|
||||
<div class="fclk-report-info">
|
||||
<h4>
|
||||
<t t-esc="payslip.date_from.strftime('%b %d')"/> -
|
||||
<t t-esc="payslip.date_to.strftime('%b %d, %Y')"/>
|
||||
</h4>
|
||||
<p>
|
||||
Net
|
||||
<span t-field="payslip.net_wage"
|
||||
t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
|
||||
</p>
|
||||
</div>
|
||||
<span t-attf-class="fclk-payslip-status fclk-payslip-status--#{payslip.state}">
|
||||
<t t-if="payslip.state == 'paid'">Paid</t>
|
||||
<t t-else="">Done</t>
|
||||
</span>
|
||||
</a>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="fclk-empty-state">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#6b7280" stroke-width="1.5">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
</svg>
|
||||
<p>No payslips available yet.</p>
|
||||
<p style="font-size:12px; margin-top:4px;">Finalized pay slips will appear here after each pay run.</p>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<div class="fclk-nav-bar">
|
||||
<a href="/my/clock" class="fclk-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
<span>Clock</span>
|
||||
</a>
|
||||
<a href="/my/clock/timesheets" class="fclk-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
<span>Timesheets</span>
|
||||
</a>
|
||||
<a href="/my/clock/reports" class="fclk-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
</svg>
|
||||
<span>Reports</span>
|
||||
</a>
|
||||
<a href="/my/clock/payslips" class="fclk-nav-item fclk-nav-active">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="5" width="20" height="14" rx="2"/>
|
||||
<line x1="2" y1="10" x2="22" y2="10"/>
|
||||
</svg>
|
||||
<span>Payslips</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Payslip Detail — inline paystub -->
|
||||
<template id="portal_payslip_detail_page" name="Fusion Clock Payslip Detail">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="False"/>
|
||||
<t t-set="no_breadcrumbs" t-value="True"/>
|
||||
<t t-set="no_header" t-value="True"/>
|
||||
|
||||
<div class="fclk-app">
|
||||
<div class="fclk-reports-container">
|
||||
<div class="fclk-ts-header fclk-payslip-detail-header" style="margin-bottom:16px;">
|
||||
<a href="/my/clock/payslips" class="fclk-payslip-back">← Payslips</a>
|
||||
<h2>
|
||||
<t t-esc="payslip.date_from.strftime('%b %d')"/> -
|
||||
<t t-esc="payslip.date_to.strftime('%b %d, %Y')"/>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Net pay highlight -->
|
||||
<div class="fclk-status-card fclk-payslip-net">
|
||||
<span class="fclk-payslip-net-label">Net Pay</span>
|
||||
<span class="fclk-payslip-net-value"
|
||||
t-field="payslip.net_wage"
|
||||
t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
|
||||
</div>
|
||||
|
||||
<!-- Breakdown — iterate the payslip's computed lines so this
|
||||
works for any payroll provider (enterprise hr_payroll or
|
||||
the custom fusion_payroll), independent of one field schema. -->
|
||||
<div class="fclk-status-card fclk-payslip-section">
|
||||
<h4 class="fclk-payslip-section-title">Breakdown</h4>
|
||||
<t t-foreach="payslip.line_ids" t-as="line">
|
||||
<div t-attf-class="fclk-payslip-row#{' fclk-payslip-row--total' if line.code == 'NET' else ''}">
|
||||
<span t-esc="line.name"/>
|
||||
<span t-field="line.total"
|
||||
t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="not payslip.line_ids">
|
||||
<div class="fclk-payslip-row">
|
||||
<span>Gross</span>
|
||||
<span t-field="payslip.gross_wage"
|
||||
t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
|
||||
</div>
|
||||
<div class="fclk-payslip-row fclk-payslip-row--total">
|
||||
<span>Net</span>
|
||||
<span t-field="payslip.net_wage"
|
||||
t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<t t-if="has_pdf">
|
||||
<a t-attf-href="/my/clock/payslips/#{payslip.id}/pdf"
|
||||
class="fclk-payslip-pdf-btn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:middle; margin-right:6px;">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
Download PDF
|
||||
</a>
|
||||
</t>
|
||||
|
||||
<div class="fclk-nav-bar">
|
||||
<a href="/my/clock" class="fclk-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
<span>Clock</span>
|
||||
</a>
|
||||
<a href="/my/clock/timesheets" class="fclk-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
<span>Timesheets</span>
|
||||
</a>
|
||||
<a href="/my/clock/reports" class="fclk-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
</svg>
|
||||
<span>Reports</span>
|
||||
</a>
|
||||
<a href="/my/clock/payslips" class="fclk-nav-item fclk-nav-active">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="5" width="20" height="14" rx="2"/>
|
||||
<line x1="2" y1="10" x2="22" y2="10"/>
|
||||
</svg>
|
||||
<span>Payslips</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -86,6 +86,15 @@
|
||||
</svg>
|
||||
<span>Reports</span>
|
||||
</a>
|
||||
<t t-if="show_payslips">
|
||||
<a href="/my/clock/payslips" class="fclk-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="5" width="20" height="14" rx="2"/>
|
||||
<line x1="2" y1="10" x2="22" y2="10"/>
|
||||
</svg>
|
||||
<span>Payslips</span>
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -146,6 +146,15 @@
|
||||
</svg>
|
||||
<span>Reports</span>
|
||||
</a>
|
||||
<t t-if="show_payslips">
|
||||
<a href="/my/clock/payslips" class="fclk-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="5" width="20" height="14" rx="2"/>
|
||||
<line x1="2" y1="10" x2="22" y2="10"/>
|
||||
</svg>
|
||||
<span>Payslips</span>
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -258,6 +258,10 @@
|
||||
<label for="fclk_nfc_photo_required" string="Require Photo" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_nfc_photo_required"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="fclk_photo_retention_days" string="Auto-Wipe Photos After (days)" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_photo_retention_days"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="fclk_nfc_enroll_password" string="Enroll Password" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_nfc_enroll_password" password="True"/>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Inventory',
|
||||
'version': '19.0.1.0.0',
|
||||
'version': '19.0.1.1.0',
|
||||
'category': 'Inventory',
|
||||
'summary': 'Advanced inventory management with margin tracking, sync, portal sheet, and inter-company transfers',
|
||||
'description': """
|
||||
|
||||
@@ -72,15 +72,24 @@ class FusionSyncConfig(models.Model):
|
||||
|
||||
# ── XML-RPC Connection ──
|
||||
|
||||
def _get_xmlrpc_connection(self):
|
||||
def _get_xmlrpc_connection(self, force=False):
|
||||
self.ensure_one()
|
||||
url = self.url.rstrip('/')
|
||||
try:
|
||||
models_proxy = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/object', allow_none=True)
|
||||
# Reuse the cached uid to skip the authenticate() handshake — each
|
||||
# authenticate writes a login-audit row on the remote. execute_kw
|
||||
# still re-checks the API key on every call, so this stays secure.
|
||||
# force=True forces a fresh authenticate (Test Connection / manual
|
||||
# inter-company ops, which must work even if the cache went stale).
|
||||
if self.remote_uid and not force:
|
||||
return self.remote_uid, models_proxy
|
||||
common = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/common', allow_none=True)
|
||||
uid = common.authenticate(self.db_name, self.username, self.api_key, {})
|
||||
if not uid:
|
||||
raise UserError('Authentication failed. Check username/API key.')
|
||||
models_proxy = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/object', allow_none=True)
|
||||
if uid != self.remote_uid:
|
||||
self.sudo().write({'remote_uid': uid})
|
||||
return uid, models_proxy
|
||||
except xmlrpc.client.Fault as e:
|
||||
raise UserError(f'XML-RPC error: {e.faultString}')
|
||||
@@ -92,7 +101,7 @@ class FusionSyncConfig(models.Model):
|
||||
def action_test_connection(self):
|
||||
self.ensure_one()
|
||||
try:
|
||||
uid, models_proxy = self._get_xmlrpc_connection()
|
||||
uid, models_proxy = self._get_xmlrpc_connection(force=True)
|
||||
version_info = xmlrpc.client.ServerProxy(
|
||||
f'{self.url.rstrip("/")}/xmlrpc/2/common', allow_none=True
|
||||
).version()
|
||||
@@ -174,6 +183,9 @@ class FusionSyncConfig(models.Model):
|
||||
'state': 'error',
|
||||
'last_sync': fields.Datetime.now(),
|
||||
'last_sync_status': error_msg,
|
||||
# Drop the cached uid so the next run re-authenticates fresh
|
||||
# (the failure may have been a stale/invalid cached uid).
|
||||
'remote_uid': False,
|
||||
})
|
||||
self.env['fusion.sync.log'].create({
|
||||
'config_id': self.id,
|
||||
@@ -544,7 +556,7 @@ class FusionSyncConfig(models.Model):
|
||||
def _create_remote_sale_order(self, product_mapping, qty, partner_name):
|
||||
"""Create a sale order on the remote instance for inter-company transfers."""
|
||||
self.ensure_one()
|
||||
uid, models_proxy = self._get_xmlrpc_connection()
|
||||
uid, models_proxy = self._get_xmlrpc_connection(force=True)
|
||||
|
||||
partners = models_proxy.execute_kw(
|
||||
self.db_name, uid, self.api_key,
|
||||
@@ -593,7 +605,7 @@ class FusionSyncConfig(models.Model):
|
||||
def _create_remote_invoice(self, remote_so_id):
|
||||
"""Create and post an invoice for a remote sale order."""
|
||||
self.ensure_one()
|
||||
uid, models_proxy = self._get_xmlrpc_connection()
|
||||
uid, models_proxy = self._get_xmlrpc_connection(force=True)
|
||||
|
||||
models_proxy.execute_kw(
|
||||
self.db_name, uid, self.api_key,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Login Audit',
|
||||
'version': '19.0.1.0.0',
|
||||
'version': '19.0.1.1.0',
|
||||
'category': 'Tools',
|
||||
'summary': 'Durable login audit log with geo-enrichment, retention, and failure alerts.',
|
||||
'description': """
|
||||
|
||||
@@ -72,6 +72,17 @@ class FusionLoginAudit(models.Model):
|
||||
string='Device Type', default='unknown',
|
||||
)
|
||||
database = fields.Char(string='Database', size=64)
|
||||
login_kind = fields.Selection(
|
||||
[
|
||||
('interactive', 'Interactive'),
|
||||
('service', 'Service / Automation'),
|
||||
],
|
||||
string='Login Kind', default='interactive', index=True,
|
||||
help="Interactive = a real browser/HTTP login. Service = server-to-server "
|
||||
"auth (XML-RPC/JSON-RPC, cron, inter-instance sync) with no HTTP "
|
||||
"request. Service logins are hidden from the default Login Events "
|
||||
"view to keep it focused on real user activity.",
|
||||
)
|
||||
|
||||
# Odoo 19 replaces the legacy `_sql_constraints = [...]` list with
|
||||
# declarative `models.Constraint` attributes. The plan template used the
|
||||
|
||||
@@ -86,11 +86,16 @@ class ResUsers(models.Model):
|
||||
else:
|
||||
vals['device_type'] = 'unknown'
|
||||
vals['geo_lookup_state'] = 'pending'
|
||||
# A live HTTP request means a real browser/interactive login.
|
||||
vals['login_kind'] = 'interactive'
|
||||
else:
|
||||
vals['ip_address'] = 'internal'
|
||||
vals['user_agent_raw'] = '<no-request>'
|
||||
vals['device_type'] = 'unknown'
|
||||
vals['geo_lookup_state'] = 'internal'
|
||||
# No HTTP request = server-to-server auth (XML-RPC/JSON-RPC, cron,
|
||||
# inter-instance sync). Tagged so the default view can hide it.
|
||||
vals['login_kind'] = 'service'
|
||||
|
||||
# _credential is accepted in the signature so callers (T6 _check_credentials,
|
||||
# T7 _login) can hand the dict in without filtering. The helper deliberately
|
||||
@@ -388,5 +393,6 @@ class ResUsers(models.Model):
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('user_id', '=', self.id)],
|
||||
'context': {'create': False, 'edit': False, 'delete': False,
|
||||
'default_user_id': self.id},
|
||||
'default_user_id': self.id,
|
||||
'search_default_filter_interactive': 1},
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ class TestFusionLoginAuditModel(TransactionCase):
|
||||
self.assertEqual(vals['ip_address'], 'internal')
|
||||
self.assertEqual(vals['user_agent_raw'], '<no-request>')
|
||||
self.assertEqual(vals['geo_lookup_state'], 'internal')
|
||||
self.assertEqual(vals['login_kind'], 'service')
|
||||
self.assertEqual(vals['database'], self.env.cr.dbname)
|
||||
|
||||
def test_build_event_vals_parses_user_agent(self):
|
||||
@@ -91,6 +92,7 @@ class TestFusionLoginAuditModel(TransactionCase):
|
||||
self.assertIn('Windows', vals['os'])
|
||||
self.assertEqual(vals['device_type'], 'desktop')
|
||||
self.assertEqual(vals['geo_lookup_state'], 'pending')
|
||||
self.assertEqual(vals['login_kind'], 'interactive')
|
||||
|
||||
def test_build_event_vals_strips_password(self):
|
||||
"""If a credential dict sneaks in, no password leaks into vals."""
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<field name="user_id"/>
|
||||
<field name="attempted_login"/>
|
||||
<field name="result" widget="badge"/>
|
||||
<field name="login_kind" optional="show"/>
|
||||
<field name="failure_reason"/>
|
||||
<field name="ip_address"/>
|
||||
<field name="country_code"/>
|
||||
@@ -36,6 +37,7 @@
|
||||
<group string="Event">
|
||||
<field name="event_time" readonly="1"/>
|
||||
<field name="result" readonly="1" widget="badge"/>
|
||||
<field name="login_kind" readonly="1"/>
|
||||
<field name="failure_reason" readonly="1"/>
|
||||
<field name="user_id" readonly="1"/>
|
||||
<field name="attempted_login" readonly="1"/>
|
||||
@@ -77,6 +79,11 @@
|
||||
<filter name="filter_failure" string="Failures"
|
||||
domain="[('result','=','failure')]"/>
|
||||
<separator/>
|
||||
<filter name="filter_interactive" string="Interactive"
|
||||
domain="[('login_kind','=','interactive')]"/>
|
||||
<filter name="filter_service" string="Service / Automation"
|
||||
domain="[('login_kind','=','service')]"/>
|
||||
<separator/>
|
||||
<filter name="filter_24h" string="Last 24 hours"
|
||||
domain="[('event_time','>=', (context_today() - relativedelta(days=1)).strftime('%Y-%m-%d 00:00:00'))]"/>
|
||||
<filter name="filter_7d" string="Last 7 days"
|
||||
@@ -93,6 +100,8 @@
|
||||
context="{'group_by': 'country_code'}"/>
|
||||
<filter name="group_ip" string="IP"
|
||||
context="{'group_by': 'ip_address'}"/>
|
||||
<filter name="group_kind" string="Login Kind"
|
||||
context="{'group_by': 'login_kind'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
@@ -104,7 +113,10 @@
|
||||
<field name="res_model">fusion.login.audit</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_fusion_login_audit_search"/>
|
||||
<field name="context">{}</field>
|
||||
<!-- Default to interactive logins only; service/automation auth
|
||||
(inter-instance sync, cron, XML-RPC) is hidden but reachable via
|
||||
the "Service / Automation" filter. -->
|
||||
<field name="context">{'search_default_filter_interactive': 1}</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_login_audit_failures_24h" model="ir.actions.act_window">
|
||||
|
||||
@@ -0,0 +1,759 @@
|
||||
# Employee Portal — Clock + Payslips Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Give internal staff a clean employee portal (Clock + Payslips, no customer sidebar) while customers keep the existing customer portal unchanged.
|
||||
|
||||
**Architecture:** `fusion_plating_portal` gates its own sidebar shell and redirects internal users to the clock page. `fusion_clock` owns all employee-portal pages — it adds finalized-payslip list + inline paystub routes under `/my/clock/payslips`, reading `hr.payslip` through a soft (`'hr.payslip' in env`) check so it never hard-depends on `fusion_payroll`.
|
||||
|
||||
**Tech Stack:** Odoo 19, `portal` controllers (`CustomerPortal`), QWeb templates, CSS (`portal_clock.css`), SCSS (`fp_portal_sidebar.scss`).
|
||||
|
||||
**Spec:** [2026-05-30-employee-portal-design.md](../specs/2026-05-30-employee-portal-design.md)
|
||||
|
||||
**Key facts established during planning (do not re-derive):**
|
||||
- Odoo merges every `CustomerPortal` subclass into one MRO, so `FpCustomerPortal._prepare_portal_layout_values` runs on the clock pages too — the gating flag reaches them.
|
||||
- On entech, `FpCustomerPortal.home()` is the active `/my/home` handler (that's why employees see the customer dashboard today). Editing it is the reliable fix.
|
||||
- `.o_fp_portal_shell` is a CSS grid `240px 1fr`; hiding only the `<aside>` leaves a 240px gutter, so the grid must collapse to `1fr` when there's no sidebar.
|
||||
- `.fclk-app` already hides Odoo's `.o_portal_navbar` + footer via CSS; only the FP sidebar leaks onto `/my/clock`.
|
||||
- `.fclk-nav-bar` is `display:flex; justify-content:center` — a 4th nav item fits.
|
||||
- Clock/timesheet/report pages set `no_breadcrumbs=True` + `no_header=True`; the new payslip pages must do the same.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**`fusion_plating_portal`** (gating only — customer module fixes itself)
|
||||
- Modify `controllers/portal.py` — add `fp_show_customer_sidebar` flag; redirect internal-with-employee users in `home()`.
|
||||
- Modify `views/fp_portal_shell.xml` — gate sidebar pieces + add grid-collapse modifier class.
|
||||
- Modify `static/src/scss/fp_portal_sidebar.scss` — `o_fp_portal_shell--no-sidebar` rule.
|
||||
- Modify `tests/` — HttpCase for redirect + sidebar gating.
|
||||
- Modify `__manifest__.py` — version bump.
|
||||
|
||||
**`fusion_clock`** (owns the employee-portal pages)
|
||||
- Modify `controllers/portal_clock.py` — `show_payslips` flag on the 3 existing pages; 3 new payslip routes (list / detail / pdf).
|
||||
- Create `views/portal_payslip_templates.xml` — payslip list + inline paystub.
|
||||
- Modify `views/portal_clock_templates.xml`, `views/portal_timesheet_templates.xml`, `views/portal_report_templates.xml` — add Payslips nav tab (gated on `show_payslips`) + a Sign Out control in the header.
|
||||
- Modify `static/src/css/portal_clock.css` — payslip list/detail styles, 4-item nav, sign-out button.
|
||||
- Modify `__manifest__.py` — register new view file; version bump.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Gate the customer sidebar + redirect employees (`fusion_plating_portal`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_plating/fusion_plating_portal/controllers/portal.py`
|
||||
- Modify: `fusion_plating/fusion_plating_portal/views/fp_portal_shell.xml`
|
||||
- Modify: `fusion_plating/fusion_plating_portal/static/src/scss/fp_portal_sidebar.scss`
|
||||
- Modify: `fusion_plating/fusion_plating_portal/__manifest__.py`
|
||||
|
||||
- [ ] **Step 1: Add the sidebar flag to the layout values**
|
||||
|
||||
In `controllers/portal.py`, inside `_prepare_portal_layout_values` (currently ends with `return values` after setting `fp_partner_display_name`), add the flag before the return:
|
||||
|
||||
```python
|
||||
values['fp_partner_display_name'] = commercial.name or partner.name
|
||||
# Internal staff (share=False) get the clean employee experience — no
|
||||
# customer sidebar. Customers (share=True / portal users) keep it.
|
||||
values['fp_show_customer_sidebar'] = bool(request.env.user.share)
|
||||
return values
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Carry the flag onto detail pages**
|
||||
|
||||
In `controllers/portal.py`, `_get_page_view_values` already setdefaults `fp_sidebar_items` and `fp_partner_display_name` from `layout`. Add the new key right after them:
|
||||
|
||||
```python
|
||||
values.setdefault('fp_sidebar_items', layout.get('fp_sidebar_items'))
|
||||
values.setdefault('fp_partner_display_name', layout.get('fp_partner_display_name'))
|
||||
values.setdefault('fp_show_customer_sidebar', layout.get('fp_show_customer_sidebar'))
|
||||
return values
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Redirect internal-with-employee users off the customer dashboard**
|
||||
|
||||
In `controllers/portal.py`, at the very top of `home()` (currently `def home(self, **kw):` then `partner = request.env.user.partner_id`), insert the guard FIRST:
|
||||
|
||||
```python
|
||||
def home(self, **kw):
|
||||
# Internal staff don't belong on the customer dashboard. Send them to
|
||||
# the employee clock portal — but only when fusion_clock is installed
|
||||
# (x_fclk_enable_clock proves it) AND the user actually has an employee
|
||||
# record, otherwise we'd bounce them into /my/clock -> /my -> loop.
|
||||
user = request.env.user
|
||||
if not user.share and 'hr.employee' in request.env:
|
||||
Employee = request.env['hr.employee'].sudo()
|
||||
if 'x_fclk_enable_clock' in Employee._fields and \
|
||||
Employee.search_count([('user_id', '=', user.id)]):
|
||||
return request.redirect('/my/clock')
|
||||
partner = request.env.user.partner_id
|
||||
commercial = partner.commercial_partner_id
|
||||
# ... existing body unchanged ...
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Gate the sidebar in the shell + collapse the grid**
|
||||
|
||||
In `views/fp_portal_shell.xml`, replace the `#wrap` xpath body (the `<div class="o_fp_portal_shell"> ... </div>` block) with the gated version. The `<main>$0</main>` stays a single `$0` (safe); the hamburger, backdrop, and sidebar are wrapped in `t-if`; the shell gets a modifier class when there's no sidebar:
|
||||
|
||||
```xml
|
||||
<xpath expr="//div[@id='wrap']" position="replace">
|
||||
<div t-attf-class="o_fp_portal_shell#{'' if (fp_show_customer_sidebar if fp_show_customer_sidebar is defined else True) else ' o_fp_portal_shell--no-sidebar'}">
|
||||
<t t-if="fp_show_customer_sidebar if fp_show_customer_sidebar is defined else True">
|
||||
<!-- Mobile hamburger (shown only below 768px via SCSS) -->
|
||||
<button type="button"
|
||||
class="o_fp_portal_hamburger d-md-none"
|
||||
aria-label="Open navigation">
|
||||
<i class="fa fa-bars"/>
|
||||
</button>
|
||||
<!-- Backdrop for mobile drawer (hidden by default) -->
|
||||
<div class="o_fp_portal_backdrop"/>
|
||||
<!-- Sidebar navigation component -->
|
||||
<t t-call="fusion_plating_portal.fp_portal_sidebar"/>
|
||||
</t>
|
||||
<!-- Main content area — original #wrap re-emitted here via $0 -->
|
||||
<main class="o_fp_portal_main">$0</main>
|
||||
</div>
|
||||
</xpath>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add the grid-collapse SCSS rule**
|
||||
|
||||
In `static/src/scss/fp_portal_sidebar.scss`, immediately after the `.o_fp_portal_shell { ... }` block (closes at the `}` after the `@media` block, around line 22), add:
|
||||
|
||||
```scss
|
||||
// Internal staff (employee portal) — no customer sidebar. Collapse the grid
|
||||
// to a single column so the page content isn't pushed right by the now-empty
|
||||
// 240px sidebar track.
|
||||
.o_fp_portal_shell--no-sidebar {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Bump the module version**
|
||||
|
||||
In `fusion_plating/fusion_plating_portal/__manifest__.py`, bump `version` (e.g. `19.0.4.4.1` → `19.0.4.5.0`).
|
||||
|
||||
- [ ] **Step 7: Write the HttpCase test**
|
||||
|
||||
Create or extend a test file `fusion_plating/fusion_plating_portal/tests/test_employee_portal_gating.py`:
|
||||
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests import tagged, HttpCase
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestEmployeePortalGating(HttpCase):
|
||||
|
||||
def test_customer_sees_sidebar_on_home(self):
|
||||
# The demo portal user is a share user -> customer portal with sidebar.
|
||||
portal_user = self.env.ref('base.demo_user0', raise_if_not_found=False) \
|
||||
or self.env['res.users'].search([('share', '=', True)], limit=1)
|
||||
self.assertTrue(portal_user, "need a share/portal user for this test")
|
||||
self.authenticate(portal_user.login, portal_user.login)
|
||||
r = self.url_open('/my/home')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertIn('o_fp_portal_sidebar', r.text,
|
||||
"customer should still see the FP sidebar shell")
|
||||
|
||||
def test_internal_employee_redirected_to_clock(self):
|
||||
internal = self.env['res.users'].create({
|
||||
'name': 'Shop Hand', 'login': 'shop_hand_test',
|
||||
'password': 'shop_hand_test',
|
||||
'group_ids': [(6, 0, [self.env.ref('base.group_user').id])],
|
||||
})
|
||||
self.assertFalse(internal.share)
|
||||
self.env['hr.employee'].create({'name': 'Shop Hand', 'user_id': internal.id})
|
||||
self.authenticate(internal.login, internal.login)
|
||||
# Don't follow the redirect (fusion_clock may not be installed in this
|
||||
# DB) — just assert we're bounced toward /my/clock.
|
||||
r = self.url_open('/my/home', allow_redirects=False)
|
||||
self.assertIn(r.status_code, (301, 302, 303, 307, 308))
|
||||
self.assertIn('/my/clock', r.headers.get('Location', ''))
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Run the test**
|
||||
|
||||
Run (note: requires `hr` installed in the test DB; `hr.employee` create needs it):
|
||||
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable \
|
||||
--test-tags /fusion_plating_portal -u fusion_plating_portal \
|
||||
--stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -40
|
||||
```
|
||||
|
||||
Expected: both tests PASS. If no Odoo dev container is running, this verification moves to the entech smoke test (Task 6) — note it and continue.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_plating/fusion_plating_portal/controllers/portal.py \
|
||||
fusion_plating/fusion_plating_portal/views/fp_portal_shell.xml \
|
||||
fusion_plating/fusion_plating_portal/static/src/scss/fp_portal_sidebar.scss \
|
||||
fusion_plating/fusion_plating_portal/__manifest__.py \
|
||||
fusion_plating/fusion_plating_portal/tests/test_employee_portal_gating.py
|
||||
git commit -m "feat(fusion_plating_portal): hide customer sidebar from internal staff + redirect them to the clock portal"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Payslip backend routes (`fusion_clock`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_clock/controllers/portal_clock.py`
|
||||
|
||||
- [ ] **Step 1: Add a `show_payslips` flag to the three existing pages**
|
||||
|
||||
In `controllers/portal_clock.py`, add a tiny helper on `FusionClockPortal` (near `_get_portal_employee`):
|
||||
|
||||
```python
|
||||
def _payroll_available(self):
|
||||
"""True when fusion_payroll (hr.payslip) is installed on this DB."""
|
||||
return 'hr.payslip' in request.env
|
||||
```
|
||||
|
||||
Then in each of `portal_clock`, `portal_timesheets`, and `portal_reports`, add `'show_payslips'` to the `values` dict that is rendered (alongside the existing `page_name`):
|
||||
|
||||
```python
|
||||
'page_name': 'clock',
|
||||
'show_payslips': self._payroll_available(),
|
||||
```
|
||||
(repeat the `'show_payslips': self._payroll_available(),` line in the `portal_timesheets` values and the `portal_reports` values).
|
||||
|
||||
- [ ] **Step 2: Add the payslip helper to find the employee's finalized slips**
|
||||
|
||||
In `controllers/portal_clock.py`, add a helper:
|
||||
|
||||
```python
|
||||
def _get_my_payslips(self, employee):
|
||||
"""Finalized payslips for this employee, newest first. Empty when
|
||||
payroll isn't installed."""
|
||||
if not self._payroll_available() or not employee:
|
||||
return request.env['hr.payslip'].browse() if self._payroll_available() else []
|
||||
return request.env['hr.payslip'].sudo().search(
|
||||
[('employee_id', '=', employee.id), ('state', 'in', ('done', 'paid'))],
|
||||
order='date_to desc, id desc',
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the payslip list route**
|
||||
|
||||
In `controllers/portal_clock.py`, after the reports routes, add:
|
||||
|
||||
```python
|
||||
# =========================================================================
|
||||
# Payslips
|
||||
# =========================================================================
|
||||
|
||||
@http.route('/my/clock/payslips', type='http', auth='user', website=True)
|
||||
def portal_payslips(self, **kw):
|
||||
"""List the employee's finalized pay slips."""
|
||||
employee = self._get_portal_employee()
|
||||
if not employee:
|
||||
return request.redirect('/my/clock')
|
||||
if not self._payroll_available():
|
||||
return request.redirect('/my/clock')
|
||||
payslips = self._get_my_payslips(employee)
|
||||
values = {
|
||||
'employee': employee,
|
||||
'payslips': payslips,
|
||||
'show_payslips': True,
|
||||
'page_name': 'payslips',
|
||||
}
|
||||
return request.render('fusion_clock.portal_payslip_list_page', values)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the payslip detail (inline paystub) route**
|
||||
|
||||
```python
|
||||
@http.route('/my/clock/payslips/<int:payslip_id>', type='http', auth='user', website=True)
|
||||
def portal_payslip_detail(self, payslip_id, **kw):
|
||||
"""Inline paystub for one finalized slip the employee owns."""
|
||||
employee = self._get_portal_employee()
|
||||
if not employee or not self._payroll_available():
|
||||
return request.redirect('/my/clock')
|
||||
payslip = request.env['hr.payslip'].sudo().browse(payslip_id)
|
||||
if not payslip.exists() or payslip.employee_id.id != employee.id \
|
||||
or payslip.state not in ('done', 'paid'):
|
||||
return request.redirect('/my/clock/payslips')
|
||||
# PDF availability: any qweb-pdf report bound to hr.payslip.
|
||||
pdf_report = request.env['ir.actions.report'].sudo().search(
|
||||
[('model', '=', 'hr.payslip'), ('report_type', '=', 'qweb-pdf')], limit=1)
|
||||
values = {
|
||||
'employee': employee,
|
||||
'payslip': payslip,
|
||||
'has_pdf': bool(pdf_report),
|
||||
'show_payslips': True,
|
||||
'page_name': 'payslips',
|
||||
}
|
||||
return request.render('fusion_clock.portal_payslip_detail_page', values)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add the PDF download route (sudo render + ownership guard)**
|
||||
|
||||
```python
|
||||
@http.route('/my/clock/payslips/<int:payslip_id>/pdf', type='http', auth='user', website=True)
|
||||
def portal_payslip_pdf(self, payslip_id, **kw):
|
||||
"""Render the standard payslip PDF (sudo) for a slip the employee owns."""
|
||||
employee = self._get_portal_employee()
|
||||
if not employee or not self._payroll_available():
|
||||
return request.redirect('/my/clock')
|
||||
payslip = request.env['hr.payslip'].sudo().browse(payslip_id)
|
||||
if not payslip.exists() or payslip.employee_id.id != employee.id \
|
||||
or payslip.state not in ('done', 'paid'):
|
||||
return request.redirect('/my/clock/payslips')
|
||||
report = request.env['ir.actions.report'].sudo().search(
|
||||
[('model', '=', 'hr.payslip'), ('report_type', '=', 'qweb-pdf')], limit=1)
|
||||
if not report:
|
||||
return request.redirect('/my/clock/payslips/%s' % payslip_id)
|
||||
pdf, _ = report._render_qweb_pdf(report.report_name, [payslip.id])
|
||||
filename = 'Payslip-%s.pdf' % (payslip.number or payslip.id)
|
||||
return request.make_response(pdf, headers=[
|
||||
('Content-Type', 'application/pdf'),
|
||||
('Content-Disposition', 'attachment; filename="%s"' % filename),
|
||||
])
|
||||
```
|
||||
|
||||
> Note: `payslip.number` is the standard slip reference on `hr.payslip`; if absent on this install, the `or payslip.id` fallback covers it.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_clock/controllers/portal_clock.py
|
||||
git commit -m "feat(fusion_clock): portal routes for employee payslips (list / inline paystub / pdf)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Payslip templates (`fusion_clock`)
|
||||
|
||||
**Files:**
|
||||
- Create: `fusion_clock/views/portal_payslip_templates.xml`
|
||||
- Modify: `fusion_clock/__manifest__.py`
|
||||
|
||||
- [ ] **Step 1: Create the templates file**
|
||||
|
||||
Create `fusion_clock/views/portal_payslip_templates.xml`. The list clones the Reports page; the detail is an inline paystub. Both carry the 4-item bottom nav with Payslips active. Money uses `t-field` monetary with `payslip.currency_id`.
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Payslip List -->
|
||||
<template id="portal_payslip_list_page" name="Fusion Clock Payslips">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="False"/>
|
||||
<t t-set="no_breadcrumbs" t-value="True"/>
|
||||
<t t-set="no_header" t-value="True"/>
|
||||
|
||||
<div class="fclk-app">
|
||||
<div class="fclk-reports-container">
|
||||
<div class="fclk-ts-header" style="margin-bottom:24px;">
|
||||
<h2>Payslips</h2>
|
||||
</div>
|
||||
|
||||
<t t-if="payslips">
|
||||
<t t-foreach="payslips" t-as="payslip">
|
||||
<a t-attf-href="/my/clock/payslips/#{payslip.id}"
|
||||
class="fclk-report-item fclk-payslip-item">
|
||||
<div class="fclk-report-info">
|
||||
<h4>
|
||||
<t t-esc="payslip.date_from.strftime('%b %d')"/> -
|
||||
<t t-esc="payslip.date_to.strftime('%b %d, %Y')"/>
|
||||
</h4>
|
||||
<p>
|
||||
Net
|
||||
<span t-field="payslip.net_wage"
|
||||
t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
|
||||
</p>
|
||||
</div>
|
||||
<span t-attf-class="fclk-payslip-status fclk-payslip-status--#{payslip.state}">
|
||||
<t t-if="payslip.state == 'paid'">Paid</t>
|
||||
<t t-else="">Done</t>
|
||||
</span>
|
||||
</a>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="fclk-empty-state">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#6b7280" stroke-width="1.5">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
</svg>
|
||||
<p>No payslips available yet.</p>
|
||||
<p style="font-size:12px; margin-top:4px;">Finalized pay slips will appear here after each pay run.</p>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-call="fusion_clock.portal_employee_navbar">
|
||||
<t t-set="active" t-value="'payslips'"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Payslip Detail — inline paystub -->
|
||||
<template id="portal_payslip_detail_page" name="Fusion Clock Payslip Detail">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="False"/>
|
||||
<t t-set="no_breadcrumbs" t-value="True"/>
|
||||
<t t-set="no_header" t-value="True"/>
|
||||
|
||||
<div class="fclk-app">
|
||||
<div class="fclk-reports-container">
|
||||
<div class="fclk-ts-header fclk-payslip-detail-header" style="margin-bottom:16px;">
|
||||
<a href="/my/clock/payslips" class="fclk-payslip-back">← Payslips</a>
|
||||
<h2>
|
||||
<t t-esc="payslip.date_from.strftime('%b %d')"/> -
|
||||
<t t-esc="payslip.date_to.strftime('%b %d, %Y')"/>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Net pay highlight -->
|
||||
<div class="fclk-status-card fclk-payslip-net">
|
||||
<span class="fclk-payslip-net-label">Net Pay</span>
|
||||
<span class="fclk-payslip-net-value"
|
||||
t-field="payslip.net_wage"
|
||||
t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
|
||||
</div>
|
||||
|
||||
<!-- Deductions -->
|
||||
<div class="fclk-status-card fclk-payslip-section">
|
||||
<h4 class="fclk-payslip-section-title">Deductions</h4>
|
||||
<div class="fclk-payslip-row">
|
||||
<span>CPP</span>
|
||||
<span t-field="payslip.employee_cpp" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
|
||||
</div>
|
||||
<div class="fclk-payslip-row" t-if="payslip.employee_cpp2">
|
||||
<span>CPP2</span>
|
||||
<span t-field="payslip.employee_cpp2" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
|
||||
</div>
|
||||
<div class="fclk-payslip-row">
|
||||
<span>EI</span>
|
||||
<span t-field="payslip.employee_ei" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
|
||||
</div>
|
||||
<div class="fclk-payslip-row">
|
||||
<span>Income Tax</span>
|
||||
<span t-field="payslip.employee_income_tax" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
|
||||
</div>
|
||||
<div class="fclk-payslip-row fclk-payslip-row--total">
|
||||
<span>Total Deductions</span>
|
||||
<span t-field="payslip.total_employee_deductions" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Year to date -->
|
||||
<div class="fclk-status-card fclk-payslip-section">
|
||||
<h4 class="fclk-payslip-section-title">Year to Date</h4>
|
||||
<div class="fclk-payslip-row">
|
||||
<span>Gross</span>
|
||||
<span t-field="payslip.ytd_gross" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
|
||||
</div>
|
||||
<div class="fclk-payslip-row">
|
||||
<span>CPP</span>
|
||||
<span t-field="payslip.ytd_cpp" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
|
||||
</div>
|
||||
<div class="fclk-payslip-row">
|
||||
<span>EI</span>
|
||||
<span t-field="payslip.ytd_ei" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
|
||||
</div>
|
||||
<div class="fclk-payslip-row">
|
||||
<span>Income Tax</span>
|
||||
<span t-field="payslip.ytd_income_tax" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
|
||||
</div>
|
||||
<div class="fclk-payslip-row fclk-payslip-row--total">
|
||||
<span>Net</span>
|
||||
<span t-field="payslip.ytd_net" t-options="{'widget': 'monetary', 'display_currency': payslip.currency_id}"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<t t-if="has_pdf">
|
||||
<a t-attf-href="/my/clock/payslips/#{payslip.id}/pdf"
|
||||
class="fclk-payslip-pdf-btn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:middle; margin-right:6px;">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
Download PDF
|
||||
</a>
|
||||
</t>
|
||||
|
||||
<t t-call="fusion_clock.portal_employee_navbar">
|
||||
<t t-set="active" t-value="'payslips'"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Shared employee bottom nav (Clock / Timesheets / Reports / Payslips).
|
||||
`active` = clock|timesheets|reports|payslips. Payslips tab shows only
|
||||
when show_payslips is truthy. -->
|
||||
<template id="portal_employee_navbar" name="Fusion Clock Employee Navbar">
|
||||
<div class="fclk-nav-bar">
|
||||
<a href="/my/clock" t-attf-class="fclk-nav-item#{' fclk-nav-active' if active == 'clock' else ''}">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
<span>Clock</span>
|
||||
</a>
|
||||
<a href="/my/clock/timesheets" t-attf-class="fclk-nav-item#{' fclk-nav-active' if active == 'timesheets' else ''}">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
<span>Timesheets</span>
|
||||
</a>
|
||||
<a href="/my/clock/reports" t-attf-class="fclk-nav-item#{' fclk-nav-active' if active == 'reports' else ''}">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
</svg>
|
||||
<span>Reports</span>
|
||||
</a>
|
||||
<t t-if="show_payslips">
|
||||
<a href="/my/clock/payslips" t-attf-class="fclk-nav-item#{' fclk-nav-active' if active == 'payslips' else ''}">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="5" width="20" height="14" rx="2"/>
|
||||
<line x1="2" y1="10" x2="22" y2="10"/>
|
||||
</svg>
|
||||
<span>Payslips</span>
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register the file in the manifest**
|
||||
|
||||
In `fusion_clock/__manifest__.py`, add to the `data` list near the other `views/portal_*` entries:
|
||||
|
||||
```python
|
||||
'views/portal_payslip_templates.xml',
|
||||
```
|
||||
|
||||
Also bump `version` (e.g. `19.0.3.11.8` → `19.0.3.12.0`).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_clock/views/portal_payslip_templates.xml fusion_clock/__manifest__.py
|
||||
git commit -m "feat(fusion_clock): payslip list + inline paystub templates and shared employee navbar"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Add the Payslips tab to the three existing pages + Sign Out (`fusion_clock`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_clock/views/portal_clock_templates.xml`
|
||||
- Modify: `fusion_clock/views/portal_timesheet_templates.xml`
|
||||
- Modify: `fusion_clock/views/portal_report_templates.xml`
|
||||
|
||||
- [ ] **Step 1: Replace each page's inline nav bar with the shared navbar**
|
||||
|
||||
In all three files, replace the existing `<div class="fclk-nav-bar"> ... </div>` block with a call to the shared template, setting the correct `active` value:
|
||||
|
||||
`portal_clock_templates.xml` (the main clock page):
|
||||
```xml
|
||||
<t t-call="fusion_clock.portal_employee_navbar">
|
||||
<t t-set="active" t-value="'clock'"/>
|
||||
</t>
|
||||
```
|
||||
`portal_timesheet_templates.xml`:
|
||||
```xml
|
||||
<t t-call="fusion_clock.portal_employee_navbar">
|
||||
<t t-set="active" t-value="'timesheets'"/>
|
||||
</t>
|
||||
```
|
||||
`portal_report_templates.xml`:
|
||||
```xml
|
||||
<t t-call="fusion_clock.portal_employee_navbar">
|
||||
<t t-set="active" t-value="'reports'"/>
|
||||
</t>
|
||||
```
|
||||
|
||||
> The shared navbar reads `show_payslips` from context (set by the controller in Task 2 Step 1) so the Payslips tab only appears when payroll is installed.
|
||||
|
||||
- [ ] **Step 2: Add a Sign Out control to the clock header**
|
||||
|
||||
In `portal_clock_templates.xml`, the header block is:
|
||||
```xml
|
||||
<div class="fclk-header">
|
||||
<div class="fclk-date" id="fclk-date-display"></div>
|
||||
<h1 class="fclk-greeting">Hello, <t t-esc="employee.name.split(' ')[0]"/></h1>
|
||||
</div>
|
||||
```
|
||||
Add a sign-out link inside it (top-right):
|
||||
```xml
|
||||
<div class="fclk-header">
|
||||
<a href="/web/session/logout?redirect=/" class="fclk-signout" title="Sign Out">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
</a>
|
||||
<div class="fclk-date" id="fclk-date-display"></div>
|
||||
<h1 class="fclk-greeting">Hello, <t t-esc="employee.name.split(' ')[0]"/></h1>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_clock/views/portal_clock_templates.xml \
|
||||
fusion_clock/views/portal_timesheet_templates.xml \
|
||||
fusion_clock/views/portal_report_templates.xml
|
||||
git commit -m "feat(fusion_clock): add Payslips tab to employee nav + Sign Out in clock header"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Payslip + nav + sign-out styles (`fusion_clock`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_clock/static/src/css/portal_clock.css`
|
||||
|
||||
- [ ] **Step 1: Append the new styles**
|
||||
|
||||
Append to `static/src/css/portal_clock.css` (reuses the existing `--fclk-*` CSS variables already defined in the file for light/dark):
|
||||
|
||||
```css
|
||||
/* ===== Employee nav: keep 4 items comfortable on narrow phones ===== */
|
||||
.fclk-nav-bar .fclk-nav-item { min-width: 64px; }
|
||||
|
||||
/* ===== Sign out (clock header, top-right) ===== */
|
||||
.fclk-header { position: relative; }
|
||||
.fclk-signout {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
color: var(--fclk-text-muted, #9ca3af);
|
||||
background: var(--fclk-card, rgba(255,255,255,0.04));
|
||||
border: 1px solid var(--fclk-card-border, rgba(255,255,255,0.08));
|
||||
text-decoration: none;
|
||||
}
|
||||
.fclk-signout:hover { color: var(--fclk-text, #fff); }
|
||||
|
||||
/* ===== Payslip list rows (extends .fclk-report-item) ===== */
|
||||
.fclk-payslip-item { text-decoration: none; cursor: pointer; }
|
||||
.fclk-payslip-status {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.fclk-payslip-status--paid { background: rgba(16,185,129,0.15); color: #10B981; }
|
||||
.fclk-payslip-status--done { background: rgba(107,114,128,0.18); color: #9ca3af; }
|
||||
|
||||
/* ===== Payslip detail (inline paystub) ===== */
|
||||
.fclk-payslip-detail-header .fclk-payslip-back {
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
color: var(--fclk-accent, #10B981);
|
||||
text-decoration: none;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.fclk-payslip-net {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.fclk-payslip-net-label { font-size: 13px; color: var(--fclk-text-muted, #9ca3af); }
|
||||
.fclk-payslip-net-value { font-size: 26px; font-weight: 700; color: var(--fclk-accent, #10B981); }
|
||||
.fclk-payslip-section { margin-bottom: 16px; }
|
||||
.fclk-payslip-section-title {
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
color: var(--fclk-text-muted, #9ca3af);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.fclk-payslip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
font-size: 14px;
|
||||
color: var(--fclk-text, #e5e7eb);
|
||||
border-bottom: 1px solid var(--fclk-card-border, rgba(255,255,255,0.06));
|
||||
}
|
||||
.fclk-payslip-row:last-child { border-bottom: none; }
|
||||
.fclk-payslip-row--total { font-weight: 700; }
|
||||
.fclk-payslip-pdf-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
margin-bottom: 90px; /* clear the fixed bottom nav */
|
||||
border-radius: 12px;
|
||||
background: var(--fclk-accent, #10B981);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
```
|
||||
|
||||
> CSS-variable names assume the existing `--fclk-*` palette in this file. During execution, grep the top of `portal_clock.css` for the exact variable names (`--fclk-card`, `--fclk-accent`, `--fclk-text*`, `--fclk-card-border`) and adjust if they differ; fall back to the hardcoded hex already provided.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_clock/static/src/css/portal_clock.css
|
||||
git commit -m "style(fusion_clock): payslip list/detail, 4-item nav, and sign-out styles"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Verify end-to-end (entech smoke + asset cache bust)
|
||||
|
||||
- [ ] **Step 1: Confirm the `hr.payslip` fields used actually exist on entech**
|
||||
|
||||
The detail template references `employee_cpp`, `employee_cpp2`, `employee_ei`, `employee_income_tax`, `total_employee_deductions`, `ytd_gross`, `ytd_cpp`, `ytd_ei`, `ytd_income_tax`, `ytd_net`, `net_wage`, `currency_id`, `state`, `number`, `date_from`, `date_to`. These were read from `fusion_payroll/models/hr_payslip.py` (the YTD + employer/employee blocks). On entech, verify quickly via shell that they resolve; if any is absent on the installed payroll, remove that single row from the template.
|
||||
|
||||
- [ ] **Step 2: Deploy to entech**
|
||||
|
||||
```bash
|
||||
cat fusion_clock/... | ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/fusion_clock/...'" # push each changed file
|
||||
# (and the fusion_plating_portal files under /mnt/extra-addons/custom/fusion_plating_portal/...)
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_clock,fusion_plating_portal --stop-after-init\" && systemctl start odoo'"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Bust the asset cache (CSS/SCSS changed)**
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- su - postgres -c \"psql -d admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';\\\"\""
|
||||
ssh pve-worker5 "pct exec 111 -- systemctl restart odoo"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Manual checks (hard-refresh the browser each time)**
|
||||
- Log in as an internal employee → landing on `/my` redirects to `/my/clock`; **no left sidebar** on Clock / Timesheets / Reports / Payslips.
|
||||
- Bottom nav shows 4 tabs; Payslips opens the list; only the employee's own Done/Paid slips appear.
|
||||
- Open a payslip → inline paystub renders; Download PDF present only if a payslip report exists; PDF downloads the right slip.
|
||||
- Try another employee's payslip id in the URL → redirected to the list (no leak).
|
||||
- Log in as a customer (share user) → `/my/home` still shows the dashboard **with** the sidebar; all customer pages unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review notes
|
||||
- **Spec coverage:** audience split (T1 S1/S3), sidebar suppression (T1 S4/S5), employee redirect (T1 S3), bottom-nav Payslips (T3/T4), finalized-only payslip list (T2 S2), inline paystub + PDF button (T2 S4/S5, T3), sign out (T4 S2), guards for payroll-absent + ownership (T2). All covered.
|
||||
- **Open items from the spec:** PDF report is detected at runtime (no hard dependency); `hr.payslip` state filter uses the confirmed `('done','paid')`; the risky double-`$0` was avoided in favour of a single `$0` + grid-collapse modifier.
|
||||
- **Type/name consistency:** template id `portal_payslip_list_page` / `portal_payslip_detail_page` / `portal_employee_navbar`; flag `fp_show_customer_sidebar`; context key `show_payslips`; nav `active` values `clock|timesheets|reports|payslips` — consistent across tasks.
|
||||
@@ -0,0 +1,190 @@
|
||||
# Employee Portal — Clock + Payslips (separating staff from the customer portal)
|
||||
|
||||
**Date:** 2026-05-30
|
||||
**Status:** Design approved — ready for implementation plan
|
||||
**Deployment target:** entech (LXC 111 on pve-worker5, DB `admin`)
|
||||
**Modules touched:** `fusion_plating_portal`, `fusion_clock` (+ optional `fusion_payroll` if a payslip PDF must be built)
|
||||
|
||||
---
|
||||
|
||||
## 1. Context & Problem
|
||||
|
||||
EN Technologies (entech) runs three relevant modules on one Odoo:
|
||||
|
||||
- `fusion_plating_portal` — the **customer** portal: a rich dashboard + a left sidebar shell wrapping every `/my/*` page.
|
||||
- `fusion_clock` — employee clock-in/out, exposed on the front end at `/my/clock` (+ `/my/clock/timesheets`, `/my/clock/reports`). A polished dark, mobile-first UI with its own bottom nav.
|
||||
- `fusion_payroll` — Canadian payroll; owns `hr.payslip` with full earnings/deductions/YTD data. **No employee self-service surface today** (payslips are backend-only).
|
||||
|
||||
Two concrete problems:
|
||||
|
||||
1. **The customer portal applies to everyone.** `fusion_plating_portal`'s `home()` override (`/my`, `/my/home`) returns the customer dashboard for *every* logged-in user, with no internal-vs-customer check. Internal employees who land on `/my` see a customer dashboard that is empty/irrelevant to them.
|
||||
|
||||
2. **The customer sidebar bleeds onto the clock page.** `fp_portal_shell` (`fusion_plating_portal/views/fp_portal_shell.xml`) inherits `portal.portal_layout` and injects the left sidebar into **all** `/my/*` pages, unconditionally. Because `fusion_clock`'s `/my/clock` page renders inside `portal.portal_layout`, it inherits that customer sidebar even though the clock UI was designed clean (it sets `no_header`/`no_breadcrumbs` and has its own bottom nav).
|
||||
|
||||
**Goal:** internal employees get a dedicated, clean employee portal (Clock + Payslips, no customer sidebar); external customers keep the customer portal exactly as it is.
|
||||
|
||||
---
|
||||
|
||||
## 2. Goals / Non-goals
|
||||
|
||||
**Goals**
|
||||
- Internal staff never see the customer dashboard or the customer sidebar.
|
||||
- Employees land on the Clock page and navigate Clock / Timesheets / Reports / **Payslips** via the existing bottom nav.
|
||||
- Employees can view their own finalized pay slips (inline paystub) and download a PDF when one is available.
|
||||
- Zero change to the customer experience.
|
||||
|
||||
**Non-goals (v1)**
|
||||
- Profile/password editing in the employee portal (internal staff use the backend).
|
||||
- Payslips for non-finalized states (draft / verify hidden).
|
||||
- RMA/quote/customer features for employees.
|
||||
- Building a new payslip PDF report *unless* Odoo's standard one is absent on entech (see §9 Open items).
|
||||
- Cross-instance payroll (payslips are confirmed to live on the same Odoo).
|
||||
|
||||
---
|
||||
|
||||
## 3. Decisions (locked during brainstorming)
|
||||
|
||||
| # | Decision |
|
||||
|---|----------|
|
||||
| Audience split | **Internal users → employee portal; share/portal users → customer portal.** Detected via `request.env.user.share` (`False` = internal staff, `True` = customer). |
|
||||
| Employee landing & nav | **Clock is home.** `/my` and `/my/home` redirect employees to `/my/clock`. The existing bottom nav gains a **Payslips** tab. **No left sidebar anywhere** for employees. |
|
||||
| Payslip scope | **Finalized only** — `hr.payslip` where `state in ('done','paid')`, scoped to the logged-in employee. From `fusion_payroll` on the same Odoo. |
|
||||
| Payslip presentation | **Inline paystub page + Download-PDF button.** Inline always works; the PDF button appears only when a payslip PDF report exists on the server. |
|
||||
| Architecture | **Approach 1** — `fusion_clock` owns the employee-portal pages (incl. payslips, via a *soft* `hr.payslip` read); `fusion_plating_portal` fixes its own gating + redirect. No new module, no hard cross-dependency. |
|
||||
| Sign out | A compact **Sign Out** affordance in the employee header (the bottom-nav UI lacks one today). |
|
||||
|
||||
---
|
||||
|
||||
## 4. Architecture — Approach 1
|
||||
|
||||
Two modules change; responsibilities stay where they naturally belong.
|
||||
|
||||
**`fusion_plating_portal`** (the module causing the bleed — fixes itself):
|
||||
- Adds `fp_show_customer_sidebar` to the portal layout context = `request.env.user.share`.
|
||||
- Gates its sidebar shell on that flag.
|
||||
- Branches `home()`: internal user → redirect to `/my/clock`; customer → existing dashboard.
|
||||
|
||||
**`fusion_clock`** (owns all employee-portal pages + the bottom nav + the dark SCSS):
|
||||
- Adds `/my/clock/payslips` (list) and `/my/clock/payslips/<id>` (inline paystub) routes.
|
||||
- Reads `hr.payslip` through a **soft** check (`'hr.payslip' in request.env`) — **no** `fusion_payroll` dependency added, so `fusion_clock` stays installable without payroll (the Payslips tab simply doesn't appear).
|
||||
- Adds the **Payslips** tab to the existing bottom nav.
|
||||
|
||||
**Why not the alternatives:** payslip page in `fusion_payroll` makes the bottom nav shared chrome across two modules (duplication or a new cross-dep); a dedicated `fusion_employee_portal` module is new scaffolding that would have to couple to the entech-specific customer module to win the `/my/home` override — and it conflicts with the "edit existing files, don't add modules" rule.
|
||||
|
||||
> **Why the redirect lives in `fusion_plating_portal.home()`:** that override is the one currently winning at `/my/home` on entech (it's why employees see the customer dashboard today). Editing it to branch is guaranteed to take effect on entech without depending on fragile multi-module MRO ordering (`fusion_clock` does **not** override `home()`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Detailed design
|
||||
|
||||
### 5.1 Audience detection
|
||||
Single signal: `request.env.user.share`.
|
||||
- `share == True` → customer → customer portal (dashboard + sidebar).
|
||||
- `share == False` → internal staff → employee portal (clock + payslips, no sidebar).
|
||||
|
||||
The clock pages already resolve the person via `hr.employee` where `user_id == env.user.id` (`_get_portal_employee`). An internal user with no employee record (e.g. a bare admin) gets the clean layout, an empty clock that redirects, and retains full backend access.
|
||||
|
||||
### 5.2 Suppress the sidebar for employees
|
||||
In `fusion_plating_portal/controllers/portal.py`, `_prepare_portal_layout_values` adds:
|
||||
```python
|
||||
values['fp_show_customer_sidebar'] = request.env.user.share
|
||||
```
|
||||
In `fp_portal_shell.xml`, the `#wrap` replacement becomes conditional:
|
||||
- `fp_show_customer_sidebar` true → render the full `.o_fp_portal_shell` (sidebar + `<main>$0</main>`).
|
||||
- false → re-emit the raw original `#wrap` ($0) with no shell.
|
||||
|
||||
**Implementation note / risk:** the shell uses Odoo 19's `$0` re-emission inside a `position="replace"`. Putting `$0` in **both** a `t-if` and a `t-else` branch needs verifying — the inheritance engine appends a deep copy of the original node for *each* `$0` occurrence; both copies live in the compiled arch but only one renders at runtime (t-if/t-else). If that proves unreliable at load time, **fallback:** keep a single `$0` inside `.o_fp_portal_main` always, gate only the `<aside>` with `t-if`, and add a body/`<main>` modifier class so the main column goes full-width (and the clock UI renders edge-to-edge) when the sidebar is absent. Either way the customer path is byte-for-byte unchanged.
|
||||
|
||||
### 5.3 Redirect employees to Clock
|
||||
`fusion_plating_portal.home()` (routes `['/my','/my/home']`), first lines:
|
||||
```python
|
||||
if not request.env.user.share: # internal staff
|
||||
return request.redirect('/my/clock')
|
||||
```
|
||||
Customers fall through to the existing dashboard build untouched.
|
||||
|
||||
### 5.4 Bottom nav gains Payslips
|
||||
The nav bar lives in three `fusion_clock` templates (`portal_clock_templates.xml`, `portal_timesheet_templates.xml`, `portal_report_templates.xml`) plus the new payslip templates. Add a 4th item **Payslips** (`/my/clock/payslips`) with an appropriate icon, marking the active tab per page. The tab is gated on a `show_payslips` flag (true only when `'hr.payslip' in env`) so `fusion_clock` stays clean on payroll-less deployments.
|
||||
|
||||
### 5.5 Payslips list — `GET /my/clock/payslips`
|
||||
- Resolve employee via `_get_portal_employee()`; if none → redirect `/my/clock`.
|
||||
- If `'hr.payslip' not in request.env` → redirect `/my/clock` (and the tab won't show anyway).
|
||||
- Query: `hr.payslip` `sudo()` where `employee_id == employee.id` and `state in ('done','paid')`, ordered `date_to desc`.
|
||||
- Card list (same dark styling as Reports): pay period (`date_from`–`date_to`), net pay (`net_wage`, `$` + currency), status chip (Paid / Done), link to detail.
|
||||
- `page_name='payslips'` for nav active-state.
|
||||
|
||||
### 5.6 Payslip detail (inline paystub) — `GET /my/clock/payslips/<int:payslip_id>`
|
||||
- **Ownership guard** (mirrors `portal_report_download`): browse the slip; if it doesn't exist, isn't `state in ('done','paid')`, or `employee_id != employee.id` → redirect `/my/clock/payslips`.
|
||||
- Render a mobile-friendly paystub from data already on the record:
|
||||
- Header: employee name, pay period, pay date, status.
|
||||
- Earnings: gross / line items (`line_ids` totals).
|
||||
- Deductions: `employee_cpp`, `employee_cpp2`, `employee_ei`, `employee_income_tax` (+ total).
|
||||
- **Net pay** (highlighted).
|
||||
- YTD block: `ytd_gross`, `ytd_cpp`, `ytd_cpp2`, `ytd_ei`, `ytd_income_tax`, `ytd_net`.
|
||||
- **Download PDF** button: visible only when a payslip PDF report action exists on the server (see §9). Links to that report for this `payslip_id`. Routed through `fusion_pdf_preview` where applicable per repo convention (PDF → preview dialog), otherwise standard `/report/pdf/...`.
|
||||
|
||||
### 5.7 Sign out
|
||||
Add a compact **Sign Out** control to the employee header area (e.g. a small icon next to the "Hello, {name}" greeting on the clock page, and consistently on the payslip pages) → `/web/session/logout?redirect=/`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Files touched
|
||||
|
||||
**`fusion_plating_portal`**
|
||||
- `controllers/portal.py` — `_prepare_portal_layout_values` (+`fp_show_customer_sidebar`), `home()` (employee redirect).
|
||||
- `views/fp_portal_shell.xml` — gate the shell on `fp_show_customer_sidebar`.
|
||||
- `__manifest__.py` — version bump.
|
||||
|
||||
**`fusion_clock`**
|
||||
- `controllers/portal_clock.py` — `/my/clock/payslips` + `/my/clock/payslips/<id>` routes; `show_payslips` flag in existing page values.
|
||||
- `views/portal_clock_templates.xml`, `views/portal_timesheet_templates.xml`, `views/portal_report_templates.xml` — add Payslips nav item.
|
||||
- `views/portal_payslip_templates.xml` — **new**: list + inline paystub.
|
||||
- `static/src/scss/...` — payslip card/paystub styles (reuse `fclk-*` design language) + Sign Out control.
|
||||
- `__manifest__.py` — register new view file; version bump.
|
||||
|
||||
**`fusion_payroll`** — only if §9 finds no payslip PDF report and we decide to build a minimal paystub report (separate, optional task).
|
||||
|
||||
---
|
||||
|
||||
## 7. Edge cases & guards
|
||||
- Internal user, no `hr.employee` → clean layout, clock redirects to `/my`, backend still reachable.
|
||||
- `fusion_payroll` not installed → Payslips tab hidden; routes redirect to `/my/clock`.
|
||||
- Payslip not owned / not finalized → redirect to the list (no leak).
|
||||
- Admin needs to preview the *customer* portal → use a customer test login (documented), since internal users are redirected.
|
||||
- Customer experience: unchanged — sidebar + dashboard render exactly as before when `share == True`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing
|
||||
- **Internal user:** `/my` and `/my/home` → 302 to `/my/clock`; `/my/clock`, `/my/clock/timesheets`, `/my/clock/reports`, `/my/clock/payslips` render with **no** `.o_fp_portal_sidebar` in the DOM.
|
||||
- **Customer (share) user:** `/my/home` → customer dashboard **with** sidebar; all existing customer pages unchanged.
|
||||
- **Payslips list:** shows only the logged-in employee's `done`/`paid` slips; another employee's slips never appear.
|
||||
- **Payslip detail:** ownership guard blocks a slip belonging to someone else / a draft slip (redirect).
|
||||
- **PDF button:** present only when the payslip report action exists; downloads the right slip.
|
||||
- **Payroll absent:** Payslips tab hidden; `/my/clock/payslips` redirects cleanly.
|
||||
|
||||
Use ephemeral test ports per repo rule:
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable \
|
||||
--test-tags /fusion_clock,/fusion_plating_portal \
|
||||
-u fusion_clock,fusion_plating_portal --stop-after-init \
|
||||
--http-port=0 --gevent-port=0 2>&1 | tail -60
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Open items (verify during implementation)
|
||||
1. **Payslip PDF report on entech.** Confirm whether Odoo's repackaged `hr_payroll` ships a per-employee payslip report (`hr_payroll.action_report_payslip` / `report_payslip[_lang]`) on entech. If present → wire the Download-PDF button to it. If absent → keep inline-only for v1 and log a follow-up to build a minimal paystub report in `fusion_payroll`.
|
||||
2. **Exact `hr.payslip.state` values.** Confirm the selection (`draft`/`verify`/`done`/`paid`/`cancel`) on the installed payroll so the `('done','paid')` filter and the status chip labels are exact.
|
||||
3. **`$0`-in-two-branches** load-time behaviour (see §5.2) — adopt the fallback if the conditional double-`$0` doesn't compile cleanly.
|
||||
|
||||
---
|
||||
|
||||
## 10. Deployment (entech)
|
||||
Bump versions, then update both modules and bust the asset cache (SCSS/JS changed):
|
||||
```bash
|
||||
# update modules
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_clock,fusion_plating_portal --stop-after-init\" && systemctl start odoo'"
|
||||
# asset cache bust (if needed)
|
||||
# DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';
|
||||
```
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Configurator',
|
||||
'version': '19.0.23.6.0',
|
||||
'version': '19.0.22.10.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||
'description': """
|
||||
@@ -44,7 +44,6 @@ Provides:
|
||||
'views/fp_part_catalog_views.xml',
|
||||
'views/fp_process_node_part_scoped_views.xml',
|
||||
'views/fp_pricing_rule_views.xml',
|
||||
'views/fp_additional_charge_type_views.xml',
|
||||
'views/fp_quote_configurator_views.xml',
|
||||
'views/sale_order_views.xml',
|
||||
'views/res_partner_views.xml',
|
||||
@@ -60,7 +59,6 @@ Provides:
|
||||
'views/fp_configurator_menu.xml',
|
||||
'views/fp_so_job_sort_views.xml',
|
||||
'data/fp_sale_description_template_data.xml',
|
||||
'data/fp_additional_charge_type_data.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
|
||||
@@ -233,10 +233,6 @@
|
||||
<span><strong>OPEN</strong> open the part record</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 d-flex align-items-center gap-2">
|
||||
<field name="is_lot_order" widget="boolean_toggle"/>
|
||||
<span><strong>Lot Order</strong> — price each line as a flat lot total (qty preserved for production)</span>
|
||||
</div>
|
||||
<div class="mb-2 d-flex gap-2">
|
||||
<button name="action_add_from_prior_so"
|
||||
type="object"
|
||||
@@ -290,17 +286,10 @@
|
||||
width="120px"/>
|
||||
<field name="internal_description" string="Internal Notes" optional="show"/>
|
||||
<field name="quantity" string="Qty" width="55px"/>
|
||||
<field name="lot_total"
|
||||
string="Lot Total"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"
|
||||
column_invisible="not parent.is_lot_order"
|
||||
width="90px"/>
|
||||
<field name="unit_price"
|
||||
string="Price"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"
|
||||
readonly="parent.is_lot_order"
|
||||
width="80px"/>
|
||||
<field name="line_subtotal"
|
||||
string="Subtotal"
|
||||
@@ -318,6 +307,12 @@
|
||||
options="{'no_quick_create': True}"
|
||||
invisible="not part_catalog_id"
|
||||
optional="hide"/>
|
||||
<field name="tax_ids"
|
||||
string="Tax"
|
||||
widget="many2many_tags"
|
||||
options="{'no_create': True}"
|
||||
optional="hide"
|
||||
width="110px"/>
|
||||
<field name="currency_id" column_invisible="1"/>
|
||||
</list>
|
||||
</field>
|
||||
@@ -346,7 +341,6 @@
|
||||
|
||||
<div class="o_fp_xpr_footer_right">
|
||||
<div class="o_fp_xpr_card o_fp_xpr_totals">
|
||||
<div class="o_fp_xpr_totals_head">Order Summary</div>
|
||||
<div class="o_fp_xpr_total_row">
|
||||
<span class="o_fp_xpr_total_label">Subtotal</span>
|
||||
<field name="total_subtotal"
|
||||
@@ -355,29 +349,19 @@
|
||||
readonly="1" nolabel="1"/>
|
||||
</div>
|
||||
<div class="o_fp_xpr_total_row">
|
||||
<div class="o_fp_xpr_total_label">
|
||||
<span>Additional Charge</span>
|
||||
<field name="charge_type_id" nolabel="1"
|
||||
placeholder="Type..."
|
||||
options="{'no_open': True}"/>
|
||||
</div>
|
||||
<field name="charge_amount"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"
|
||||
nolabel="1"/>
|
||||
</div>
|
||||
<div class="o_fp_xpr_total_row">
|
||||
<div class="o_fp_xpr_total_label">
|
||||
<span>Tax</span>
|
||||
<field name="tax_id" nolabel="1"
|
||||
placeholder="Tax type..."
|
||||
options="{'no_create': True}"/>
|
||||
</div>
|
||||
<span class="o_fp_xpr_total_label">Tax</span>
|
||||
<field name="total_tax"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"
|
||||
readonly="1" nolabel="1"/>
|
||||
</div>
|
||||
<div class="o_fp_xpr_total_row">
|
||||
<span class="o_fp_xpr_total_label">Tooling Charge</span>
|
||||
<field name="tooling_charge"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"
|
||||
nolabel="1"/>
|
||||
</div>
|
||||
<div class="o_fp_xpr_total_row">
|
||||
<span class="o_fp_xpr_total_label">Total Lines</span>
|
||||
<field name="total_line_count" readonly="1" nolabel="1"/>
|
||||
|
||||
@@ -165,6 +165,40 @@ class FpDirectOrderWizard(models.Model):
|
||||
"""Default pricelist = company's default. Re-resolved on partner pick."""
|
||||
return self.env.company.partner_id.property_product_pricelist.id or False
|
||||
|
||||
@api.model
|
||||
def _fp_default_terms_and_conditions(self):
|
||||
"""Seed Terms & Conditions from the Accounting default — same source
|
||||
as the standard sale.order.note field.
|
||||
|
||||
Respects the `account.use_invoice_terms` system parameter (toggled
|
||||
from Settings → Invoicing → Default Terms & Conditions). Strips HTML
|
||||
tags defensively since Odoo's rich-text editor sometimes leaks
|
||||
markup into the plain `invoice_terms` field; the wizard field is
|
||||
Text and must not store raw markup.
|
||||
"""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
if not ICP.get_param('account.use_invoice_terms'):
|
||||
return False
|
||||
company = self.env.company
|
||||
raw = (
|
||||
(company.invoice_terms or '').strip()
|
||||
or (getattr(company, 'invoice_terms_html', False) or '').strip()
|
||||
)
|
||||
if not raw:
|
||||
return False
|
||||
# Defensive HTML strip — works whether the source was clean plain
|
||||
# text, a real html field, or a "plain" field polluted by the
|
||||
# rich-text editor (entech case 2026-05-27).
|
||||
if '<' in raw and '>' in raw:
|
||||
try:
|
||||
from lxml import html as lxml_html
|
||||
raw = lxml_html.fromstring(raw).text_content().strip()
|
||||
except Exception:
|
||||
# Last-ditch regex fallback — no lxml, malformed html, etc.
|
||||
import re
|
||||
raw = re.sub(r'<[^>]+>', '', raw).strip()
|
||||
return raw or False
|
||||
|
||||
# ---- Express Orders: auto-apply order recipe to all lines ----
|
||||
@api.onchange('material_process')
|
||||
def _onchange_material_process_apply_to_lines(self):
|
||||
@@ -273,9 +307,10 @@ class FpDirectOrderWizard(models.Model):
|
||||
# so the existing data preserves its customer-facing semantic.
|
||||
terms_and_conditions = fields.Text(
|
||||
string='Terms & Conditions',
|
||||
default=lambda self: self._fp_default_terms_and_conditions(),
|
||||
help='Customer-facing terms — prints on quote / SO / invoice. '
|
||||
'Seeded from res.company.invoice_terms_html with partner-level '
|
||||
'override via res.partner.invoice_terms.',
|
||||
'Seeded from the Accounting default terms '
|
||||
'(Settings → Invoicing → Default Terms & Conditions).',
|
||||
)
|
||||
internal_notes = fields.Text(
|
||||
string='Order-Level Internal Notes',
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Customer Portal',
|
||||
'version': '19.0.4.4.1',
|
||||
'version': '19.0.4.5.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Customer-facing portal for plating shops: online RFQ, job status, '
|
||||
'CoC downloads, invoice access.',
|
||||
|
||||
@@ -194,6 +194,9 @@ class FpCustomerPortal(CustomerPortal):
|
||||
partner = request.env.user.partner_id
|
||||
commercial = partner.commercial_partner_id
|
||||
values['fp_partner_display_name'] = commercial.name or partner.name
|
||||
# Internal staff (share=False) get the clean employee experience — no
|
||||
# customer sidebar. Customers (share=True / portal users) keep it.
|
||||
values['fp_show_customer_sidebar'] = bool(request.env.user.share)
|
||||
return values
|
||||
|
||||
def _get_page_view_values(self, document, access_token, values, session_history, no_breadcrumbs, **kwargs):
|
||||
@@ -208,6 +211,7 @@ class FpCustomerPortal(CustomerPortal):
|
||||
layout = self._prepare_portal_layout_values()
|
||||
values.setdefault('fp_sidebar_items', layout.get('fp_sidebar_items'))
|
||||
values.setdefault('fp_partner_display_name', layout.get('fp_partner_display_name'))
|
||||
values.setdefault('fp_show_customer_sidebar', layout.get('fp_show_customer_sidebar'))
|
||||
return values
|
||||
|
||||
# ==========================================================================
|
||||
@@ -616,6 +620,16 @@ class FpCustomerPortal(CustomerPortal):
|
||||
website=True,
|
||||
)
|
||||
def home(self, **kw):
|
||||
# Internal staff don't belong on the customer dashboard. Send them to
|
||||
# the employee clock portal — but only when fusion_clock is installed
|
||||
# (x_fclk_enable_clock proves it) AND the user actually has an employee
|
||||
# record, otherwise /my/clock -> /my would bounce into a redirect loop.
|
||||
user = request.env.user
|
||||
if not user.share and 'hr.employee' in request.env:
|
||||
Employee = request.env['hr.employee'].sudo()
|
||||
if 'x_fclk_enable_clock' in Employee._fields and \
|
||||
Employee.search_count([('user_id', '=', user.id)]):
|
||||
return request.redirect('/my/clock')
|
||||
partner = request.env.user.partner_id
|
||||
commercial = partner.commercial_partner_id
|
||||
|
||||
|
||||
@@ -21,6 +21,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Internal staff (employee portal) — no customer sidebar. Collapse the grid
|
||||
// to a single column so page content isn't pushed right by the now-empty
|
||||
// 240px sidebar track.
|
||||
.o_fp_portal_shell--no-sidebar {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.o_fp_portal_sidebar {
|
||||
position: sticky;
|
||||
top: $fp-space-4;
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
from . import test_portal_dashboard
|
||||
from . import test_employee_portal_gating
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1.
|
||||
|
||||
from odoo.tests import HttpCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'fp_portal')
|
||||
class TestEmployeePortalGating(HttpCase):
|
||||
"""Internal staff get the clean employee experience (no customer sidebar,
|
||||
redirected off the customer dashboard); customers are untouched."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.customer_partner = cls.env['res.partner'].create({
|
||||
'name': 'Gating Customer Co.',
|
||||
'email': 'gating_customer@example.com',
|
||||
})
|
||||
cls.customer_user = cls.env['res.users'].create({
|
||||
'name': 'Gating Portal User',
|
||||
'login': 'gating_portal_user',
|
||||
'password': 'gating_portal_user',
|
||||
'partner_id': cls.customer_partner.id,
|
||||
'group_ids': [(6, 0, [cls.env.ref('base.group_portal').id])],
|
||||
})
|
||||
|
||||
def test_customer_sees_sidebar_on_home(self):
|
||||
"""A share/portal user still gets the FP customer sidebar shell."""
|
||||
self.assertTrue(self.customer_user.share)
|
||||
self.authenticate('gating_portal_user', 'gating_portal_user')
|
||||
r = self.url_open('/my/home')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertIn('o_fp_portal_sidebar', r.text,
|
||||
"customer should still see the FP sidebar shell")
|
||||
|
||||
def test_internal_employee_redirected_to_clock(self):
|
||||
"""An internal user with an employee record is bounced to /my/clock.
|
||||
|
||||
Only meaningful when fusion_clock is installed (the redirect guard
|
||||
checks for its x_fclk_enable_clock field, so it never sends anyone to
|
||||
a non-existent /my/clock). Skip otherwise.
|
||||
"""
|
||||
if 'hr.employee' not in self.env:
|
||||
self.skipTest('hr not installed')
|
||||
if 'x_fclk_enable_clock' not in self.env['hr.employee']._fields:
|
||||
self.skipTest('fusion_clock not installed — redirect intentionally inert')
|
||||
internal = self.env['res.users'].create({
|
||||
'name': 'Shop Hand',
|
||||
'login': 'gating_shop_hand',
|
||||
'password': 'gating_shop_hand',
|
||||
'group_ids': [(6, 0, [self.env.ref('base.group_user').id])],
|
||||
})
|
||||
self.assertFalse(internal.share)
|
||||
self.env['hr.employee'].create({'name': 'Shop Hand', 'user_id': internal.id})
|
||||
self.authenticate('gating_shop_hand', 'gating_shop_hand')
|
||||
# Don't follow the redirect — just assert we're bounced toward /my/clock.
|
||||
r = self.url_open('/my/home', allow_redirects=False)
|
||||
self.assertIn(r.status_code, (301, 302, 303, 307, 308))
|
||||
self.assertIn('/my/clock', r.headers.get('Location', ''))
|
||||
@@ -57,17 +57,21 @@
|
||||
content slot inside #wrap is preserved verbatim.
|
||||
-->
|
||||
<xpath expr="//div[@id='wrap']" position="replace">
|
||||
<div class="o_fp_portal_shell">
|
||||
<!-- Mobile hamburger (shown only below 768px via SCSS) -->
|
||||
<button type="button"
|
||||
class="o_fp_portal_hamburger d-md-none"
|
||||
aria-label="Open navigation">
|
||||
<i class="fa fa-bars"/>
|
||||
</button>
|
||||
<!-- Backdrop for mobile drawer (hidden by default) -->
|
||||
<div class="o_fp_portal_backdrop"/>
|
||||
<!-- Sidebar navigation component -->
|
||||
<t t-call="fusion_plating_portal.fp_portal_sidebar"/>
|
||||
<div t-attf-class="o_fp_portal_shell#{'' if (fp_show_customer_sidebar if fp_show_customer_sidebar is defined else True) else ' o_fp_portal_shell--no-sidebar'}">
|
||||
<!-- Sidebar chrome only for customers (share users). Internal
|
||||
staff get the clean employee experience with no sidebar. -->
|
||||
<t t-if="fp_show_customer_sidebar if fp_show_customer_sidebar is defined else True">
|
||||
<!-- Mobile hamburger (shown only below 768px via SCSS) -->
|
||||
<button type="button"
|
||||
class="o_fp_portal_hamburger d-md-none"
|
||||
aria-label="Open navigation">
|
||||
<i class="fa fa-bars"/>
|
||||
</button>
|
||||
<!-- Backdrop for mobile drawer (hidden by default) -->
|
||||
<div class="o_fp_portal_backdrop"/>
|
||||
<!-- Sidebar navigation component -->
|
||||
<t t-call="fusion_plating_portal.fp_portal_sidebar"/>
|
||||
</t>
|
||||
<!-- Main content area — original #wrap re-emitted here via $0 -->
|
||||
<main class="o_fp_portal_main">$0</main>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Tasks',
|
||||
'version': '19.0.1.1.0',
|
||||
'version': '19.0.1.2.0',
|
||||
'category': 'Services/Field Service',
|
||||
'summary': 'Technician scheduling, route planning, GPS tracking, and cross-instance sync.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
|
||||
@@ -58,6 +58,12 @@ class FusionTaskSyncConfig(models.Model):
|
||||
active = fields.Boolean(default=True)
|
||||
last_sync = fields.Datetime('Last Successful Sync', readonly=True)
|
||||
last_sync_error = fields.Text('Last Error', readonly=True)
|
||||
remote_uid = fields.Integer(
|
||||
'Remote User ID', readonly=True, copy=False,
|
||||
help='Cached uid from the last successful authenticate(). Reused for '
|
||||
'execute_kw so we do not re-authenticate on every RPC (each '
|
||||
'authenticate writes a login-audit row on the remote). Cleared on '
|
||||
'auth failure or when the credentials change.')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# JSON-RPC helpers (uses /jsonrpc dispatch, muted on receiving side)
|
||||
@@ -92,25 +98,50 @@ class FusionTaskSyncConfig(models.Model):
|
||||
_logger.warning("Task sync: timeout connecting to %s", self.url)
|
||||
return None
|
||||
|
||||
def _authenticate(self):
|
||||
"""Authenticate with the remote instance and return the uid."""
|
||||
def _authenticate(self, force=False):
|
||||
"""Return the remote uid.
|
||||
|
||||
Reuses the cached ``remote_uid`` so we do NOT call ``authenticate()``
|
||||
on every RPC — each authenticate triggers ``_login`` →
|
||||
``_update_last_login`` on the remote, which writes a login-audit row.
|
||||
``execute_kw`` re-checks the API key on every call, so reusing the uid
|
||||
is safe; it just skips the audit-row-producing handshake. Pass
|
||||
``force=True`` to bypass the cache (e.g. the Test Connection button).
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.remote_uid and not force:
|
||||
return self.remote_uid
|
||||
uid = self._jsonrpc('common', 'authenticate',
|
||||
[self.database, self.username, self.api_key, {}])
|
||||
if not uid:
|
||||
_logger.error("Task sync: authentication failed for %s", self.name)
|
||||
return uid
|
||||
if uid != self.remote_uid:
|
||||
self.sudo().write({'remote_uid': uid})
|
||||
return uid
|
||||
|
||||
def _rpc(self, model, method, args, kwargs=None):
|
||||
"""Execute a method on the remote instance via execute_kw."""
|
||||
"""Execute a method on the remote instance via execute_kw.
|
||||
|
||||
Uses the cached uid; on a remote error (e.g. the cached uid went
|
||||
stale) it clears the cache, re-authenticates once, and retries.
|
||||
"""
|
||||
self.ensure_one()
|
||||
uid = self._authenticate()
|
||||
if not uid:
|
||||
return None
|
||||
call_args = [self.database, uid, self.api_key, model, method, args]
|
||||
if kwargs:
|
||||
call_args.append(kwargs)
|
||||
return self._jsonrpc('object', 'execute_kw', call_args)
|
||||
for attempt in (1, 2):
|
||||
uid = self._authenticate(force=(attempt == 2))
|
||||
if not uid:
|
||||
return None
|
||||
call_args = [self.database, uid, self.api_key, model, method, args]
|
||||
if kwargs:
|
||||
call_args.append(kwargs)
|
||||
try:
|
||||
return self._jsonrpc('object', 'execute_kw', call_args)
|
||||
except UserError:
|
||||
if attempt == 2:
|
||||
raise
|
||||
# uid may be stale — drop the cache and retry with a fresh auth
|
||||
self.sudo().write({'remote_uid': False})
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tech sync ID helpers
|
||||
@@ -173,7 +204,7 @@ class FusionTaskSyncConfig(models.Model):
|
||||
def action_test_connection(self):
|
||||
"""Test the connection to the remote instance."""
|
||||
self.ensure_one()
|
||||
uid = self._authenticate()
|
||||
uid = self._authenticate(force=True)
|
||||
if uid:
|
||||
remote_map = self._get_remote_tech_map()
|
||||
local_map = self._get_local_tech_map()
|
||||
|
||||
Reference in New Issue
Block a user