Compare commits
31 Commits
phase2-cro
...
phase6_2-l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ca9a58a8c | ||
|
|
d86c120969 | ||
|
|
85609f99cd | ||
|
|
29821bd541 | ||
|
|
1fdafd34d1 | ||
|
|
9584953467 | ||
|
|
52097ca59b | ||
|
|
1d6184dd2f | ||
|
|
88a473e7eb | ||
|
|
08ababc2c7 | ||
|
|
59ad77839a | ||
|
|
a594431eb6 | ||
|
|
58d02598da | ||
|
|
395bd4949e | ||
|
|
a6546ac858 | ||
|
|
233e5e6e72 | ||
|
|
b06a5b2d12 | ||
|
|
3ef67c6beb | ||
|
|
4a304e02f3 | ||
|
|
0d08d2d135 | ||
|
|
f9cb1b11ce | ||
|
|
1122f84007 | ||
|
|
2cdb2e3d0b | ||
|
|
f00dda2abd | ||
|
|
3b7b2477cf | ||
|
|
e762ee4b68 | ||
|
|
5d086c7f27 | ||
|
|
3eba80bb31 | ||
|
|
2a0d1862df | ||
|
|
7f70785b79 | ||
|
|
9dcd00d9b2 |
@@ -166,6 +166,20 @@ These modules have **source code in this repo** but are **intentionally NOT inst
|
||||
| `fusion_plating_culture` | `state=uninstalled`, dir removed from entech disk | Soft people-ops feature (peer kudos / "Fundamental of the Week"); zero data entered; not a client priority. Top-level "Culture" menu confused operators. | Ask the client whether they want it before reinstalling. If yes: re-sync folder + `-i fusion_plating_culture` + seed a value set. |
|
||||
| `fusion_plating_sensors` | deleted entirely (not in repo anymore) | Duplicated `fusion_plating_iot`'s scope but with no working alerting logic. Its valuables (sensor_type taxonomy, dashboard, location flexibility) were ported into `fusion_iot/fusion_plating_iot/`. | N/A — gone. Any new sensor work goes in `fusion_iot/fusion_plating_iot/`. |
|
||||
|
||||
## Removing menus/records — Odoo does NOT auto-delete orphans
|
||||
Deleting a `<menuitem>` (or any `<record>`) from a data XML file does NOT remove the corresponding database row. The XML loader only updates records it sees; orphans persist in `ir.ui.menu` / `ir.model.data` until you delete them explicitly. Symptom: the menu still appears in the UI after `-u`. Fix — add a `<delete>` directive in a data file with `noupdate="0"`:
|
||||
```xml
|
||||
<delete model="ir.ui.menu" id="module_name.menu_xmlid_to_remove"/>
|
||||
```
|
||||
Caught 2026-05-22 when the Phase 3 Plant Overview menu kept showing alongside the new Workstation menu after deploy.
|
||||
|
||||
## Odoo 19 ir.cron — `numbercall` and `doall` are gone
|
||||
The legacy `numbercall=-1` (run-forever) and `doall=False` (catch-up-missed) fields were removed from `ir.cron` in Odoo 19. Including them in `<record model="ir.cron">` data XML produces:
|
||||
```
|
||||
ValueError: Invalid field 'numbercall' in 'ir.cron'
|
||||
```
|
||||
Use only: `name`, `model_id`, `state`, `code` (or `function`/`model`), `interval_number`, `interval_type`, `active`. Caught during the 2026-05-22 entech deploy of the auto-pause cron.
|
||||
|
||||
## Critical Rules — Odoo 19
|
||||
1. **NEVER code from memory** — Read reference files from the server first.
|
||||
2. **Backend OWL**: `static template`, `static props = ["*"]`, standalone `rpc()` from `@web/core/network/rpc`. NOT `useService("rpc")`.
|
||||
@@ -339,13 +353,57 @@ POST /fp/recipe/duplicate — deep-copy recipe
|
||||
### Client Recipes Created
|
||||
- `ENP-ALUM-BASIC` — Electroless Nickel Plating Aluminium Basic (9 operations, 15 steps). Data file: `fusion_plating/data/fp_recipe_enp_alum_basic.xml`
|
||||
|
||||
## Plant Overview Dashboard
|
||||
- OWL client action: `fp_plant_overview` in `fusion_plating_shopfloor`
|
||||
- Kanban columns = work centres, cards = active `mrp.workorder` records
|
||||
- Drag & drop between columns (writes `workcenter_id` on the work order)
|
||||
- Endpoint: `POST /fp/shopfloor/plant_overview`
|
||||
- Move endpoint: `POST /fp/shopfloor/plant_overview/move_card`
|
||||
- Auto-refreshes every 30s
|
||||
## Shop Floor Architecture (2026-05-22 tablet redesign — Phases 1-4)
|
||||
|
||||
Spec: [docs/superpowers/specs/2026-05-22-shopfloor-tablet-redesign-design.md](docs/superpowers/specs/2026-05-22-shopfloor-tablet-redesign-design.md)
|
||||
Plan: [docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md](docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md)
|
||||
|
||||
**Three OWL client actions** (registered under `registry.category("actions")`):
|
||||
- `fp_shopfloor_landing` — Workstation kanban entry. Station-scoped or All-Plant mode toggle. Tap a card → JobWorkspace. Replaces the legacy `fp_shopfloor_tablet` and folds in `fp_plant_overview`.
|
||||
- `fp_job_workspace` — Full-screen single-WO surface. Sticky header (WO #, customer, qty, workflow chip), sticky 9-stage workflow bar, step list with GateViz blockers, side panel (spec/attachments/chatter), sticky action rail (Hold/Note/Milestone). Opens from kanban tap, smart button, QR scan, or manager card tap.
|
||||
- `fp_manager_dashboard` — Manager Desk with 4 sibling tabs: **Workflow Funnel** (default), **Approval Inbox**, **Plant Board** (existing 3-column), **At-Risk** (trending late + hold reasons + bottleneck heatmap).
|
||||
|
||||
**Five shared OWL services** in `fusion_plating_shopfloor/static/src/js/components/`:
|
||||
- `WorkflowChip` — workflow.state pill + optional next-action hint
|
||||
- `GateViz` — "Can't start because…" explainer (reads `fp.job.step.blocker_kind`/`reason`)
|
||||
- `FpSignaturePad` — Dialog canvas signature capture
|
||||
- `FpHoldComposer` — Dialog hold-create form with reason picker + qty + photo
|
||||
- `FpKanbanCard` — standard WO/step card with embedded WorkflowChip + blocker badge
|
||||
|
||||
**Backend endpoints (Phase 1-4):**
|
||||
- Workspace: `/fp/workspace/{load,hold,sign_off,advance_milestone}`
|
||||
- Landing: `/fp/landing/kanban` (mode=station|all_plant)
|
||||
- Manager: `/fp/manager/{overview,funnel,approval_inbox,at_risk}`
|
||||
|
||||
**Auto-pause cron — fixes 411-hour ghost timers:**
|
||||
- `_cron_autopause_stale_steps()` runs every 30 min
|
||||
- Threshold from `ir.config_parameter` **`fp.shopfloor.autopause_threshold_hours`** (default 8.0)
|
||||
- Recipe nodes opt out via `fusion.plating.process.node.long_running=True` for 24h bakes etc.
|
||||
- Flips `state=in_progress` idle > threshold to `paused` with chatter audit
|
||||
|
||||
**Key model fields added (Phase 1-4):**
|
||||
- `fp.job.display_wo_name` — "WO # 00001" formatter for tablet/dashboard (model `name` field stays `WH/JOB/…`; system-wide sequence rename deferred)
|
||||
- `fp.job.late_risk_ratio` — stored Float (remaining_planned / minutes_to_deadline) driving At-Risk view
|
||||
- `fp.job.active_step_id` — computed M2o to currently in_progress step
|
||||
- `fp.job.step.blocker_kind` / `blocker_reason` / `blocker_jump_target_*` — drives GateViz
|
||||
- `fp.work.centre.bottleneck_score` / `avg_wait_minutes` — drives At-Risk bottleneck heatmap
|
||||
|
||||
**Operator ACL lift (per "techs wear multiple hats" rule):**
|
||||
- `fp.certificate` — operator gained write (flip draft → issued from tablet)
|
||||
- `fp.thickness.reading` — operator gained create+write (Fischerscope capture from tablet)
|
||||
- `fp.job.node.override` — operator gained read (see opt-out badges on steps)
|
||||
- Supervisor-only ops (step Skip, hold Release) enforced in `workspace_controller.py`, not ACL
|
||||
|
||||
**Deprecated but still live** (cleanup is Phase 5):
|
||||
- OWL components: `fp_shopfloor_tablet`, `fp_plant_overview` — registered but no menu points at them
|
||||
- Endpoints: `/fp/shopfloor/tablet_overview`, `plant_overview`, `queue` — marked DEPRECATED with INFO log lines, bodies intact for back-compat
|
||||
- `/fp/shopfloor/plant_overview/move_card` is **NOT** deprecated — the new Landing component uses it for drag-and-drop
|
||||
|
||||
**Old patterns to avoid:**
|
||||
- Don't read `fp.job.name` for display — use `display_wo_name` everywhere on tablet/dashboard
|
||||
- Don't add SCSS that uses raw hex without an `@if $o-webclient-color-scheme == dark` branch (dark mode breaks otherwise; see existing `_workflow_chip.scss` as the template)
|
||||
- Don't add `web.assets_web_dark` entries to the manifest — Odoo 19 auto-compiles `web.assets_backend` SCSS into both bundles
|
||||
- Don't bypass `_fp_should_block_predecessors()` when computing step blockers — keep `blocker_kind=predecessor` logic in sync with `can_start`
|
||||
|
||||
## Deployment
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,358 @@
|
||||
# Shop Floor PIN Gate + Auto-Lock — Design Spec
|
||||
|
||||
**Date:** 2026-05-22
|
||||
**Status:** Awaiting user review
|
||||
**Phase:** 6 (sequel to Phases 1-5 of 2026-05-22-shopfloor-tablet-redesign)
|
||||
**Module owners:** `fusion_plating_shopfloor`, `fusion_plating_jobs`
|
||||
**Target client:** EN Technologies (Fusion Plating)
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
Phases 1-5 of the tablet redesign shipped on 2026-05-22 (entech LXC 111). They assume a single user is "logged in" to a tablet for the duration of use. Real shop floors don't work that way:
|
||||
|
||||
- A single tablet sits at the **EN Plating tank** (or de-rack table, masking station, QC bench).
|
||||
- Multiple technicians rotate through the station during a shift.
|
||||
- A tech walks away mid-shift; the next tech walks up — and without a lock, the new tech is operating under the previous tech's identity.
|
||||
- Every step start, finish, scrap, hold, signature, and milestone advance gets attributed to the wrong person.
|
||||
- AS9100 / Nadcap audit trails break. Operators sign off on each other's work without knowing it.
|
||||
|
||||
The fix needs to be **fast** (PIN in < 2 seconds), **familiar** (matches iPad / debit-card UX techs already know), and **silent on the timer side** (locking the tablet must not pause a part in a tank).
|
||||
|
||||
## 2. Goals
|
||||
|
||||
- Each tech identifies themselves with a personal 4-digit PIN.
|
||||
- Tablet auto-locks after a configurable idle period (default 5 min).
|
||||
- Quick-switch UX: tap your face tile → enter PIN → unlocked. No typing usernames.
|
||||
- All shop-floor actions (step start/finish, holds, sign-offs, milestone advances) carry the correct tech identity for audit.
|
||||
- No interruption to in-progress step timers — the server keeps counting.
|
||||
- Manager can reset a forgotten PIN; no SMS/email infrastructure required.
|
||||
- Per-station roster: a tablet at EN Plating only shows techs trained on EN Plating.
|
||||
|
||||
## 3. Non-goals (v1)
|
||||
|
||||
- Multi-factor authentication (TOTP, SMS).
|
||||
- Biometric unlock (Face ID, fingerprint).
|
||||
- NFC badges or RFID readers.
|
||||
- Self-service PIN reset via email/SMS (manager-side only).
|
||||
- "Remember me" cross-device sessions.
|
||||
- Camera-based presence detection / liveness checks.
|
||||
|
||||
## 4. UX
|
||||
|
||||
### 4.1 Lock screen — tile grid
|
||||
|
||||
Rendered first thing when the tablet boots, after any auto-lock, and after the Hand-Off button. Replaces the current Landing/Workspace/Dashboard view entirely.
|
||||
|
||||
- 3-5 tiles per row, sized for touch (~120×140 px each).
|
||||
- Sort: clocked-in techs first, alphabetical within bucket.
|
||||
- Each tile: avatar + name + small green dot if clocked in.
|
||||
- A station with `x_fc_authorised_user_ids` configured shows only those techs; otherwise all techs in the operator group.
|
||||
- "Other..." chip at the end opens a username search for off-roster cases (cross-trained tech covering an unfamiliar station).
|
||||
- Tap a tile → PIN pad slides up as a modal.
|
||||
|
||||
### 4.2 PIN pad
|
||||
|
||||
- Numeric 0-9 in a 3×4 grid + Clear + Submit.
|
||||
- 4 dot placeholders fill as digits are typed.
|
||||
- Auto-submit on the 4th digit (no Enter required).
|
||||
- Wrong PIN → quick shake animation (CSS keyframe), dots clear, tile grid stays.
|
||||
- After 5 sequential failures for one tech, that tech is locked for 5 minutes. Other techs can still unlock the tablet.
|
||||
- "Forgot?" link surfaces a friendly message: "Ask a manager to reset your PIN."
|
||||
|
||||
### 4.3 Hand-Off button
|
||||
|
||||
- Top-right corner of every authenticated view (Landing, Workspace, Manager Dashboard), next to QR scan.
|
||||
- Big icon + label: 🔒 **Hand Off**.
|
||||
- Tap → confirm dialog "Lock this tablet now?" → instant lock to tile grid.
|
||||
- Confirm dialog prevents accidental locks; can be skipped in v2 with rapid double-tap.
|
||||
|
||||
### 4.4 Idle warning
|
||||
|
||||
- At 30 seconds before auto-lock, a yellow pulsing border appears around the entire viewport.
|
||||
- A toast slides in: "Locking in 28s · tap anywhere to stay".
|
||||
- Countdown decrements in 1s ticks until 0.
|
||||
- Any pointer/touch event clears the warning and resets the timer.
|
||||
- At 0s, the tile grid replaces the current view.
|
||||
|
||||
### 4.5 Session continuity (state preservation on lock)
|
||||
|
||||
| State | On lock | On unlock |
|
||||
|---|---|---|
|
||||
| In-progress step timer | Server-side timer keeps running. No pause event fired. | Resumes accurate elapsed time. |
|
||||
| OWL state (scroll, expanded step) | Preserved in memory | Restored |
|
||||
| HoldComposer modal open | Preserved (dialog still mounted under the lock overlay) | Available immediately |
|
||||
| SignaturePad open mid-stroke | **Thrown away.** Signature flow restarts. | Fresh signature required. |
|
||||
| QR scan drawer open | Preserved | Available |
|
||||
| Refresh interval (15s/8s polling) | Paused | Resumed |
|
||||
|
||||
### 4.6 Profile preferences — set / change PIN
|
||||
|
||||
- New "Tablet PIN" group on `res.users` preferences (user-facing form).
|
||||
- Single button: **Set Tablet PIN** or **Change PIN** (label flips depending on whether a hash exists).
|
||||
- Tapping it opens a modal with 3 PIN inputs:
|
||||
- Current PIN (only if a PIN is set; skipped on first-time set)
|
||||
- New PIN
|
||||
- Confirm new PIN
|
||||
- All three use the same `FpPinPad` component as the unlock screen.
|
||||
- Subtext shows "Last changed: 2026-05-22" or "Cleared by manager".
|
||||
|
||||
### 4.7 Manager-side reset
|
||||
|
||||
- New header button on `res.users` form: **Reset Tablet PIN** (visible only to `group_fusion_plating_manager` and above).
|
||||
- Tap → confirm dialog → posts to user's chatter ("Tablet PIN reset by Manager X on 2026-05-22") + clears the hash.
|
||||
- Tech sets a new PIN on next unlock attempt.
|
||||
|
||||
## 5. Backend
|
||||
|
||||
### 5.1 Model fields on `res.users` (in `fusion_plating_shopfloor/models/res_users.py` — new file)
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `x_fc_tablet_pin_hash` | Char | Hash (SHA-256 + per-user salt) of the PIN. Stored as `<salt>$<hash>`. `groups='fusion_plating.group_fusion_plating_manager'` — non-manager users cannot even read other users' hash field. |
|
||||
| `x_fc_tablet_pin_set_date` | Datetime | When the current hash was set. NULL if PIN was cleared by manager. |
|
||||
| `x_fc_tablet_pin_failed_count` | Integer (0) | Sequential failed attempts since last success. Resets to 0 on a correct PIN. |
|
||||
| `x_fc_tablet_locked_until` | Datetime | Lockout expiry. NULL when not locked. |
|
||||
|
||||
### 5.2 Extras on `fusion.plating.shopfloor.station`
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `x_fc_authorised_user_ids` | Many2many → res.users | If non-empty, the tile grid restricts to these users. Empty = "all operators". |
|
||||
| `x_fc_idle_lock_minutes` | Integer, nullable | Per-station override; null = use global default. |
|
||||
|
||||
### 5.3 `ir.config_parameter` defaults
|
||||
|
||||
| Key | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `fp.shopfloor.tablet_idle_lock_minutes` | `5` | Global idle threshold |
|
||||
| `fp.shopfloor.tablet_pin_fail_threshold` | `5` | Failures before lockout |
|
||||
| `fp.shopfloor.tablet_pin_fail_lockout_minutes` | `5` | Lockout duration |
|
||||
| `fp.shopfloor.tablet_warn_seconds_before_lock` | `30` | When the yellow border appears |
|
||||
|
||||
### 5.4 HTTP endpoints (`/fp/tablet/*`)
|
||||
|
||||
All `type='jsonrpc'`, `auth='user'`. Auth = the tablet's persistent Odoo session (a "shopfloor service" account or any non-locked user).
|
||||
|
||||
| Endpoint | Body | Returns |
|
||||
|---|---|---|
|
||||
| `POST /fp/tablet/tiles` | `{station_id?}` | `{ok, tiles: [{user_id, name, avatar_url, is_clocked_in, has_pin}, ...]}`. Respects `station.x_fc_authorised_user_ids`. |
|
||||
| `POST /fp/tablet/unlock` | `{user_id, pin}` | `{ok: true, current_tech_id, current_tech_name}` or `{ok: false, error, locked_until?, attempts_remaining}`. |
|
||||
| `POST /fp/tablet/set_pin` | `{old_pin?, new_pin}` | Caller's PIN only. `old_pin` required if a hash exists. `{ok, error?}`. |
|
||||
| `POST /fp/tablet/reset_pin_for` | `{user_id}` | Manager-only; manager group enforced server-side. Clears target user's hash + posts chatter. |
|
||||
| `POST /fp/tablet/ping` | `{current_tech_id}` | Bumps a server-side "last active" timestamp for forensics. Called on every successful tech action. |
|
||||
|
||||
### 5.5 Hash algorithm
|
||||
|
||||
```python
|
||||
import hashlib, secrets
|
||||
|
||||
def _hash_pin(pin: str, salt: bytes = None) -> str:
|
||||
salt = salt or secrets.token_bytes(16)
|
||||
digest = hashlib.pbkdf2_hmac('sha256', pin.encode('utf-8'), salt, 200_000)
|
||||
return f"{salt.hex()}${digest.hex()}"
|
||||
|
||||
def _verify_pin(pin: str, stored: str) -> bool:
|
||||
salt_hex, expected_hex = stored.split('$', 1)
|
||||
salt = bytes.fromhex(salt_hex)
|
||||
digest = hashlib.pbkdf2_hmac('sha256', pin.encode('utf-8'), salt, 200_000)
|
||||
return digest.hex() == expected_hex
|
||||
```
|
||||
|
||||
200,000 PBKDF2 iterations gives ~50ms verify time on entech-class hardware — fast enough for tech UX, slow enough to make a brute-force attack expensive even with the database stolen.
|
||||
|
||||
### 5.6 Audit propagation (Phase 6.3)
|
||||
|
||||
All existing shop-floor endpoints that take an action (`/fp/shopfloor/start_wo`, `stop_wo`, `bump_qty_done`, `bump_qty_scrapped`, `log_chemistry`, `log_thickness_reading`, `quality_hold`, `mark_gate`, `start_bake`, `end_bake`, `/fp/workspace/{hold,sign_off,advance_milestone}`) gain an **optional** `tablet_tech_id` kwarg.
|
||||
|
||||
When the OWL component passes `tablet_tech_id`:
|
||||
|
||||
- Server verifies the id corresponds to a recent successful `/fp/tablet/unlock` (within session's idle window).
|
||||
- All chatter posts use that user's name instead of `env.uid`.
|
||||
- All writes to records set `create_uid` / `write_uid` to that user (via `with_user(...)` context manager).
|
||||
- If `tablet_tech_id` is missing or stale, server falls back to `env.uid` (the tablet's session user) for back-compat.
|
||||
|
||||
This keeps the audit trail honest without forcing a full Odoo session swap on every PIN unlock (which would clear all OWL state and JS bundle cache).
|
||||
|
||||
## 6. Frontend architecture
|
||||
|
||||
### 6.1 New OWL components
|
||||
|
||||
| Component | File | Purpose |
|
||||
|---|---|---|
|
||||
| `FpTabletLock` | `static/src/js/tablet_lock.js` | Top-level wrapper around Landing/Workspace/Manager. Renders tile grid when locked; renders children when unlocked. |
|
||||
| `FpPinPad` | `static/src/js/components/pin_pad.js` | Numeric pad modal. Used by FpTabletLock unlock AND Profile set-PIN flow. |
|
||||
| `FpPinSetup` | `static/src/js/components/pin_setup.js` | Modal for set/change PIN. Wraps 3 instances of FpPinPad (old + new + confirm). |
|
||||
| `FpIdleWarning` | `static/src/js/components/idle_warning.js` | Yellow-border + countdown toast component shown at T-30s. |
|
||||
|
||||
### 6.2 Activity tracker service
|
||||
|
||||
- Registered as `fp_shopfloor_activity` in the OWL `services` registry.
|
||||
- Tracks `lastActiveAt` (epoch ms).
|
||||
- Listens at document level: `pointerdown`, `touchstart`, `keydown`, `visibilitychange`.
|
||||
- Public API: `bumpActivity()`, `getSecondsUntilLock()`, `subscribe(cb)`, `lock()`.
|
||||
- Bumps server-side on every `ping` (debounced to once per 30s).
|
||||
|
||||
### 6.3 Auto-lock flow
|
||||
|
||||
```js
|
||||
// inside FpTabletLock setup()
|
||||
this.activity = useService("fp_shopfloor_activity");
|
||||
this._tick = setInterval(() => {
|
||||
const remaining = this.activity.getSecondsUntilLock();
|
||||
if (remaining <= 0) {
|
||||
this.state.locked = true;
|
||||
this.state.currentTechId = null;
|
||||
} else if (remaining <= this.warnThresholdSec) {
|
||||
this.state.idleWarning = remaining;
|
||||
} else if (this.state.idleWarning) {
|
||||
this.state.idleWarning = null; // user tapped, reset
|
||||
}
|
||||
}, 1000);
|
||||
```
|
||||
|
||||
### 6.4 RPC plumbing
|
||||
|
||||
A tiny client-side helper wraps `rpc()` so every shop-floor call automatically includes `tablet_tech_id`:
|
||||
|
||||
```js
|
||||
// fp_rpc.js
|
||||
import { rpc as baseRpc } from "@web/core/network/rpc";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
const techStore = registry.category("services").get("fp_shopfloor_tech_store");
|
||||
|
||||
export function fpRpc(url, params = {}) {
|
||||
if (techStore.currentTechId) {
|
||||
params = { ...params, tablet_tech_id: techStore.currentTechId };
|
||||
}
|
||||
return baseRpc(url, params);
|
||||
}
|
||||
```
|
||||
|
||||
Landing, Workspace, Manager Dashboard switch from `rpc(...)` to `fpRpc(...)` for action calls. Read-only calls (load, tiles, kanban) don't need the kwarg.
|
||||
|
||||
### 6.5 Component composition
|
||||
|
||||
```
|
||||
FpTabletLock (NEW outer wrapper, mounted by every client action)
|
||||
├── if locked → FpPinPad (tile grid + entry)
|
||||
├── if idle warning → FpIdleWarning overlay
|
||||
└── else → existing client action (Landing | Workspace | Manager Dashboard)
|
||||
+ Hand-Off button injected into existing headers
|
||||
```
|
||||
|
||||
The "tablet locked" boolean lives in a shared OWL service (`fp_shopfloor_tech_store`) — every client action checks it on mount and subscribes for changes.
|
||||
|
||||
## 7. Edge cases
|
||||
|
||||
| Case | Handling |
|
||||
|---|---|
|
||||
| No tech has set a PIN yet | Tile shows "PIN required" overlay. Tap tile → guided "you must set a PIN before using this tablet" → set-PIN flow → unlock. |
|
||||
| Manager just reset a tech's PIN | Tile still shows; tap → "PIN was cleared by a manager — set a new one" → set-PIN flow → unlock. |
|
||||
| Tablet boots with no station paired | Tile grid shows + a "Pair this station" CTA. Station QR scan works before any tech is logged in. |
|
||||
| Network drop mid-unlock | Spinner + Retry button after 5s. Backend tolerates duplicate unlocks (idempotent on success — counter just stays at 0). |
|
||||
| Tech mid-step when tablet locks | Step timer keeps running on server. Auto-pause cron (Phase 2) is the upper-bound safety net. |
|
||||
| Tech A's PIN locked for 5 min — can tech B unlock? | Yes. Lockout is per-user, not per-tablet. |
|
||||
| Tech keeps tablet active by setting a heavy weight on it | Activity = pointer/touch/key events only, not mouse-move. A weight doesn't fire those events. Still locks after 5 min. |
|
||||
| Tech is mid-RPC when lock fires | RPC completes (server keeps running). Response is dropped silently — UI is already showing the tile grid. |
|
||||
| Two tabs / windows on the same browser | Each tab has its own FpTabletLock state. They lock independently. Acceptable for v1; not a real shop scenario. |
|
||||
| Manager wants to act AS a tech | Out of scope. Manager unlocks with their own PIN; their actions carry their own uid. |
|
||||
|
||||
## 8. Testing
|
||||
|
||||
### 8.1 Python tests (`fusion_plating_shopfloor/tests/test_tablet_pin.py`)
|
||||
|
||||
| Test | Verifies |
|
||||
|---|---|
|
||||
| `test_set_pin_first_time` | User with no hash can set PIN; resulting hash is salted and length > 32. |
|
||||
| `test_set_pin_change_requires_old` | Setting a new PIN when one exists requires correct old_pin; wrong old_pin rejected. |
|
||||
| `test_unlock_correct_pin_resets_failure_count` | Failed → failed → correct → counter is 0. |
|
||||
| `test_unlock_5_wrong_locks_user` | 5 wrong attempts → 6th returns `locked_until`. 7th still rejected. |
|
||||
| `test_lockout_expires_after_threshold` | After 5 min sim time elapsed → next attempt allowed again. |
|
||||
| `test_reset_pin_for_requires_manager` | Operator → AccessError. Supervisor → AccessError. Manager → success. |
|
||||
| `test_reset_pin_clears_hash_and_posts_chatter` | After reset: hash is False, set_date is False, chatter has "PIN reset by Manager X". |
|
||||
| `test_tiles_filtered_by_station_roster` | Station with authorised_user_ids → tiles is subset. Empty list → all operator-group users. |
|
||||
| `test_audit_kwarg_used_in_step_finish` | RPC with `tablet_tech_id=N` → step's `write_uid == N` (not env.uid). |
|
||||
| `test_audit_kwarg_invalid_falls_back_to_session` | Invalid `tablet_tech_id` → write_uid == env.uid, no error. |
|
||||
|
||||
### 8.2 Manual QA
|
||||
|
||||
`docs/qa/2026-05-22-shopfloor-pin-gate-qa.md` walkthrough:
|
||||
|
||||
1. Tech A sets PIN via Preferences
|
||||
2. Tech A unlocks tablet → starts a step
|
||||
3. 5 min idle elapses → tablet locks
|
||||
4. Tech B unlocks → finishes Tech A's step
|
||||
5. Audit chatter shows: started by A at T+0, finished by B at T+6
|
||||
6. Manager taps Reset PIN on Tech A's res.users form
|
||||
7. Tech A unlocks → set-PIN flow
|
||||
8. Tech A fails PIN 5 times → lockout kicks in
|
||||
9. Tech A waits 5 min → unlocks successfully
|
||||
|
||||
## 9. Build sequence (3 sub-phases)
|
||||
|
||||
Each ships independently and can be rolled back independently.
|
||||
|
||||
| Sub-phase | Ships | Independently deployable? |
|
||||
|---|---|---|
|
||||
| **6.1 — Backend** | model fields on res.users + station extras + ir.config_parameter defaults + 5 `/fp/tablet/*` endpoints + Profile prefs Set/Change PIN button + Manager Reset PIN button on res.users form | Yes — works silently behind the scenes. Techs can set PINs but the lock screen doesn't render yet. |
|
||||
| **6.2 — Frontend lock screen** | FpTabletLock wrapper + FpPinPad + FpIdleWarning + activity tracker service + Hand-Off button injection into existing headers | Yes — lock screen goes live. Audit credit still defaults to tablet session user without 6.3. |
|
||||
| **6.3 — Audit propagation** | `tablet_tech_id` optional kwarg on all existing action endpoints + `fpRpc()` wrapper + Landing/Workspace/Manager updated to use it | Yes — refines the audit trail. Without it, actions are recorded against the tablet's session uid. |
|
||||
|
||||
## 10. Backwards compatibility
|
||||
|
||||
- Any tablet that hasn't been upgraded to Phase 6.2 continues to work unauthenticated (no lock screen). Once 6.2 lands, ALL tablets start showing the lock screen.
|
||||
- Endpoints from Phases 1-5 keep their existing signatures. `tablet_tech_id` is purely additive.
|
||||
- Setting / changing the PIN is opt-in per user. A tech without a PIN sees a "set one to continue" prompt; they can't dismiss it.
|
||||
- No model migration required — all new fields default to NULL.
|
||||
- `ir.config_parameter` defaults are read at runtime, no install-time setup needed.
|
||||
|
||||
## 11. Rollback strategy
|
||||
|
||||
| Sub-phase | Rollback |
|
||||
|---|---|
|
||||
| 6.1 | Disable endpoints in `controllers/__init__.py`. Model fields are additive, safe to drop. |
|
||||
| 6.2 | Hide `FpTabletLock` via a feature flag (`ir.config_parameter` `fp.shopfloor.tablet_lock_enabled`, default true; set false to bypass). Existing client actions render directly again. |
|
||||
| 6.3 | Stop sending `tablet_tech_id` from `fpRpc()` — server falls back to `env.uid`. |
|
||||
|
||||
## 12. Out of scope for v1
|
||||
|
||||
- Biometric (Face ID, fingerprint)
|
||||
- NFC badges
|
||||
- TOTP / SMS / email-based reset
|
||||
- "Remember me" cross-device sessions
|
||||
- Per-tech idle threshold (only per-station + global)
|
||||
- Lock-screen widgets (weather, time, KPIs) — keep the tile grid focused
|
||||
- Camera-based presence / liveness
|
||||
- Pre-fetched tile grid (each unlock call fetches fresh)
|
||||
- Different PIN lengths per tech (4 digits for everyone)
|
||||
|
||||
## 13. Decisions log
|
||||
|
||||
| Decision | Rationale |
|
||||
|---|---|
|
||||
| 4-digit PIN over 6-digit | Speed. Industry norm. Lockout + per-user-fail-counter makes 10,000 combos secure enough. |
|
||||
| PBKDF2-SHA256, 200k iterations | ~50ms verify on entech hardware. Safe against rainbow tables; brute-force-resistant even with DB stolen. |
|
||||
| Hash field is manager-readable only | Operators can't even view other users' hash. Reduces lateral attack surface. |
|
||||
| Per-user lockout, not per-tablet | A bad-actor wrong-PIN'ing one user shouldn't deny service to other techs on the same tablet. |
|
||||
| 5 minute idle default | Compromise: long enough for legitimate idle-watching of a tank, short enough that a walk-away is caught. Configurable per-station. |
|
||||
| Server-side step timer keeps running on lock | Locking is UI; nothing should pause physical processes. Auto-pause cron is the deeper safety net. |
|
||||
| Single Odoo session, PIN overlay credits via `tablet_tech_id` kwarg | No JS bundle reload, no state loss, no flicker. Audit kwarg keeps the trail honest. |
|
||||
| Manager-only reset (no self-service) | Plating shops rarely have per-tech email/SMS. Manager is always present. Lower infra. |
|
||||
| 30s warning before lock | Compromise: catches "I was right there" cases without being annoyingly chatty. |
|
||||
| `tablet_tech_id` is opt-in additive kwarg | Lets 6.3 ship after 6.2 without breaking anything; lets older callers continue working unchanged. |
|
||||
|
||||
## 14. Future v2 candidates
|
||||
|
||||
- NFC badge tap (cheap USB readers, ~$30)
|
||||
- Personal QR badge on lanyard (no hardware beyond what we already have)
|
||||
- Per-tech idle threshold (long-shift senior techs vs cross-trained probationers)
|
||||
- Lock-screen KPIs (shop output today, hot WOs visible without unlocking)
|
||||
- "Switch tech without re-PIN" — keep both signed in for hand-off audit on the same step
|
||||
- Mobile app companion with biometric unlock
|
||||
|
||||
---
|
||||
|
||||
**Next step:** user reviews this spec. Once approved, transition to `superpowers:writing-plans` to produce the phased implementation plan.
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating',
|
||||
'version': '19.0.20.7.0',
|
||||
'version': '19.0.20.8.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||
'description': """
|
||||
|
||||
@@ -65,6 +65,51 @@ class FpWorkCentre(models.Model):
|
||||
# field via _inherit if/when the bake-oven coupling is needed.
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
# Phase 4 tablet redesign — Manager At-Risk heatmap inputs.
|
||||
# Non-stored (recomputed on every read by /fp/manager/at_risk; the
|
||||
# endpoint caches the payload for 60s anyway so the cost is bounded).
|
||||
bottleneck_score = fields.Float(
|
||||
compute='_compute_bottleneck',
|
||||
string='Bottleneck Score',
|
||||
help='active_step_count * avg_wait_minutes (rolling 7-day). '
|
||||
'Drives the Manager At-Risk heatmap — work centres with '
|
||||
'high score have queue + wait pressure.',
|
||||
)
|
||||
avg_wait_minutes = fields.Float(
|
||||
compute='_compute_bottleneck',
|
||||
string='Avg Wait (min)',
|
||||
help='Average minutes that steps at this work centre waited in '
|
||||
'ready state before starting, over the last 7 days.',
|
||||
)
|
||||
|
||||
def _compute_bottleneck(self):
|
||||
from datetime import timedelta
|
||||
Step = self.env['fp.job.step']
|
||||
now = fields.Datetime.now()
|
||||
seven_days_ago = now - timedelta(days=7)
|
||||
for wc in self:
|
||||
active_n = Step.search_count([
|
||||
('work_centre_id', '=', wc.id),
|
||||
('state', 'in', ('ready', 'in_progress')),
|
||||
])
|
||||
# Avg wait: recent steps where date_started is set; approximate
|
||||
# "ready since" as create_date when no explicit ready timestamp
|
||||
# is recorded. Bounded set (last 7 days) keeps the search cheap.
|
||||
recent = Step.search([
|
||||
('work_centre_id', '=', wc.id),
|
||||
('date_started', '>=', seven_days_ago),
|
||||
('date_started', '!=', False),
|
||||
])
|
||||
waits = []
|
||||
for s in recent:
|
||||
if s.create_date and s.date_started:
|
||||
waits.append(
|
||||
(s.date_started - s.create_date).total_seconds() / 60.0
|
||||
)
|
||||
avg = (sum(waits) / len(waits)) if waits else 0.0
|
||||
wc.avg_wait_minutes = avg
|
||||
wc.bottleneck_score = active_n * avg
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_code', 'UNIQUE(code)', 'Work centre code must be unique.'),
|
||||
]
|
||||
|
||||
@@ -44,8 +44,6 @@
|
||||
<field name="code">model._cron_autopause_stale_steps()</field>
|
||||
<field name="interval_number">30</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="doall" eval="False"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -1200,6 +1200,33 @@ class FpJobStep(models.Model):
|
||||
# quick-look modal. The modal is bound via context= on the parent
|
||||
# job form's <field name="step_ids"/> — no TransientModel needed.
|
||||
|
||||
# Job-level context for the quick-look modal — restored after commit
|
||||
# b0070afc accidentally removed these while still referencing them in
|
||||
# fp_job_step_quick_look_views.xml (entech caught the mismatch during
|
||||
# the 2026-05-22 Phase 1-4 deploy).
|
||||
quick_look_partner_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Customer',
|
||||
related='job_id.partner_id',
|
||||
readonly=True,
|
||||
)
|
||||
quick_look_part_catalog_id = fields.Many2one(
|
||||
'fp.part.catalog',
|
||||
string='Part',
|
||||
related='job_id.part_catalog_id',
|
||||
readonly=True,
|
||||
)
|
||||
quick_look_qty = fields.Float(
|
||||
string='Order Qty',
|
||||
related='job_id.qty',
|
||||
readonly=True,
|
||||
)
|
||||
quick_look_instruction_attachment_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
string='Instruction Images',
|
||||
related='recipe_node_id.instruction_attachment_ids',
|
||||
readonly=True,
|
||||
)
|
||||
quick_look_instructions = fields.Html(
|
||||
string='Operator Instructions',
|
||||
related='recipe_node_id.description',
|
||||
|
||||
@@ -9,20 +9,26 @@
|
||||
site that needs to bring legacy menus back can simply add a
|
||||
user to the group. -->
|
||||
|
||||
<!-- Reset group_ids on the 3 shopfloor menus that used to be
|
||||
<!-- Reset group_ids on the shopfloor menus that used to be
|
||||
hidden — they are now the canonical UIs and should be visible
|
||||
to all users (subject to the original groups= attribute on
|
||||
each menuitem in fusion_plating_shopfloor/views/fp_menu.xml). -->
|
||||
<record id="fusion_plating_shopfloor.menu_fp_shopfloor_manager" model="ir.ui.menu">
|
||||
<field name="group_ids" eval="[(6, 0, [ref('fusion_plating.group_fusion_plating_manager')])]"/>
|
||||
</record>
|
||||
<record id="fusion_plating_shopfloor.menu_fp_shopfloor_plant_overview" model="ir.ui.menu">
|
||||
<field name="group_ids" eval="[(6, 0, [])]"/>
|
||||
</record>
|
||||
<record id="fusion_plating_shopfloor.menu_fp_shopfloor_tablet" model="ir.ui.menu">
|
||||
<field name="group_ids" eval="[(6, 0, [])]"/>
|
||||
</record>
|
||||
|
||||
<!-- Phase 3 tablet redesign (2026-05-22) — the standalone Plant
|
||||
Overview menu is superseded by Workstation > All Plant toggle.
|
||||
The fp_menu.xml record was removed but the database row
|
||||
persists (Odoo doesn't auto-delete orphan records). Force-
|
||||
delete here so the menu disappears from the Shop Floor tree.
|
||||
The action record (action_fp_plant_overview) is kept and
|
||||
retargeted to fp_shopfloor_landing for bookmark back-compat. -->
|
||||
<delete model="ir.ui.menu" id="fusion_plating_shopfloor.menu_fp_shopfloor_plant_overview"/>
|
||||
|
||||
<!-- bridge_mrp Production Priorities reference removed post-Sub 11
|
||||
(the bridge module is uninstalled and its menu xmlid no longer
|
||||
resolves). fp.job has its own priority field on the header. -->
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Shop Floor',
|
||||
'version': '19.0.27.1.0',
|
||||
'version': '19.0.30.1.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
||||
'first-piece inspection gates.',
|
||||
@@ -45,7 +45,9 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'security/ir.model.access.csv',
|
||||
'data/fp_sequence_data.xml',
|
||||
'data/fp_cron_data.xml',
|
||||
'data/fp_tablet_config_data.xml',
|
||||
'views/fp_shopfloor_station_views.xml',
|
||||
'views/res_users_views.xml',
|
||||
'views/fp_bake_oven_views.xml',
|
||||
'views/fp_bake_window_views.xml',
|
||||
'views/fp_first_piece_gate_views.xml',
|
||||
@@ -80,10 +82,28 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_kanban_card.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/kanban_card.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/kanban_card.js',
|
||||
# ---- Phase 6.2 tablet PIN gate ----
|
||||
'fusion_plating_shopfloor/static/src/js/services/tech_store.js',
|
||||
'fusion_plating_shopfloor/static/src/js/services/activity_tracker.js',
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_pin_pad.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/pin_pad.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/pin_pad.js',
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_idle_warning.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/idle_warning.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/idle_warning.js',
|
||||
'fusion_plating_shopfloor/static/src/scss/tablet_lock.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/tablet_lock.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/tablet_lock.js',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/pin_setup.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/pin_setup.js',
|
||||
# ---- Job Workspace (Phase 1 — tablet redesign) ----
|
||||
'fusion_plating_shopfloor/static/src/scss/job_workspace.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/job_workspace.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/job_workspace.js',
|
||||
# ---- Shop Floor Landing (Phase 3 — tablet redesign) ----
|
||||
'fusion_plating_shopfloor/static/src/scss/shopfloor_landing.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/shopfloor_landing.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/shopfloor_landing.js',
|
||||
'fusion_plating_shopfloor/static/src/scss/qr_scanner.scss',
|
||||
'fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss',
|
||||
'fusion_plating_shopfloor/static/src/scss/plant_overview.scss',
|
||||
|
||||
@@ -7,3 +7,5 @@ from . import manager_controller
|
||||
from . import tank_status
|
||||
from . import move_controller
|
||||
from . import workspace_controller
|
||||
from . import landing_controller
|
||||
from . import tablet_controller
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""JSON-RPC endpoint for the Shop Floor Landing kanban (Phase 3).
|
||||
|
||||
Replaces the data path for both fp_shopfloor_tablet (legacy) and
|
||||
fp_plant_overview (legacy). Two modes:
|
||||
|
||||
station — paired station's work centre + Unassigned + next 1-2 WCs
|
||||
in the recipe flow. The physical-station view.
|
||||
all_plant — every active work centre, sorted by recipe flow.
|
||||
|
||||
The card payload shape matches the existing plant_overview cards so
|
||||
the front-end can share the KanbanCard component. Tapping a card opens
|
||||
the JobWorkspace via doAction (handled client-side).
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import fields, http
|
||||
from odoo.addons.fusion_plating.models.fp_tz import fp_format
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_ACTIVE_STEP_STATES = ('ready', 'in_progress', 'paused')
|
||||
|
||||
|
||||
class FpLandingController(http.Controller):
|
||||
|
||||
# ======================================================================
|
||||
# /fp/landing/kanban
|
||||
# ======================================================================
|
||||
@http.route('/fp/landing/kanban', type='jsonrpc', auth='user')
|
||||
def kanban(self, mode='all_plant', station_id=None, search=None):
|
||||
env = request.env
|
||||
Step = env['fp.job.step']
|
||||
WorkCentre = env['fp.work.centre']
|
||||
|
||||
# ---- Resolve station / facility scope ----------------------------
|
||||
station = None
|
||||
facility = None
|
||||
if station_id:
|
||||
stn = env['fusion.plating.shopfloor.station'].browse(int(station_id))
|
||||
if stn.exists():
|
||||
station = stn
|
||||
facility = stn.facility_id
|
||||
if not facility:
|
||||
facility = env['fusion.plating.facility'].search([], limit=1)
|
||||
|
||||
# ---- Which work centres to render --------------------------------
|
||||
wc_dom = [('active', '=', True)]
|
||||
if facility:
|
||||
wc_dom.append(('facility_id', '=', facility.id))
|
||||
all_wcs = WorkCentre.search(wc_dom, order='sequence, code, name')
|
||||
|
||||
if mode == 'station' and station and station.work_center_id:
|
||||
this_wc = station.work_center_id
|
||||
# Show this WC + next 1-2 WCs in the recipe flow (preview)
|
||||
after = all_wcs.filtered(
|
||||
lambda w: w.sequence > this_wc.sequence
|
||||
)[:2]
|
||||
relevant_wcs = this_wc | after
|
||||
else:
|
||||
relevant_wcs = all_wcs
|
||||
|
||||
# ---- Active steps in scope ---------------------------------------
|
||||
step_dom = [('state', 'in', _ACTIVE_STEP_STATES)]
|
||||
if facility:
|
||||
step_dom.append(('work_centre_id.facility_id', '=', facility.id))
|
||||
if mode == 'station' and relevant_wcs:
|
||||
# In station mode, include the relevant WCs + Unassigned only.
|
||||
# The OR-of-three-leaves is what makes this filter "this WC,
|
||||
# the next 1-2 WCs, or Unassigned" — three branches OR'd.
|
||||
step_dom = step_dom + [
|
||||
'|', '|',
|
||||
('work_centre_id', 'in', relevant_wcs.ids),
|
||||
('work_centre_id', '=', False),
|
||||
('work_centre_id', 'in', relevant_wcs.ids),
|
||||
]
|
||||
|
||||
steps = Step.search(step_dom, order='sequence, id')
|
||||
|
||||
if search:
|
||||
search_l = search.strip().lower()
|
||||
steps = steps.filtered(lambda s: (
|
||||
search_l in (s.job_id.display_wo_name or '').lower()
|
||||
or search_l in (s.job_id.partner_id.name or '').lower()
|
||||
or search_l in (
|
||||
s.job_id.part_catalog_id.part_number or ''
|
||||
if s.job_id.part_catalog_id else ''
|
||||
).lower()
|
||||
))
|
||||
|
||||
# ---- Group into columns ------------------------------------------
|
||||
cards_by_wc = {0: []} # 0 = Unassigned sentinel
|
||||
for step in steps:
|
||||
wc_id = step.work_centre_id.id or 0
|
||||
cards_by_wc.setdefault(wc_id, []).append(self._step_to_card(step))
|
||||
|
||||
columns = []
|
||||
for wc in relevant_wcs:
|
||||
columns.append({
|
||||
'work_center_id': wc.id,
|
||||
'work_center_name': wc.name,
|
||||
'cards': cards_by_wc.get(wc.id, []),
|
||||
})
|
||||
if cards_by_wc.get(0):
|
||||
columns.append({
|
||||
'work_center_id': 0,
|
||||
'work_center_name': 'Unassigned',
|
||||
'cards': cards_by_wc[0],
|
||||
})
|
||||
|
||||
# ---- KPIs — 4 tech-relevant tiles --------------------------------
|
||||
ready = sum(1 for s in steps if s.state == 'ready')
|
||||
running = sum(1 for s in steps if s.state == 'in_progress')
|
||||
|
||||
BakeWindow = env['fusion.plating.bake.window']
|
||||
bake_dom = [('state', 'in', ('awaiting_bake', 'bake_in_progress'))]
|
||||
if facility:
|
||||
bake_dom.append(('facility_id', '=', facility.id))
|
||||
bakes_due = BakeWindow.search_count(bake_dom)
|
||||
|
||||
Hold = env['fusion.plating.quality.hold']
|
||||
holds = Hold.search_count([('state', 'in', ('on_hold', 'under_review'))])
|
||||
|
||||
# ---- Station picker payload (so client can switch stations) ------
|
||||
all_stations = env['fusion.plating.shopfloor.station'].search(
|
||||
[], order='facility_id, name',
|
||||
)
|
||||
stations = [
|
||||
{
|
||||
'id': s.id,
|
||||
'name': s.name,
|
||||
'code': s.code or '',
|
||||
'facility': s.facility_id.name or '',
|
||||
'work_center_name': s.work_center_id.name or '',
|
||||
}
|
||||
for s in all_stations
|
||||
]
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'mode': mode,
|
||||
'station': {
|
||||
'id': station.id,
|
||||
'name': station.name,
|
||||
'code': station.code or '',
|
||||
'work_center_name': station.work_center_id.name or '',
|
||||
} if station else None,
|
||||
'facility_name': facility.name if facility else '',
|
||||
'columns': columns,
|
||||
'kpis': {
|
||||
'ready': ready,
|
||||
'running': running,
|
||||
'bakes_due': bakes_due,
|
||||
'holds': holds,
|
||||
},
|
||||
'stations': stations,
|
||||
'server_time': fp_format(env, fields.Datetime.now(), fmt='%H:%M:%S'),
|
||||
}
|
||||
|
||||
def _step_to_card(self, step):
|
||||
"""Build the kanban card payload for one fp.job.step.
|
||||
|
||||
Shape matches the KanbanCard OWL component (Phase 1 — P1.7).
|
||||
"""
|
||||
job = step.job_id
|
||||
return {
|
||||
'step_id': step.id,
|
||||
'job_id': job.id,
|
||||
'display_wo_name': job.display_wo_name,
|
||||
'customer': job.partner_id.name or '',
|
||||
'part': (
|
||||
job.part_catalog_id.part_number
|
||||
if 'part_catalog_id' in job._fields and job.part_catalog_id
|
||||
else (job.product_id.display_name or '')
|
||||
),
|
||||
'qty': int(job.qty or 0),
|
||||
'qty_done': int(job.qty_done or 0),
|
||||
'qty_scrapped': int(job.qty_scrapped or 0),
|
||||
'date_deadline': fp_format(
|
||||
request.env, job.date_deadline, fmt='%b %d',
|
||||
) if job.date_deadline else '',
|
||||
'priority': job.priority or 'normal',
|
||||
'workflow_state': {
|
||||
'id': job.workflow_state_id.id,
|
||||
'name': job.workflow_state_id.name,
|
||||
'color': job.workflow_state_id.color or 'grey',
|
||||
} if job.workflow_state_id else None,
|
||||
'blocker_kind': step.blocker_kind,
|
||||
'blocker_reason': step.blocker_reason or '',
|
||||
'current_step_id': step.id,
|
||||
'current_step_name': step.name,
|
||||
'work_center': step.work_centre_id.name or '',
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import logging
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import http
|
||||
from odoo import fields, http
|
||||
from odoo.addons.fusion_plating.models.fp_tz import fp_format
|
||||
from odoo.http import request
|
||||
|
||||
@@ -466,3 +466,231 @@ class FpManagerDashboardController(http.Controller):
|
||||
),
|
||||
)
|
||||
return {'ok': True, 'user_name': user.name}
|
||||
|
||||
# ======================================================================
|
||||
# Phase 4 tablet redesign — 3 new tabs on the Manager Desk
|
||||
# ======================================================================
|
||||
|
||||
@http.route('/fp/manager/funnel', type='jsonrpc', auth='user')
|
||||
def funnel(self, facility_id=None):
|
||||
"""Workflow funnel: jobs grouped by fp.job.workflow.state.
|
||||
|
||||
One row per workflow stage with its count + top 5 WO cards.
|
||||
Drives the default tab on the refactored Manager Dashboard.
|
||||
"""
|
||||
env = request.env
|
||||
Job = env['fp.job']
|
||||
all_states = env['fp.job.workflow.state'].search(
|
||||
[], order='sequence, id',
|
||||
)
|
||||
# All in-flight jobs (not done/cancelled)
|
||||
job_dom = [('state', 'not in', _NEG_JOB_STATES)]
|
||||
if facility_id:
|
||||
job_dom.append(('facility_id', '=', int(facility_id)))
|
||||
jobs = Job.search(job_dom, order='priority desc, date_deadline asc')
|
||||
|
||||
# Group jobs by workflow_state_id (in-memory — list is bounded by
|
||||
# active job count, typically < 200)
|
||||
by_stage = {ws.id: [] for ws in all_states}
|
||||
for job in jobs:
|
||||
if job.workflow_state_id and job.workflow_state_id.id in by_stage:
|
||||
by_stage[job.workflow_state_id.id].append(job)
|
||||
|
||||
def _job_card_compact(job):
|
||||
return {
|
||||
'job_id': job.id,
|
||||
'display_wo_name': job.display_wo_name,
|
||||
'customer': job.partner_id.name or '',
|
||||
'priority': job.priority or 'normal',
|
||||
'days_in_stage': (
|
||||
(fields.Datetime.now() - job.write_date).days
|
||||
if job.write_date else 0
|
||||
),
|
||||
}
|
||||
|
||||
stages = []
|
||||
for ws in all_states:
|
||||
jobs_in_stage = by_stage[ws.id]
|
||||
stages.append({
|
||||
'id': ws.id,
|
||||
'name': ws.name,
|
||||
'color': ws.color or 'grey',
|
||||
'sequence': ws.sequence or 0,
|
||||
'count': len(jobs_in_stage),
|
||||
'jobs': [_job_card_compact(j) for j in jobs_in_stage[:5]],
|
||||
})
|
||||
|
||||
return {'ok': True, 'stages': stages}
|
||||
|
||||
@http.route('/fp/manager/approval_inbox', type='jsonrpc', auth='user')
|
||||
def approval_inbox(self, facility_id=None):
|
||||
"""Approval Inbox: things waiting on a manager decision.
|
||||
|
||||
Four buckets: holds to release, certs to issue, recent scrap to
|
||||
acknowledge, override requests (deferred — empty for now).
|
||||
"""
|
||||
env = request.env
|
||||
|
||||
# ---- Holds to Release -------------------------------------------
|
||||
Hold = env['fusion.plating.quality.hold']
|
||||
hold_dom = [('state', 'in', ('on_hold', 'under_review'))]
|
||||
holds = Hold.search(hold_dom, order='create_date desc', limit=50)
|
||||
holds_to_release = [
|
||||
{
|
||||
'hold_id': h.id,
|
||||
'name': h.name,
|
||||
'job_name': (
|
||||
h.x_fc_job_id.display_wo_name
|
||||
if 'x_fc_job_id' in Hold._fields and h.x_fc_job_id
|
||||
else (h.x_fc_job_id.name or '' if 'x_fc_job_id' in Hold._fields else '')
|
||||
),
|
||||
'reason': dict(Hold._fields['hold_reason'].selection).get(
|
||||
h.hold_reason, h.hold_reason or '',
|
||||
),
|
||||
'qty': h.qty_on_hold or 0,
|
||||
'requested_by': h.operator_id.name or '—',
|
||||
'requested_at': fp_format(env, h.create_date) if h.create_date else '',
|
||||
}
|
||||
for h in holds
|
||||
]
|
||||
|
||||
# ---- Certs to Issue ---------------------------------------------
|
||||
# Jobs where all_steps_terminal AND at least one required cert
|
||||
# is still draft.
|
||||
Job = env['fp.job']
|
||||
job_dom = [
|
||||
('all_steps_terminal', '=', True),
|
||||
('state', 'not in', _NEG_JOB_STATES),
|
||||
]
|
||||
if facility_id:
|
||||
job_dom.append(('facility_id', '=', int(facility_id)))
|
||||
terminal_jobs = Job.search(job_dom, order='write_date desc', limit=100)
|
||||
certs_to_issue = []
|
||||
for job in terminal_jobs:
|
||||
try:
|
||||
if not job._fp_has_draft_required_certs():
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
certs_to_issue.append({
|
||||
'job_id': job.id,
|
||||
'display_wo_name': job.display_wo_name,
|
||||
'customer': job.partner_id.name or '',
|
||||
'cert_types': list(job._resolve_required_cert_types()),
|
||||
'all_steps_done_at': fp_format(env, job.write_date) if job.write_date else '',
|
||||
})
|
||||
|
||||
# ---- Scrap to Review --------------------------------------------
|
||||
# Recent qty_scrapped bumps via S17 hook auto-spawn holds with
|
||||
# hold_reason in ('scrap', 'other'). Surface the last 24h worth.
|
||||
from datetime import timedelta
|
||||
scrap_cutoff = fields.Datetime.now() - timedelta(hours=24)
|
||||
scrap_holds = Hold.search([
|
||||
('mark_for_scrap', '=', True),
|
||||
('create_date', '>=', scrap_cutoff),
|
||||
], order='create_date desc', limit=20)
|
||||
scrap_to_review = [
|
||||
{
|
||||
'hold_id': h.id,
|
||||
'job_name': (
|
||||
h.x_fc_job_id.display_wo_name
|
||||
if 'x_fc_job_id' in Hold._fields and h.x_fc_job_id else ''
|
||||
),
|
||||
'scrap_qty': h.qty_on_hold or 0,
|
||||
'reason': h.description or '',
|
||||
'operator': h.operator_id.name or '—',
|
||||
'at': fp_format(env, h.create_date) if h.create_date else '',
|
||||
}
|
||||
for h in scrap_holds
|
||||
]
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'holds_to_release': holds_to_release,
|
||||
'certs_to_issue': certs_to_issue,
|
||||
'scrap_to_review': scrap_to_review,
|
||||
'override_requests': [], # deferred — placeholder
|
||||
}
|
||||
|
||||
@http.route('/fp/manager/at_risk', type='jsonrpc', auth='user')
|
||||
def at_risk(self, facility_id=None):
|
||||
"""At-Risk view: trending-late jobs + hold reasons + bottleneck.
|
||||
|
||||
Sub-panels:
|
||||
- trending_late: top 20 jobs by late_risk_ratio desc (> 0)
|
||||
- hold_reasons: open holds grouped by hold_reason
|
||||
- bottleneck: work centres sorted by bottleneck_score desc
|
||||
"""
|
||||
env = request.env
|
||||
|
||||
# ---- Trending Late ----------------------------------------------
|
||||
Job = env['fp.job']
|
||||
job_dom = [
|
||||
('state', 'not in', _NEG_JOB_STATES),
|
||||
('late_risk_ratio', '>', 0),
|
||||
]
|
||||
if facility_id:
|
||||
job_dom.append(('facility_id', '=', int(facility_id)))
|
||||
late_jobs = Job.search(
|
||||
job_dom, order='late_risk_ratio desc', limit=20,
|
||||
)
|
||||
trending_late = [
|
||||
{
|
||||
'job_id': j.id,
|
||||
'display_wo_name': j.display_wo_name,
|
||||
'customer': j.partner_id.name or '',
|
||||
'late_risk_ratio': round(j.late_risk_ratio, 2),
|
||||
'deadline': fp_format(env, j.date_deadline, fmt='%Y-%m-%d') if j.date_deadline else '',
|
||||
'stuck_at': (
|
||||
j.active_step_id.name
|
||||
if 'active_step_id' in j._fields and j.active_step_id
|
||||
else ''
|
||||
),
|
||||
}
|
||||
for j in late_jobs
|
||||
]
|
||||
|
||||
# ---- Hold Reasons grouped --------------------------------------
|
||||
Hold = env['fusion.plating.quality.hold']
|
||||
reason_selection = dict(Hold._fields['hold_reason'].selection)
|
||||
# read_group is the cheap way to bucket
|
||||
groups = Hold.read_group(
|
||||
domain=[('state', 'in', ('on_hold', 'under_review'))],
|
||||
fields=['hold_reason'],
|
||||
groupby=['hold_reason'],
|
||||
)
|
||||
hold_reasons = [
|
||||
{
|
||||
'reason': g.get('hold_reason') or 'unknown',
|
||||
'label': reason_selection.get(g.get('hold_reason'), g.get('hold_reason') or 'unknown'),
|
||||
'count': g.get('hold_reason_count', 0),
|
||||
}
|
||||
for g in groups
|
||||
]
|
||||
hold_reasons.sort(key=lambda r: r['count'], reverse=True)
|
||||
|
||||
# ---- Bottleneck heatmap ----------------------------------------
|
||||
WC = env['fp.work.centre']
|
||||
wc_dom = [('active', '=', True)]
|
||||
if facility_id:
|
||||
wc_dom.append(('facility_id', '=', int(facility_id)))
|
||||
wcs = WC.search(wc_dom)
|
||||
bottlenecks = []
|
||||
for wc in wcs:
|
||||
# Skip work centres with zero queue — no signal
|
||||
if wc.bottleneck_score <= 0:
|
||||
continue
|
||||
bottlenecks.append({
|
||||
'work_centre_id': wc.id,
|
||||
'work_centre_name': wc.name,
|
||||
'score': round(wc.bottleneck_score, 1),
|
||||
'avg_wait_minutes': round(wc.avg_wait_minutes, 1),
|
||||
})
|
||||
bottlenecks.sort(key=lambda b: b['score'], reverse=True)
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'trending_late': trending_late,
|
||||
'hold_reasons': hold_reasons,
|
||||
'bottleneck': bottlenecks[:10], # top 10
|
||||
}
|
||||
|
||||
@@ -620,15 +620,26 @@ class FpShopfloorController(http.Controller):
|
||||
# ----------------------------------------------------------------------
|
||||
# Tablet Overview — one-shot dashboard payload
|
||||
# ----------------------------------------------------------------------
|
||||
# DEPRECATED (Phase 3 tablet redesign — 2026-05-22).
|
||||
# New Shop Floor Landing client action (fp_shopfloor_landing) uses
|
||||
# /fp/landing/kanban. The Tablet Station menu now points at the new
|
||||
# surface. This endpoint stays live as long as the legacy
|
||||
# fp_shopfloor_tablet OWL component is still registered — it consumes
|
||||
# the rich payload (my_queue, active_wo, baths, bake_windows, gates,
|
||||
# holds, pending_qcs, stations). Phase 5 cleanup will retire both the
|
||||
# legacy component and this endpoint together.
|
||||
@http.route('/fp/shopfloor/tablet_overview', type='jsonrpc', auth='user')
|
||||
def tablet_overview(self, station_id=None, facility_id=None):
|
||||
"""Return a rich dashboard snapshot for the Tablet Station page.
|
||||
"""[DEPRECATED] Legacy Tablet Station dashboard payload.
|
||||
|
||||
Data layer: fp.job + fp.job.step. Field names on the response
|
||||
keep the legacy `_wo` suffix where they were referenced from the
|
||||
XML so the template doesn't need to be rewritten — internally
|
||||
these now point at fp.job.step rows.
|
||||
New consumers should use /fp/landing/kanban via the
|
||||
fp_shopfloor_landing client action (Phase 3 tablet redesign).
|
||||
"""
|
||||
_logger.info(
|
||||
"DEPRECATED /fp/shopfloor/tablet_overview called by uid %s — "
|
||||
"Phase 5 cleanup will remove this endpoint.",
|
||||
request.env.uid,
|
||||
)
|
||||
env = request.env
|
||||
user = env.user
|
||||
|
||||
@@ -1002,8 +1013,20 @@ class FpShopfloorController(http.Controller):
|
||||
# ----------------------------------------------------------------------
|
||||
# Operator queue snapshot (legacy fusion.plating.operator.queue helper)
|
||||
# ----------------------------------------------------------------------
|
||||
# DEPRECATED (Phase 3 tablet redesign — 2026-05-22).
|
||||
# The new fp_shopfloor_landing component does NOT use this endpoint;
|
||||
# it uses /fp/landing/kanban which already filters per station. The
|
||||
# only remaining consumer is the legacy fp_shopfloor_tablet OWL
|
||||
# component (still registered, no menu pointing at it). Phase 5
|
||||
# cleanup will retire both this endpoint and the legacy component
|
||||
# together — no replacement, the kanban supersedes it entirely.
|
||||
@http.route('/fp/shopfloor/queue', type='jsonrpc', auth='user')
|
||||
def queue(self, facility_id=None):
|
||||
_logger.info(
|
||||
"DEPRECATED /fp/shopfloor/queue called by uid %s — "
|
||||
"Phase 5 cleanup will remove this endpoint.",
|
||||
request.env.uid,
|
||||
)
|
||||
Queue = request.env.get('fusion.plating.operator.queue')
|
||||
if Queue is None or not hasattr(Queue, 'build_for_user'):
|
||||
# Fallback: synthesize the queue directly from fp.job.step.
|
||||
@@ -1093,14 +1116,26 @@ class FpShopfloorController(http.Controller):
|
||||
|
||||
return {'ok': True}
|
||||
|
||||
# DEPRECATED (Phase 3 tablet redesign — 2026-05-22).
|
||||
# The new fp_shopfloor_landing client action has an "All Plant" mode
|
||||
# that supersedes the standalone Plant Overview surface. Old endpoint
|
||||
# stays live for the move_card sibling endpoint and the legacy
|
||||
# fp_plant_overview OWL component (still registered but unhooked
|
||||
# from the menu). Phase 5 cleanup will retire both together.
|
||||
@http.route('/fp/shopfloor/plant_overview', type='jsonrpc', auth='user')
|
||||
def plant_overview(self, facility_id=None, search=None):
|
||||
"""Return active fp.job.step rows grouped by fp.work.centre.
|
||||
"""[DEPRECATED] Legacy Plant Overview payload.
|
||||
|
||||
Cards are individual fp.job.step rows in ready / in_progress /
|
||||
paused state. Columns are fp.work.centre rows; an "Unassigned"
|
||||
pseudo-column collects steps without a work centre.
|
||||
New consumers should use /fp/landing/kanban with mode='all_plant'
|
||||
via the fp_shopfloor_landing client action (Phase 3 tablet
|
||||
redesign). Note: /fp/shopfloor/plant_overview/move_card is NOT
|
||||
deprecated — the Landing component still uses it for drag-drop.
|
||||
"""
|
||||
_logger.info(
|
||||
"DEPRECATED /fp/shopfloor/plant_overview called by uid %s — "
|
||||
"Phase 5 cleanup will remove this endpoint.",
|
||||
request.env.uid,
|
||||
)
|
||||
env = request.env
|
||||
search = (search or '').strip().lower()
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""JSON-RPC endpoints for the tablet PIN gate (Phase 6 tablet redesign).
|
||||
|
||||
POST /fp/tablet/tiles — list of tiles for the lock screen
|
||||
POST /fp/tablet/unlock — verify PIN + clear/increment failure counter
|
||||
POST /fp/tablet/set_pin — self-service set/change PIN
|
||||
POST /fp/tablet/reset_pin_for — manager-only reset of another user's PIN
|
||||
POST /fp/tablet/ping — bump server-side last-active timestamp
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-22-shopfloor-pin-gate-design.md
|
||||
"""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import _, fields, http
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_manager(env):
|
||||
"""True if calling user is in the fusion_plating manager group."""
|
||||
return env.user.has_group('fusion_plating.group_fusion_plating_manager')
|
||||
|
||||
|
||||
class FpTabletController(http.Controller):
|
||||
"""Tablet PIN gate endpoints. All require an authenticated Odoo
|
||||
session (the tablet logs in once as a 'shopfloor service' user).
|
||||
"""
|
||||
|
||||
# ======================================================================
|
||||
# /fp/tablet/set_pin — self-service set or change
|
||||
# ======================================================================
|
||||
@http.route('/fp/tablet/set_pin', type='jsonrpc', auth='user')
|
||||
def set_pin(self, new_pin, old_pin=None):
|
||||
env = request.env
|
||||
user = env.user
|
||||
existing_hash = user.sudo().x_fc_tablet_pin_hash
|
||||
if existing_hash:
|
||||
if not old_pin:
|
||||
return {'ok': False, 'error': _('Current PIN is required to change it.')}
|
||||
if not user.verify_tablet_pin(old_pin):
|
||||
return {'ok': False, 'error': _('Current PIN is incorrect.')}
|
||||
try:
|
||||
user.set_tablet_pin(new_pin)
|
||||
except UserError as e:
|
||||
return {'ok': False, 'error': str(e.args[0]) if e.args else str(e)}
|
||||
_logger.info(
|
||||
"Tablet PIN set/changed for uid %s by self", user.id,
|
||||
)
|
||||
return {'ok': True}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/tablet/reset_pin_for — manager-only
|
||||
# ======================================================================
|
||||
@http.route('/fp/tablet/reset_pin_for', type='jsonrpc', auth='user')
|
||||
def reset_pin_for(self, user_id):
|
||||
env = request.env
|
||||
if not _is_manager(env):
|
||||
_logger.warning(
|
||||
"Non-manager uid %s attempted to reset PIN for user %s",
|
||||
env.uid, user_id,
|
||||
)
|
||||
return {'ok': False, 'error': _('Manager privilege required.')}
|
||||
target = env['res.users'].browse(int(user_id))
|
||||
if not target.exists():
|
||||
return {'ok': False, 'error': _('User not found.')}
|
||||
target.clear_tablet_pin()
|
||||
_logger.info(
|
||||
"Tablet PIN reset for uid %s by manager uid %s",
|
||||
target.id, env.uid,
|
||||
)
|
||||
return {'ok': True}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/tablet/unlock — verify PIN + manage failure counter / lockout
|
||||
# ======================================================================
|
||||
@http.route('/fp/tablet/unlock', type='jsonrpc', auth='user')
|
||||
def unlock(self, user_id, pin):
|
||||
env = request.env
|
||||
Users = env['res.users'].sudo() # need sudo to read hash field
|
||||
target = Users.browse(int(user_id))
|
||||
if not target.exists():
|
||||
return {'ok': False, 'error': _('User not found.')}
|
||||
|
||||
# No PIN set yet — caller must set one first
|
||||
if not target.x_fc_tablet_pin_hash:
|
||||
return {
|
||||
'ok': False,
|
||||
'error': _('No PIN set. Set one in Preferences first.'),
|
||||
'needs_setup': True,
|
||||
}
|
||||
|
||||
# Currently locked out?
|
||||
now = fields.Datetime.now()
|
||||
if target.x_fc_tablet_locked_until and target.x_fc_tablet_locked_until > now:
|
||||
return {
|
||||
'ok': False,
|
||||
'error': _('Account locked. Try again in a few minutes.'),
|
||||
'locked_until': target.x_fc_tablet_locked_until.isoformat(),
|
||||
}
|
||||
|
||||
if target.verify_tablet_pin(pin):
|
||||
# Reset failure state on success
|
||||
target.write({
|
||||
'x_fc_tablet_pin_failed_count': 0,
|
||||
'x_fc_tablet_locked_until': False,
|
||||
})
|
||||
_logger.info(
|
||||
"Tablet unlocked by uid %s (session uid %s)",
|
||||
target.id, env.uid,
|
||||
)
|
||||
return {
|
||||
'ok': True,
|
||||
'current_tech_id': target.id,
|
||||
'current_tech_name': target.name,
|
||||
}
|
||||
|
||||
# Wrong PIN — increment and check threshold
|
||||
new_count = (target.x_fc_tablet_pin_failed_count or 0) + 1
|
||||
threshold = int(env['ir.config_parameter'].sudo().get_param(
|
||||
'fp.shopfloor.tablet_pin_fail_threshold', 5,
|
||||
))
|
||||
lockout_min = int(env['ir.config_parameter'].sudo().get_param(
|
||||
'fp.shopfloor.tablet_pin_fail_lockout_minutes', 5,
|
||||
))
|
||||
vals = {'x_fc_tablet_pin_failed_count': new_count}
|
||||
if new_count >= threshold:
|
||||
vals['x_fc_tablet_locked_until'] = now + timedelta(minutes=lockout_min)
|
||||
target.write(vals)
|
||||
_logger.warning(
|
||||
"Tablet PIN failure for uid %s (count=%d, locked=%s)",
|
||||
target.id, new_count, bool(vals.get('x_fc_tablet_locked_until')),
|
||||
)
|
||||
if vals.get('x_fc_tablet_locked_until'):
|
||||
return {
|
||||
'ok': False,
|
||||
'error': _('Too many failed attempts. Locked for %d minutes.') % lockout_min,
|
||||
'locked_until': vals['x_fc_tablet_locked_until'].isoformat(),
|
||||
}
|
||||
return {
|
||||
'ok': False,
|
||||
'error': _('Incorrect PIN.'),
|
||||
'attempts_remaining': threshold - new_count,
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/tablet/tiles — lock-screen tile grid
|
||||
# ======================================================================
|
||||
@http.route('/fp/tablet/tiles', type='jsonrpc', auth='user')
|
||||
def tiles(self, station_id=None):
|
||||
env = request.env
|
||||
op_group = env.ref(
|
||||
'fusion_plating.group_fusion_plating_operator',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
if not op_group:
|
||||
return {'ok': False, 'error': 'operator group missing'}
|
||||
|
||||
# Determine candidate users — station roster wins if non-empty
|
||||
users = op_group.user_ids
|
||||
if station_id:
|
||||
Station = env['fusion.plating.shopfloor.station']
|
||||
station = Station.browse(int(station_id))
|
||||
if (station.exists()
|
||||
and 'x_fc_authorised_user_ids' in station._fields
|
||||
and station.x_fc_authorised_user_ids):
|
||||
users = station.x_fc_authorised_user_ids
|
||||
|
||||
# has_pin needs sudo-read on the hash field
|
||||
clocked_ids = set()
|
||||
if 'hr.employee' in env and hasattr(
|
||||
env['hr.employee'], '_fp_clocked_in_user_ids',
|
||||
):
|
||||
clocked_ids = env['hr.employee']._fp_clocked_in_user_ids() or set()
|
||||
|
||||
users_sorted = users.sorted('name')
|
||||
users_sudo = users_sorted.sudo()
|
||||
tiles = []
|
||||
for u, u_sudo in zip(users_sorted, users_sudo):
|
||||
tiles.append({
|
||||
'user_id': u.id,
|
||||
'name': u.name,
|
||||
'avatar_url': f'/web/image/res.users/{u.id}/avatar_128',
|
||||
'is_clocked_in': u.id in clocked_ids,
|
||||
'has_pin': bool(u_sudo.x_fc_tablet_pin_hash),
|
||||
})
|
||||
# Clocked-in first, then alphabetical within bucket
|
||||
tiles.sort(key=lambda t: (not t['is_clocked_in'], t['name']))
|
||||
return {'ok': True, 'tiles': tiles}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/tablet/ping — heartbeat used by the OWL component on every action
|
||||
# ======================================================================
|
||||
@http.route('/fp/tablet/ping', type='jsonrpc', auth='user')
|
||||
def ping(self, current_tech_id=None):
|
||||
"""Lightweight heartbeat. Used by the OWL component to confirm
|
||||
the server-side session is alive AND to log the tech-of-record
|
||||
every few minutes so the server has forensic visibility into
|
||||
which tech was 'driving' the tablet at any moment.
|
||||
"""
|
||||
if current_tech_id:
|
||||
_logger.debug(
|
||||
"Tablet ping: session uid %s carrying tablet_tech_id=%s",
|
||||
request.env.uid, current_tech_id,
|
||||
)
|
||||
return {'ok': True, 'server_time': fields.Datetime.now().isoformat()}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
Phase 6 tablet PIN gate — default knobs.
|
||||
All overridable via Settings → Technical → Parameters → System Parameters.
|
||||
-->
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="ir_config_param_tablet_idle_lock_minutes" model="ir.config_parameter">
|
||||
<field name="key">fp.shopfloor.tablet_idle_lock_minutes</field>
|
||||
<field name="value">5</field>
|
||||
</record>
|
||||
|
||||
<record id="ir_config_param_tablet_pin_fail_threshold" model="ir.config_parameter">
|
||||
<field name="key">fp.shopfloor.tablet_pin_fail_threshold</field>
|
||||
<field name="value">5</field>
|
||||
</record>
|
||||
|
||||
<record id="ir_config_param_tablet_pin_fail_lockout_minutes" model="ir.config_parameter">
|
||||
<field name="key">fp.shopfloor.tablet_pin_fail_lockout_minutes</field>
|
||||
<field name="value">5</field>
|
||||
</record>
|
||||
|
||||
<record id="ir_config_param_tablet_warn_seconds_before_lock" model="ir.config_parameter">
|
||||
<field name="key">fp.shopfloor.tablet_warn_seconds_before_lock</field>
|
||||
<field name="value">30</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -8,3 +8,4 @@ from . import fp_bake_window
|
||||
from . import fp_first_piece_gate
|
||||
from . import fp_operator_queue
|
||||
from . import fp_tank
|
||||
from . import res_users
|
||||
|
||||
@@ -73,6 +73,26 @@ class FpShopfloorStation(models.Model):
|
||||
string='Notes',
|
||||
)
|
||||
|
||||
# Phase 6 tablet PIN gate — per-station roster + idle override.
|
||||
x_fc_authorised_user_ids = fields.Many2many(
|
||||
'res.users',
|
||||
relation='fp_shopfloor_station_authorised_user_rel',
|
||||
column1='station_id',
|
||||
column2='user_id',
|
||||
string='Authorised Operators',
|
||||
help='If set, the tablet lock screen only shows tiles for these '
|
||||
'users. Empty = all operator-group users are shown. Use to '
|
||||
'restrict a tablet at a specialised station (e.g. EN Plating) '
|
||||
'to techs trained on that station.',
|
||||
)
|
||||
x_fc_idle_lock_minutes = fields.Integer(
|
||||
string='Idle Lock (minutes)',
|
||||
help='Per-station override for the auto-lock idle threshold. '
|
||||
'Leave blank to use the global default '
|
||||
'(ir.config_parameter fp.shopfloor.tablet_idle_lock_minutes, '
|
||||
'default 5).',
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
'fp_shopfloor_station_code_uniq',
|
||||
|
||||
128
fusion_plating/fusion_plating_shopfloor/models/res_users.py
Normal file
128
fusion_plating/fusion_plating_shopfloor/models/res_users.py
Normal file
@@ -0,0 +1,128 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""Tablet-PIN extensions on res.users (Phase 6 tablet redesign).
|
||||
|
||||
Adds the 4-digit PIN gate fields + helpers used by /fp/tablet/* endpoints
|
||||
and the FpTabletLock OWL component. PIN is stored as a salted PBKDF2-SHA256
|
||||
hash; never plaintext.
|
||||
"""
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
# PBKDF2 iteration count. ~50ms verify on entech-class hardware. Safe
|
||||
# against brute-force even if the DB leaks.
|
||||
_PBKDF2_ITERATIONS = 200_000
|
||||
|
||||
|
||||
class ResUsers(models.Model):
|
||||
_inherit = 'res.users'
|
||||
|
||||
x_fc_tablet_pin_hash = fields.Char(
|
||||
string='Tablet PIN (hashed)',
|
||||
groups='fusion_plating.group_fusion_plating_manager',
|
||||
help='PBKDF2-SHA256 hash + salt of the user\'s 4-digit tablet '
|
||||
'PIN. Format: <salt_hex>$<digest_hex>. Never readable to '
|
||||
'non-managers; never logged.',
|
||||
)
|
||||
x_fc_tablet_pin_set_date = fields.Datetime(
|
||||
string='Tablet PIN Set Date',
|
||||
help='When the current PIN was last set or changed.',
|
||||
)
|
||||
x_fc_tablet_pin_failed_count = fields.Integer(
|
||||
string='Failed PIN Attempts',
|
||||
default=0,
|
||||
help='Sequential failed unlock attempts since the last success. '
|
||||
'Resets to 0 on a correct PIN.',
|
||||
)
|
||||
x_fc_tablet_locked_until = fields.Datetime(
|
||||
string='Tablet Lockout Until',
|
||||
help='Wall-clock time at which the per-user lockout expires. '
|
||||
'Null when not locked. Set after the configured fail '
|
||||
'threshold (default 5) is reached.',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _hash_tablet_pin(pin, salt=None):
|
||||
"""Hash `pin` with optional salt. Returns "salt_hex$digest_hex"."""
|
||||
if salt is None:
|
||||
salt = secrets.token_bytes(16)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
'sha256', pin.encode('utf-8'), salt, _PBKDF2_ITERATIONS,
|
||||
)
|
||||
return f"{salt.hex()}${digest.hex()}"
|
||||
|
||||
@staticmethod
|
||||
def _verify_tablet_pin_hash(pin, stored):
|
||||
"""Constant-time verify of `pin` against a stored hash string."""
|
||||
if not stored or '$' not in stored:
|
||||
return False
|
||||
salt_hex, expected_hex = stored.split('$', 1)
|
||||
try:
|
||||
salt = bytes.fromhex(salt_hex)
|
||||
except ValueError:
|
||||
return False
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
'sha256', pin.encode('utf-8'), salt, _PBKDF2_ITERATIONS,
|
||||
)
|
||||
return secrets.compare_digest(digest.hex(), expected_hex)
|
||||
|
||||
def set_tablet_pin(self, pin):
|
||||
"""Set or change this user's tablet PIN. Requires sudo OR self.
|
||||
|
||||
Caller is responsible for verifying the OLD pin separately if a
|
||||
hash already exists — this method just writes the new one.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not pin or not pin.isdigit() or len(pin) != 4:
|
||||
raise UserError(_('Tablet PIN must be exactly 4 digits.'))
|
||||
self.sudo().write({
|
||||
'x_fc_tablet_pin_hash': self._hash_tablet_pin(pin),
|
||||
'x_fc_tablet_pin_set_date': fields.Datetime.now(),
|
||||
'x_fc_tablet_pin_failed_count': 0,
|
||||
'x_fc_tablet_locked_until': False,
|
||||
})
|
||||
return True
|
||||
|
||||
def verify_tablet_pin(self, pin):
|
||||
"""Return True if `pin` matches this user's stored hash."""
|
||||
self.ensure_one()
|
||||
if not pin:
|
||||
return False
|
||||
# sudo: even non-manager callers may need to verify their OWN PIN.
|
||||
# The hash field has manager-only read; sudo bypasses that.
|
||||
return self._verify_tablet_pin_hash(pin, self.sudo().x_fc_tablet_pin_hash)
|
||||
|
||||
def clear_tablet_pin(self):
|
||||
"""Manager-side reset. Clears hash so target must set a new PIN.
|
||||
Posts to chatter for audit.
|
||||
"""
|
||||
self.ensure_one()
|
||||
manager_name = self.env.user.name
|
||||
self.sudo().write({
|
||||
'x_fc_tablet_pin_hash': False,
|
||||
'x_fc_tablet_pin_set_date': False,
|
||||
'x_fc_tablet_pin_failed_count': 0,
|
||||
'x_fc_tablet_locked_until': False,
|
||||
})
|
||||
self.message_post(
|
||||
body=_('Tablet PIN reset by %s. User must set a new PIN '
|
||||
'on next unlock attempt.') % manager_name,
|
||||
)
|
||||
return True
|
||||
|
||||
def action_open_tablet_pin_setup(self):
|
||||
"""Trigger the FpPinSetup OWL modal from the Preferences form.
|
||||
The Phase 6.2 OWL component intercepts this action tag.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_tablet_pin_setup',
|
||||
'name': 'Set Tablet PIN',
|
||||
'target': 'new',
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — FpIdleWarning (shared OWL service)
|
||||
//
|
||||
// Yellow-border overlay + countdown toast shown during the last
|
||||
// (default 30) seconds before auto-lock. Any pointer/touch event on
|
||||
// the document elsewhere resets the activity tracker, which causes
|
||||
// this component's parent (FpTabletLock) to hide the warning.
|
||||
// =============================================================================
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class FpIdleWarning extends Component {
|
||||
static template = "fusion_plating_shopfloor.IdleWarning";
|
||||
static props = {
|
||||
secondsRemaining: { type: Number },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — FpPinPad (shared OWL service)
|
||||
//
|
||||
// Numeric 4-digit PIN pad. Auto-submits on the 4th digit via onSubmit
|
||||
// callback. Used by FpTabletLock unlock flow AND FpPinSetup change flow.
|
||||
//
|
||||
// Props:
|
||||
// onSubmit : (pin: string) => Promise<{ok: boolean, error?: string}>
|
||||
// title : optional header text
|
||||
// subtitle : optional smaller text
|
||||
// onCancel : optional cancel callback (e.g. close modal)
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
|
||||
export class FpPinPad extends Component {
|
||||
static template = "fusion_plating_shopfloor.PinPad";
|
||||
static props = {
|
||||
onSubmit: { type: Function },
|
||||
title: { type: String, optional: true },
|
||||
subtitle: { type: String, optional: true },
|
||||
onCancel: { type: Function, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.state = useState({
|
||||
pin: "",
|
||||
submitting: false,
|
||||
error: "",
|
||||
shake: false,
|
||||
});
|
||||
}
|
||||
|
||||
async _press(digit) {
|
||||
if (this.state.submitting) return;
|
||||
if (this.state.pin.length >= 4) return;
|
||||
this.state.pin = this.state.pin + digit;
|
||||
this.state.error = "";
|
||||
if (this.state.pin.length === 4) {
|
||||
await this._submit();
|
||||
}
|
||||
}
|
||||
|
||||
_clear() {
|
||||
this.state.pin = "";
|
||||
this.state.error = "";
|
||||
}
|
||||
|
||||
async _submit() {
|
||||
this.state.submitting = true;
|
||||
try {
|
||||
const result = await this.props.onSubmit(this.state.pin);
|
||||
if (result && !result.ok) {
|
||||
this.state.error = result.error || "Incorrect PIN";
|
||||
this.state.shake = true;
|
||||
setTimeout(() => { this.state.shake = false; }, 400);
|
||||
this.state.pin = "";
|
||||
}
|
||||
} catch (err) {
|
||||
this.state.error = err.message || String(err);
|
||||
this.state.pin = "";
|
||||
} finally {
|
||||
this.state.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
get dots() {
|
||||
// Render 4 dot slots: filled if typed, empty otherwise
|
||||
return [0, 1, 2, 3].map((i) => this.state.pin.length > i);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — FpPinSetup (client action `fp_tablet_pin_setup`)
|
||||
//
|
||||
// Modal flow for setting OR changing the user's tablet PIN. Triggered
|
||||
// from res.users preferences via action_open_tablet_pin_setup. Three
|
||||
// stages: (1) old PIN (only if has_pin), (2) new PIN, (3) confirm new.
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState, onMounted } from "@odoo/owl";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { user } from "@web/core/user";
|
||||
import { FpPinPad } from "./pin_pad";
|
||||
|
||||
export class FpPinSetup extends Component {
|
||||
static template = "fusion_plating_shopfloor.PinSetup";
|
||||
static components = { FpPinPad };
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.action = useService("action");
|
||||
this.state = useState({
|
||||
stage: "loading", // 'loading' | 'old' | 'new' | 'confirm' | 'done'
|
||||
newPin: "",
|
||||
hasExistingPin: false,
|
||||
});
|
||||
onMounted(() => this._init());
|
||||
}
|
||||
|
||||
async _init() {
|
||||
// Cheap probe: search_count on the user's own record filtered
|
||||
// by pin_set_date. Non-manager users can read their own set_date
|
||||
// (not the hash). If the count is 1, they have a PIN; 0 = no PIN.
|
||||
try {
|
||||
const has = await rpc("/web/dataset/call_kw", {
|
||||
model: "res.users",
|
||||
method: "search_count",
|
||||
args: [[
|
||||
["id", "=", user.userId],
|
||||
["x_fc_tablet_pin_set_date", "!=", false],
|
||||
]],
|
||||
kwargs: {},
|
||||
});
|
||||
this.state.hasExistingPin = has > 0;
|
||||
} catch (e) {
|
||||
this.state.hasExistingPin = false;
|
||||
}
|
||||
this.state.stage = this.state.hasExistingPin ? "old" : "new";
|
||||
}
|
||||
|
||||
async onOldPinSubmit(pin) {
|
||||
// Stash for the final call; set_pin verifies it server-side
|
||||
this._oldPin = pin;
|
||||
this.state.stage = "new";
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async onNewPinSubmit(pin) {
|
||||
this.state.newPin = pin;
|
||||
this.state.stage = "confirm";
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async onConfirmPinSubmit(pin) {
|
||||
if (pin !== this.state.newPin) {
|
||||
return { ok: false, error: "PINs don't match. Try again." };
|
||||
}
|
||||
const params = { new_pin: this.state.newPin };
|
||||
if (this._oldPin) params.old_pin = this._oldPin;
|
||||
const res = await rpc("/fp/tablet/set_pin", params);
|
||||
if (res && res.ok) {
|
||||
this.notification.add("Tablet PIN updated.", { type: "success" });
|
||||
this.state.stage = "done";
|
||||
setTimeout(() => this._close(), 1500);
|
||||
return { ok: true };
|
||||
}
|
||||
// Reset back to start on hard error so user can retry cleanly
|
||||
this.notification.add((res && res.error) || "Failed to set PIN", { type: "danger" });
|
||||
this._oldPin = null;
|
||||
this.state.newPin = "";
|
||||
this.state.stage = this.state.hasExistingPin ? "old" : "new";
|
||||
return { ok: false, error: (res && res.error) || "Failed" };
|
||||
}
|
||||
|
||||
_close() {
|
||||
this.action.doAction({ type: "ir.actions.act_window_close" });
|
||||
}
|
||||
|
||||
onCancel() {
|
||||
this._close();
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_tablet_pin_setup", FpPinSetup);
|
||||
@@ -25,16 +25,18 @@ import { WorkflowChip } from "./components/workflow_chip";
|
||||
import { GateViz } from "./components/gate_viz";
|
||||
import { FpSignaturePad } from "./components/signature_pad";
|
||||
import { FpHoldComposer } from "./components/hold_composer";
|
||||
import { FpTabletLock } from "./tablet_lock";
|
||||
|
||||
export class FpJobWorkspace extends Component {
|
||||
static template = "fusion_plating_shopfloor.JobWorkspace";
|
||||
static props = ["*"];
|
||||
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer };
|
||||
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock };
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.action = useService("action");
|
||||
this.dialog = useService("dialog");
|
||||
this.techStore = useService("fp_shopfloor_tech_store");
|
||||
|
||||
this.state = useState({
|
||||
data: null,
|
||||
@@ -76,6 +78,11 @@ export class FpJobWorkspace extends Component {
|
||||
this.action.doAction({ type: "ir.actions.act_window_close" });
|
||||
}
|
||||
|
||||
// ---- Hand-Off (Phase 6.2) ---------------------------------------------
|
||||
handOff() {
|
||||
this.techStore.lock();
|
||||
}
|
||||
|
||||
onJumpToBlocker({ model, id }) {
|
||||
// If the predecessor is in this same workspace, just scroll to it
|
||||
const inThisJob = (this.state.data.steps || []).find((s) => s.id === id);
|
||||
|
||||
@@ -17,15 +17,17 @@ import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { QrScanner } from "./qr_scanner";
|
||||
import { FpTabletLock } from "./tablet_lock";
|
||||
|
||||
export class ManagerDashboard extends Component {
|
||||
static template = "fusion_plating_shopfloor.ManagerDashboard";
|
||||
static props = ["*"];
|
||||
static components = { QrScanner };
|
||||
static components = { QrScanner, FpTabletLock };
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.action = useService("action");
|
||||
this.techStore = useService("fp_shopfloor_tech_store");
|
||||
|
||||
this.state = useState({
|
||||
overview: null,
|
||||
@@ -43,15 +45,27 @@ export class ManagerDashboard extends Component {
|
||||
// Defaults to false because lead-hand coverage often needs
|
||||
// off-roster names.
|
||||
hideOffShift: false,
|
||||
// Phase 4 tablet redesign — 4 sibling tabs.
|
||||
// funnel | inbox | plant_board | at_risk
|
||||
activeTab: "funnel",
|
||||
funnel: null, // /fp/manager/funnel payload
|
||||
inbox: null, // /fp/manager/approval_inbox payload
|
||||
atRisk: null, // /fp/manager/at_risk payload
|
||||
});
|
||||
|
||||
this._lastHash = null; // sent to server to skip unchanged polls
|
||||
|
||||
onMounted(async () => {
|
||||
await this.refresh();
|
||||
// Load the default tab's data (Workflow Funnel) on first paint
|
||||
await this.loadFunnel();
|
||||
// 8s cadence: fast enough for production pace, light on the
|
||||
// network since unchanged payloads short-circuit server-side.
|
||||
this._interval = setInterval(() => this.refresh(), 8000);
|
||||
// The active tab's data also refreshes on each tick.
|
||||
this._interval = setInterval(() => {
|
||||
this.refresh();
|
||||
this.refreshActiveTab();
|
||||
}, 8000);
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
@@ -136,6 +150,11 @@ export class ManagerDashboard extends Component {
|
||||
this.state.mode = this.state.mode === "quick" ? "detailed" : "quick";
|
||||
}
|
||||
|
||||
// ---- Hand-Off (Phase 6.2) ---------------------------------------------
|
||||
handOff() {
|
||||
this.techStore.lock();
|
||||
}
|
||||
|
||||
toggleCard(jobId) {
|
||||
this.state.expandedJobId = this.state.expandedJobId === jobId ? null : jobId;
|
||||
}
|
||||
@@ -283,6 +302,85 @@ export class ManagerDashboard extends Component {
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// Phase 4 tablet redesign — 4 sibling tabs
|
||||
// ==================================================================
|
||||
|
||||
async setActiveTab(tab) {
|
||||
if (this.state.activeTab === tab) return;
|
||||
this.state.activeTab = tab;
|
||||
// Load the tab's data on first switch — subsequent ticks refresh
|
||||
// via the auto-poll.
|
||||
await this.refreshActiveTab();
|
||||
}
|
||||
|
||||
async refreshActiveTab() {
|
||||
if (this.state.activeTab === "funnel") return this.loadFunnel();
|
||||
if (this.state.activeTab === "inbox") return this.loadInbox();
|
||||
if (this.state.activeTab === "at_risk") return this.loadAtRisk();
|
||||
// plant_board uses /fp/manager/overview via refresh()
|
||||
}
|
||||
|
||||
async loadFunnel() {
|
||||
try {
|
||||
const res = await rpc("/fp/manager/funnel", {});
|
||||
if (res && res.ok) this.state.funnel = res;
|
||||
} catch (err) {
|
||||
this.setMessage(`Funnel: ${err.message}`, "danger");
|
||||
}
|
||||
}
|
||||
|
||||
async loadInbox() {
|
||||
try {
|
||||
const res = await rpc("/fp/manager/approval_inbox", {});
|
||||
if (res && res.ok) this.state.inbox = res;
|
||||
} catch (err) {
|
||||
this.setMessage(`Inbox: ${err.message}`, "danger");
|
||||
}
|
||||
}
|
||||
|
||||
async loadAtRisk() {
|
||||
try {
|
||||
const res = await rpc("/fp/manager/at_risk", {});
|
||||
if (res && res.ok) this.state.atRisk = res;
|
||||
} catch (err) {
|
||||
this.setMessage(`At-Risk: ${err.message}`, "danger");
|
||||
}
|
||||
}
|
||||
|
||||
// Tap a WO card on any tab → open the JobWorkspace (Phase 1)
|
||||
openJobWorkspace(jobId) {
|
||||
this.action.doAction({
|
||||
type: "ir.actions.client",
|
||||
tag: "fp_job_workspace",
|
||||
params: { job_id: jobId },
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
// Pill colour from workflow_state.color (mirrors WorkflowChip toneClass)
|
||||
funnelStageTone(color) {
|
||||
const map = {
|
||||
grey: "muted", blue: "info", cyan: "info",
|
||||
yellow: "warning", orange: "warning",
|
||||
green: "success", success: "success",
|
||||
danger: "danger", purple: "info",
|
||||
};
|
||||
return map[color] || "muted";
|
||||
}
|
||||
|
||||
// Bottleneck severity tone for the heatmap bar colour
|
||||
bottleneckTone(score) {
|
||||
if (score >= 200) return "danger";
|
||||
if (score >= 60) return "warning";
|
||||
return "success";
|
||||
}
|
||||
|
||||
bottleneckPct(score) {
|
||||
// Normalize to 0-100 for the bar width; cap at 100
|
||||
return Math.min(100, Math.round(score / 5));
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_manager_dashboard", ManagerDashboard);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — Activity Tracker (shared OWL service)
|
||||
//
|
||||
// Watches the document for pointer/touch/keydown/visibility events and
|
||||
// tracks lastActiveAt. FpTabletLock reads getSecondsUntilLock() once per
|
||||
// second to drive the idle warning + auto-lock transitions.
|
||||
//
|
||||
// Threshold reads from ir.config_parameter at service start; refreshes
|
||||
// every 5 min in case the manager changed it.
|
||||
// =============================================================================
|
||||
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
const DEFAULT_IDLE_MIN = 5;
|
||||
const DEFAULT_WARN_SEC = 30;
|
||||
|
||||
export const fpShopfloorActivityTracker = {
|
||||
async start() {
|
||||
let lastActiveAt = Date.now();
|
||||
let idleThresholdMs = DEFAULT_IDLE_MIN * 60 * 1000;
|
||||
let warnThresholdSec = DEFAULT_WARN_SEC;
|
||||
|
||||
async function refreshThreshold() {
|
||||
try {
|
||||
const minutes = await rpc("/web/dataset/call_kw", {
|
||||
model: "ir.config_parameter",
|
||||
method: "get_param",
|
||||
args: ["fp.shopfloor.tablet_idle_lock_minutes", String(DEFAULT_IDLE_MIN)],
|
||||
kwargs: {},
|
||||
});
|
||||
idleThresholdMs = (parseInt(minutes, 10) || DEFAULT_IDLE_MIN) * 60 * 1000;
|
||||
const warn = await rpc("/web/dataset/call_kw", {
|
||||
model: "ir.config_parameter",
|
||||
method: "get_param",
|
||||
args: ["fp.shopfloor.tablet_warn_seconds_before_lock", String(DEFAULT_WARN_SEC)],
|
||||
kwargs: {},
|
||||
});
|
||||
warnThresholdSec = parseInt(warn, 10) || DEFAULT_WARN_SEC;
|
||||
} catch (e) {
|
||||
// keep defaults if RPC fails (e.g. no session yet)
|
||||
}
|
||||
}
|
||||
await refreshThreshold();
|
||||
setInterval(refreshThreshold, 5 * 60 * 1000);
|
||||
|
||||
// Activity = explicit user input. Mouse-move alone DOES NOT count
|
||||
// because something brushing the screen (a stray glove, a tool
|
||||
// resting on the tablet) could otherwise keep the session alive.
|
||||
const bump = () => { lastActiveAt = Date.now(); };
|
||||
document.addEventListener("pointerdown", bump, { capture: true });
|
||||
document.addEventListener("touchstart", bump, { capture: true, passive: true });
|
||||
document.addEventListener("keydown", bump, { capture: true });
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") bump();
|
||||
});
|
||||
|
||||
return {
|
||||
bump,
|
||||
getSecondsUntilLock() {
|
||||
return Math.max(0, Math.floor((lastActiveAt + idleThresholdMs - Date.now()) / 1000));
|
||||
},
|
||||
getWarnThresholdSec() { return warnThresholdSec; },
|
||||
getIdleThresholdMs() { return idleThresholdMs; },
|
||||
getLastActiveAt() { return lastActiveAt; },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
registry
|
||||
.category("services")
|
||||
.add("fp_shopfloor_activity", fpShopfloorActivityTracker);
|
||||
@@ -0,0 +1,42 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — Tech Store (shared OWL service)
|
||||
//
|
||||
// Holds the "current tech of record" for the locked tablet. Set by
|
||||
// FpTabletLock on successful PIN unlock; cleared on auto-lock / Hand-Off.
|
||||
// Other components read currentTechId via useService("fp_shopfloor_tech_store")
|
||||
// and pass it through fpRpc() so server actions credit the right user.
|
||||
// =============================================================================
|
||||
|
||||
import { reactive } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
export const fpShopfloorTechStore = {
|
||||
start() {
|
||||
const state = reactive({
|
||||
currentTechId: null,
|
||||
currentTechName: "",
|
||||
lockedAt: null,
|
||||
});
|
||||
return {
|
||||
get currentTechId() { return state.currentTechId; },
|
||||
get currentTechName() { return state.currentTechName; },
|
||||
get isLocked() { return !state.currentTechId; },
|
||||
setTech(id, name) {
|
||||
state.currentTechId = id;
|
||||
state.currentTechName = name;
|
||||
state.lockedAt = null;
|
||||
},
|
||||
lock() {
|
||||
state.currentTechId = null;
|
||||
state.currentTechName = "";
|
||||
state.lockedAt = Date.now();
|
||||
},
|
||||
state, // exposed for OWL reactive subscriptions
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
registry
|
||||
.category("services")
|
||||
.add("fp_shopfloor_tech_store", fpShopfloorTechStore);
|
||||
@@ -0,0 +1,276 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — Shop Floor Landing (OWL client action)
|
||||
// Client action: fp_shopfloor_landing
|
||||
//
|
||||
// Replaces fp_shopfloor_tablet AND folds in fp_plant_overview. Single
|
||||
// kanban entry surface for technicians. Two modes:
|
||||
//
|
||||
// station — paired station's work centre + Unassigned + next 1-2
|
||||
// WCs in recipe flow. Default when a station is paired.
|
||||
// all_plant — every active work centre. Default with no station.
|
||||
//
|
||||
// Tap a card → JobWorkspace. QR scan: stations pair, jobs jump.
|
||||
// Drag-and-drop between columns reassigns step.work_centre_id (existing
|
||||
// /fp/shopfloor/plant_overview/move_card endpoint).
|
||||
//
|
||||
// Auto-refresh: 15s. Mode + station_id persist in localStorage.
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { QrScanner } from "./qr_scanner";
|
||||
import { FpKanbanCard } from "./components/kanban_card";
|
||||
import { FpTabletLock } from "./tablet_lock";
|
||||
|
||||
const LS_STATION_ID = "fp_landing_station_id";
|
||||
const LS_MODE = "fp_landing_mode";
|
||||
const REFRESH_MS = 15000;
|
||||
|
||||
export class FpShopfloorLanding extends Component {
|
||||
static template = "fusion_plating_shopfloor.ShopfloorLanding";
|
||||
static props = ["*"];
|
||||
static components = { QrScanner, FpKanbanCard, FpTabletLock };
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.action = useService("action");
|
||||
this.techStore = useService("fp_shopfloor_tech_store");
|
||||
|
||||
this.state = useState({
|
||||
mode: localStorage.getItem(LS_MODE) || "all_plant",
|
||||
stationId: parseInt(localStorage.getItem(LS_STATION_ID) || "0") || null,
|
||||
data: null,
|
||||
search: "",
|
||||
scanInput: "",
|
||||
showScan: false,
|
||||
lastRefresh: "",
|
||||
});
|
||||
|
||||
this._draggedCard = null;
|
||||
this._movesInFlight = 0;
|
||||
this._lastDropAt = 0;
|
||||
this._searchTimer = null;
|
||||
|
||||
onMounted(async () => {
|
||||
await this.refresh();
|
||||
this._refreshInterval = setInterval(() => {
|
||||
if (this._movesInFlight > 0) return;
|
||||
if (Date.now() - this._lastDropAt < 5000) return;
|
||||
this.refresh();
|
||||
}, REFRESH_MS);
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
if (this._refreshInterval) clearInterval(this._refreshInterval);
|
||||
if (this._searchTimer) clearTimeout(this._searchTimer);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Data load ---------------------------------------------------------
|
||||
async refresh() {
|
||||
try {
|
||||
const res = await rpc("/fp/landing/kanban", {
|
||||
mode: this.state.mode,
|
||||
station_id: this.state.stationId,
|
||||
search: this.state.search || null,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.state.data = res;
|
||||
this.state.lastRefresh = res.server_time || new Date().toLocaleTimeString();
|
||||
// If station resolved (e.g. via QR scan), persist its id
|
||||
if (res.station && res.station.id) {
|
||||
this.state.stationId = res.station.id;
|
||||
localStorage.setItem(LS_STATION_ID, String(res.station.id));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message || String(err), { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Mode toggle -------------------------------------------------------
|
||||
setMode(mode) {
|
||||
if (this.state.mode === mode) return;
|
||||
this.state.mode = mode;
|
||||
localStorage.setItem(LS_MODE, mode);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
// ---- Station picker ----------------------------------------------------
|
||||
onPickStation(ev) {
|
||||
const id = parseInt(ev.target.value) || null;
|
||||
this.state.stationId = id;
|
||||
if (id) {
|
||||
localStorage.setItem(LS_STATION_ID, String(id));
|
||||
// Picking a station naturally switches to station mode
|
||||
this.state.mode = "station";
|
||||
localStorage.setItem(LS_MODE, "station");
|
||||
} else {
|
||||
localStorage.removeItem(LS_STATION_ID);
|
||||
}
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
onUnpairStation() {
|
||||
this.state.stationId = null;
|
||||
this.state.mode = "all_plant";
|
||||
localStorage.removeItem(LS_STATION_ID);
|
||||
localStorage.setItem(LS_MODE, "all_plant");
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
// ---- Hand-Off (Phase 6.2) ---------------------------------------------
|
||||
handOff() {
|
||||
// Tech walking away: lock the tablet so the next operator must PIN in
|
||||
this.techStore.lock();
|
||||
}
|
||||
|
||||
// ---- Search ------------------------------------------------------------
|
||||
onSearchInput(ev) {
|
||||
this.state.search = ev.target.value;
|
||||
if (this._searchTimer) clearTimeout(this._searchTimer);
|
||||
this._searchTimer = setTimeout(() => this.refresh(), 200);
|
||||
}
|
||||
|
||||
onSearchKey(ev) {
|
||||
if (ev.key === "Enter") {
|
||||
if (this._searchTimer) clearTimeout(this._searchTimer);
|
||||
this.refresh();
|
||||
} else if (ev.key === "Escape") {
|
||||
this.state.search = "";
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Tap card → JobWorkspace ------------------------------------------
|
||||
onCardTap(cardData) {
|
||||
this.action.doAction({
|
||||
type: "ir.actions.client",
|
||||
tag: "fp_job_workspace",
|
||||
params: {
|
||||
job_id: cardData.job_id,
|
||||
focus_step_id: cardData.current_step_id,
|
||||
},
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
// ---- QR scan -----------------------------------------------------------
|
||||
toggleScan() {
|
||||
this.state.showScan = !this.state.showScan;
|
||||
}
|
||||
|
||||
async onScanSubmit() {
|
||||
const code = (this.state.scanInput || "").trim();
|
||||
if (!code) return;
|
||||
try {
|
||||
const res = await rpc("/fp/shopfloor/scan", { qr_code: code });
|
||||
if (!res || !res.ok) {
|
||||
this.notification.add((res && res.error) || "Unrecognised QR", { type: "danger" });
|
||||
return;
|
||||
}
|
||||
if (res.model === "fusion.plating.shopfloor.station") {
|
||||
this.state.stationId = res.id;
|
||||
this.state.mode = "station";
|
||||
localStorage.setItem(LS_STATION_ID, String(res.id));
|
||||
localStorage.setItem(LS_MODE, "station");
|
||||
this.notification.add(`Paired to ${res.name}`, { type: "success" });
|
||||
} else if (res.model === "fp.job") {
|
||||
this.action.doAction({
|
||||
type: "ir.actions.client",
|
||||
tag: "fp_job_workspace",
|
||||
params: { job_id: res.id },
|
||||
target: "current",
|
||||
});
|
||||
return;
|
||||
} else if (res.model === "fp.job.step") {
|
||||
this.action.doAction({
|
||||
type: "ir.actions.client",
|
||||
tag: "fp_job_workspace",
|
||||
params: { job_id: res.job_id || 0, focus_step_id: res.id },
|
||||
target: "current",
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
this.notification.add(`Scanned ${res.model}`, { type: "info" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message, { type: "danger" });
|
||||
} finally {
|
||||
this.state.scanInput = "";
|
||||
await this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
onScanKey(ev) {
|
||||
if (ev.key === "Enter") this.onScanSubmit();
|
||||
}
|
||||
|
||||
// ---- Drag-and-drop -----------------------------------------------------
|
||||
// Reuses the existing /fp/shopfloor/plant_overview/move_card endpoint,
|
||||
// which still works for re-assigning step.work_centre_id.
|
||||
onCardDragStart(card, col, ev) {
|
||||
this._draggedCard = {
|
||||
id: card.step_id,
|
||||
source_wc_id: col.work_center_id,
|
||||
};
|
||||
ev.dataTransfer.effectAllowed = "move";
|
||||
ev.dataTransfer.setData("text/plain", String(card.step_id));
|
||||
}
|
||||
|
||||
onColDragOver(col, ev) {
|
||||
ev.preventDefault();
|
||||
ev.dataTransfer.dropEffect = "move";
|
||||
}
|
||||
|
||||
async onColDrop(col, ev) {
|
||||
ev.preventDefault();
|
||||
const dragged = this._draggedCard;
|
||||
this._draggedCard = null;
|
||||
if (!dragged) return;
|
||||
if (dragged.source_wc_id === col.work_center_id) return;
|
||||
|
||||
// Optimistic move: pop from source, push to target
|
||||
const srcIdx = this.state.data.columns.findIndex(c => c.work_center_id === dragged.source_wc_id);
|
||||
const tgtIdx = this.state.data.columns.findIndex(c => c.work_center_id === col.work_center_id);
|
||||
let movedCard = null;
|
||||
if (srcIdx >= 0 && tgtIdx >= 0) {
|
||||
const src = this.state.data.columns[srcIdx].cards;
|
||||
const idx = src.findIndex(c => c.step_id === dragged.id);
|
||||
if (idx >= 0) {
|
||||
movedCard = src[idx];
|
||||
this.state.data.columns[srcIdx].cards = [
|
||||
...src.slice(0, idx), ...src.slice(idx + 1),
|
||||
];
|
||||
this.state.data.columns[tgtIdx].cards = [
|
||||
movedCard, ...this.state.data.columns[tgtIdx].cards,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
this._movesInFlight += 1;
|
||||
this._lastDropAt = Date.now();
|
||||
try {
|
||||
const res = await rpc("/fp/shopfloor/plant_overview/move_card", {
|
||||
card_id: dragged.id,
|
||||
target_workcenter_id: col.work_center_id,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.notification.add(`Moved to ${col.work_center_name}`, { type: "success" });
|
||||
} else {
|
||||
this.notification.add((res && res.error) || "Move failed", { type: "warning" });
|
||||
await this.refresh(); // server is the source of truth on conflict
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message, { type: "danger" });
|
||||
await this.refresh();
|
||||
} finally {
|
||||
this._movesInFlight -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_shopfloor_landing", FpShopfloorLanding);
|
||||
@@ -0,0 +1,135 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — FpTabletLock (top-level wrapper)
|
||||
//
|
||||
// Mounted by Landing / Workspace / Manager Dashboard as their outermost
|
||||
// element. Renders the lock screen (tile grid + PIN pad) when no tech
|
||||
// is signed in; renders <t t-slot="default"/> (the wrapped client
|
||||
// action) otherwise. Also drives the auto-lock countdown + idle warning.
|
||||
//
|
||||
// Usage in a parent template:
|
||||
//
|
||||
// <FpTabletLock>
|
||||
// <t t-set-slot="default">
|
||||
// <div class="o_fp_landing"> ...your existing tree... </div>
|
||||
// </t>
|
||||
// </FpTabletLock>
|
||||
//
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { FpPinPad } from "./components/pin_pad";
|
||||
import { FpIdleWarning } from "./components/idle_warning";
|
||||
|
||||
export class FpTabletLock extends Component {
|
||||
static template = "fusion_plating_shopfloor.TabletLock";
|
||||
static components = { FpPinPad, FpIdleWarning };
|
||||
static props = {
|
||||
slots: { type: Object, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.techStore = useService("fp_shopfloor_tech_store");
|
||||
this.activity = useService("fp_shopfloor_activity");
|
||||
this.notification = useService("notification");
|
||||
|
||||
this.state = useState({
|
||||
tiles: [],
|
||||
selectedTileUserId: null,
|
||||
idleSecondsRemaining: null,
|
||||
loadingTiles: false,
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await this._loadTiles();
|
||||
this._tick = setInterval(() => this._checkIdle(), 1000);
|
||||
// Heartbeat ping every 60s — for forensic visibility
|
||||
this._ping = setInterval(() => {
|
||||
if (this.techStore.currentTechId) {
|
||||
rpc("/fp/tablet/ping", { current_tech_id: this.techStore.currentTechId })
|
||||
.catch(() => {});
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
if (this._tick) clearInterval(this._tick);
|
||||
if (this._ping) clearInterval(this._ping);
|
||||
});
|
||||
}
|
||||
|
||||
get isLocked() {
|
||||
return this.techStore.isLocked;
|
||||
}
|
||||
|
||||
async _loadTiles() {
|
||||
this.state.loadingTiles = true;
|
||||
try {
|
||||
const stationId = parseInt(localStorage.getItem("fp_landing_station_id")) || null;
|
||||
const res = await rpc("/fp/tablet/tiles", { station_id: stationId });
|
||||
if (res && res.ok) {
|
||||
this.state.tiles = res.tiles;
|
||||
}
|
||||
} catch (err) {
|
||||
// Quiet fail — tile grid stays empty; user gets prompted
|
||||
} finally {
|
||||
this.state.loadingTiles = false;
|
||||
}
|
||||
}
|
||||
|
||||
_checkIdle() {
|
||||
if (!this.techStore.currentTechId) {
|
||||
this.state.idleSecondsRemaining = null;
|
||||
return;
|
||||
}
|
||||
const remaining = this.activity.getSecondsUntilLock();
|
||||
const warnThreshold = this.activity.getWarnThresholdSec();
|
||||
if (remaining <= 0) {
|
||||
this.handOff();
|
||||
} else if (remaining <= warnThreshold) {
|
||||
this.state.idleSecondsRemaining = remaining;
|
||||
} else if (this.state.idleSecondsRemaining !== null) {
|
||||
this.state.idleSecondsRemaining = null;
|
||||
}
|
||||
}
|
||||
|
||||
onTileClick(userId) {
|
||||
this.state.selectedTileUserId = userId;
|
||||
}
|
||||
|
||||
_selectedTileName() {
|
||||
const tile = this.state.tiles.find(t => t.user_id === this.state.selectedTileUserId);
|
||||
return tile ? tile.name : "";
|
||||
}
|
||||
|
||||
async unlock(pin) {
|
||||
try {
|
||||
const res = await rpc("/fp/tablet/unlock", {
|
||||
user_id: this.state.selectedTileUserId,
|
||||
pin,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.techStore.setTech(res.current_tech_id, res.current_tech_name);
|
||||
this.activity.bump();
|
||||
this.state.selectedTileUserId = null;
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, error: (res && res.error) || "Unlock failed" };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err.message || String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
onPinCancel() {
|
||||
this.state.selectedTileUserId = null;
|
||||
}
|
||||
|
||||
handOff() {
|
||||
this.techStore.lock();
|
||||
this.state.selectedTileUserId = null;
|
||||
this.state.idleSecondsRemaining = null;
|
||||
this._loadTiles();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// =============================================================================
|
||||
// FpIdleWarning — yellow-border countdown overlay before auto-lock
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_idle_warning_overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9998;
|
||||
box-shadow: inset 0 0 0 4px #ff9f0a;
|
||||
animation: o_fp_idle_pulse 1s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes o_fp_idle_pulse {
|
||||
from { box-shadow: inset 0 0 0 4px rgba(255, 159, 10, 0.6); }
|
||||
to { box-shadow: inset 0 0 0 4px rgba(255, 159, 10, 1); }
|
||||
}
|
||||
|
||||
.o_fp_idle_warning_toast {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #1d1d1f;
|
||||
color: #ffd585;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
|
||||
strong { color: #ffb84d; margin: 0 0.2rem; }
|
||||
> i { margin-right: 0.4rem; }
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// =============================================================================
|
||||
// FpPinPad — numeric keypad for tablet lock screen + PIN setup
|
||||
// Dark-mode aware via $o-webclient-color-scheme branch.
|
||||
// =============================================================================
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
$_pin-bg-hex: #ffffff;
|
||||
$_pin-key-bg-hex: #f3f4f6;
|
||||
$_pin-key-hover-hex: #e5e7eb;
|
||||
$_pin-border-hex: #d8dadd;
|
||||
$_pin-dot-hex: #d8dadd;
|
||||
$_pin-dot-fill-hex: #1d1d1f;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_pin-bg-hex: #22262d !global;
|
||||
$_pin-key-bg-hex: #2d3138 !global;
|
||||
$_pin-key-hover-hex: #3a3f48 !global;
|
||||
$_pin-border-hex: #424245 !global;
|
||||
$_pin-dot-fill-hex: #f5f5f7 !global;
|
||||
}
|
||||
|
||||
.o_fp_pin_pad {
|
||||
background: $_pin-bg-hex;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.o_fp_pin_title { font-size: 1.1rem; font-weight: 600; }
|
||||
.o_fp_pin_subtitle { font-size: 0.85rem; color: var(--text-secondary, #666); text-align: center; }
|
||||
|
||||
.o_fp_pin_dots {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.o_fp_pin_dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: $_pin-dot-hex;
|
||||
transition: background 0.1s ease;
|
||||
&.filled { background: $_pin-dot-fill-hex; }
|
||||
}
|
||||
|
||||
.o_fp_pin_error {
|
||||
color: #ff3b30;
|
||||
font-size: 0.85rem;
|
||||
min-height: 1.2rem;
|
||||
}
|
||||
|
||||
.o_fp_pin_grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.o_fp_pin_key {
|
||||
background: $_pin-key-bg-hex;
|
||||
border: 1px solid $_pin-border-hex;
|
||||
border-radius: 10px;
|
||||
padding: 1rem 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease, transform 0.05s ease;
|
||||
|
||||
&:hover { background: $_pin-key-hover-hex; }
|
||||
&:active { transform: scale(0.97); }
|
||||
&:disabled { opacity: 0.5; cursor: wait; }
|
||||
}
|
||||
|
||||
.o_fp_pin_key_clear { font-size: 0.95rem; color: var(--text-secondary, #666); }
|
||||
.o_fp_pin_key_cancel { font-size: 0.95rem; color: var(--text-secondary, #666); }
|
||||
|
||||
@keyframes o_fp_pin_shake_kf {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-8px); }
|
||||
50% { transform: translateX(8px); }
|
||||
75% { transform: translateX(-4px); }
|
||||
}
|
||||
.o_fp_pin_shake { animation: o_fp_pin_shake_kf 0.4s ease; }
|
||||
@@ -646,3 +646,230 @@
|
||||
display: flex; gap: $fp-space-1; margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Phase 4 tablet redesign — Manager dashboard sibling tabs
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_mgr_tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px 16px 0;
|
||||
border-bottom: 1px solid $fp-border;
|
||||
background: $fp-card;
|
||||
|
||||
.o_fp_mgr_tab {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 8px 14px;
|
||||
font-size: 0.9rem;
|
||||
color: $fp-ink-soft;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
|
||||
&:hover { color: $fp-ink; }
|
||||
&.active {
|
||||
color: $fp-accent;
|
||||
border-bottom-color: $fp-accent;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.o_fp_mgr_tab_badge {
|
||||
background: $fp-accent;
|
||||
color: white;
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Workflow Funnel tab -------------------------------------------------
|
||||
.o_fp_mgr_funnel {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.o_fp_funnel_row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid $fp-border;
|
||||
}
|
||||
|
||||
.o_fp_funnel_stage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.o_fp_funnel_count {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
min-width: 28px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.o_fp_funnel_cards {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.o_fp_funnel_card {
|
||||
background: $fp-card;
|
||||
border: 1px solid $fp-border;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.78rem;
|
||||
min-width: 130px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease, border-color 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, #{$fp-accent} 5%, #{$fp-card});
|
||||
border-color: color-mix(in srgb, #{$fp-accent} 30%, #{$fp-border});
|
||||
}
|
||||
|
||||
.o_fp_funnel_card_wo { font-weight: 600; }
|
||||
.o_fp_funnel_card_meta { color: $fp-ink-soft; font-size: 0.7rem; }
|
||||
}
|
||||
|
||||
.o_fp_funnel_more, .o_fp_funnel_empty {
|
||||
color: $fp-ink-soft;
|
||||
font-size: 0.78rem;
|
||||
padding: 0 6px;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Approval Inbox tab --------------------------------------------------
|
||||
.o_fp_mgr_inbox {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.o_fp_inbox_strip {
|
||||
background: $fp-card;
|
||||
border: 1px solid $fp-border;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
|
||||
h4 {
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: $fp-ink-soft;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_inbox_row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
font-size: 0.85rem;
|
||||
border-bottom: 1px dashed $fp-border;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
.ms-auto { margin-left: auto; }
|
||||
}
|
||||
|
||||
.o_fp_empty_small {
|
||||
color: $fp-ink-soft;
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
padding: 4px 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- At-Risk tab ---------------------------------------------------------
|
||||
.o_fp_mgr_atrisk {
|
||||
padding: 16px;
|
||||
|
||||
.o_fp_atrisk_grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr 1.2fr;
|
||||
gap: 12px;
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_atrisk_card {
|
||||
background: $fp-card;
|
||||
border: 1px solid $fp-border;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
|
||||
h4 {
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: $fp-ink-soft;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_atrisk_row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 6px 0;
|
||||
font-size: 0.82rem;
|
||||
border-bottom: 1px dashed $fp-border;
|
||||
align-items: center;
|
||||
cursor: default;
|
||||
|
||||
&[t-on-click], &:hover { cursor: pointer; }
|
||||
&:last-child { border-bottom: none; }
|
||||
.ms-auto { margin-left: auto; }
|
||||
}
|
||||
|
||||
.o_fp_atrisk_bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
font-size: 0.78rem;
|
||||
|
||||
.o_fp_atrisk_bar_name { min-width: 100px; }
|
||||
.o_fp_atrisk_bar_track {
|
||||
flex: 1;
|
||||
height: 10px;
|
||||
background: color-mix(in srgb, #{$fp-ink-soft} 15%, transparent);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.o_fp_atrisk_bar_fill { height: 100%; display: block; }
|
||||
.o_fp_atrisk_bar_danger { background: #ff3b30; }
|
||||
.o_fp_atrisk_bar_warning { background: #ff9f0a; }
|
||||
.o_fp_atrisk_bar_success { background: #34c759; }
|
||||
.o_fp_atrisk_bar_score { font-weight: 600; min-width: 32px; text-align: right; }
|
||||
}
|
||||
|
||||
.o_fp_empty_small {
|
||||
color: $fp-ink-soft;
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
padding: 4px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
// =============================================================================
|
||||
// Shop Floor Landing — kanban entry surface (Phase 3 tablet redesign)
|
||||
// Replaces fp_shopfloor_tablet + fp_plant_overview.
|
||||
// Dark-mode aware via $o-webclient-color-scheme branch.
|
||||
// =============================================================================
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
$_lan-page-hex: #f3f4f6;
|
||||
$_lan-card-hex: #ffffff;
|
||||
$_lan-border-hex: #d8dadd;
|
||||
$_lan-text-hex: #1d1d1f;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_lan-page-hex: #1a1d21 !global;
|
||||
$_lan-card-hex: #22262d !global;
|
||||
$_lan-border-hex: #424245 !global;
|
||||
$_lan-text-hex: #f5f5f7 !global;
|
||||
}
|
||||
|
||||
.o_fp_landing {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: $_lan-page-hex;
|
||||
color: $_lan-text-hex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.o_fp_landing_loading {
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #666);
|
||||
|
||||
> div { margin-top: 0.6rem; }
|
||||
}
|
||||
|
||||
// ---- HEADER ------------------------------------------------------------
|
||||
.o_fp_landing_head {
|
||||
background: $_lan-card-hex;
|
||||
border-bottom: 1px solid $_lan-border-hex;
|
||||
padding: 0.55rem 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.o_fp_landing_title_block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.o_fp_landing_title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.o_fp_landing_station_chip {
|
||||
background: rgba(0, 113, 227, 0.12);
|
||||
color: #0050a0;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.78rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
.o_fp_landing_station_chip { color: #6cb6ff; }
|
||||
}
|
||||
|
||||
.o_fp_landing_unpair { padding: 0 0.2rem; color: inherit; opacity: 0.6;
|
||||
&:hover { opacity: 1; }
|
||||
}
|
||||
|
||||
.o_fp_landing_head_actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.o_fp_landing_station_picker { min-width: 180px; }
|
||||
|
||||
.o_fp_landing_refresh {
|
||||
font-size: 0.7rem;
|
||||
margin-left: 0.5rem;
|
||||
color: var(--text-secondary, #999);
|
||||
}
|
||||
|
||||
// ---- Scan drawer -------------------------------------------------------
|
||||
.o_fp_landing_scan_drawer {
|
||||
background: $_lan-card-hex;
|
||||
border-bottom: 1px solid $_lan-border-hex;
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
// ---- KPI strip ---------------------------------------------------------
|
||||
.o_fp_landing_kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 1rem;
|
||||
background: $_lan-page-hex;
|
||||
}
|
||||
|
||||
.o_fp_landing_kpi {
|
||||
background: $_lan-card-hex;
|
||||
border: 1px solid $_lan-border-hex;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.7rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
|
||||
> i {
|
||||
position: absolute;
|
||||
top: 0.45rem;
|
||||
right: 0.55rem;
|
||||
opacity: 0.4;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.o_fp_landing_kpi_v {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.o_fp_landing_kpi_l {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary, #777);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
&.o_fp_landing_kpi_success { border-color: rgba(52, 199, 89, 0.3); }
|
||||
&.o_fp_landing_kpi_warning {
|
||||
border-color: rgba(255, 159, 10, 0.4);
|
||||
.o_fp_landing_kpi_v { color: #b06600; }
|
||||
}
|
||||
&.o_fp_landing_kpi_danger {
|
||||
border-color: rgba(255, 59, 48, 0.4);
|
||||
background: rgba(255, 59, 48, 0.06);
|
||||
.o_fp_landing_kpi_v { color: #b00018; }
|
||||
}
|
||||
}
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
.o_fp_landing_kpi_warning .o_fp_landing_kpi_v { color: #ffb84d; }
|
||||
.o_fp_landing_kpi_danger .o_fp_landing_kpi_v { color: #ff7a72; }
|
||||
}
|
||||
|
||||
// ---- Search bar --------------------------------------------------------
|
||||
.o_fp_landing_search {
|
||||
background: $_lan-page-hex;
|
||||
padding: 0.3rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
|
||||
> i { color: var(--text-secondary, #999); font-size: 0.85rem; }
|
||||
> input { max-width: 320px; }
|
||||
}
|
||||
|
||||
// ---- Kanban board ------------------------------------------------------
|
||||
.o_fp_landing_board {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 1rem 1rem;
|
||||
overflow-x: auto;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.o_fp_landing_empty {
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #999);
|
||||
|
||||
> div { margin-top: 0.6rem; max-width: 280px; }
|
||||
}
|
||||
|
||||
.o_fp_landing_col {
|
||||
flex: 0 0 240px;
|
||||
background: $_lan-card-hex;
|
||||
border: 1px solid $_lan-border-hex;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 100%;
|
||||
|
||||
&.o_fp_drop_target {
|
||||
outline: 2px dashed #0071e3;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_landing_col_head {
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-bottom: 1px solid $_lan-border-hex;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.o_fp_landing_col_name { flex: 1; }
|
||||
|
||||
.o_fp_landing_col_count {
|
||||
background: $_lan-page-hex;
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary, #777);
|
||||
}
|
||||
|
||||
.o_fp_landing_col_body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.o_fp_landing_col_empty {
|
||||
color: var(--text-tertiary, #aaa);
|
||||
text-align: center;
|
||||
font-size: 0.78rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// =============================================================================
|
||||
// FpTabletLock — lock screen with tile grid + PIN pad overlay
|
||||
// =============================================================================
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
$_lock-bg-hex: #f3f4f6;
|
||||
$_lock-card-hex: #ffffff;
|
||||
$_lock-border-hex: #d8dadd;
|
||||
$_lock-ink-hex: #1d1d1f;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_lock-bg-hex: #1a1d21 !global;
|
||||
$_lock-card-hex: #22262d !global;
|
||||
$_lock-border-hex: #424245 !global;
|
||||
$_lock-ink-hex: #f5f5f7 !global;
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: $_lock-bg-hex;
|
||||
color: $_lock-ink-hex;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
z-index: 9000;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_header {
|
||||
h1 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_loading, .o_fp_tablet_lock_empty {
|
||||
margin: 2rem auto;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 1rem;
|
||||
max-width: 900px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_tile {
|
||||
background: $_lock-card-hex;
|
||||
border: 2px solid $_lock-border-hex;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: border-color 0.1s ease, transform 0.05s ease;
|
||||
|
||||
&:hover { border-color: #0071e3; }
|
||||
&:active { transform: scale(0.98); }
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_tile_avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_tile_name {
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_tile_clocked {
|
||||
color: #34c759;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_tile_nopin {
|
||||
color: #ff9f0a;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.o_fp_tablet_lock_pinwrap {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.IdleWarning">
|
||||
<div class="o_fp_idle_warning_overlay">
|
||||
<div class="o_fp_idle_warning_toast">
|
||||
<i class="fa fa-clock-o"/>
|
||||
Locking in <strong t-esc="props.secondsRemaining"/>s · tap anywhere to stay
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.PinPad">
|
||||
<div t-att-class="'o_fp_pin_pad' + (state.shake ? ' o_fp_pin_shake' : '')">
|
||||
<div t-if="props.title" class="o_fp_pin_title" t-esc="props.title"/>
|
||||
<div t-if="props.subtitle" class="o_fp_pin_subtitle" t-esc="props.subtitle"/>
|
||||
|
||||
<div class="o_fp_pin_dots">
|
||||
<t t-foreach="dots" t-as="filled" t-key="filled_index">
|
||||
<span t-att-class="'o_fp_pin_dot' + (filled ? ' filled' : '')"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<div t-if="state.error" class="o_fp_pin_error" t-esc="state.error"/>
|
||||
|
||||
<div class="o_fp_pin_grid">
|
||||
<t t-foreach="[1,2,3,4,5,6,7,8,9]" t-as="d" t-key="d">
|
||||
<button class="o_fp_pin_key"
|
||||
t-on-click="() => this._press(String(d))"
|
||||
t-att-disabled="state.submitting">
|
||||
<t t-esc="d"/>
|
||||
</button>
|
||||
</t>
|
||||
<button class="o_fp_pin_key o_fp_pin_key_clear"
|
||||
t-on-click="_clear">Clear</button>
|
||||
<button class="o_fp_pin_key"
|
||||
t-on-click="() => this._press('0')"
|
||||
t-att-disabled="state.submitting">0</button>
|
||||
<button t-if="props.onCancel"
|
||||
class="o_fp_pin_key o_fp_pin_key_cancel"
|
||||
t-on-click="() => this.props.onCancel()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.PinSetup">
|
||||
<div class="o_fp_pin_setup">
|
||||
<div t-if="state.stage === 'loading'" class="o_fp_pin_setup_loading">
|
||||
<i class="fa fa-spinner fa-spin"/> Loading…
|
||||
</div>
|
||||
<FpPinPad t-if="state.stage === 'old'"
|
||||
onSubmit.bind="onOldPinSubmit"
|
||||
title="'Enter your current PIN'"
|
||||
onCancel.bind="onCancel"/>
|
||||
<FpPinPad t-if="state.stage === 'new'"
|
||||
onSubmit.bind="onNewPinSubmit"
|
||||
title="'Choose a new 4-digit PIN'"
|
||||
onCancel.bind="onCancel"/>
|
||||
<FpPinPad t-if="state.stage === 'confirm'"
|
||||
onSubmit.bind="onConfirmPinSubmit"
|
||||
title="'Confirm your new PIN'"
|
||||
subtitle="'Enter it again to confirm'"
|
||||
onCancel.bind="onCancel"/>
|
||||
<div t-if="state.stage === 'done'" class="o_fp_pin_setup_done">
|
||||
<i class="fa fa-check-circle text-success fa-3x"/>
|
||||
<h3>PIN updated</h3>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -2,6 +2,8 @@
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.JobWorkspace">
|
||||
<FpTabletLock>
|
||||
<t t-set-slot="default">
|
||||
<div class="o_fp_ws">
|
||||
|
||||
<!-- Loading state -->
|
||||
@@ -20,6 +22,12 @@
|
||||
<button class="btn btn-link o_fp_ws_back" t-on-click="onBack">
|
||||
<i class="fa fa-arrow-left"/> Back
|
||||
</button>
|
||||
<!-- Phase 6.2 — Hand-Off: lock the tablet -->
|
||||
<button class="btn btn-sm btn-warning ms-2"
|
||||
t-on-click="handOff"
|
||||
title="Lock the tablet for the next operator">
|
||||
<i class="fa fa-lock"/> Hand Off
|
||||
</button>
|
||||
<span class="o_fp_ws_wo"><t t-esc="state.data.job.display_wo_name"/></span>
|
||||
<span class="o_fp_ws_dot"> · </span>
|
||||
<span class="o_fp_ws_cust"><t t-esc="state.data.job.partner_name"/></span>
|
||||
@@ -225,6 +233,8 @@
|
||||
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</FpTabletLock>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.ManagerDashboard">
|
||||
<FpTabletLock>
|
||||
<t t-set-slot="default">
|
||||
<div class="o_fp_manager">
|
||||
|
||||
<!-- ============ Hero ============ -->
|
||||
@@ -45,6 +47,12 @@
|
||||
<i t-att-class="'fa fa-refresh' + (state.isFetching ? ' fa-spin' : '')"/>
|
||||
</button>
|
||||
<QrScanner cssClass="'btn'"/>
|
||||
<!-- Phase 6.2 — Hand-Off: lock the tablet -->
|
||||
<button class="btn btn-warning"
|
||||
t-on-click="handOff"
|
||||
title="Lock the tablet for the next operator">
|
||||
<i class="fa fa-lock"/> Hand Off
|
||||
</button>
|
||||
<button t-att-class="'btn ' + (state.mode === 'quick' ? 'btn-primary' : '')"
|
||||
t-on-click="toggleMode">
|
||||
<t t-if="state.mode === 'quick'">Quick View</t>
|
||||
@@ -153,10 +161,53 @@
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Phase 4 tablet redesign — Pending Cert + At-Risk tiles -->
|
||||
<div class="o_fp_kpi o_fp_kpi_warning"
|
||||
t-if="state.inbox and state.inbox.certs_to_issue and state.inbox.certs_to_issue.length"
|
||||
t-on-click="() => this.setActiveTab('inbox')">
|
||||
<i class="fa fa-file-text"/>
|
||||
<div class="o_fp_kpi_value">
|
||||
<t t-esc="state.inbox.certs_to_issue.length"/>
|
||||
</div>
|
||||
<div class="o_fp_kpi_label">Pending Cert</div>
|
||||
</div>
|
||||
<div class="o_fp_kpi o_fp_kpi_danger"
|
||||
t-if="state.atRisk and state.atRisk.trending_late and state.atRisk.trending_late.length"
|
||||
t-on-click="() => this.setActiveTab('at_risk')">
|
||||
<i class="fa fa-exclamation-triangle"/>
|
||||
<div class="o_fp_kpi_value">
|
||||
<t t-esc="state.atRisk.trending_late.length"/>
|
||||
</div>
|
||||
<div class="o_fp_kpi_label">At-Risk</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ Workload grid ============ -->
|
||||
<div class="o_fp_manager_grid" t-if="state.overview">
|
||||
<!-- ============ Phase 4 tab navigation ============ -->
|
||||
<div class="o_fp_mgr_tabs" t-if="state.overview">
|
||||
<button t-att-class="'o_fp_mgr_tab ' + (state.activeTab === 'funnel' ? 'active' : '')"
|
||||
t-on-click="() => this.setActiveTab('funnel')">
|
||||
<i class="fa fa-filter"/> Workflow Funnel
|
||||
</button>
|
||||
<button t-att-class="'o_fp_mgr_tab ' + (state.activeTab === 'inbox' ? 'active' : '')"
|
||||
t-on-click="() => this.setActiveTab('inbox')">
|
||||
<i class="fa fa-inbox"/> Approval Inbox
|
||||
<span t-if="state.inbox" class="o_fp_mgr_tab_badge">
|
||||
<t t-esc="(state.inbox.holds_to_release.length + state.inbox.certs_to_issue.length + state.inbox.scrap_to_review.length)"/>
|
||||
</span>
|
||||
</button>
|
||||
<button t-att-class="'o_fp_mgr_tab ' + (state.activeTab === 'plant_board' ? 'active' : '')"
|
||||
t-on-click="() => this.setActiveTab('plant_board')">
|
||||
<i class="fa fa-th"/> Plant Board
|
||||
</button>
|
||||
<button t-att-class="'o_fp_mgr_tab ' + (state.activeTab === 'at_risk' ? 'active' : '')"
|
||||
t-on-click="() => this.setActiveTab('at_risk')">
|
||||
<i class="fa fa-fire"/> At-Risk
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ============ PLANT BOARD TAB (existing 3-column grid) ============ -->
|
||||
<div class="o_fp_manager_grid"
|
||||
t-if="state.overview and state.activeTab === 'plant_board'">
|
||||
|
||||
<!-- Needs a Worker -->
|
||||
<section class="o_fp_panel o_fp_panel_unassigned">
|
||||
@@ -369,12 +420,179 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ============ WORKFLOW FUNNEL TAB (Phase 4) ============ -->
|
||||
<div class="o_fp_mgr_funnel"
|
||||
t-if="state.overview and state.activeTab === 'funnel'">
|
||||
<div t-if="!state.funnel" class="o_fp_empty">
|
||||
<i class="fa fa-spinner fa-spin"/>
|
||||
<div>Loading workflow funnel…</div>
|
||||
</div>
|
||||
<t t-if="state.funnel">
|
||||
<div t-foreach="state.funnel.stages" t-as="stage" t-key="stage.id"
|
||||
class="o_fp_funnel_row">
|
||||
<div class="o_fp_funnel_stage">
|
||||
<span t-att-class="'o_fp_wf_chip o_fp_wf_chip_' + funnelStageTone(stage.color)">
|
||||
<span class="o_fp_wf_dot"/>
|
||||
<span class="o_fp_wf_label" t-esc="stage.name"/>
|
||||
</span>
|
||||
<span class="o_fp_funnel_count" t-esc="stage.count"/>
|
||||
</div>
|
||||
<div class="o_fp_funnel_cards">
|
||||
<t t-foreach="stage.jobs" t-as="card" t-key="card.job_id">
|
||||
<div class="o_fp_funnel_card"
|
||||
t-on-click="() => this.openJobWorkspace(card.job_id)">
|
||||
<div class="o_fp_funnel_card_wo" t-esc="card.display_wo_name"/>
|
||||
<div class="o_fp_funnel_card_meta">
|
||||
<t t-esc="card.customer"/> · <t t-esc="card.days_in_stage"/>d
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<span t-if="stage.count > stage.jobs.length" class="o_fp_funnel_more">
|
||||
+<t t-esc="stage.count - stage.jobs.length"/> more
|
||||
</span>
|
||||
<span t-if="!stage.jobs.length" class="o_fp_funnel_empty">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- ============ APPROVAL INBOX TAB (Phase 4) ============ -->
|
||||
<div class="o_fp_mgr_inbox"
|
||||
t-if="state.overview and state.activeTab === 'inbox'">
|
||||
<div t-if="!state.inbox" class="o_fp_empty">
|
||||
<i class="fa fa-spinner fa-spin"/>
|
||||
<div>Loading approval inbox…</div>
|
||||
</div>
|
||||
<t t-if="state.inbox">
|
||||
<!-- Holds to Release -->
|
||||
<section class="o_fp_inbox_strip">
|
||||
<h4>
|
||||
<i class="fa fa-pause-circle text-danger"/>
|
||||
Holds to Release (<t t-esc="state.inbox.holds_to_release.length"/>)
|
||||
</h4>
|
||||
<div t-if="!state.inbox.holds_to_release.length" class="o_fp_empty_small">
|
||||
No open holds.
|
||||
</div>
|
||||
<t t-foreach="state.inbox.holds_to_release" t-as="h" t-key="h.hold_id">
|
||||
<div class="o_fp_inbox_row">
|
||||
<span><strong t-esc="h.name"/> · <t t-esc="h.job_name"/></span>
|
||||
<span class="text-muted">· <t t-esc="h.reason"/> · qty <t t-esc="h.qty"/></span>
|
||||
<span class="text-muted ms-auto"><t t-esc="h.requested_by"/> · <t t-esc="h.requested_at"/></span>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-2"
|
||||
t-on-click="() => this.openRecord('fusion.plating.quality.hold', h.hold_id)">
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</section>
|
||||
<!-- Certs to Issue -->
|
||||
<section class="o_fp_inbox_strip">
|
||||
<h4>
|
||||
<i class="fa fa-certificate text-warning"/>
|
||||
Certs to Issue (<t t-esc="state.inbox.certs_to_issue.length"/>)
|
||||
</h4>
|
||||
<div t-if="!state.inbox.certs_to_issue.length" class="o_fp_empty_small">
|
||||
No certs waiting to be issued.
|
||||
</div>
|
||||
<t t-foreach="state.inbox.certs_to_issue" t-as="c" t-key="c.job_id">
|
||||
<div class="o_fp_inbox_row">
|
||||
<span><strong t-esc="c.display_wo_name"/> · <t t-esc="c.customer"/></span>
|
||||
<span class="text-muted">· needs <t t-esc="c.cert_types.join(', ')"/></span>
|
||||
<span class="text-muted ms-auto">all steps done <t t-esc="c.all_steps_done_at"/></span>
|
||||
<button class="btn btn-sm btn-primary ms-2"
|
||||
t-on-click="() => this.openJobWorkspace(c.job_id)">
|
||||
Open Workspace
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</section>
|
||||
<!-- Scrap to Review -->
|
||||
<section class="o_fp_inbox_strip">
|
||||
<h4>
|
||||
<i class="fa fa-trash text-muted"/>
|
||||
Scrap to Review (<t t-esc="state.inbox.scrap_to_review.length"/>)
|
||||
</h4>
|
||||
<div t-if="!state.inbox.scrap_to_review.length" class="o_fp_empty_small">
|
||||
No recent scrap to acknowledge.
|
||||
</div>
|
||||
<t t-foreach="state.inbox.scrap_to_review" t-as="s" t-key="s.hold_id">
|
||||
<div class="o_fp_inbox_row">
|
||||
<span><strong t-esc="s.job_name"/> · <t t-esc="s.scrap_qty"/> scrapped</span>
|
||||
<span class="text-muted" t-if="s.reason">· "<t t-esc="s.reason"/>"</span>
|
||||
<span class="text-muted ms-auto"><t t-esc="s.operator"/> · <t t-esc="s.at"/></span>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-2"
|
||||
t-on-click="() => this.openRecord('fusion.plating.quality.hold', s.hold_id)">
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</section>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- ============ AT-RISK TAB (Phase 4) ============ -->
|
||||
<div class="o_fp_mgr_atrisk"
|
||||
t-if="state.overview and state.activeTab === 'at_risk'">
|
||||
<div t-if="!state.atRisk" class="o_fp_empty">
|
||||
<i class="fa fa-spinner fa-spin"/>
|
||||
<div>Loading at-risk view…</div>
|
||||
</div>
|
||||
<t t-if="state.atRisk">
|
||||
<div class="o_fp_atrisk_grid">
|
||||
<section class="o_fp_atrisk_card">
|
||||
<h4><i class="fa fa-clock-o"/> Trending Late (<t t-esc="state.atRisk.trending_late.length"/>)</h4>
|
||||
<div t-if="!state.atRisk.trending_late.length" class="o_fp_empty_small">
|
||||
No late-risk jobs right now.
|
||||
</div>
|
||||
<t t-foreach="state.atRisk.trending_late" t-as="j" t-key="j.job_id">
|
||||
<div class="o_fp_atrisk_row"
|
||||
t-on-click="() => this.openJobWorkspace(j.job_id)">
|
||||
<span><strong t-esc="j.display_wo_name"/> · <t t-esc="j.customer"/></span>
|
||||
<span t-if="j.stuck_at" class="text-muted">· stuck at <t t-esc="j.stuck_at"/></span>
|
||||
<span class="text-danger ms-auto">×<t t-esc="j.late_risk_ratio"/></span>
|
||||
</div>
|
||||
</t>
|
||||
</section>
|
||||
<section class="o_fp_atrisk_card">
|
||||
<h4><i class="fa fa-pause-circle"/> Hold Reasons</h4>
|
||||
<div t-if="!state.atRisk.hold_reasons.length" class="o_fp_empty_small">
|
||||
No open holds.
|
||||
</div>
|
||||
<t t-foreach="state.atRisk.hold_reasons" t-as="r" t-key="r.reason">
|
||||
<div class="o_fp_atrisk_row">
|
||||
<span t-esc="r.label"/>
|
||||
<strong class="ms-auto" t-esc="r.count"/>
|
||||
</div>
|
||||
</t>
|
||||
</section>
|
||||
<section class="o_fp_atrisk_card">
|
||||
<h4><i class="fa fa-fire"/> Bottleneck</h4>
|
||||
<div t-if="!state.atRisk.bottleneck.length" class="o_fp_empty_small">
|
||||
No bottlenecks detected.
|
||||
</div>
|
||||
<t t-foreach="state.atRisk.bottleneck" t-as="b" t-key="b.work_centre_id">
|
||||
<div class="o_fp_atrisk_bar">
|
||||
<span class="o_fp_atrisk_bar_name" t-esc="b.work_centre_name"/>
|
||||
<span class="o_fp_atrisk_bar_track">
|
||||
<span t-att-class="'o_fp_atrisk_bar_fill o_fp_atrisk_bar_' + bottleneckTone(b.score)"
|
||||
t-att-style="'width: ' + bottleneckPct(b.score) + '%'"/>
|
||||
</span>
|
||||
<span class="o_fp_atrisk_bar_score" t-esc="b.score"/>
|
||||
</div>
|
||||
</t>
|
||||
</section>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- ============ Loading ============ -->
|
||||
<div t-if="!state.overview and !state.loadError" class="o_fp_empty">
|
||||
<i class="fa fa-spinner fa-spin"/>
|
||||
<div>Loading manager data…</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</FpTabletLock>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.ShopfloorLanding">
|
||||
<FpTabletLock>
|
||||
<t t-set-slot="default">
|
||||
<div class="o_fp_landing">
|
||||
|
||||
<!-- Loading state -->
|
||||
<div t-if="!state.data" class="o_fp_landing_loading">
|
||||
<i class="fa fa-spinner fa-spin fa-2x"/>
|
||||
<div>Loading Shop Floor…</div>
|
||||
</div>
|
||||
|
||||
<t t-if="state.data">
|
||||
|
||||
<!-- ===== HEADER ===== -->
|
||||
<header class="o_fp_landing_head">
|
||||
<div class="o_fp_landing_title_block">
|
||||
<h1 class="o_fp_landing_title">
|
||||
<i class="fa fa-industry"/> Shop Floor
|
||||
</h1>
|
||||
<t t-if="state.data.station">
|
||||
<span class="o_fp_landing_station_chip">
|
||||
@ <t t-esc="state.data.station.work_center_name or state.data.station.name"/>
|
||||
<button class="btn btn-sm btn-link o_fp_landing_unpair"
|
||||
t-on-click="onUnpairStation"
|
||||
title="Unpair this tablet">
|
||||
<i class="fa fa-times"/>
|
||||
</button>
|
||||
</span>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_landing_head_actions">
|
||||
<!-- Station picker -->
|
||||
<select class="o_fp_landing_station_picker form-select form-select-sm"
|
||||
t-on-change="onPickStation">
|
||||
<option value="">— Pick station —</option>
|
||||
<t t-foreach="state.data.stations" t-as="s" t-key="s.id">
|
||||
<option t-att-value="s.id"
|
||||
t-att-selected="state.stationId === s.id">
|
||||
<t t-esc="s.name"/>
|
||||
<t t-if="s.work_center_name"> · <t t-esc="s.work_center_name"/></t>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
|
||||
<!-- Mode toggle -->
|
||||
<div class="o_fp_landing_mode_toggle btn-group btn-group-sm">
|
||||
<button t-att-class="'btn ' + (state.mode === 'station' ? 'btn-primary' : 'btn-outline-secondary')"
|
||||
t-on-click="() => this.setMode('station')">
|
||||
Station
|
||||
</button>
|
||||
<button t-att-class="'btn ' + (state.mode === 'all_plant' ? 'btn-primary' : 'btn-outline-secondary')"
|
||||
t-on-click="() => this.setMode('all_plant')">
|
||||
All Plant
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Scan controls -->
|
||||
<button class="btn btn-sm btn-outline-secondary" t-on-click="toggleScan">
|
||||
<i class="fa fa-qrcode"/> Code
|
||||
</button>
|
||||
<QrScanner cssClass="'btn btn-sm btn-outline-secondary'" label="'Camera'"/>
|
||||
|
||||
<!-- Phase 6.2 — Hand-Off: lock the tablet for the next operator -->
|
||||
<button class="btn btn-sm btn-warning"
|
||||
t-on-click="handOff"
|
||||
title="Lock the tablet for the next operator">
|
||||
<i class="fa fa-lock"/> Hand Off
|
||||
</button>
|
||||
|
||||
<!-- Refresh indicator -->
|
||||
<span class="o_fp_landing_refresh text-muted">
|
||||
<i class="fa fa-clock-o"/> <t t-esc="state.lastRefresh"/>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ===== Scan drawer ===== -->
|
||||
<div t-if="state.showScan" class="o_fp_landing_scan_drawer">
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
placeholder="Scan FP-STATION:… FP-JOB:… FP-STEP:…"
|
||||
t-model="state.scanInput"
|
||||
t-on-keydown="onScanKey"
|
||||
autofocus="autofocus"/>
|
||||
<button class="btn btn-primary" t-on-click="onScanSubmit">Scan</button>
|
||||
</div>
|
||||
|
||||
<!-- ===== KPI strip (4 tech-relevant tiles) ===== -->
|
||||
<div class="o_fp_landing_kpis">
|
||||
<div class="o_fp_landing_kpi">
|
||||
<i class="fa fa-hourglass-half"/>
|
||||
<span class="o_fp_landing_kpi_v"><t t-esc="state.data.kpis.ready"/></span>
|
||||
<span class="o_fp_landing_kpi_l">Ready</span>
|
||||
</div>
|
||||
<div class="o_fp_landing_kpi o_fp_landing_kpi_success">
|
||||
<i class="fa fa-cogs"/>
|
||||
<span class="o_fp_landing_kpi_v"><t t-esc="state.data.kpis.running"/></span>
|
||||
<span class="o_fp_landing_kpi_l">Running</span>
|
||||
</div>
|
||||
<div t-att-class="'o_fp_landing_kpi ' + (state.data.kpis.bakes_due ? 'o_fp_landing_kpi_warning' : '')">
|
||||
<i class="fa fa-fire"/>
|
||||
<span class="o_fp_landing_kpi_v"><t t-esc="state.data.kpis.bakes_due"/></span>
|
||||
<span class="o_fp_landing_kpi_l">Bakes Due</span>
|
||||
</div>
|
||||
<div t-att-class="'o_fp_landing_kpi ' + (state.data.kpis.holds ? 'o_fp_landing_kpi_danger' : '')">
|
||||
<i class="fa fa-pause-circle"/>
|
||||
<span class="o_fp_landing_kpi_v"><t t-esc="state.data.kpis.holds"/></span>
|
||||
<span class="o_fp_landing_kpi_l">Holds</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Search bar ===== -->
|
||||
<div class="o_fp_landing_search">
|
||||
<i class="fa fa-search"/>
|
||||
<input type="text"
|
||||
class="form-control form-control-sm"
|
||||
placeholder="Search WO #, customer, part…"
|
||||
t-model="state.search"
|
||||
t-on-input="onSearchInput"
|
||||
t-on-keydown="onSearchKey"/>
|
||||
</div>
|
||||
|
||||
<!-- ===== Kanban board ===== -->
|
||||
<div class="o_fp_landing_board">
|
||||
<div t-if="!state.data.columns.length" class="o_fp_landing_empty">
|
||||
<i class="fa fa-check-circle fa-2x text-success"/>
|
||||
<div t-if="state.mode === 'station'">
|
||||
No jobs at this station right now. Switch to All Plant
|
||||
to pull one over.
|
||||
</div>
|
||||
<div t-else="">
|
||||
Plant is quiet — nothing in progress.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<t t-foreach="state.data.columns" t-as="col" t-key="col.work_center_id">
|
||||
<div class="o_fp_landing_col"
|
||||
t-on-dragover="(ev) => this.onColDragOver(col, ev)"
|
||||
t-on-drop="(ev) => this.onColDrop(col, ev)">
|
||||
<div class="o_fp_landing_col_head">
|
||||
<span class="o_fp_landing_col_name" t-esc="col.work_center_name"/>
|
||||
<span class="o_fp_landing_col_count"><t t-esc="col.cards.length"/></span>
|
||||
</div>
|
||||
<div class="o_fp_landing_col_body">
|
||||
<t t-foreach="col.cards" t-as="card" t-key="card.step_id">
|
||||
<div draggable="true"
|
||||
t-on-dragstart="(ev) => this.onCardDragStart(card, col, ev)">
|
||||
<FpKanbanCard
|
||||
data="card"
|
||||
density="'normal'"
|
||||
showWorkflowChip="true"
|
||||
showWorkcenter="state.mode === 'all_plant'"
|
||||
onTap.bind="onCardTap"/>
|
||||
</div>
|
||||
</t>
|
||||
<div t-if="!col.cards.length" class="o_fp_landing_col_empty">
|
||||
—
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</FpTabletLock>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.TabletLock">
|
||||
<t t-if="isLocked">
|
||||
<div class="o_fp_tablet_lock">
|
||||
<div class="o_fp_tablet_lock_header">
|
||||
<h1><i class="fa fa-lock"/> Tap your name to unlock</h1>
|
||||
</div>
|
||||
<div t-if="state.loadingTiles" class="o_fp_tablet_lock_loading">
|
||||
<i class="fa fa-spinner fa-spin"/> Loading…
|
||||
</div>
|
||||
<div t-elif="!state.selectedTileUserId" class="o_fp_tablet_lock_tiles">
|
||||
<t t-if="!state.tiles.length">
|
||||
<div class="o_fp_tablet_lock_empty">
|
||||
No operators configured.
|
||||
</div>
|
||||
</t>
|
||||
<t t-foreach="state.tiles" t-as="tile" t-key="tile.user_id">
|
||||
<button class="o_fp_tablet_lock_tile"
|
||||
t-on-click="() => this.onTileClick(tile.user_id)">
|
||||
<img class="o_fp_tablet_lock_tile_avatar"
|
||||
t-att-src="tile.avatar_url"
|
||||
t-att-alt="tile.name"/>
|
||||
<div class="o_fp_tablet_lock_tile_name" t-esc="tile.name"/>
|
||||
<span t-if="tile.is_clocked_in" class="o_fp_tablet_lock_tile_clocked">
|
||||
● Clocked in
|
||||
</span>
|
||||
<span t-if="!tile.has_pin" class="o_fp_tablet_lock_tile_nopin">
|
||||
PIN required
|
||||
</span>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
<div t-else="" class="o_fp_tablet_lock_pinwrap">
|
||||
<FpPinPad onSubmit.bind="unlock"
|
||||
title="_selectedTileName()"
|
||||
subtitle="'Enter your 4-digit PIN'"
|
||||
onCancel.bind="onPinCancel"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-slot="default"/>
|
||||
<FpIdleWarning t-if="state.idleSecondsRemaining !== null"
|
||||
secondsRemaining="state.idleSecondsRemaining"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -1,2 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_workspace_controller
|
||||
from . import test_landing_kanban
|
||||
from . import test_tablet_pin
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc. — License OPL-1
|
||||
"""Plan task P3.1 — /fp/landing/kanban endpoint."""
|
||||
import json
|
||||
|
||||
from odoo.tests.common import HttpCase, tagged
|
||||
|
||||
|
||||
def _rpc(case, url, **params):
|
||||
res = case.url_open(
|
||||
url,
|
||||
data=json.dumps({'jsonrpc': '2.0', 'params': params}),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
return res.json()['result']
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_shopfloor')
|
||||
class TestLandingKanban(HttpCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.authenticate("admin", "admin")
|
||||
|
||||
def test_all_plant_returns_columns_and_kpis(self):
|
||||
res = _rpc(self, '/fp/landing/kanban', mode='all_plant')
|
||||
self.assertTrue(res['ok'])
|
||||
self.assertEqual(res['mode'], 'all_plant')
|
||||
self.assertIn('columns', res)
|
||||
self.assertIn('kpis', res)
|
||||
for kpi in ('ready', 'running', 'bakes_due', 'holds'):
|
||||
self.assertIn(kpi, res['kpis'])
|
||||
self.assertIn('stations', res)
|
||||
|
||||
def test_station_mode_with_invalid_id_falls_back_to_all_plant_shape(self):
|
||||
# No real station paired → station resolution returns None, but
|
||||
# endpoint still produces a valid columns/kpis payload.
|
||||
res = _rpc(self, '/fp/landing/kanban', mode='station', station_id=999999)
|
||||
self.assertTrue(res['ok'])
|
||||
self.assertIsNone(res['station'])
|
||||
self.assertIn('columns', res)
|
||||
216
fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py
Normal file
216
fusion_plating/fusion_plating_shopfloor/tests/test_tablet_pin.py
Normal file
@@ -0,0 +1,216 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc. — License OPL-1
|
||||
"""Phase 6 — Tablet PIN gate tests."""
|
||||
import json
|
||||
|
||||
from odoo.tests.common import HttpCase, TransactionCase, tagged
|
||||
|
||||
|
||||
def _rpc(case, url, **params):
|
||||
"""Helper for HTTP/JSON-RPC tests — wraps Odoo's url_open."""
|
||||
res = case.url_open(
|
||||
url,
|
||||
data=json.dumps({'jsonrpc': '2.0', 'params': params}),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
return res.json()['result']
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
|
||||
class TestTabletPinHash(TransactionCase):
|
||||
"""P6.1.1 — model fields + hash helpers + set/verify/clear methods."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = self.env['res.users'].create({
|
||||
'name': 'Pin Test',
|
||||
'login': 'pintest@example.com',
|
||||
})
|
||||
|
||||
def test_set_pin_stores_salted_hash(self):
|
||||
self.user.sudo().set_tablet_pin('1234')
|
||||
stored = self.user.sudo().x_fc_tablet_pin_hash
|
||||
self.assertTrue(stored)
|
||||
self.assertIn('$', stored, 'hash must include salt separator')
|
||||
# Hash is non-deterministic — setting same PIN twice gives different stored values
|
||||
self.user.sudo().set_tablet_pin('1234')
|
||||
self.assertNotEqual(stored, self.user.sudo().x_fc_tablet_pin_hash)
|
||||
|
||||
def test_verify_correct_pin(self):
|
||||
self.user.sudo().set_tablet_pin('1234')
|
||||
self.assertTrue(self.user.sudo().verify_tablet_pin('1234'))
|
||||
|
||||
def test_verify_wrong_pin(self):
|
||||
self.user.sudo().set_tablet_pin('1234')
|
||||
self.assertFalse(self.user.sudo().verify_tablet_pin('0000'))
|
||||
|
||||
def test_verify_pin_no_hash_set(self):
|
||||
self.assertFalse(self.user.sudo().verify_tablet_pin('1234'))
|
||||
|
||||
def test_set_pin_rejects_invalid_format(self):
|
||||
from odoo.exceptions import UserError
|
||||
for bad in ('123', '12345', 'abcd', '', None):
|
||||
with self.assertRaises(UserError):
|
||||
self.user.sudo().set_tablet_pin(bad)
|
||||
|
||||
def test_clear_pin_wipes_hash_and_posts_chatter(self):
|
||||
self.user.sudo().set_tablet_pin('1234')
|
||||
self.user.clear_tablet_pin()
|
||||
self.user.invalidate_recordset(['x_fc_tablet_pin_hash'])
|
||||
self.assertFalse(self.user.sudo().x_fc_tablet_pin_hash)
|
||||
self.assertFalse(self.user.sudo().x_fc_tablet_pin_set_date)
|
||||
# Check chatter
|
||||
last_msg = self.user.message_ids[:1]
|
||||
self.assertIn('reset by', (last_msg.body or '').lower())
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
|
||||
class TestTabletSetPin(HttpCase):
|
||||
"""P6.1.2 — /fp/tablet/set_pin endpoint."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.authenticate("admin", "admin")
|
||||
|
||||
def test_set_pin_first_time(self):
|
||||
res = _rpc(self, '/fp/tablet/set_pin', new_pin='1234')
|
||||
self.assertTrue(res['ok'])
|
||||
|
||||
def test_set_pin_change_requires_old(self):
|
||||
admin = self.env.ref('base.user_admin')
|
||||
admin.set_tablet_pin('1234')
|
||||
# Change without old — rejected
|
||||
res = _rpc(self, '/fp/tablet/set_pin', new_pin='5678')
|
||||
self.assertFalse(res['ok'])
|
||||
# Change with wrong old — rejected
|
||||
res = _rpc(self, '/fp/tablet/set_pin', old_pin='9999', new_pin='5678')
|
||||
self.assertFalse(res['ok'])
|
||||
# Change with correct old — accepted
|
||||
res = _rpc(self, '/fp/tablet/set_pin', old_pin='1234', new_pin='5678')
|
||||
self.assertTrue(res['ok'])
|
||||
|
||||
def test_set_pin_rejects_non_4_digit(self):
|
||||
for bad in ('123', '12345', 'abcd', ''):
|
||||
res = _rpc(self, '/fp/tablet/set_pin', new_pin=bad)
|
||||
self.assertFalse(res['ok'], f'PIN {bad!r} should be rejected')
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
|
||||
class TestTabletResetPinFor(HttpCase):
|
||||
"""P6.1.2 — /fp/tablet/reset_pin_for endpoint."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.target = self.env['res.users'].create({
|
||||
'name': 'Reset Target', 'login': 'reset@example.com',
|
||||
})
|
||||
self.target.sudo().set_tablet_pin('1234')
|
||||
|
||||
def test_reset_requires_manager_group(self):
|
||||
# Plain operator can't reset
|
||||
op_group = self.env.ref('fusion_plating.group_fusion_plating_operator')
|
||||
self.env['res.users'].create({
|
||||
'name': 'Op', 'login': 'op@example.com',
|
||||
'password': 'op@example.com',
|
||||
'group_ids': [(6, 0, [op_group.id])],
|
||||
})
|
||||
self.authenticate('op@example.com', 'op@example.com')
|
||||
res = _rpc(self, '/fp/tablet/reset_pin_for', user_id=self.target.id)
|
||||
self.assertFalse(res['ok'])
|
||||
|
||||
def test_reset_as_admin_clears_hash(self):
|
||||
self.authenticate('admin', 'admin')
|
||||
res = _rpc(self, '/fp/tablet/reset_pin_for', user_id=self.target.id)
|
||||
self.assertTrue(res['ok'])
|
||||
self.target.invalidate_recordset(['x_fc_tablet_pin_hash'])
|
||||
self.assertFalse(self.target.sudo().x_fc_tablet_pin_hash)
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
|
||||
class TestTabletUnlock(HttpCase):
|
||||
"""P6.1.3 — /fp/tablet/unlock endpoint + lockout."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.authenticate("admin", "admin")
|
||||
self.target = self.env['res.users'].create({
|
||||
'name': 'Unlock Target', 'login': 'unlock@example.com',
|
||||
})
|
||||
self.target.sudo().set_tablet_pin('1234')
|
||||
|
||||
def _unlock(self, pin):
|
||||
return _rpc(self, '/fp/tablet/unlock',
|
||||
user_id=self.target.id, pin=pin)
|
||||
|
||||
def test_unlock_correct_pin(self):
|
||||
res = self._unlock('1234')
|
||||
self.assertTrue(res['ok'])
|
||||
self.assertEqual(res['current_tech_id'], self.target.id)
|
||||
self.assertEqual(res['current_tech_name'], 'Unlock Target')
|
||||
|
||||
def test_unlock_correct_pin_resets_fail_counter(self):
|
||||
self._unlock('0000') # fail once
|
||||
self._unlock('1234') # succeed
|
||||
self.target.invalidate_recordset(['x_fc_tablet_pin_failed_count'])
|
||||
self.assertEqual(self.target.sudo().x_fc_tablet_pin_failed_count, 0)
|
||||
|
||||
def test_unlock_wrong_pin_increments_counter(self):
|
||||
self._unlock('0000')
|
||||
self.target.invalidate_recordset(['x_fc_tablet_pin_failed_count'])
|
||||
self.assertEqual(self.target.sudo().x_fc_tablet_pin_failed_count, 1)
|
||||
|
||||
def test_lockout_after_5_fails(self):
|
||||
for _ in range(5):
|
||||
self._unlock('0000')
|
||||
res = self._unlock('0000') # 6th
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('locked', res['error'].lower())
|
||||
self.target.invalidate_recordset(['x_fc_tablet_locked_until'])
|
||||
self.assertTrue(self.target.sudo().x_fc_tablet_locked_until)
|
||||
|
||||
def test_lockout_blocks_even_correct_pin(self):
|
||||
for _ in range(5):
|
||||
self._unlock('0000')
|
||||
# Even the correct PIN now rejected
|
||||
res = self._unlock('1234')
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertIn('locked', res['error'].lower())
|
||||
|
||||
def test_unlock_no_pin_set(self):
|
||||
self.target.clear_tablet_pin()
|
||||
res = self._unlock('1234')
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertTrue(res.get('needs_setup'))
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
|
||||
class TestTabletTiles(HttpCase):
|
||||
"""P6.1.4 — /fp/tablet/tiles endpoint."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.authenticate("admin", "admin")
|
||||
self.op_group = self.env.ref('fusion_plating.group_fusion_plating_operator')
|
||||
self.alice = self.env['res.users'].create({
|
||||
'name': 'Alice Tile', 'login': 'alice_tile@example.com',
|
||||
'group_ids': [(6, 0, [self.op_group.id])],
|
||||
})
|
||||
self.bob = self.env['res.users'].create({
|
||||
'name': 'Bob Tile', 'login': 'bob_tile@example.com',
|
||||
'group_ids': [(6, 0, [self.op_group.id])],
|
||||
})
|
||||
self.alice.sudo().set_tablet_pin('1111')
|
||||
|
||||
def test_tiles_returns_all_operators_without_station(self):
|
||||
res = _rpc(self, '/fp/tablet/tiles')
|
||||
self.assertTrue(res['ok'])
|
||||
names = [t['name'] for t in res['tiles']]
|
||||
self.assertIn('Alice Tile', names)
|
||||
self.assertIn('Bob Tile', names)
|
||||
|
||||
def test_tile_has_pin_flag(self):
|
||||
res = _rpc(self, '/fp/tablet/tiles')
|
||||
alice_tile = next(t for t in res['tiles'] if t['name'] == 'Alice Tile')
|
||||
bob_tile = next(t for t in res['tiles'] if t['name'] == 'Bob Tile')
|
||||
self.assertTrue(alice_tile['has_pin'])
|
||||
self.assertFalse(bob_tile['has_pin'])
|
||||
@@ -26,14 +26,12 @@
|
||||
sequence="3"
|
||||
groups="fusion_plating.group_fusion_plating_manager"/>
|
||||
|
||||
<menuitem id="menu_fp_shopfloor_plant_overview"
|
||||
name="Plant Overview"
|
||||
parent="menu_fp_shopfloor"
|
||||
action="action_fp_plant_overview"
|
||||
sequence="5"/>
|
||||
|
||||
<!-- Phase 3 tablet redesign — single Workstation menu entry replaces
|
||||
the legacy "Tablet Station" + "Plant Overview" pair. The new
|
||||
fp_shopfloor_landing component has a Station/All-Plant toggle
|
||||
so one menu item covers both old surfaces. -->
|
||||
<menuitem id="menu_fp_shopfloor_tablet"
|
||||
name="Tablet Station"
|
||||
name="Workstation"
|
||||
parent="menu_fp_shopfloor"
|
||||
action="action_fp_shopfloor_tablet"
|
||||
sequence="10"/>
|
||||
|
||||
@@ -7,11 +7,17 @@
|
||||
<odoo>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- Client action — Plant Overview Dashboard -->
|
||||
<!-- Client action — was "Plant Overview" (fp_plant_overview). -->
|
||||
<!-- Phase 3 tablet redesign retargets the tag at the new -->
|
||||
<!-- fp_shopfloor_landing component (it has an "All Plant" mode that -->
|
||||
<!-- supersedes the standalone plant overview). Old bookmarks keep -->
|
||||
<!-- working; the legacy fp_plant_overview OWL component is still -->
|
||||
<!-- registered. Menu entry removed in fp_menu.xml. -->
|
||||
<!-- ================================================================== -->
|
||||
<record id="action_fp_plant_overview" model="ir.actions.client">
|
||||
<field name="name">Plant Overview</field>
|
||||
<field name="tag">fp_plant_overview</field>
|
||||
<field name="tag">fp_shopfloor_landing</field>
|
||||
<field name="params" eval="{'mode': 'all_plant'}"/>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
|
||||
@@ -49,6 +49,13 @@
|
||||
<field name="active"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Tablet PIN Gate (Phase 6)">
|
||||
<field name="x_fc_authorised_user_ids"
|
||||
widget="many2many_tags"
|
||||
options="{'no_create': True}"/>
|
||||
<field name="x_fc_idle_lock_minutes"
|
||||
placeholder="default 5 (system parameter)"/>
|
||||
</group>
|
||||
<separator string="Notes"/>
|
||||
<field name="notes"/>
|
||||
</sheet>
|
||||
@@ -89,11 +96,15 @@
|
||||
</record>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- Client action that launches the OWL tablet component -->
|
||||
<!-- Client action — was "Tablet Station" (fp_shopfloor_tablet). -->
|
||||
<!-- Phase 3 tablet redesign retargets the tag at the new -->
|
||||
<!-- fp_shopfloor_landing component so old bookmarks keep working. -->
|
||||
<!-- The legacy fp_shopfloor_tablet OWL component is still registered -->
|
||||
<!-- (no code removed) but no menu points at it anymore. -->
|
||||
<!-- ================================================================== -->
|
||||
<record id="action_fp_shopfloor_tablet" model="ir.actions.client">
|
||||
<field name="name">Tablet Station</field>
|
||||
<field name="tag">fp_shopfloor_tablet</field>
|
||||
<field name="name">Shop Floor</field>
|
||||
<field name="tag">fp_shopfloor_landing</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
Phase 6 tablet PIN gate — surfaces:
|
||||
(a) self-service "Set/Change PIN" on the user's Preferences form
|
||||
(b) manager-only "Reset Tablet PIN" header button on res.users form
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<!-- =================================================================
|
||||
(a) Preferences form — Set/Change Tablet PIN
|
||||
The actual modal is OWL-side (FpPinSetup, Phase 6.2). Here we
|
||||
just surface a status indicator + the button under a group.
|
||||
================================================================= -->
|
||||
<record id="view_users_form_preferences_tablet_pin" model="ir.ui.view">
|
||||
<field name="name">res.users.preferences.tablet.pin</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_form_simple_modif"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//sheet" position="inside">
|
||||
<group string="Tablet PIN" name="fp_tablet_pin_group">
|
||||
<field name="x_fc_tablet_pin_set_date" readonly="1"
|
||||
string="PIN Last Set"/>
|
||||
<button name="action_open_tablet_pin_setup"
|
||||
type="object"
|
||||
string="Set / Change Tablet PIN"
|
||||
class="btn-secondary"
|
||||
icon="fa-key"/>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- =================================================================
|
||||
(b) res.users form — Manager-only "Reset Tablet PIN" header
|
||||
================================================================= -->
|
||||
<record id="view_users_form_reset_tablet_pin" model="ir.ui.view">
|
||||
<field name="name">res.users.form.reset.tablet.pin</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//header" position="inside">
|
||||
<button name="clear_tablet_pin"
|
||||
type="object"
|
||||
string="Reset Tablet PIN"
|
||||
class="btn-warning"
|
||||
icon="fa-eraser"
|
||||
groups="fusion_plating.group_fusion_plating_manager"
|
||||
invisible="not x_fc_tablet_pin_set_date"
|
||||
confirm="Reset this user's tablet PIN? They'll need to set a new one on their next unlock."/>
|
||||
<field name="x_fc_tablet_pin_set_date" invisible="1"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user