diff --git a/fusion_plating/docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md b/fusion_plating/docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md new file mode 100644 index 00000000..e8a47248 --- /dev/null +++ b/fusion_plating/docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md @@ -0,0 +1,3792 @@ +# Shop Floor Tablet Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the outdated `fp_shopfloor_tablet` and `fp_plant_overview` with a unified Shop Floor Landing + Job Workspace + refactored Manager Dashboard, surfacing every feature added to `fp.job` / `fp.job.step` / `fp.certificate` since the original tablet was built. + +**Architecture:** Three OWL client actions (`fp_shopfloor_landing`, `fp_job_workspace`, `fp_manager_dashboard`) + five shared OWL services (WorkflowChip, GateViz, SignaturePad, HoldComposer, KanbanCard) + new backend computes/cron/ACLs. Per spec [2026-05-22-shopfloor-tablet-redesign-design.md](../specs/2026-05-22-shopfloor-tablet-redesign-design.md), built in 5 sequential phases, each independently deployable. + +**Tech Stack:** Odoo 19, Python (controllers, models, crons), OWL 2.x (`static template`, `static props = ["*"]`, standalone `rpc()`), SCSS with `$o-webclient-color-scheme` branch for dark mode, Odoo `dialog` service for modals. + +**Spec reference:** [docs/superpowers/specs/2026-05-22-shopfloor-tablet-redesign-design.md](../specs/2026-05-22-shopfloor-tablet-redesign-design.md) + +**Local dev:** +- Container: `odoo-modsdev-app`, db: `modsdev` (NOT `odoo-dev-app`/`fusion-dev` — that's stale CLAUDE.md) +- Update: `docker exec odoo-modsdev-app odoo -d modsdev -u --stop-after-init` +- Test: `docker exec odoo-modsdev-app odoo -d modsdev -i --test-tags --stop-after-init` +- URL: http://localhost:8069 + +--- + +## Module version bumps (apply with each phase's commits) + +| Module | Current | Phase 1 | Phase 2 | Phase 3 | Phase 4 | Phase 5 | +|---|---|---|---|---|---|---| +| `fusion_plating` | 19.0.20.6.2 | — | 19.0.20.7.0 (`long_running` on process.node, `bottleneck_score` on work.centre) | — | 19.0.20.8.0 | — | +| `fusion_plating_jobs` | 19.0.10.18.0 | 19.0.10.19.0 (`display_wo_name`, `blocker_*`, `active_step_id`) | 19.0.10.20.0 (cron + late_risk) | — | — | — | +| `fusion_plating_shopfloor` | 19.0.26.2.0 | 19.0.27.0.0 (workspace + 5 services) | 19.0.27.1.0 (ACL lift) | 19.0.28.0.0 (Landing) | 19.0.29.0.0 (Manager refactor) | 19.0.29.1.0 | +| `fusion_plating_certificates` | (check) | — | 19.0.x+1 (ACL write on cert) | — | — | — | + +--- + +## File structure + +### NEW files + +``` +fusion_plating_shopfloor/ + controllers/ + landing_controller.py [Phase 3] + workspace_controller.py [Phase 1] + static/src/js/components/ + workflow_chip.js [Phase 1] + gate_viz.js [Phase 1] + signature_pad.js [Phase 1] + hold_composer.js [Phase 1] + kanban_card.js [Phase 1] + static/src/xml/components/ + workflow_chip.xml [Phase 1] + gate_viz.xml [Phase 1] + signature_pad.xml [Phase 1] + hold_composer.xml [Phase 1] + kanban_card.xml [Phase 1] + static/src/scss/components/ + _workflow_chip.scss [Phase 1] + _gate_viz.scss [Phase 1] + _signature_pad.scss [Phase 1] + _hold_composer.scss [Phase 1] + _kanban_card.scss [Phase 1] + static/src/js/ + job_workspace.js [Phase 1] + shopfloor_landing.js [Phase 3] + static/src/xml/ + job_workspace.xml [Phase 1] + shopfloor_landing.xml [Phase 3] + static/src/scss/ + job_workspace.scss [Phase 1] + shopfloor_landing.scss [Phase 3] + tests/ + __init__.py [Phase 1] + test_workspace_load.py [Phase 1] + test_workspace_actions.py [Phase 1] + test_landing_kanban.py [Phase 3] + test_manager_funnel.py [Phase 4] + test_manager_inbox.py [Phase 4] + test_manager_at_risk.py [Phase 4] + +fusion_plating_jobs/tests/ + test_display_wo_name.py [Phase 1] + test_blocker_compute.py [Phase 1] + test_active_step_id.py [Phase 2] + test_late_risk_ratio.py [Phase 2] + test_autopause_cron.py [Phase 2] +``` + +### MODIFIED files + +``` +fusion_plating_jobs/ + __manifest__.py [P1, P2 version bumps] + models/fp_job.py [P1: display_wo_name | P2: late_risk_ratio, active_step_id] + models/fp_job_step.py [P1: blocker_kind/reason/jump_target | P2: _cron_autopause_stale_steps] + views/fp_job_form_inherit.xml [P1: smart-button "Open Job Workspace"] + data/fp_cron_data.xml [P2: add ir_cron_autopause_stale_steps] + +fusion_plating/ + __manifest__.py [P2, P4 bumps] + models/fp_process_node.py [P2: long_running Boolean] + models/fp_work_centre.py [P4: bottleneck_score, avg_wait_minutes] + views/ [P2: long_running toggle on process_node form] + +fusion_plating_shopfloor/ + __manifest__.py [P1, P2, P3, P4 bumps + asset registrations] + controllers/__init__.py [P1: + workspace_controller | P3: + landing_controller] + controllers/shopfloor_controller.py [P3: stub tablet_overview, plant_overview, drop queue | P5: remove stubs] + controllers/manager_controller.py [P4: add /fp/manager/funnel, approval_inbox, at_risk] + static/src/js/manager_dashboard.js [P4: tab refactor + 3 new tab components] + static/src/xml/manager_dashboard.xml [P4: tab nav + 4 tab templates] + static/src/scss/manager_dashboard.scss [P4: tab styles] + views/fp_menu.xml [P3: redirect old action, hide PlantOverview] + security/ir.model.access.csv [P2: append fp.job.node.override read] + +fusion_plating_certificates/ + __manifest__.py [P2 bump] + security/ir.model.access.csv [P2: extend operator perm_write on fp.certificate; perm_write+create on fp.thickness.reading] +``` + +--- + +# Phase 1 — Workspace Foundation + +**Goal:** Ship `fp_job_workspace` as a standalone client action accessible from a smart button on the existing `fp.job` form. Plus the 5 shared OWL services. Phase 1 must be functional on its own — a user can open any WO and use the Workspace before Landing exists. + +**Phase 1 deliverables:** +- `fp.job.display_wo_name` compute +- `fp.job.step.blocker_kind`, `blocker_reason`, `blocker_jump_target_model`, `blocker_jump_target_id` computes +- 5 shared OWL services with tests +- `workspace_controller.py` with 4 endpoints +- `JobWorkspace` OWL component +- Smart button on `fp.job` form opening the Workspace +- Version bumps + manifest asset registration + +--- + +### Task 1.1 — Add `display_wo_name` compute on `fp.job` + +**Files:** +- Modify: `fusion_plating/fusion_plating_jobs/models/fp_job.py` +- Create: `fusion_plating/fusion_plating_jobs/tests/test_display_wo_name.py` + +- [ ] **Step 1: Write the failing test** + +Create `fusion_plating/fusion_plating_jobs/tests/test_display_wo_name.py`: + +```python +# -*- coding: utf-8 -*- +from odoo.tests.common import TransactionCase, tagged + + +@tagged('-at_install', 'post_install', 'fp_jobs') +class TestDisplayWoName(TransactionCase): + + def setUp(self): + super().setUp() + self.partner = self.env['res.partner'].create({'name': 'Test Cust'}) + self.product = self.env['product.product'].create({'name': 'Test Prod'}) + + def _make_job(self, name): + return self.env['fp.job'].create({ + 'name': name, + 'partner_id': self.partner.id, + 'product_id': self.product.id, + 'qty': 1, + }) + + def test_wh_job_prefix_formatted(self): + job = self._make_job('WH/JOB/00001') + self.assertEqual(job.display_wo_name, 'WO # 00001') + + def test_wh_job_with_year(self): + job = self._make_job('WH/JOB/2026/00042') + self.assertEqual(job.display_wo_name, 'WO # 00042') + + def test_plain_numeric(self): + job = self._make_job('00123') + self.assertEqual(job.display_wo_name, 'WO # 00123') + + def test_falsy_name(self): + # New record before save → name is False; computed returns empty + job = self.env['fp.job'].new({'name': False}) + self.assertEqual(job.display_wo_name, '') +``` + +- [ ] **Step 2: Verify test fails** + +``` +docker exec odoo-modsdev-app odoo -d modsdev -i fusion_plating_jobs --test-tags fp_jobs --stop-after-init +``` + +Expected: FAIL with `AttributeError: 'fp.job' object has no attribute 'display_wo_name'`. + +- [ ] **Step 3: Implement the compute on `fp.job`** + +In `fusion_plating/fusion_plating_jobs/models/fp_job.py`, add inside the `FpJob(models.Model)` class (after the existing `_inherit` line and other fields, before any methods): + +```python + # Display formatter — "WO # 00001" used everywhere on tablet/dashboard. + # The underlying `name` field stays untouched (WH/JOB/00001) so reports, + # emails, and back-office forms continue using their canonical name. + # System-wide sequence rename is a separate decision (see spec §6.5). + display_wo_name = fields.Char( + compute='_compute_display_wo_name', + string='WO #', + help='Tablet/dashboard formatter — "WO # 00001" derived from name. ' + 'Underlying name field is unchanged.', + ) + + @api.depends('name') + def _compute_display_wo_name(self): + for job in self: + raw = (job.name or '').strip() + if not raw: + job.display_wo_name = '' + continue + # Take the last "/"-separated segment as the number portion. + # WH/JOB/00001 → 00001 ; WH/JOB/2026/00042 → 00042 ; 00123 → 00123 + tail = raw.rsplit('/', 1)[-1] + job.display_wo_name = f'WO # {tail}' +``` + +(The `@api.depends` import is already present at the top of the file.) + +- [ ] **Step 4: Verify test passes** + +``` +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_jobs --test-tags fp_jobs --stop-after-init +``` + +Expected: 4 tests pass. + +- [ ] **Step 5: Commit** + +```bash +cd /k/Github/Odoo-Modules +git add fusion_plating/fusion_plating_jobs/models/fp_job.py \ + fusion_plating/fusion_plating_jobs/tests/test_display_wo_name.py +git commit -m "feat(fusion_plating_jobs): fp.job.display_wo_name compute (WO # 00001)" +``` + +--- + +### Task 1.2 — Add `blocker_kind` / `blocker_reason` / `blocker_jump_target_*` on `fp.job.step` + +**Files:** +- Modify: `fusion_plating/fusion_plating_jobs/models/fp_job_step.py` +- Create: `fusion_plating/fusion_plating_jobs/tests/test_blocker_compute.py` + +- [ ] **Step 1: Write the failing test** + +Create `fusion_plating/fusion_plating_jobs/tests/test_blocker_compute.py`: + +```python +# -*- coding: utf-8 -*- +from odoo.tests.common import TransactionCase, tagged + + +@tagged('-at_install', 'post_install', 'fp_jobs') +class TestBlockerCompute(TransactionCase): + + def setUp(self): + super().setUp() + self.partner = self.env['res.partner'].create({'name': 'Test Cust'}) + self.product = self.env['product.product'].create({'name': 'Test Prod'}) + self.job = self.env['fp.job'].create({ + 'name': 'WH/JOB/T1', + 'partner_id': self.partner.id, + 'product_id': self.product.id, + 'qty': 1, + }) + + def _make_step(self, name, sequence, state='ready'): + return self.env['fp.job.step'].create({ + 'job_id': self.job.id, + 'name': name, + 'sequence': sequence, + 'state': state, + }) + + def test_step_not_blocked_returns_none(self): + step = self._make_step('Solo', 10, state='ready') + # No predecessor, no other gates → blocker_kind = 'none' + self.assertEqual(step.blocker_kind, 'none') + self.assertEqual(step.blocker_reason, '') + + def test_predecessor_open_blocks(self): + s1 = self._make_step('Earlier', 10, state='in_progress') + s2 = self._make_step('Later', 20, state='ready') + # Trigger recompute + s2.invalidate_recordset(['blocker_kind', 'blocker_reason']) + if s2.requires_predecessor_done: + self.assertEqual(s2.blocker_kind, 'predecessor') + self.assertIn('Earlier', s2.blocker_reason or '') + self.assertEqual(s2.blocker_jump_target_model, 'fp.job.step') + self.assertEqual(s2.blocker_jump_target_id, s1.id) + + def test_done_step_has_no_blocker(self): + step = self._make_step('Done', 10, state='done') + self.assertEqual(step.blocker_kind, 'none') +``` + +- [ ] **Step 2: Verify test fails** + +``` +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_jobs --test-tags fp_jobs --stop-after-init +``` + +Expected: FAIL — `'fp.job.step' object has no attribute 'blocker_kind'`. + +- [ ] **Step 3: Implement the 4 computes on `fp.job.step`** + +In `fusion_plating/fusion_plating_jobs/models/fp_job_step.py`, add to the `FpJobStep` class (after existing fields): + +```python + # Gate visualizer — drives the OWL GateViz component. Returns the kind + # of blocker preventing this step from starting, a human reason, and an + # optional (model, id) target the operator can tap to jump to. + blocker_kind = fields.Selection( + [ + ('none', 'Not blocked'), + ('predecessor', 'Waiting on predecessor'), + ('contract_review', 'Contract review pending'), + ('parts_not_received', 'Parts not received'), + ('racking_required', 'Racking inspection required'), + ('manager_input', 'Manager input required'), + ('other', 'Other'), + ], + compute='_compute_blocker', + string='Blocker Kind', + ) + blocker_reason = fields.Char( + compute='_compute_blocker', + string='Blocker Reason', + help='Human-readable explanation surfaced in the GateViz block.', + ) + blocker_jump_target_model = fields.Char(compute='_compute_blocker') + blocker_jump_target_id = fields.Integer(compute='_compute_blocker') + + @api.depends( + 'state', 'sequence', 'requires_predecessor_done', + 'job_id.step_ids.state', 'job_id.step_ids.sequence', + ) + def _compute_blocker(self): + for step in self: + # Steps in a terminal state are never "blocked" + if step.state in ('done', 'skipped', 'cancelled', 'in_progress'): + step.blocker_kind = 'none' + step.blocker_reason = '' + step.blocker_jump_target_model = False + step.blocker_jump_target_id = 0 + continue + + # Predecessor check — earlier-sequence step still open + if getattr(step, 'requires_predecessor_done', False): + earlier_open = step.job_id.step_ids.filtered(lambda x: ( + x.id != step.id + and x.sequence < step.sequence + and x.state not in ('done', 'skipped', 'cancelled') + )) + if earlier_open: + first_blocker = earlier_open.sorted('sequence')[0] + step.blocker_kind = 'predecessor' + step.blocker_reason = f'Waiting on Step {first_blocker.sequence // 10}: {first_blocker.name}' + step.blocker_jump_target_model = 'fp.job.step' + step.blocker_jump_target_id = first_blocker.id + continue + + # Contract review / parts / racking gates — these are recognised + # by existing code paths but expressed here as the canonical + # source of truth for the tablet. Extend with explicit checks + # as those gate models mature. + step.blocker_kind = 'none' + step.blocker_reason = '' + step.blocker_jump_target_model = False + step.blocker_jump_target_id = 0 +``` + +(The `@api.depends` import is already present at the top of the file.) + +- [ ] **Step 4: Verify test passes** + +``` +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_jobs --test-tags fp_jobs --stop-after-init +``` + +Expected: 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +cd /k/Github/Odoo-Modules +git add fusion_plating/fusion_plating_jobs/models/fp_job_step.py \ + fusion_plating/fusion_plating_jobs/tests/test_blocker_compute.py +git commit -m "feat(fusion_plating_jobs): fp.job.step blocker_kind/reason/jump_target computes" +``` + +--- + +### Task 1.3 — Bootstrap shopfloor `tests/` directory + WorkflowChip component + +**Files:** +- Create: `fusion_plating/fusion_plating_shopfloor/tests/__init__.py` (empty) +- Create: `fusion_plating/fusion_plating_shopfloor/static/src/js/components/workflow_chip.js` +- Create: `fusion_plating/fusion_plating_shopfloor/static/src/xml/components/workflow_chip.xml` +- Create: `fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_workflow_chip.scss` +- Modify: `fusion_plating/fusion_plating_shopfloor/__manifest__.py` (add new assets) + +- [ ] **Step 1: Create tests/__init__.py (empty file, enables future Python tests)** + +Create `fusion_plating/fusion_plating_shopfloor/tests/__init__.py` as an empty file. + +- [ ] **Step 2: Create WorkflowChip OWL component JS** + +Create `fusion_plating/fusion_plating_shopfloor/static/src/js/components/workflow_chip.js`: + +```javascript +/** @odoo-module **/ +// ============================================================================= +// Fusion Plating — WorkflowChip (shared OWL service) +// Renders an fp.job.workflow.state as a colored chip + optional next-action. +// ============================================================================= + +import { Component } from "@odoo/owl"; + +export class WorkflowChip extends Component { + static template = "fusion_plating_shopfloor.WorkflowChip"; + static props = { + state: { type: Object, optional: false }, // {id, name, color} + nextActionLabel: { type: String, optional: true }, + }; + + get toneClass() { + // Map workflow.state.color (Selection: grey/blue/cyan/yellow/orange/ + // green/success/danger/purple) to a Bootstrap-friendly suffix. + const map = { + grey: "muted", blue: "info", cyan: "info", + yellow: "warning", orange: "warning", + green: "success", success: "success", + danger: "danger", purple: "info", + }; + return map[this.props.state.color] || "muted"; + } +} +``` + +- [ ] **Step 3: Create WorkflowChip template XML** + +Create `fusion_plating/fusion_plating_shopfloor/static/src/xml/components/workflow_chip.xml`: + +```xml + + + + + + + + + · next: + + + + + +``` + +- [ ] **Step 4: Create WorkflowChip SCSS** + +Create `fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_workflow_chip.scss`: + +```scss +// ============================================================================= +// WorkflowChip — colored milestone pill +// Dark-mode aware via $o-webclient-color-scheme branch (registered in BOTH +// web.assets_backend AND web.assets_web_dark — see manifest). +// ============================================================================= + +$o-webclient-color-scheme: bright !default; + +$_wf-bg-muted-hex: #f0f0f2; +$_wf-bg-info-hex: rgba(0, 113, 227, 0.15); +$_wf-bg-success-hex: rgba(52, 199, 89, 0.15); +$_wf-bg-warning-hex: rgba(255, 159, 10, 0.15); +$_wf-bg-danger-hex: rgba(255, 59, 48, 0.15); + +@if $o-webclient-color-scheme == dark { + $_wf-bg-muted-hex: #2d2d2f !global; +} + +.o_fp_wf_chip { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.25rem 0.65rem; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 600; + line-height: 1.2; + white-space: nowrap; +} + +.o_fp_wf_dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + opacity: 0.85; +} + +.o_fp_wf_next { + font-weight: 400; + opacity: 0.75; + margin-left: 0.15rem; +} + +.o_fp_wf_chip_muted { background: $_wf-bg-muted-hex; color: #666; } +.o_fp_wf_chip_info { background: $_wf-bg-info-hex; color: #0050a0; } +.o_fp_wf_chip_success { background: $_wf-bg-success-hex; color: #1d6e2f; } +.o_fp_wf_chip_warning { background: $_wf-bg-warning-hex; color: #b06600; } +.o_fp_wf_chip_danger { background: $_wf-bg-danger-hex; color: #b00018; } + +@if $o-webclient-color-scheme == dark { + .o_fp_wf_chip_muted { color: #a8a8b0; } + .o_fp_wf_chip_info { color: #6cb6ff; } + .o_fp_wf_chip_success { color: #6be398; } + .o_fp_wf_chip_warning { color: #ffb84d; } + .o_fp_wf_chip_danger { color: #ff7a72; } +} +``` + +- [ ] **Step 5: Register the 3 new assets in the manifest** + +In `fusion_plating/fusion_plating_shopfloor/__manifest__.py`, bump the version and add the new assets. Bump: + +```python +'version': '19.0.27.0.0', +``` + +Add to `assets['web.assets_backend']` AFTER the tokens line and BEFORE other SCSS (so component tokens are available to consumers). The full insertion (find the existing `'fusion_plating_shopfloor/static/src/scss/_fp_shopfloor_tokens.scss',` line and add immediately after): + +```python + # ---- Shared OWL services (Phase 1) ---- + 'fusion_plating_shopfloor/static/src/scss/components/_workflow_chip.scss', + 'fusion_plating_shopfloor/static/src/xml/components/workflow_chip.xml', + 'fusion_plating_shopfloor/static/src/js/components/workflow_chip.js', +``` + +Mirror the same 3 lines into `assets['web.assets_web_dark']` (after its tokens line) so dark mode picks up the SCSS branch. + +- [ ] **Step 6: Update + verify install** + +``` +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --stop-after-init +``` + +Expected: no errors. Check container log for `Module fusion_plating_shopfloor: loaded` line. + +- [ ] **Step 7: Commit** + +```bash +cd /k/Github/Odoo-Modules +git add fusion_plating/fusion_plating_shopfloor/tests/__init__.py \ + fusion_plating/fusion_plating_shopfloor/static/src/js/components/workflow_chip.js \ + fusion_plating/fusion_plating_shopfloor/static/src/xml/components/workflow_chip.xml \ + fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_workflow_chip.scss \ + fusion_plating/fusion_plating_shopfloor/__manifest__.py +git commit -m "feat(fusion_plating_shopfloor): WorkflowChip shared OWL service + dark-mode SCSS" +``` + +--- + +### Task 1.4 — GateViz shared OWL component + +**Files:** +- Create: `fusion_plating/fusion_plating_shopfloor/static/src/js/components/gate_viz.js` +- Create: `fusion_plating/fusion_plating_shopfloor/static/src/xml/components/gate_viz.xml` +- Create: `fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_gate_viz.scss` +- Modify: `fusion_plating/fusion_plating_shopfloor/__manifest__.py` (add 3 new asset lines) + +- [ ] **Step 1: Create GateViz JS** + +```javascript +/** @odoo-module **/ +// ============================================================================= +// Fusion Plating — GateViz (shared OWL service) +// "Can't start because…" explainer. Drives off step.blocker_kind/reason +// from the backend compute (Phase 1, task 1.2). +// ============================================================================= + +import { Component } from "@odoo/owl"; + +export class GateViz extends Component { + static template = "fusion_plating_shopfloor.GateViz"; + static props = { + canStart: { type: Boolean, optional: false }, + blockerKind: { type: String, optional: true }, + blockerReason: { type: String, optional: true }, + jumpTargetModel: { type: String, optional: true }, + jumpTargetId: { type: Number, optional: true }, + onJump: { type: Function, optional: true }, + }; + + get iconClass() { + const map = { + predecessor: "fa-lock", + contract_review: "fa-file-text-o", + parts_not_received: "fa-truck", + racking_required: "fa-th-large", + manager_input: "fa-user-md", + }; + return map[this.props.blockerKind] || "fa-pause-circle"; + } + + onJumpClick() { + if (this.props.onJump && this.props.jumpTargetModel && this.props.jumpTargetId) { + this.props.onJump({ + model: this.props.jumpTargetModel, + id: this.props.jumpTargetId, + }); + } + } +} +``` + +- [ ] **Step 2: Create GateViz template XML** + +```xml + + + + +
+ +
+
Can't start yet
+
+ +
+
+ +
+
+ +
+``` + +- [ ] **Step 3: Create GateViz SCSS** + +```scss +$o-webclient-color-scheme: bright !default; + +$_gate-bg-hex: rgba(255, 159, 10, 0.10); +$_gate-border-hex: #ff9f0a; +$_gate-text-hex: #b06600; + +@if $o-webclient-color-scheme == dark { + $_gate-text-hex: #ffb84d !global; +} + +.o_fp_gate { + background: $_gate-bg-hex; + border-left: 3px solid $_gate-border-hex; + padding: 0.5rem 0.75rem; + border-radius: 0 6px 6px 0; + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +.o_fp_gate_icon { color: $_gate-border-hex; margin-top: 0.15rem; } +.o_fp_gate_body { flex: 1; } +.o_fp_gate_title { font-weight: 600; color: $_gate-text-hex; font-size: 0.85rem; } +.o_fp_gate_reason { color: var(--text-secondary, #666); font-size: 0.78rem; margin-top: 0.1rem; } +.o_fp_gate_jump { flex-shrink: 0; } +``` + +- [ ] **Step 4: Register the 3 assets in manifest (both bundles)** + +Add the 3 lines after the WorkflowChip lines in BOTH `web.assets_backend` and `web.assets_web_dark`: + +```python + 'fusion_plating_shopfloor/static/src/scss/components/_gate_viz.scss', + 'fusion_plating_shopfloor/static/src/xml/components/gate_viz.xml', + 'fusion_plating_shopfloor/static/src/js/components/gate_viz.js', +``` + +- [ ] **Step 5: Update and verify** + +``` +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --stop-after-init +``` + +- [ ] **Step 6: Commit** + +```bash +cd /k/Github/Odoo-Modules +git add fusion_plating/fusion_plating_shopfloor/static/src/js/components/gate_viz.js \ + fusion_plating/fusion_plating_shopfloor/static/src/xml/components/gate_viz.xml \ + fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_gate_viz.scss \ + fusion_plating/fusion_plating_shopfloor/__manifest__.py +git commit -m "feat(fusion_plating_shopfloor): GateViz shared OWL service" +``` + +--- + +### Task 1.5 — SignaturePad shared OWL component + +**Files:** +- Create: `fusion_plating/fusion_plating_shopfloor/static/src/js/components/signature_pad.js` +- Create: `fusion_plating/fusion_plating_shopfloor/static/src/xml/components/signature_pad.xml` +- Create: `fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_signature_pad.scss` +- Modify: `fusion_plating/fusion_plating_shopfloor/__manifest__.py` (add 3 lines × 2 bundles) + +- [ ] **Step 1: Create SignaturePad JS (Dialog component)** + +```javascript +/** @odoo-module **/ +// ============================================================================= +// Fusion Plating — SignaturePad (shared OWL service) +// Modal canvas signature capture. Returns dataURI via onSubmit, caller commits. +// Mounted via the dialog service: `dialog.add(FpSignaturePad, { props })`. +// ============================================================================= + +import { Component, useRef, onMounted, onWillUnmount } from "@odoo/owl"; +import { Dialog } from "@web/core/dialog/dialog"; + +export class FpSignaturePad extends Component { + static template = "fusion_plating_shopfloor.SignaturePad"; + static components = { Dialog }; + static props = { + close: Function, // dialog service injects + title: { type: String, optional: true }, + contextLabel: { type: String, optional: true }, + onSubmit: { type: Function, optional: false }, // (dataUri) => void + onCancel: { type: Function, optional: true }, + }; + + setup() { + this.canvasRef = useRef("canvas"); + this.isDrawing = false; + this.lastPoint = null; + this.hasInk = false; + + onMounted(() => { + const canvas = this.canvasRef.el; + // Match canvas pixel size to its CSS box so strokes don't stretch + canvas.width = canvas.clientWidth; + canvas.height = canvas.clientHeight; + const ctx = canvas.getContext("2d"); + ctx.lineWidth = 2; + ctx.lineCap = "round"; + ctx.strokeStyle = "#000"; + + canvas.addEventListener("pointerdown", this._onDown); + canvas.addEventListener("pointermove", this._onMove); + canvas.addEventListener("pointerup", this._onUp); + canvas.addEventListener("pointercancel", this._onUp); + canvas.addEventListener("pointerleave", this._onUp); + }); + + onWillUnmount(() => { + const canvas = this.canvasRef.el; + if (!canvas) return; + canvas.removeEventListener("pointerdown", this._onDown); + canvas.removeEventListener("pointermove", this._onMove); + canvas.removeEventListener("pointerup", this._onUp); + canvas.removeEventListener("pointercancel", this._onUp); + canvas.removeEventListener("pointerleave", this._onUp); + }); + + this._onDown = (ev) => { + this.isDrawing = true; + this.lastPoint = this._localPoint(ev); + }; + this._onMove = (ev) => { + if (!this.isDrawing) return; + const p = this._localPoint(ev); + const ctx = this.canvasRef.el.getContext("2d"); + ctx.beginPath(); + ctx.moveTo(this.lastPoint.x, this.lastPoint.y); + ctx.lineTo(p.x, p.y); + ctx.stroke(); + this.lastPoint = p; + this.hasInk = true; + }; + this._onUp = () => { + this.isDrawing = false; + this.lastPoint = null; + }; + } + + _localPoint(ev) { + const r = this.canvasRef.el.getBoundingClientRect(); + return { x: ev.clientX - r.left, y: ev.clientY - r.top }; + } + + onClear() { + const canvas = this.canvasRef.el; + canvas.getContext("2d").clearRect(0, 0, canvas.width, canvas.height); + this.hasInk = false; + } + + onSubmit() { + if (!this.hasInk) return; + const dataUri = this.canvasRef.el.toDataURL("image/png"); + this.props.onSubmit(dataUri); + this.props.close(); + } + + onCancel() { + if (this.props.onCancel) this.props.onCancel(); + this.props.close(); + } +} +``` + +- [ ] **Step 2: Create SignaturePad template XML** + +```xml + + + + + +
+
+ +
+ +
Draw your signature above
+
+ + + + + +
+
+ +
+``` + +- [ ] **Step 3: Create SignaturePad SCSS** + +```scss +$o-webclient-color-scheme: bright !default; + +$_sig-canvas-bg-hex: #ffffff; +$_sig-canvas-border-hex: #d8dadd; + +@if $o-webclient-color-scheme == dark { + $_sig-canvas-bg-hex: #f5f5f5 !global; // signatures stay light for legibility + $_sig-canvas-border-hex: #5a5a5e !global; +} + +.o_fp_sig_pad { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.o_fp_sig_ctx { + font-size: 0.85rem; + color: var(--text-secondary, #666); +} + +.o_fp_sig_canvas { + width: 100%; + height: 200px; + background: $_sig-canvas-bg-hex; + border: 2px solid $_sig-canvas-border-hex; + border-radius: 6px; + cursor: crosshair; + touch-action: none; +} + +.o_fp_sig_hint { + font-size: 0.75rem; + color: var(--text-secondary, #999); + text-align: center; +} +``` + +- [ ] **Step 4: Register the 3 assets in manifest (both bundles)** + +Same pattern — append after GateViz lines in both `web.assets_backend` and `web.assets_web_dark`: + +```python + 'fusion_plating_shopfloor/static/src/scss/components/_signature_pad.scss', + 'fusion_plating_shopfloor/static/src/xml/components/signature_pad.xml', + 'fusion_plating_shopfloor/static/src/js/components/signature_pad.js', +``` + +- [ ] **Step 5: Update + commit** + +``` +docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --stop-after-init +``` + +```bash +cd /k/Github/Odoo-Modules +git add fusion_plating/fusion_plating_shopfloor/static/src/js/components/signature_pad.js \ + fusion_plating/fusion_plating_shopfloor/static/src/xml/components/signature_pad.xml \ + fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_signature_pad.scss \ + fusion_plating/fusion_plating_shopfloor/__manifest__.py +git commit -m "feat(fusion_plating_shopfloor): SignaturePad shared OWL service" +``` + +--- + +### Task 1.6 — HoldComposer shared OWL component + +**Files:** +- Create: `fusion_plating/fusion_plating_shopfloor/static/src/js/components/hold_composer.js` +- Create: `fusion_plating/fusion_plating_shopfloor/static/src/xml/components/hold_composer.xml` +- Create: `fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_hold_composer.scss` +- Modify: `fusion_plating/fusion_plating_shopfloor/__manifest__.py` + +- [ ] **Step 1: Create HoldComposer JS** + +```javascript +/** @odoo-module **/ +// ============================================================================= +// Fusion Plating — HoldComposer (shared OWL service) +// Modal form to create a quality.hold with reason + qty + photo + notes. +// Calls /fp/workspace/hold (Phase 1 backend). Caller passes onCreated(hold). +// ============================================================================= + +import { Component, useState } from "@odoo/owl"; +import { Dialog } from "@web/core/dialog/dialog"; +import { rpc } from "@web/core/network/rpc"; +import { useService } from "@web/core/utils/hooks"; + +const HOLD_REASONS = [ + { value: "dimensional", label: "Dimensional" }, + { value: "thickness", label: "Thickness fail" }, + { value: "plating_defect", label: "Plating defect" }, + { value: "contamination", label: "Contamination" }, + { value: "wrong_part", label: "Wrong part" }, + { value: "other", label: "Other" }, +]; + +export class FpHoldComposer extends Component { + static template = "fusion_plating_shopfloor.HoldComposer"; + static components = { Dialog }; + static props = { + close: Function, + jobId: { type: Number, optional: false }, + stepId: { type: Number, optional: true }, + defaultQty: { type: Number, optional: true }, + partRef: { type: String, optional: true }, + onCreated: { type: Function, optional: true }, + }; + + setup() { + this.notification = useService("notification"); + this.reasons = HOLD_REASONS; + this.state = useState({ + reason: "dimensional", + qty: this.props.defaultQty || 1, + description: "", + photoDataUri: null, + photoFilename: "", + markForScrap: false, + submitting: false, + }); + } + + onPhotoChange(ev) { + const file = ev.target.files && ev.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (e) => { + // Strip "data:...;base64," prefix — backend expects raw base64 + const dataUri = e.target.result; + const base64 = dataUri.split(",", 2)[1] || ""; + this.state.photoDataUri = base64; + this.state.photoFilename = file.name; + }; + reader.readAsDataURL(file); + } + + async onSubmit() { + if (this.state.qty < 1) { + this.notification.add("Qty on hold must be at least 1", { type: "warning" }); + return; + } + this.state.submitting = true; + try { + const res = await rpc("/fp/workspace/hold", { + job_id: this.props.jobId, + step_id: this.props.stepId || null, + part_ref: this.props.partRef || "", + reason: this.state.reason, + qty_on_hold: this.state.qty, + description: this.state.description || "", + mark_for_scrap: this.state.markForScrap, + photo_data: this.state.photoDataUri, + photo_filename: this.state.photoFilename, + }); + if (res && res.ok) { + this.notification.add(`Hold ${res.hold_name} created.`, { type: "success" }); + if (this.props.onCreated) this.props.onCreated(res); + this.props.close(); + } else { + this.notification.add(res.error || "Hold creation failed", { type: "danger" }); + } + } catch (err) { + this.notification.add(err.message || String(err), { type: "danger" }); + } finally { + this.state.submitting = false; + } + } +} +``` + +- [ ] **Step 2: Create HoldComposer template XML** + +```xml + + + + + +
+
+ + +
+
+ + +
+
+ +