# 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