Files
Odoo-Modules/fusion_plating/docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md
gsinghpal 27465cfeac docs(fusion_plating_shopfloor): implementation plan for tablet redesign
5-phase TDD plan with 28+ tasks executing the spec at
docs/superpowers/specs/2026-05-22-shopfloor-tablet-redesign-design.md:

- Phase 1: Workspace foundation — 5 shared OWL services
  (WorkflowChip, GateViz, SignaturePad, HoldComposer, KanbanCard),
  JobWorkspace OWL client action, workspace_controller with 4 endpoints,
  display_wo_name + blocker_* computes, smart button on fp.job form.

- Phase 2: Auto-pause cron (fixes 411h ghost timer),
  late_risk_ratio + active_step_id computes, long_running flag on
  process node, ACL lift for operator (cert write, thickness create,
  override read).

- Phase 3: Landing refactor — fp_shopfloor_landing replaces
  fp_shopfloor_tablet + folds in fp_plant_overview. Station-scoped
  kanban with All Plant toggle.

- Phase 4: Manager dashboard refactor — 4 sibling tabs (Workflow
  Funnel, Approval Inbox, At-Risk, existing Plant Board), 3 new
  endpoints, bottleneck_score on fp.work.centre, 2 new KPI tiles.

- Phase 5: Cleanup — remove deprecation stubs, retire plant_overview
  menu, update CLAUDE.md + README.

Each phase ships independently; each task is a self-contained TDD
cycle (write test → fail → implement → pass → commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:38:01 -04:00

146 KiB
Raw Blame History

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

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 <module> --stop-after-init
  • Test: docker exec odoo-modsdev-app odoo -d modsdev -i <module> --test-tags <tag> --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:

# -*- 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):

    # 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
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:

# -*- 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):

    # 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
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:

/** @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 version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">

    <t t-name="fusion_plating_shopfloor.WorkflowChip">
        <span t-att-class="'o_fp_wf_chip o_fp_wf_chip_' + toneClass">
            <span class="o_fp_wf_dot"/>
            <span class="o_fp_wf_label" t-esc="props.state.name"/>
            <t t-if="props.nextActionLabel">
                <span class="o_fp_wf_next">· next: <t t-esc="props.nextActionLabel"/></span>
            </t>
        </span>
    </t>

</templates>
  • Step 4: Create WorkflowChip SCSS

Create fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_workflow_chip.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:

'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):

            # ---- 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
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

/** @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 version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">

    <t t-name="fusion_plating_shopfloor.GateViz">
        <div class="o_fp_gate" t-if="!props.canStart">
            <i t-att-class="'fa o_fp_gate_icon ' + iconClass"/>
            <div class="o_fp_gate_body">
                <div class="o_fp_gate_title">Can't start yet</div>
                <div class="o_fp_gate_reason">
                    <t t-esc="props.blockerReason or 'Reason unknown — open the step in the back-office.'"/>
                </div>
            </div>
            <button t-if="props.jumpTargetModel and props.jumpTargetId and props.onJump"
                    class="btn btn-sm btn-outline-warning o_fp_gate_jump"
                    t-on-click="onJumpClick">
                Jump <i class="fa fa-arrow-right"/>
            </button>
        </div>
    </t>

</templates>
  • Step 3: Create GateViz 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:

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

/** @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 version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">

    <t t-name="fusion_plating_shopfloor.SignaturePad">
        <Dialog title="props.title or 'Signature required'" size="'md'">
            <div class="o_fp_sig_pad">
                <div class="o_fp_sig_ctx" t-if="props.contextLabel">
                    <t t-esc="props.contextLabel"/>
                </div>
                <canvas class="o_fp_sig_canvas" t-ref="canvas"/>
                <div class="o_fp_sig_hint">Draw your signature above</div>
            </div>
            <t t-set-slot="footer">
                <button class="btn btn-secondary" t-on-click="onClear">Clear</button>
                <button class="btn btn-link" t-on-click="onCancel">Cancel</button>
                <button class="btn btn-primary" t-on-click="onSubmit">Sign &amp; Finish</button>
            </t>
        </Dialog>
    </t>

</templates>
  • Step 3: Create SignaturePad 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:

            '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
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

/** @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 version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">

    <t t-name="fusion_plating_shopfloor.HoldComposer">
        <Dialog title="'Place hold'" size="'md'">
            <div class="o_fp_hc">
                <div class="o_fp_hc_row">
                    <label>Reason</label>
                    <select class="form-select" t-model="state.reason">
                        <t t-foreach="reasons" t-as="r" t-key="r.value">
                            <option t-att-value="r.value"><t t-esc="r.label"/></option>
                        </t>
                    </select>
                </div>
                <div class="o_fp_hc_row">
                    <label>Qty on hold</label>
                    <input type="number" min="1" class="form-control" t-model.number="state.qty"/>
                </div>
                <div class="o_fp_hc_row">
                    <label>Description</label>
                    <textarea class="form-control" rows="3" t-model="state.description"
                              placeholder="What happened?"/>
                </div>
                <div class="o_fp_hc_row">
                    <label>Photo (optional)</label>
                    <input type="file" accept="image/*" capture="environment"
                           class="form-control" t-on-change="onPhotoChange"/>
                    <small t-if="state.photoFilename" class="text-success">
                        <i class="fa fa-check"/> <t t-esc="state.photoFilename"/>
                    </small>
                </div>
                <div class="o_fp_hc_row">
                    <label class="form-check-label">
                        <input type="checkbox" class="form-check-input me-1"
                               t-model="state.markForScrap"/>
                        Mark for scrap
                    </label>
                </div>
            </div>
            <t t-set-slot="footer">
                <button class="btn btn-link" t-on-click="() => this.props.close()">Cancel</button>
                <button class="btn btn-warning" t-on-click="onSubmit"
                        t-att-disabled="state.submitting">
                    <i class="fa fa-exclamation-triangle me-1"/> Create Hold
                </button>
            </t>
        </Dialog>
    </t>

</templates>
  • Step 3: Create HoldComposer SCSS
.o_fp_hc {
    display: flex;
    flex-direction: column;
    gap: 0.75rem;
}
.o_fp_hc_row {
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
}
.o_fp_hc_row label {
    font-size: 0.8rem;
    font-weight: 600;
    color: var(--text-secondary, #666);
}
  • Step 4: Register assets, update, commit

Add 3 lines after SignaturePad in both bundles:

            'fusion_plating_shopfloor/static/src/scss/components/_hold_composer.scss',
            'fusion_plating_shopfloor/static/src/xml/components/hold_composer.xml',
            'fusion_plating_shopfloor/static/src/js/components/hold_composer.js',
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --stop-after-init
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_shopfloor/static/src/js/components/hold_composer.js \
        fusion_plating/fusion_plating_shopfloor/static/src/xml/components/hold_composer.xml \
        fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_hold_composer.scss \
        fusion_plating/fusion_plating_shopfloor/__manifest__.py
git commit -m "feat(fusion_plating_shopfloor): HoldComposer shared OWL service"

Task 1.7 — KanbanCard shared OWL component

Files:

  • Create: fusion_plating/fusion_plating_shopfloor/static/src/js/components/kanban_card.js

  • Create: fusion_plating/fusion_plating_shopfloor/static/src/xml/components/kanban_card.xml

  • Create: fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_kanban_card.scss

  • Modify: fusion_plating/fusion_plating_shopfloor/__manifest__.py

  • Step 1: Create KanbanCard JS

/** @odoo-module **/
// =============================================================================
// Fusion Plating — KanbanCard (shared OWL service)
// Standard WO/step card used on Landing kanban + Manager Plant Board/Funnel.
// Embeds WorkflowChip + GateViz badge.
// =============================================================================

import { Component } from "@odoo/owl";
import { WorkflowChip } from "./workflow_chip";

export class FpKanbanCard extends Component {
    static template = "fusion_plating_shopfloor.KanbanCard";
    static components = { WorkflowChip };
    static props = {
        data: { type: Object, optional: false },
        // {job_id, display_wo_name, customer, part, qty, qty_done, qty_scrapped,
        //  date_deadline, workflow_state, blocker_kind, blocker_reason,
        //  urgency_band, priority, current_step_id, work_center}
        density: { type: String, optional: true },  // 'compact' | 'normal'
        showWorkflowChip: { type: Boolean, optional: true },
        showWorkcenter: { type: Boolean, optional: true },
        showAssignedTo: { type: Boolean, optional: true },
        onTap: { type: Function, optional: true },
    };

    get isCompact() {
        return this.props.density === "compact";
    }

    get progressPct() {
        const d = this.props.data;
        if (!d.qty || d.qty <= 0) return 0;
        return Math.min(100, Math.round((d.qty_done || 0) * 100 / d.qty));
    }

    get priorityDot() {
        const p = this.props.data.priority;
        if (p === "rush") return "danger";
        if (p === "high") return "warning";
        return "muted";
    }

    onClick() {
        if (this.props.onTap) this.props.onTap(this.props.data);
    }
}
  • Step 2: Create KanbanCard template XML
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">

    <t t-name="fusion_plating_shopfloor.KanbanCard">
        <div t-att-class="'o_fp_kcard ' + (isCompact ? 'o_fp_kcard_compact' : '')"
             t-on-click="onClick">
            <div class="o_fp_kcard_h1">
                <span class="o_fp_kcard_wo"><t t-esc="props.data.display_wo_name"/></span>
                <span t-att-class="'o_fp_kcard_pri o_fp_kcard_pri_' + priorityDot"/>
            </div>
            <div class="o_fp_kcard_h2">
                <span t-esc="props.data.customer or ''"/>
                <t t-if="props.data.part"> · <t t-esc="props.data.part"/></t>
            </div>
            <div class="o_fp_kcard_qty" t-if="props.data.qty">
                <span><t t-esc="props.data.qty_done or 0"/> / <t t-esc="props.data.qty"/> done</span>
                <span t-if="props.data.date_deadline" class="o_fp_kcard_due">
                    Due <t t-esc="props.data.date_deadline"/>
                </span>
            </div>
            <div class="o_fp_kcard_bar" t-if="props.data.qty">
                <div t-att-style="'width: ' + progressPct + '%'"/>
            </div>
            <div class="o_fp_kcard_chips">
                <WorkflowChip t-if="props.showWorkflowChip and props.data.workflow_state"
                              state="props.data.workflow_state"/>
                <span t-if="props.data.blocker_kind and props.data.blocker_kind !== 'none'"
                      class="o_fp_kcard_blocked"
                      t-att-title="props.data.blocker_reason">
                    <i class="fa fa-lock"/> Blocked
                </span>
                <span t-if="props.showWorkcenter and props.data.work_center"
                      class="o_fp_kcard_wc">
                    @ <t t-esc="props.data.work_center"/>
                </span>
            </div>
        </div>
    </t>

</templates>
  • Step 3: Create KanbanCard SCSS
$o-webclient-color-scheme: bright !default;

$_kc-bg-hex:     #ffffff;
$_kc-border-hex: #d8dadd;
$_kc-hover-hex:  #f5f5f7;

@if $o-webclient-color-scheme == dark {
    $_kc-bg-hex:     #22262d !global;
    $_kc-border-hex: #424245 !global;
    $_kc-hover-hex:  #2d3138 !global;
}

.o_fp_kcard {
    background: $_kc-bg-hex;
    border: 1px solid $_kc-border-hex;
    border-radius: 6px;
    padding: 0.55rem 0.7rem;
    cursor: pointer;
    transition: background 0.1s ease;

    &:hover { background: $_kc-hover-hex; }
}
.o_fp_kcard_compact { padding: 0.4rem 0.55rem; }

.o_fp_kcard_h1 { display: flex; justify-content: space-between; align-items: center; font-weight: 700; font-size: 0.85rem; }
.o_fp_kcard_h2 { color: var(--text-secondary, #666); font-size: 0.75rem; margin-top: 0.15rem; }
.o_fp_kcard_qty { display: flex; justify-content: space-between; font-size: 0.7rem; color: var(--text-secondary, #777); margin-top: 0.3rem; }
.o_fp_kcard_due { color: var(--text-secondary, #999); }

.o_fp_kcard_bar { height: 4px; background: rgba(0,0,0,0.08); border-radius: 2px; overflow: hidden; margin-top: 0.3rem;
    > div { height: 100%; background: #34c759; }
}

.o_fp_kcard_chips { display: flex; gap: 0.35rem; flex-wrap: wrap; margin-top: 0.4rem; }

.o_fp_kcard_pri { width: 8px; height: 8px; border-radius: 50%; }
.o_fp_kcard_pri_danger  { background: #ff3b30; }
.o_fp_kcard_pri_warning { background: #ff9f0a; }
.o_fp_kcard_pri_muted   { background: transparent; }

.o_fp_kcard_blocked { background: rgba(255,159,10,0.15); color: #b06600; padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.7rem; }
.o_fp_kcard_wc      { color: var(--text-secondary, #999); font-size: 0.7rem; }

@if $o-webclient-color-scheme == dark {
    .o_fp_kcard_blocked { color: #ffb84d; }
}
  • Step 4: Register assets, update, commit

Add 3 lines after HoldComposer:

            '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',
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --stop-after-init
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_shopfloor/static/src/js/components/kanban_card.js \
        fusion_plating/fusion_plating_shopfloor/static/src/xml/components/kanban_card.xml \
        fusion_plating/fusion_plating_shopfloor/static/src/scss/components/_kanban_card.scss \
        fusion_plating/fusion_plating_shopfloor/__manifest__.py
git commit -m "feat(fusion_plating_shopfloor): KanbanCard shared OWL service"

Task 1.8 — workspace_controller.py: /fp/workspace/load endpoint

Files:

  • Create: fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py

  • Modify: fusion_plating/fusion_plating_shopfloor/controllers/__init__.py

  • Create: fusion_plating/fusion_plating_shopfloor/tests/test_workspace_load.py

  • Step 1: Write the failing test

Create fusion_plating/fusion_plating_shopfloor/tests/test_workspace_load.py:

# -*- coding: utf-8 -*-
from odoo.tests.common import HttpCase, tagged


@tagged('-at_install', 'post_install', 'fp_shopfloor')
class TestWorkspaceLoad(HttpCase):

    def setUp(self):
        super().setUp()
        self.authenticate("admin", "admin")
        self.partner = self.env['res.partner'].create({'name': 'WS Cust'})
        self.product = self.env['product.product'].create({'name': 'WS Prod'})
        self.job = self.env['fp.job'].create({
            'name': 'WH/JOB/WS001',
            'partner_id': self.partner.id,
            'product_id': self.product.id,
            'qty': 5,
        })

    def test_load_returns_full_payload(self):
        res = self.url_open(
            '/fp/workspace/load',
            data='{"jsonrpc":"2.0","params":{"job_id":%d}}' % self.job.id,
            headers={'Content-Type': 'application/json'},
        ).json()['result']
        self.assertTrue(res['ok'])
        self.assertEqual(res['job']['display_wo_name'], 'WO # WS001')
        self.assertEqual(res['job']['id'], self.job.id)
        self.assertIn('steps', res)
        self.assertIn('workflow_states', res)
        self.assertIn('chatter', res)

    def test_load_bad_job_id_returns_error(self):
        res = self.url_open(
            '/fp/workspace/load',
            data='{"jsonrpc":"2.0","params":{"job_id":999999}}',
            headers={'Content-Type': 'application/json'},
        ).json()['result']
        self.assertFalse(res['ok'])
        self.assertIn('not found', res['error'].lower())
  • Step 2: Verify test fails
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --test-tags fp_shopfloor --stop-after-init

Expected: FAIL — endpoint does not exist (404).

  • Step 3: Create the controller

Create fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py:

# -*- 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 Job Workspace client action.

Surfaces a single fp.job + step list + workflow milestones + side-panel
data (spec PDF, attachments, chatter) + action endpoints (hold, sign-off,
milestone advance).
"""

import base64
import logging

from odoo import http
from odoo.addons.fusion_plating.models.fp_tz import fp_format
from odoo.http import request

_logger = logging.getLogger(__name__)


class FpWorkspaceController(http.Controller):

    # ----------------------------------------------------------------------
    # Load
    # ----------------------------------------------------------------------
    @http.route('/fp/workspace/load', type='jsonrpc', auth='user')
    def load(self, job_id):
        env = request.env
        job = env['fp.job'].browse(int(job_id))
        if not job.exists():
            _logger.warning("workspace/load: job %s not found", job_id)
            return {'ok': False, 'error': f'Job {job_id} not found'}

        # ---- Workflow milestones ----------------------------------------
        all_states = env['fp.job.workflow.state'].search([], order='sequence, id')
        current = job.workflow_state_id
        passed_ids = set()
        for ws in all_states:
            passed_ids.add(ws.id)
            if ws.id == current.id:
                break
        workflow_states = [{
            'id': ws.id,
            'name': ws.name,
            'color': ws.color or 'grey',
            'sequence': ws.sequence or 0,
            'passed': ws.id in passed_ids,
            'is_current': ws.id == current.id,
        } for ws in all_states]

        # ---- Steps ------------------------------------------------------
        steps = []
        for step in job.step_ids.sorted('sequence'):
            override = job.override_ids.filtered(lambda o: o.node_id.id == step.recipe_node_id.id)
            steps.append({
                'id': step.id,
                'sequence': step.sequence,
                'sequence_display': (step.sequence or 0) // 10,
                'name': step.name or '',
                'kind': step.kind or 'other',
                'kind_label': dict(step._fields['kind'].selection).get(step.kind, ''),
                'state': step.state,
                'assigned_user_id': step.assigned_user_id.id or False,
                'assigned_user_name': step.assigned_user_id.name or '',
                'work_centre_name': step.work_centre_id.name or '',
                'duration_actual': step.duration_actual or 0,
                'duration_expected': step.duration_expected or 0,
                'date_started_iso': fp_format(
                    env, step.date_started, fmt='%Y-%m-%d %H:%M:%S',
                ) if step.date_started else '',
                'instructions': step.instructions or '',
                'thickness_target': step.thickness_target or 0,
                'thickness_uom': step.thickness_uom or '',
                'dwell_time_minutes': step.dwell_time_minutes or 0,
                'bake_setpoint_temp': step.bake_setpoint_temp or 0,
                'requires_signoff': bool(getattr(step, 'requires_signoff', False)),
                'can_start': step.state in ('ready', 'paused') and step.blocker_kind == 'none',
                'blocker_kind': step.blocker_kind,
                'blocker_reason': step.blocker_reason or '',
                'blocker_jump_target_model': step.blocker_jump_target_model or '',
                'blocker_jump_target_id': step.blocker_jump_target_id or 0,
                'override_excluded': bool(override and not override.included),
                'quick_look_prompt_count': len(getattr(step, 'quick_look_prompt_ids', [])),
            })

        # ---- Spec + attachments + chatter -------------------------------
        spec = job.customer_spec_id if 'customer_spec_id' in job._fields else False
        attachments = env['ir.attachment'].search([
            ('res_model', '=', 'fp.job'),
            ('res_id', '=', job.id),
        ], limit=20)
        chatter = job.message_ids.filtered(
            lambda m: m.message_type in ('comment', 'notification')
        ).sorted('date', reverse=True)[:10]

        # ---- Required cert state ----------------------------------------
        required_certs = {
            'needs': list(job._resolve_required_cert_types()) if hasattr(job, '_resolve_required_cert_types') else [],
            'has_draft': bool(getattr(job, '_fp_has_draft_required_certs', lambda: False)()),
        }

        # ---- Active step (the one in_progress) --------------------------
        active = job.step_ids.filtered(lambda s: s.state == 'in_progress')[:1]

        return {
            'ok': True,
            'job': {
                'id': job.id,
                'name': job.name,
                'display_wo_name': job.display_wo_name,
                'partner_name': job.partner_id.name or '',
                'product_name': job.product_id.display_name or '',
                'part_number': job.part_catalog_id.part_number if job.part_catalog_id else '',
                '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(env, job.date_deadline, fmt='%Y-%m-%d') if job.date_deadline else '',
                'state': job.state,
                'workflow_state': {
                    'id': current.id, 'name': current.name, 'color': current.color or 'grey',
                } if current else None,
                'next_milestone_action': job.next_milestone_action or '',
                'next_milestone_label': job.next_milestone_label or '',
                'quality_hold_count': job.quality_hold_count or 0,
                'priority': job.priority or 'normal',
            },
            'workflow_states': workflow_states,
            'steps': steps,
            'active_step_id': active.id if active else False,
            'spec': {
                'id': spec.id, 'name': spec.name,
            } if spec else None,
            'attachments': [
                {'id': a.id, 'name': a.name, 'mimetype': a.mimetype or '', 'url': f'/web/content/{a.id}'}
                for a in attachments
            ],
            'chatter': [
                {
                    'id': m.id,
                    'author': m.author_id.name or 'System',
                    'body': m.body or '',
                    'date': fp_format(env, m.date, fmt='%Y-%m-%d %H:%M') if m.date else '',
                }
                for m in chatter
            ],
            'required_certs': required_certs,
        }
  • Step 4: Register the controller

In fusion_plating/fusion_plating_shopfloor/controllers/__init__.py, add:

from . import workspace_controller
  • Step 5: Verify test passes
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --test-tags fp_shopfloor --stop-after-init

Expected: 2 tests pass.

  • Step 6: Commit
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py \
        fusion_plating/fusion_plating_shopfloor/controllers/__init__.py \
        fusion_plating/fusion_plating_shopfloor/tests/test_workspace_load.py
git commit -m "feat(fusion_plating_shopfloor): /fp/workspace/load endpoint + tests"

Task 1.9 — workspace_controller.py: /fp/workspace/hold endpoint

Files:

  • Modify: fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py

  • Modify: fusion_plating/fusion_plating_shopfloor/tests/test_workspace_load.py (rename to test_workspace_actions.py for clarity OR add to a new file)

  • Step 1: Write the failing test

Create fusion_plating/fusion_plating_shopfloor/tests/test_workspace_actions.py:

# -*- coding: utf-8 -*-
import json

from odoo.tests.common import HttpCase, tagged


@tagged('-at_install', 'post_install', 'fp_shopfloor')
class TestWorkspaceHold(HttpCase):

    def setUp(self):
        super().setUp()
        self.authenticate("admin", "admin")
        self.partner = self.env['res.partner'].create({'name': 'Hold Cust'})
        self.product = self.env['product.product'].create({'name': 'Hold Prod'})
        self.job = self.env['fp.job'].create({
            'name': 'WH/JOB/H001',
            'partner_id': self.partner.id,
            'product_id': self.product.id,
            'qty': 10,
        })

    def test_hold_creates_quality_hold(self):
        payload = {
            "jsonrpc": "2.0",
            "params": {
                "job_id": self.job.id,
                "reason": "dimensional",
                "qty_on_hold": 3,
                "description": "Bracket bent on de-rack",
                "part_ref": "Bracket Rev A",
            },
        }
        res = self.url_open(
            '/fp/workspace/hold',
            data=json.dumps(payload),
            headers={'Content-Type': 'application/json'},
        ).json()['result']
        self.assertTrue(res['ok'])
        hold = self.env['fusion.plating.quality.hold'].browse(res['hold_id'])
        self.assertEqual(hold.qty_on_hold, 3)
        self.assertEqual(hold.hold_reason, 'dimensional')

    def test_hold_with_photo_creates_attachment(self):
        import base64
        png_1px = base64.b64encode(
            b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01'
            b'\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\x00\x01'
            b'\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82'
        ).decode()
        payload = {
            "jsonrpc": "2.0",
            "params": {
                "job_id": self.job.id,
                "reason": "thickness",
                "qty_on_hold": 1,
                "photo_data": png_1px,
                "photo_filename": "evidence.png",
            },
        }
        res = self.url_open(
            '/fp/workspace/hold',
            data=json.dumps(payload),
            headers={'Content-Type': 'application/json'},
        ).json()['result']
        self.assertTrue(res['ok'])
        attachments = self.env['ir.attachment'].search([
            ('res_model', '=', 'fusion.plating.quality.hold'),
            ('res_id', '=', res['hold_id']),
        ])
        self.assertGreaterEqual(len(attachments), 1)
  • Step 2: Verify test fails
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --test-tags fp_shopfloor --stop-after-init

Expected: FAIL — endpoint missing.

  • Step 3: Add the endpoint to workspace_controller.py

Append to FpWorkspaceController class:

    # ----------------------------------------------------------------------
    # Hold create (with photo attachment)
    # ----------------------------------------------------------------------
    @http.route('/fp/workspace/hold', type='jsonrpc', auth='user')
    def hold(self, job_id, reason='other', qty_on_hold=1, description='',
             part_ref='', step_id=None, mark_for_scrap=False,
             photo_data=None, photo_filename=None):
        env = request.env
        job = env['fp.job'].browse(int(job_id))
        if not job.exists():
            return {'ok': False, 'error': f'Job {job_id} not found'}

        try:
            hold_vals = {
                'part_ref': part_ref or '',
                'qty_on_hold': int(qty_on_hold),
                'qty_original': int(job.qty or 0),
                'hold_reason': reason or 'other',
                'description': description or '',
                'mark_for_scrap': bool(mark_for_scrap),
            }
            if 'x_fc_job_id' in env['fusion.plating.quality.hold']._fields:
                hold_vals['x_fc_job_id'] = job.id
            if step_id and 'x_fc_step_id' in env['fusion.plating.quality.hold']._fields:
                hold_vals['x_fc_step_id'] = int(step_id)

            hold = env['fusion.plating.quality.hold'].create(hold_vals)

            # Attach photo if provided (base64 string from the camera input)
            attachment_id = False
            if photo_data:
                try:
                    att = env['ir.attachment'].create({
                        'name': photo_filename or f'hold_{hold.id}.png',
                        'datas': photo_data,
                        'res_model': 'fusion.plating.quality.hold',
                        'res_id': hold.id,
                        'mimetype': 'image/png',
                    })
                    attachment_id = att.id
                except Exception:
                    _logger.exception("Hold photo attach failed for hold %s", hold.id)
                    # Don't fail the whole hold — log and continue without photo

            _logger.info(
                "Hold %s created on job %s by uid %s, reason %s, qty %s",
                hold.name, job.name, env.uid, reason, qty_on_hold,
            )

            return {
                'ok': True,
                'hold_id': hold.id,
                'hold_name': hold.name,
                'state': hold.state,
                'attachment_id': attachment_id,
            }
        except Exception as exc:
            _logger.exception("workspace/hold failed")
            return {'ok': False, 'error': str(exc)}
  • Step 4: Verify test passes
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --test-tags fp_shopfloor --stop-after-init

Expected: 4 tests pass total (2 load + 2 hold).

  • Step 5: Commit
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py \
        fusion_plating/fusion_plating_shopfloor/tests/test_workspace_actions.py
git commit -m "feat(fusion_plating_shopfloor): /fp/workspace/hold endpoint (with photo)"

Task 1.10 — workspace_controller.py: /fp/workspace/sign_off endpoint

Files:

  • Modify: fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py

  • Modify: fusion_plating/fusion_plating_shopfloor/tests/test_workspace_actions.py

  • Step 1: Add test to test_workspace_actions.py

Append to that file:

@tagged('-at_install', 'post_install', 'fp_shopfloor')
class TestWorkspaceSignOff(HttpCase):

    def setUp(self):
        super().setUp()
        self.authenticate("admin", "admin")
        self.partner = self.env['res.partner'].create({'name': 'Sig Cust'})
        self.product = self.env['product.product'].create({'name': 'Sig Prod'})
        self.job = self.env['fp.job'].create({
            'name': 'WH/JOB/S001',
            'partner_id': self.partner.id,
            'product_id': self.product.id,
            'qty': 1,
        })
        self.step = self.env['fp.job.step'].create({
            'job_id': self.job.id,
            'name': 'ENP Plate',
            'sequence': 50,
            'state': 'in_progress',
        })

    def test_sign_off_rejects_empty_signature(self):
        import json
        res = self.url_open(
            '/fp/workspace/sign_off',
            data=json.dumps({"jsonrpc":"2.0","params":{
                "step_id": self.step.id,
                "signature_data_uri": "",
            }}),
            headers={'Content-Type': 'application/json'},
        ).json()['result']
        self.assertFalse(res['ok'])
        self.assertIn('signature', res['error'].lower())

    def test_sign_off_finishes_step(self):
        import json
        sig = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
        res = self.url_open(
            '/fp/workspace/sign_off',
            data=json.dumps({"jsonrpc":"2.0","params":{
                "step_id": self.step.id,
                "signature_data_uri": sig,
            }}),
            headers={'Content-Type': 'application/json'},
        ).json()['result']
        self.assertTrue(res['ok'])
        self.step.invalidate_recordset(['state'])
        self.assertEqual(self.step.state, 'done')
  • Step 2: Verify test fails
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --test-tags fp_shopfloor --stop-after-init

Expected: FAIL — endpoint missing.

  • Step 3: Add the endpoint

Append to FpWorkspaceController:

    # ----------------------------------------------------------------------
    # Sign off + finish step (atomic)
    # ----------------------------------------------------------------------
    @http.route('/fp/workspace/sign_off', type='jsonrpc', auth='user')
    def sign_off(self, step_id, signature_data_uri):
        env = request.env
        sig = (signature_data_uri or '').strip()
        if not sig:
            _logger.warning("workspace/sign_off: empty signature for step %s", step_id)
            return {'ok': False, 'error': 'A signature is required to finish this step.'}

        step = env['fp.job.step'].browse(int(step_id))
        if not step.exists():
            return {'ok': False, 'error': f'Step {step_id} not found'}

        # Attach signature as an ir.attachment on the step
        # Strip "data:...;base64," prefix if present
        if ',' in sig and sig.startswith('data:'):
            sig = sig.split(',', 1)[1]
        try:
            env['ir.attachment'].create({
                'name': f'signature_{step.id}.png',
                'datas': sig,
                'res_model': 'fp.job.step',
                'res_id': step.id,
                'mimetype': 'image/png',
            })
        except Exception:
            _logger.exception("Sign-off attachment failed for step %s", step.id)
            return {'ok': False, 'error': 'Failed to save signature'}

        # Finish the step
        try:
            step.button_finish()
        except Exception as exc:
            _logger.exception("Sign-off step finish failed")
            return {'ok': False, 'error': str(exc)}

        _logger.info("Step %s signed off by uid %s", step.id, env.uid)
        return {
            'ok': True,
            'step_id': step.id,
            'state': step.state,
        }
  • Step 4: Verify + commit
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --test-tags fp_shopfloor --stop-after-init
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py \
        fusion_plating/fusion_plating_shopfloor/tests/test_workspace_actions.py
git commit -m "feat(fusion_plating_shopfloor): /fp/workspace/sign_off endpoint"

Task 1.11 — workspace_controller.py: /fp/workspace/advance_milestone endpoint

Files:

  • Modify: fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py

  • Modify: fusion_plating/fusion_plating_shopfloor/tests/test_workspace_actions.py

  • Step 1: Add test

Append to test_workspace_actions.py:

@tagged('-at_install', 'post_install', 'fp_shopfloor')
class TestWorkspaceAdvanceMilestone(HttpCase):

    def setUp(self):
        super().setUp()
        self.authenticate("admin", "admin")
        self.partner = self.env['res.partner'].create({'name': 'M Cust'})
        self.product = self.env['product.product'].create({'name': 'M Prod'})
        self.job = self.env['fp.job'].create({
            'name': 'WH/JOB/M001',
            'partner_id': self.partner.id,
            'product_id': self.product.id,
            'qty': 1,
        })

    def test_advance_no_action_returns_error(self):
        import json
        res = self.url_open(
            '/fp/workspace/advance_milestone',
            data=json.dumps({"jsonrpc":"2.0","params":{"job_id": self.job.id}}),
            headers={'Content-Type': 'application/json'},
        ).json()['result']
        # Job has no steps → no next_milestone_action → reject
        self.assertFalse(res['ok'])
  • Step 2: Verify test fails
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --test-tags fp_shopfloor --stop-after-init

Expected: FAIL.

  • Step 3: Add the endpoint

Append to FpWorkspaceController:

    # ----------------------------------------------------------------------
    # Milestone advance (mark_done / issue_certs / schedule_delivery / mark_shipped)
    # ----------------------------------------------------------------------
    @http.route('/fp/workspace/advance_milestone', type='jsonrpc', auth='user')
    def advance_milestone(self, job_id):
        env = request.env
        job = env['fp.job'].browse(int(job_id))
        if not job.exists():
            return {'ok': False, 'error': f'Job {job_id} not found'}
        if not job.next_milestone_action:
            return {
                'ok': False,
                'error': 'No milestone advance available — finish all steps first.',
            }
        try:
            job.action_advance_next_milestone()
        except Exception as exc:
            _logger.exception("workspace/advance_milestone failed")
            return {'ok': False, 'error': str(exc)}

        _logger.info(
            "Job %s milestone advanced (action=%s) by uid %s",
            job.name, job.next_milestone_action or '(now next)', env.uid,
        )
        # Re-read after the action so the response reflects the new state
        job.invalidate_recordset(['workflow_state_id', 'next_milestone_action', 'next_milestone_label'])
        return {
            'ok': True,
            '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,
            'next_milestone_action': job.next_milestone_action or '',
            'next_milestone_label': job.next_milestone_label or '',
        }
  • Step 4: Verify + commit
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --test-tags fp_shopfloor --stop-after-init
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py \
        fusion_plating/fusion_plating_shopfloor/tests/test_workspace_actions.py
git commit -m "feat(fusion_plating_shopfloor): /fp/workspace/advance_milestone endpoint"

Task 1.12 — JobWorkspace OWL component: scaffold + header + workflow bar

Files:

  • Create: fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js

  • Create: fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml

  • Create: fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss

  • Modify: fusion_plating/fusion_plating_shopfloor/__manifest__.py (register action + assets)

  • Step 1: Create JobWorkspace JS scaffold

Create fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js:

/** @odoo-module **/
// =============================================================================
// Fusion Plating — Job Workspace (full-screen WO surface)
// Client action: fp_job_workspace
// Opens from: kanban tap (Landing), smart button (fp.job form),
//             QR scan (FP-JOB / FP-STEP), manager dashboard card tap.
// =============================================================================

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 { WorkflowChip } from "./components/workflow_chip";
import { GateViz } from "./components/gate_viz";
import { FpSignaturePad } from "./components/signature_pad";
import { FpHoldComposer } from "./components/hold_composer";

export class FpJobWorkspace extends Component {
    static template = "fusion_plating_shopfloor.JobWorkspace";
    static props = ["*"];
    static components = { WorkflowChip, GateViz };

    setup() {
        this.notification = useService("notification");
        this.action = useService("action");
        this.dialog = useService("dialog");
        this.state = useState({
            data: null,
            loading: false,
            jobId: null,
            focusStepId: null,
        });

        onMounted(async () => {
            this.state.jobId = (this.props.action && this.props.action.params && this.props.action.params.job_id) || null;
            this.state.focusStepId = (this.props.action && this.props.action.params && this.props.action.params.focus_step_id) || null;
            await this.refresh();
            this._refreshInterval = setInterval(() => this.refresh(), 15000);
        });

        onWillUnmount(() => {
            if (this._refreshInterval) clearInterval(this._refreshInterval);
        });
    }

    async refresh() {
        if (!this.state.jobId) return;
        try {
            const res = await rpc("/fp/workspace/load", { job_id: this.state.jobId });
            if (res && res.ok) {
                this.state.data = res;
            } else {
                this.notification.add(res.error || "Failed to load workspace", { type: "danger" });
            }
        } catch (err) {
            this.notification.add(err.message || String(err), { type: "danger" });
        }
    }

    onBack() {
        this.action.doAction({ type: "ir.actions.act_window_close" });
    }

    onAdvanceMilestone() {
        const advance = async () => {
            try {
                const res = await rpc("/fp/workspace/advance_milestone", { job_id: this.state.jobId });
                if (res && res.ok) {
                    this.notification.add("Milestone advanced.", { type: "success" });
                    await this.refresh();
                } else {
                    this.notification.add(res.error || "Advance failed", { type: "warning" });
                }
            } catch (err) {
                this.notification.add(err.message, { type: "danger" });
            }
        };
        advance();
    }

    // (Step actions, hold composer, sign-off — added in Task 1.13/1.14)
}

registry.category("actions").add("fp_job_workspace", FpJobWorkspace);
  • Step 2: Create JobWorkspace template XML (header + workflow bar only — step list comes in Task 1.13)

Create fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml:

<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">

    <t t-name="fusion_plating_shopfloor.JobWorkspace">
        <div class="o_fp_ws">

            <!-- Loading state -->
            <div t-if="!state.data" class="o_fp_ws_loading">
                <i class="fa fa-spinner fa-spin fa-2x"/>
                <div>Loading Job Workspace…</div>
            </div>

            <t t-if="state.data">

                <!-- ===== Sticky header ===== -->
                <header class="o_fp_ws_head">
                    <div class="o_fp_ws_head_l">
                        <button class="btn btn-link o_fp_ws_back" t-on-click="onBack">
                            <i class="fa fa-arrow-left"/> Back
                        </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>
                        <t t-if="state.data.job.part_number">
                            <span class="o_fp_ws_dot"> · </span>
                            <span class="o_fp_ws_part"><t t-esc="state.data.job.part_number"/></span>
                        </t>
                    </div>
                    <div class="o_fp_ws_head_r">
                        <span class="o_fp_ws_pill">
                            <t t-esc="state.data.job.qty_done"/> / <t t-esc="state.data.job.qty"/> done
                            <t t-if="state.data.job.qty_scrapped">· <t t-esc="state.data.job.qty_scrapped"/> scrap</t>
                        </span>
                        <span class="o_fp_ws_pill" t-if="state.data.job.date_deadline">
                            Due <t t-esc="state.data.job.date_deadline"/>
                        </span>
                        <WorkflowChip t-if="state.data.job.workflow_state"
                                      state="state.data.job.workflow_state"/>
                        <span t-att-class="'o_fp_ws_pill ' + (state.data.job.quality_hold_count ? 'o_fp_ws_holds_red' : 'o_fp_ws_holds_ok')">
                            <t t-esc="state.data.job.quality_hold_count"/> holds
                        </span>
                    </div>
                </header>

                <!-- ===== Sticky workflow bar ===== -->
                <div class="o_fp_ws_bar">
                    <div class="o_fp_ws_bar_line">
                        <t t-foreach="state.data.workflow_states" t-as="ws" t-key="ws.id">
                            <div t-att-class="'o_fp_ws_dot_wrap' + (ws.is_current ? ' current' : (ws.passed ? ' done' : ''))">
                                <span class="o_fp_ws_bar_dot"/>
                                <span class="o_fp_ws_bar_label" t-esc="ws.name"/>
                            </div>
                            <span t-if="!ws_last" t-att-class="'o_fp_ws_link' + (ws.passed ? ' done' : '')"/>
                        </t>
                    </div>
                    <button t-if="state.data.job.next_milestone_action"
                            class="btn btn-primary o_fp_ws_next"
                            t-on-click="onAdvanceMilestone">
                        Next: <t t-esc="state.data.job.next_milestone_label"/> <i class="fa fa-arrow-right"/>
                    </button>
                </div>

                <!-- Step list + side panel placeholder — implemented in Task 1.13 -->
                <div class="o_fp_ws_main">
                    <div class="o_fp_ws_steps">
                        <div t-if="!state.data.steps.length" class="o_fp_ws_empty">
                            <i class="fa fa-exclamation-circle"/>
                            <div>Recipe not generated for this WO.</div>
                        </div>
                        <!-- TODO Task 1.13: render step rows -->
                    </div>
                    <div class="o_fp_ws_side">
                        <!-- TODO Task 1.15: side panel -->
                    </div>
                </div>

                <!-- Action rail placeholder — implemented in Task 1.14 -->
                <footer class="o_fp_ws_rail">
                    <!-- TODO Task 1.14: action buttons -->
                </footer>

            </t>
        </div>
    </t>

</templates>
  • Step 3: Create JobWorkspace SCSS (scaffold)

Create fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss:

$o-webclient-color-scheme: bright !default;

$_ws-page-hex:   #f3f4f6;
$_ws-card-hex:   #ffffff;
$_ws-border-hex: #d8dadd;
$_ws-text-hex:   #1d1d1f;

@if $o-webclient-color-scheme == dark {
    $_ws-page-hex:   #1a1d21 !global;
    $_ws-card-hex:   #22262d !global;
    $_ws-border-hex: #424245 !global;
    $_ws-text-hex:   #f5f5f7 !global;
}

.o_fp_ws {
    display: flex;
    flex-direction: column;
    height: 100%;
    background: $_ws-page-hex;
    color: $_ws-text-hex;
    overflow: hidden;
}

.o_fp_ws_loading {
    margin: auto;
    text-align: center;
    color: var(--text-secondary, #666);

    > div { margin-top: 0.6rem; }
}

.o_fp_ws_head {
    background: $_ws-card-hex;
    border-bottom: 1px solid $_ws-border-hex;
    padding: 0.6rem 1rem;
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: 1rem;
    flex-wrap: wrap;
}

.o_fp_ws_head_l { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.o_fp_ws_head_r { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }

.o_fp_ws_back  { padding: 0.25rem 0.5rem; }
.o_fp_ws_wo    { font-weight: 700; font-size: 1.1rem; }
.o_fp_ws_dot   { color: var(--text-secondary, #999); }
.o_fp_ws_cust  { color: var(--text-secondary, #555); }
.o_fp_ws_part  { color: var(--text-secondary, #555); }

.o_fp_ws_pill {
    background: $_ws-page-hex;
    border: 1px solid $_ws-border-hex;
    padding: 0.2rem 0.55rem;
    border-radius: 4px;
    font-size: 0.78rem;
    color: var(--text-secondary, #555);
}

.o_fp_ws_holds_ok  { background: rgba(52,199,89,0.12); color: #1d6e2f; border-color: rgba(52,199,89,0.3); }
.o_fp_ws_holds_red { background: rgba(255,59,48,0.12); color: #b00018; border-color: rgba(255,59,48,0.3); }

.o_fp_ws_bar {
    background: $_ws-page-hex;
    border-bottom: 1px solid $_ws-border-hex;
    padding: 0.55rem 1rem;
    display: flex;
    align-items: center;
    gap: 1rem;
}

.o_fp_ws_bar_line { flex: 1; display: flex; align-items: center; gap: 0.3rem; }

.o_fp_ws_dot_wrap {
    display: flex; flex-direction: column; align-items: center; min-width: 60px;

    .o_fp_ws_bar_dot { width: 14px; height: 14px; border-radius: 50%; background: $_ws-border-hex; border: 2px solid $_ws-border-hex; }
    .o_fp_ws_bar_label { font-size: 0.65rem; color: var(--text-secondary, #888); margin-top: 0.2rem; text-align: center; }

    &.done .o_fp_ws_bar_dot { background: #34c759; border-color: #34c759; }
    &.current .o_fp_ws_bar_dot { background: #0071e3; border-color: #0071e3; box-shadow: 0 0 0 3px rgba(0,113,227,0.25); }
    &.current .o_fp_ws_bar_label { color: #0071e3; font-weight: 600; }
}

.o_fp_ws_link {
    flex: 0 0 8px; height: 2px; background: $_ws-border-hex; margin-top: -16px;
    &.done { background: #34c759; }
}

.o_fp_ws_next { white-space: nowrap; }

.o_fp_ws_main {
    flex: 1;
    display: grid;
    grid-template-columns: 1.7fr 1fr;
    overflow: hidden;

    @media (max-width: 900px) { grid-template-columns: 1fr; }
}

.o_fp_ws_steps { padding: 0.7rem 1rem; overflow-y: auto; border-right: 1px solid $_ws-border-hex; }
.o_fp_ws_side  { padding: 0.7rem 1rem; overflow-y: auto; background: $_ws-page-hex; }

.o_fp_ws_empty {
    text-align: center; padding: 2rem 1rem; color: var(--text-secondary, #999);

    > div { margin-top: 0.5rem; }
}

.o_fp_ws_rail {
    background: $_ws-card-hex;
    border-top: 1px solid $_ws-border-hex;
    padding: 0.55rem 1rem;
    display: flex;
    gap: 0.5rem;
    align-items: center;
    flex-wrap: wrap;
}
  • Step 4: Register the JobWorkspace assets in manifest (both bundles)

Append after KanbanCard lines in BOTH web.assets_backend and web.assets_web_dark:

            # ---- Job Workspace (Phase 1) ----
            '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',
  • Step 5: Update + manually verify
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --stop-after-init

Then open Odoo at http://localhost:8069, run the dev console:

odoo.__WOWL_DEBUG__.root.env.services.action.doAction({
    type: 'ir.actions.client',
    tag: 'fp_job_workspace',
    params: { job_id: <pick any fp.job id> },
    target: 'current',
});

Expected: workspace renders the header strip and workflow bar with the job's milestones; main + footer regions empty (placeholders for next tasks).

  • Step 6: Commit
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js \
        fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml \
        fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss \
        fusion_plating/fusion_plating_shopfloor/__manifest__.py
git commit -m "feat(fusion_plating_shopfloor): JobWorkspace scaffold + header + workflow bar"

Task 1.13 — JobWorkspace: step list rendering with GateViz

Files:

  • Modify: fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js (add step action methods)

  • Modify: fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml (replace step list TODO)

  • Modify: fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss (add step row styles)

  • Step 1: Add step action methods to JobWorkspace JS

Inside FpJobWorkspace class (replace the comment // (Step actions, hold composer, sign-off — added in Task 1.13/1.14) with):

    iconForStepState(state) {
        const map = {
            ready: "○", paused: "⏸", in_progress: "▶",
            done: "✓", skipped: "✕", cancelled: "✕",
        };
        return map[state] || "○";
    }

    isStepActive(step) {
        return step.state === "in_progress";
    }

    async onStartStep(stepId) {
        try {
            const res = await rpc("/fp/shopfloor/start_wo", { workorder_id: stepId });
            if (res && res.ok) {
                this.notification.add("Step started.", { type: "success" });
                await this.refresh();
            } else {
                this.notification.add(res.error || "Start failed", { type: "danger" });
            }
        } catch (err) {
            this.notification.add(err.message, { type: "danger" });
        }
    }

    async onFinishStep(step) {
        if (step.requires_signoff) {
            this.dialog.add(FpSignaturePad, {
                title: `Sign to finish ${step.name}`,
                contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
                onSubmit: async (dataUri) => {
                    try {
                        const res = await rpc("/fp/workspace/sign_off", {
                            step_id: step.id,
                            signature_data_uri: dataUri,
                        });
                        if (res && res.ok) {
                            this.notification.add("Step signed off and finished.", { type: "success" });
                            await this.refresh();
                        } else {
                            this.notification.add(res.error || "Sign-off failed", { type: "danger" });
                        }
                    } catch (err) {
                        this.notification.add(err.message, { type: "danger" });
                    }
                },
            });
            return;
        }
        // Plain finish
        try {
            const res = await rpc("/fp/shopfloor/stop_wo", { workorder_id: step.id, finish: true });
            if (res && res.ok) {
                this.notification.add("Step finished.", { type: "success" });
                await this.refresh();
            } else {
                this.notification.add(res.error || "Finish failed", { type: "danger" });
            }
        } catch (err) {
            this.notification.add(err.message, { type: "danger" });
        }
    }

    onJumpToBlocker({ model, id }) {
        // Predecessor: just scroll into view if same workspace; otherwise open
        const step = this.state.data.steps.find((s) => s.id === id);
        if (step) {
            const el = document.querySelector(`[data-step-id="${id}"]`);
            if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
        } else {
            this.action.doAction({
                type: "ir.actions.act_window",
                res_model: model,
                res_id: id,
                views: [[false, "form"]],
                target: "current",
            });
        }
    }

Also add FpSignaturePad to the static components list. Replace:

    static components = { WorkflowChip, GateViz };

with:

    static components = { WorkflowChip, GateViz, FpSignaturePad };
  • Step 2: Replace the step list TODO in job_workspace.xml

Replace the <!-- TODO Task 1.13: render step rows --> line with:

                        <t t-foreach="state.data.steps" t-as="step" t-key="step.id">
                            <div t-att-class="'o_fp_ws_step ' + step.state +
                                              (isStepActive(step) ? ' active' : '') +
                                              (step.override_excluded ? ' excluded' : '') +
                                              (step.blocker_kind !== 'none' ? ' blocked' : '')"
                                 t-att-data-step-id="step.id">
                                <div class="o_fp_ws_step_l1">
                                    <span class="o_fp_ws_step_icon" t-esc="iconForStepState(step.state)"/>
                                    <span class="o_fp_ws_step_num">Step <t t-esc="step.sequence_display"/></span>
                                    <span class="o_fp_ws_step_name" t-esc="step.name"/>
                                    <span t-if="isStepActive(step)" class="o_fp_ws_step_badge">ACTIVE</span>
                                    <span class="o_fp_ws_step_meta">
                                        <t t-if="step.assigned_user_name"><t t-esc="step.assigned_user_name"/></t>
                                        <t t-if="step.duration_actual"> · <t t-esc="Math.round(step.duration_actual)"/> min</t>
                                    </span>
                                </div>

                                <t t-if="isStepActive(step) or step.blocker_kind !== 'none'">
                                    <div class="o_fp_ws_step_detail">
                                        <div class="o_fp_ws_step_chips" t-if="isStepActive(step)">
                                            <span t-if="step.thickness_target" class="o_fp_chip o_fp_chip_info">
                                                🎯 Thickness <t t-esc="step.thickness_target"/> <t t-esc="step.thickness_uom or 'mils'"/>
                                            </span>
                                            <span t-if="step.dwell_time_minutes" class="o_fp_chip o_fp_chip_info">
                                                ⏱ Dwell <t t-esc="step.dwell_time_minutes"/> min
                                            </span>
                                            <span t-if="step.bake_setpoint_temp" class="o_fp_chip o_fp_chip_warning">
                                                🔥 Bake <t t-esc="step.bake_setpoint_temp"/>°
                                            </span>
                                            <span t-if="step.requires_signoff" class="o_fp_chip o_fp_chip_warning">
                                                ✎ Sign-off required
                                            </span>
                                        </div>

                                        <div t-if="step.instructions and isStepActive(step)" class="o_fp_ws_step_instr">
                                            <t t-esc="step.instructions"/>
                                        </div>

                                        <GateViz t-if="step.blocker_kind !== 'none'"
                                                 canStart="false"
                                                 blockerKind="step.blocker_kind"
                                                 blockerReason="step.blocker_reason"
                                                 jumpTargetModel="step.blocker_jump_target_model"
                                                 jumpTargetId="step.blocker_jump_target_id"
                                                 onJump.bind="onJumpToBlocker"/>

                                        <div class="o_fp_ws_step_actions" t-if="isStepActive(step) and step.blocker_kind === 'none'">
                                            <button t-if="step.requires_signoff"
                                                    class="btn btn-success"
                                                    t-on-click="() => this.onFinishStep(step)">
                                                <i class="fa fa-check"/> Finish &amp; Sign Off
                                            </button>
                                            <button t-else=""
                                                    class="btn btn-success"
                                                    t-on-click="() => this.onFinishStep(step)">
                                                <i class="fa fa-check"/> Finish
                                            </button>
                                        </div>
                                        <div class="o_fp_ws_step_actions"
                                             t-if="step.can_start and !isStepActive(step)">
                                            <button class="btn btn-primary"
                                                    t-on-click="() => this.onStartStep(step.id)">
                                                <i class="fa fa-play"/> Start
                                            </button>
                                        </div>
                                    </div>
                                </t>
                            </div>
                        </t>
  • Step 3: Add step row styles to job_workspace.scss

Append to that file:

.o_fp_ws_step {
    background: $_ws-card-hex;
    border: 1px solid $_ws-border-hex;
    border-radius: 6px;
    padding: 0.5rem 0.7rem;
    margin-bottom: 0.4rem;

    &.done { opacity: 0.7; }
    &.active { border: 2px solid #0071e3; padding: 0.6rem 0.75rem; box-shadow: 0 0 0 1px #0071e3; }
    &.blocked { background: rgba(255,159,10,0.06); border-color: #ff9f0a; }
    &.excluded { opacity: 0.5; }
}

.o_fp_ws_step_l1 {
    display: flex; align-items: center; gap: 0.45rem;
}

.o_fp_ws_step_icon { width: 18px; text-align: center; font-weight: 700; }
.o_fp_ws_step_num  { color: var(--text-secondary, #999); font-size: 0.78rem; min-width: 50px; }
.o_fp_ws_step_name { font-weight: 600; }
.o_fp_ws_step_meta { color: var(--text-secondary, #999); font-size: 0.78rem; margin-left: auto; }
.o_fp_ws_step_badge { background: #0071e3; color: white; padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.65rem; font-weight: 700; letter-spacing: 0.04em; margin-left: 0.4rem; }

.o_fp_ws_step_detail { margin-top: 0.5rem; padding-top: 0.5rem; border-top: 1px dashed $_ws-border-hex; display: flex; flex-direction: column; gap: 0.4rem; }
.o_fp_ws_step_chips { display: flex; gap: 0.3rem; flex-wrap: wrap; }
.o_fp_ws_step_instr { font-size: 0.78rem; color: var(--text-secondary, #555); font-style: italic; }
.o_fp_ws_step_actions { display: flex; gap: 0.35rem; flex-wrap: wrap; }

// Inline chip helpers
.o_fp_chip { padding: 0.15rem 0.45rem; border-radius: 999px; font-size: 0.72rem; }
.o_fp_chip_info { background: rgba(0,113,227,0.12); color: #0050a0; }
.o_fp_chip_warning { background: rgba(255,159,10,0.15); color: #b06600; }
@if $o-webclient-color-scheme == dark {
    .o_fp_chip_info { color: #6cb6ff; }
    .o_fp_chip_warning { color: #ffb84d; }
}
  • Step 4: Update + verify
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --stop-after-init

Hard-refresh the browser, re-invoke the client action with a job that has steps. Expected: all steps render; active step auto-expanded with chips + instructions; blocked steps show GateViz; Start/Finish buttons work.

  • Step 5: Commit
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js \
        fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml \
        fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss
git commit -m "feat(fusion_plating_shopfloor): JobWorkspace step list + GateViz integration"

Task 1.14 — JobWorkspace: Action rail (Hold + Note + Photo + Milestone)

Files:

  • Modify: fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js (add hold + note methods)

  • Modify: fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml (replace action rail TODO)

  • Step 1: Add Hold + Note + Photo methods to JobWorkspace JS

Inside FpJobWorkspace, before the closing } of the class:

    onCreateHold() {
        this.dialog.add(FpHoldComposer, {
            jobId: this.state.jobId,
            defaultQty: (this.state.data.job.qty - this.state.data.job.qty_done) || 1,
            partRef: this.state.data.job.part_number || "",
            onCreated: () => this.refresh(),
        });
    }

    async onAddNote() {
        const text = window.prompt("Add a note to this WO:");
        if (!text) return;
        try {
            // Use Odoo's message_post via ORM call
            await rpc("/web/dataset/call_kw", {
                model: "fp.job",
                method: "message_post",
                args: [[this.state.jobId]],
                kwargs: { body: text, message_type: "comment" },
            });
            this.notification.add("Note added.", { type: "success" });
            await this.refresh();
        } catch (err) {
            this.notification.add(err.message, { type: "danger" });
        }
    }

Also add FpHoldComposer to the components list. Replace:

    static components = { WorkflowChip, GateViz, FpSignaturePad };

with:

    static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer };
  • Step 2: Replace the action rail TODO in job_workspace.xml

Replace <!-- TODO Task 1.14: action buttons --> with:

                    <button class="btn btn-warning" t-on-click="onCreateHold">
                        <i class="fa fa-exclamation-triangle"/> Create Hold
                    </button>
                    <button class="btn btn-light" t-on-click="onAddNote">
                        <i class="fa fa-pencil"/> Note
                    </button>
                    <span style="flex: 1"/>
                    <button t-if="state.data.required_certs.has_draft"
                            class="btn btn-light"
                            t-on-click="onAdvanceMilestone">
                        <i class="fa fa-file-text"/> Issue Cert
                    </button>
                    <button t-if="state.data.job.next_milestone_action"
                            class="btn btn-primary"
                            t-on-click="onAdvanceMilestone">
                        <i class="fa fa-arrow-right"/> <t t-esc="state.data.job.next_milestone_label"/>
                    </button>
  • Step 3: Update + verify
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --stop-after-init

Open a workspace, hit Create Hold → composer opens → fill form → submit → hold appears in count badge after refresh.

  • Step 4: Commit
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js \
        fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml
git commit -m "feat(fusion_plating_shopfloor): JobWorkspace action rail (Hold + Note + Milestone)"

Task 1.15 — JobWorkspace: side panel (spec + attachments + chatter)

Files:

  • Modify: fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml (replace side panel TODO)

  • Modify: fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss (side panel styles)

  • Step 1: Replace side panel TODO in job_workspace.xml

Replace <!-- TODO Task 1.15: side panel --> with:

                        <div class="o_fp_ws_side_card" t-if="state.data.spec">
                            <h4>Customer spec</h4>
                            <div class="o_fp_ws_spec_link">
                                <i class="fa fa-file-pdf-o"/>
                                <a t-att-href="'/web/content/' + state.data.spec.id"
                                   target="_blank"><t t-esc="state.data.spec.name"/></a>
                            </div>
                        </div>
                        <div class="o_fp_ws_side_card" t-if="state.data.attachments.length">
                            <h4>Drawings &amp; attachments</h4>
                            <t t-foreach="state.data.attachments" t-as="att" t-key="att.id">
                                <div class="o_fp_ws_attach">
                                    <span>📄 <t t-esc="att.name"/></span>
                                    <a t-att-href="att.url" target="_blank">Open</a>
                                </div>
                            </t>
                        </div>
                        <div class="o_fp_ws_side_card">
                            <h4>Notes</h4>
                            <div t-if="!state.data.chatter.length" class="o_fp_ws_empty_small">
                                No notes yet.
                            </div>
                            <t t-foreach="state.data.chatter" t-as="msg" t-key="msg.id">
                                <div class="o_fp_ws_note">
                                    <div class="o_fp_ws_note_h">
                                        <span class="author"><t t-esc="msg.author"/></span>
                                        <span class="time"><t t-esc="msg.date"/></span>
                                    </div>
                                    <div class="body" t-out="msg.body"/>
                                </div>
                            </t>
                        </div>
  • Step 2: Add side panel styles to job_workspace.scss

Append:

.o_fp_ws_side_card {
    background: $_ws-card-hex;
    border: 1px solid $_ws-border-hex;
    border-radius: 6px;
    padding: 0.55rem 0.7rem;
    margin-bottom: 0.45rem;

    h4 { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-secondary, #777); margin-bottom: 0.35rem; }
}
.o_fp_ws_spec_link { display: flex; gap: 0.4rem; align-items: center; font-size: 0.82rem; }
.o_fp_ws_attach { display: flex; justify-content: space-between; padding: 0.2rem 0; font-size: 0.78rem; border-bottom: 1px dashed $_ws-border-hex;
    &:last-child { border-bottom: none; }
}
.o_fp_ws_note { font-size: 0.78rem; padding: 0.3rem 0; }
.o_fp_ws_note_h { display: flex; gap: 0.4rem; font-size: 0.72rem; color: var(--text-secondary, #777); }
.o_fp_ws_note .author { font-weight: 600; }
.o_fp_ws_note .body { color: var(--text-secondary, #555); margin-top: 0.15rem; }
.o_fp_ws_empty_small { color: var(--text-secondary, #999); font-size: 0.75rem; font-style: italic; }
  • Step 3: Update + verify
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --stop-after-init

Open a workspace; verify side panel shows spec link (if set), attachments (if any), and chatter.

  • Step 4: Commit
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml \
        fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss
git commit -m "feat(fusion_plating_shopfloor): JobWorkspace side panel (spec + attachments + chatter)"

Task 1.16 — Add "Open Workspace" smart button on fp.job form

Files:

  • Modify: fusion_plating/fusion_plating_jobs/models/fp_job.py (action method)

  • Modify: fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml (button)

  • Step 1: Add the Python action method

In fusion_plating/fusion_plating_jobs/models/fp_job.py, add this method to the FpJob class (after existing methods):

    def action_open_workspace(self):
        """Open the JobWorkspace OWL client action focused on this job."""
        self.ensure_one()
        return {
            'type': 'ir.actions.client',
            'tag': 'fp_job_workspace',
            'name': self.display_wo_name or self.name,
            'params': {'job_id': self.id},
            'target': 'current',
        }
  • Step 2: Add the header button to the form view

In fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml, add inside the <xpath expr="//header" position="inside"> block (after the Process Tree button):

                <button name="action_open_workspace" type="object"
                        string="Open Workspace"
                        class="btn-primary"
                        icon="fa-tablet"
                        invisible="state == 'draft'"/>
  • Step 3: Update + verify
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_jobs --stop-after-init

Open any non-draft fp.job form → click "Open Workspace" → JobWorkspace opens.

  • Step 4: Commit
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_jobs/models/fp_job.py \
        fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml
git commit -m "feat(fusion_plating_jobs): Open Workspace smart button on fp.job form"

Task 1.17 — Phase 1 manifest + version finalization

Files:

  • Modify: fusion_plating/fusion_plating_jobs/__manifest__.py

  • Step 1: Bump fusion_plating_jobs version

Change:

'version': '19.0.10.18.0',

to:

'version': '19.0.10.19.0',
  • Step 2: Bump fusion_plating_shopfloor version (if not already done)

Verify 'version': '19.0.27.0.0', in fusion_plating_shopfloor/__manifest__.py.

  • Step 3: Full Phase 1 integration smoke test
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_jobs,fusion_plating_shopfloor --stop-after-init
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fp_jobs,fp_shopfloor --stop-after-init

Expected: all Phase 1 tests pass.

Manual smoke: open Odoo, navigate to any active fp.job, click Open Workspace, exercise: tap step → see chips/instructions → Finish on a regular step → Finish & Sign Off on a sign-off step → Create Hold → Advance Milestone.

  • Step 4: Commit + tag Phase 1 complete
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_jobs/__manifest__.py
git commit -m "chore(fusion_plating): bump versions for Phase 1 — Workspace foundation"
git tag phase1-workspace-foundation

Phase 2 — Auto-pause cron + ACL lift + supporting computes

Goal: Add the auto-pause cron (fixes 411-hour ghost timer), late_risk_ratio + active_step_id computes (used by Manager At-Risk + Workspace focus), long_running opt-out on recipe nodes, and lift ACLs so technicians don't hit permission walls during the daily flow.


Task 2.1 — Add long_running Boolean to fusion.plating.process.node

Files:

  • Modify: fusion_plating/fusion_plating/models/fp_process_node.py

  • Step 1: Add the field

In fp_process_node.py, on the FpProcessNode class, add:

    long_running = fields.Boolean(
        string='Long-running step',
        default=False,
        help='When True, steps generated from this recipe node are exempt '
             'from the auto-pause cron (used for 24h bakes, multi-shift '
             'long soaks, etc.).',
    )
  • Step 2: Surface in the process node form view

In whichever view defines the process node form, add the field somewhere appropriate (e.g. next to a "Skippable / Sequential" toggle group). If the file is fusion_plating/views/fp_process_node_views.xml, add inside the appropriate <group>:

<field name="long_running"/>

If you can't locate the form view definition file via grep -rn 'fp_process_node\|FpProcessNode' fusion_plating/views/, defer the view update (manager can still tick the flag via dev mode form). Document in commit message.

  • Step 3: Update + commit
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating --stop-after-init
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating/models/fp_process_node.py \
        fusion_plating/fusion_plating/views/fp_process_node_views.xml  # if updated
git commit -m "feat(fusion_plating): long_running flag on process node (auto-pause opt-out)"

Task 2.2 — Add late_risk_ratio compute on fp.job

Files:

  • Modify: fusion_plating/fusion_plating_jobs/models/fp_job.py

  • Create: fusion_plating/fusion_plating_jobs/tests/test_late_risk_ratio.py

  • Step 1: Write the failing test

# fusion_plating/fusion_plating_jobs/tests/test_late_risk_ratio.py
# -*- coding: utf-8 -*-
from datetime import datetime, timedelta

from odoo.tests.common import TransactionCase, tagged


@tagged('-at_install', 'post_install', 'fp_jobs')
class TestLateRiskRatio(TransactionCase):

    def setUp(self):
        super().setUp()
        self.partner = self.env['res.partner'].create({'name': 'LR'})
        self.product = self.env['product.product'].create({'name': 'LR'})

    def _make_job(self, deadline=None):
        return self.env['fp.job'].create({
            'name': 'WH/JOB/LR',
            'partner_id': self.partner.id,
            'product_id': self.product.id,
            'qty': 1,
            'date_deadline': deadline,
        })

    def test_no_deadline_zero(self):
        job = self._make_job(deadline=False)
        self.assertEqual(job.late_risk_ratio, 0.0)

    def test_no_open_steps_zero(self):
        job = self._make_job(deadline=datetime.now() + timedelta(hours=8))
        # No steps → nothing remaining → no risk
        self.assertEqual(job.late_risk_ratio, 0.0)

    def test_ratio_above_one_when_overrun(self):
        job = self._make_job(deadline=datetime.now() + timedelta(hours=2))
        # 1 step planned for 240 min, only 120 min left → ratio = 2.0
        self.env['fp.job.step'].create({
            'job_id': job.id,
            'name': 'Long',
            'sequence': 10,
            'state': 'ready',
            'duration_expected': 240,
        })
        job.invalidate_recordset(['late_risk_ratio'])
        self.assertGreaterEqual(job.late_risk_ratio, 1.5)
  • Step 2: Verify test fails
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_jobs --test-tags fp_jobs --stop-after-init
  • Step 3: Implement the compute

In fp_job.py, add to the FpJob class:

    late_risk_ratio = fields.Float(
        compute='_compute_late_risk_ratio',
        store=True,
        string='Late-risk Ratio',
        help='remaining_planned_minutes / minutes_to_deadline. '
             '>1.0 means the job will be late if nothing changes. '
             'Drives the At-Risk view on the manager dashboard.',
    )

    @api.depends('step_ids.state', 'step_ids.duration_expected', 'date_deadline')
    def _compute_late_risk_ratio(self):
        from datetime import datetime
        for job in self:
            if not job.date_deadline:
                job.late_risk_ratio = 0.0
                continue
            open_steps = job.step_ids.filtered(
                lambda s: s.state not in ('done', 'skipped', 'cancelled')
            )
            remaining_planned = sum(open_steps.mapped('duration_expected') or [0])
            if remaining_planned <= 0:
                job.late_risk_ratio = 0.0
                continue
            now = datetime.now()
            # date_deadline is naive UTC; compare directly
            minutes_to_deadline = max(
                1.0,
                (job.date_deadline - now).total_seconds() / 60.0,
            )
            job.late_risk_ratio = remaining_planned / minutes_to_deadline
  • Step 4: Verify + commit
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_jobs --test-tags fp_jobs --stop-after-init
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_jobs/models/fp_job.py \
        fusion_plating/fusion_plating_jobs/tests/test_late_risk_ratio.py
git commit -m "feat(fusion_plating_jobs): fp.job.late_risk_ratio compute"

Task 2.3 — Add active_step_id compute on fp.job

Files:

  • Modify: fusion_plating/fusion_plating_jobs/models/fp_job.py

  • Create: fusion_plating/fusion_plating_jobs/tests/test_active_step_id.py

  • Step 1: Write the failing test

# fusion_plating/fusion_plating_jobs/tests/test_active_step_id.py
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged


@tagged('-at_install', 'post_install', 'fp_jobs')
class TestActiveStepId(TransactionCase):

    def setUp(self):
        super().setUp()
        self.partner = self.env['res.partner'].create({'name': 'AS'})
        self.product = self.env['product.product'].create({'name': 'AS'})
        self.job = self.env['fp.job'].create({
            'name': 'WH/JOB/AS', 'partner_id': self.partner.id,
            'product_id': self.product.id, 'qty': 1,
        })

    def test_no_active_step(self):
        self.env['fp.job.step'].create({
            'job_id': self.job.id, 'name': 'S1', 'sequence': 10, 'state': 'ready',
        })
        self.job.invalidate_recordset(['active_step_id'])
        self.assertFalse(self.job.active_step_id.id)

    def test_single_in_progress_step(self):
        s = self.env['fp.job.step'].create({
            'job_id': self.job.id, 'name': 'S1', 'sequence': 10, 'state': 'in_progress',
        })
        self.job.invalidate_recordset(['active_step_id'])
        self.assertEqual(self.job.active_step_id.id, s.id)

    def test_multiple_in_progress_picks_lowest_sequence(self):
        s1 = self.env['fp.job.step'].create({
            'job_id': self.job.id, 'name': 'S1', 'sequence': 10, 'state': 'in_progress',
        })
        self.env['fp.job.step'].create({
            'job_id': self.job.id, 'name': 'S2', 'sequence': 20, 'state': 'in_progress',
        })
        self.job.invalidate_recordset(['active_step_id'])
        self.assertEqual(self.job.active_step_id.id, s1.id)
  • Step 2: Verify test fails
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_jobs --test-tags fp_jobs --stop-after-init
  • Step 3: Implement

In fp_job.py, add:

    active_step_id = fields.Many2one(
        'fp.job.step',
        compute='_compute_active_step_id',
        string='Active Step',
        help='Currently in-progress step (lowest sequence if multiple — '
             'shouldn\'t happen but defensive). Drives Workspace landing focus.',
    )

    @api.depends('step_ids.state', 'step_ids.sequence')
    def _compute_active_step_id(self):
        for job in self:
            active = job.step_ids.filtered(lambda s: s.state == 'in_progress').sorted('sequence')
            job.active_step_id = active[:1].id if active else False
  • Step 4: Verify + commit
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_jobs --test-tags fp_jobs --stop-after-init
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_jobs/models/fp_job.py \
        fusion_plating/fusion_plating_jobs/tests/test_active_step_id.py
git commit -m "feat(fusion_plating_jobs): fp.job.active_step_id compute"

Task 2.4 — Auto-pause cron method _cron_autopause_stale_steps

Files:

  • Modify: fusion_plating/fusion_plating_jobs/models/fp_job_step.py

  • Create: fusion_plating/fusion_plating_jobs/tests/test_autopause_cron.py

  • Step 1: Write the failing test

# fusion_plating/fusion_plating_jobs/tests/test_autopause_cron.py
# -*- coding: utf-8 -*-
from datetime import datetime, timedelta
from unittest.mock import patch

from odoo.tests.common import TransactionCase, tagged


@tagged('-at_install', 'post_install', 'fp_jobs')
class TestAutopauseCron(TransactionCase):

    def setUp(self):
        super().setUp()
        self.partner = self.env['res.partner'].create({'name': 'AP'})
        self.product = self.env['product.product'].create({'name': 'AP'})
        self.job = self.env['fp.job'].create({
            'name': 'WH/JOB/AP',
            'partner_id': self.partner.id,
            'product_id': self.product.id,
            'qty': 1,
        })

    def test_stale_step_flips_to_paused(self):
        step = self.env['fp.job.step'].create({
            'job_id': self.job.id,
            'name': 'S1',
            'sequence': 10,
            'state': 'in_progress',
            'date_started': datetime.now() - timedelta(hours=10),
        })
        self.env['fp.job.step']._cron_autopause_stale_steps()
        step.invalidate_recordset(['state'])
        self.assertEqual(step.state, 'paused')

    def test_fresh_step_unchanged(self):
        step = self.env['fp.job.step'].create({
            'job_id': self.job.id,
            'name': 'S2',
            'sequence': 10,
            'state': 'in_progress',
            'date_started': datetime.now() - timedelta(hours=2),
        })
        self.env['fp.job.step']._cron_autopause_stale_steps()
        step.invalidate_recordset(['state'])
        self.assertEqual(step.state, 'in_progress')

    def test_long_running_step_exempt(self):
        # Create a recipe node flagged long_running, link a step to it
        node = self.env['fusion.plating.process.node'].create({
            'name': 'Long bake',
            'long_running': True,
        })
        step = self.env['fp.job.step'].create({
            'job_id': self.job.id,
            'name': 'S3',
            'sequence': 10,
            'state': 'in_progress',
            'date_started': datetime.now() - timedelta(hours=20),
            'recipe_node_id': node.id,
        })
        self.env['fp.job.step']._cron_autopause_stale_steps()
        step.invalidate_recordset(['state'])
        self.assertEqual(step.state, 'in_progress')
  • Step 2: Verify test fails
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_jobs --test-tags fp_jobs --stop-after-init
  • Step 3: Implement the cron method

At the top of fp_job_step.py, ensure imports include:

from datetime import timedelta
from markupsafe import Markup
from odoo import _, api, fields, models
import logging
_logger = logging.getLogger(__name__)

Then add to the class:

    @api.model
    def _cron_autopause_stale_steps(self):
        """Flip in_progress steps idle > threshold to paused, with chatter audit.
        Recipes can opt out per node via recipe_node_id.long_running.

        Threshold from ir.config_parameter `fp.shopfloor.autopause_threshold_hours`
        (default 8). Skipped silently for nodes with long_running=True.
        """
        threshold = float(
            self.env['ir.config_parameter'].sudo()
            .get_param('fp.shopfloor.autopause_threshold_hours', 8)
        )
        deadline = fields.Datetime.now() - timedelta(hours=threshold)
        domain = [
            ('state', '=', 'in_progress'),
            ('date_started', '<', deadline),
            '|',
                ('recipe_node_id', '=', False),
                ('recipe_node_id.long_running', '=', False),
        ]
        stale = self.search(domain)
        for step in stale:
            try:
                step.button_pause()
                step.message_post(body=Markup(
                    "<b>Auto-paused</b> after %.1fh idle. "
                    "Resume from the tablet when work continues."
                ) % threshold)
                _logger.info(
                    "Auto-paused step %s (%s) after %.1fh idle",
                    step.id, step.name, threshold,
                )
            except Exception:
                _logger.exception(
                    "Auto-pause failed for step %s — skipping", step.id,
                )
        return len(stale)

If button_pause doesn't exist on the step model yet, add a minimal implementation in the same file:

    def button_pause(self):
        for step in self:
            if step.state == 'in_progress':
                step.state = 'paused'
        return True
  • Step 4: Verify + commit
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_jobs --test-tags fp_jobs --stop-after-init
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_jobs/models/fp_job_step.py \
        fusion_plating/fusion_plating_jobs/tests/test_autopause_cron.py
git commit -m "feat(fusion_plating_jobs): _cron_autopause_stale_steps method"

Task 2.5 — Register the auto-pause cron in data XML

Files:

  • Modify: fusion_plating/fusion_plating_jobs/data/fp_cron_data.xml

  • Step 1: Add the cron record

Append (inside <odoo></odoo>):

    <record id="ir_cron_autopause_stale_steps" model="ir.cron">
        <field name="name">FP Jobs: auto-pause stale in-progress steps</field>
        <field name="model_id" ref="fusion_plating.model_fp_job_step"/>
        <field name="state">code</field>
        <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>
  • Step 2: Update + commit
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_jobs --stop-after-init

Verify in Odoo UI: Settings → Technical → Scheduled Actions → search "auto-pause" → see the new cron, next-call ~30 min out.

cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_jobs/data/fp_cron_data.xml
git commit -m "feat(fusion_plating_jobs): register auto-pause stale steps cron (30 min)"

Task 2.6 — ACL lift for operator group

Files:

  • Modify: fusion_plating/fusion_plating_certificates/security/ir.model.access.csv

  • Modify: fusion_plating/fusion_plating_shopfloor/security/ir.model.access.csv

  • Modify: fusion_plating/fusion_plating_certificates/__manifest__.py (version bump)

  • Step 1: Extend operator perms on certificate and thickness reading

In fusion_plating/fusion_plating_certificates/security/ir.model.access.csv, change the operator lines:

access_fp_certificate_operator,fp.certificate.operator,model_fp_certificate,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_thickness_reading_operator,fp.thickness.reading.operator,model_fp_thickness_reading,fusion_plating.group_fusion_plating_operator,1,0,0,0

to:

access_fp_certificate_operator,fp.certificate.operator,model_fp_certificate,fusion_plating.group_fusion_plating_operator,1,1,0,0
access_fp_thickness_reading_operator,fp.thickness.reading.operator,model_fp_thickness_reading,fusion_plating.group_fusion_plating_operator,1,1,1,0

(Cert: add write. Thickness: add write + create.)

Bump fusion_plating_certificates version (whatever it is + .1).

  • Step 2: Add operator read on fp.job.node.override

In fusion_plating/fusion_plating_shopfloor/security/ir.model.access.csv, append (the access csv lives in shopfloor since shopfloor is the consumer):

access_fp_job_node_override_operator,fp.job.node.override.operator,fusion_plating_jobs.model_fp_job_node_override,fusion_plating.group_fusion_plating_operator,1,0,0,0
  • Step 3: Update + verify
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_certificates,fusion_plating_shopfloor --stop-after-init

In Odoo, log in as an operator-only user (or impersonate). Verify they can:

  • Read a fp.certificate (already worked)

  • Edit a fp.certificate (NEW)

  • Read/Create a fp.thickness.reading (NEW)

  • Read a fp.job.node.override (NEW)

  • CANNOT write/unlink overrides (rejected)

  • Step 4: Commit

cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_certificates/security/ir.model.access.csv \
        fusion_plating/fusion_plating_certificates/__manifest__.py \
        fusion_plating/fusion_plating_shopfloor/security/ir.model.access.csv
git commit -m "feat(fusion_plating): lift operator ACL for cert write + thickness create + override read"

Task 2.7 — Phase 2 wrap-up — version bumps + smoke test

Files:

  • Modify: fusion_plating/fusion_plating/__manifest__.py

  • Modify: fusion_plating/fusion_plating_jobs/__manifest__.py

  • Modify: fusion_plating/fusion_plating_shopfloor/__manifest__.py

  • Step 1: Bump versions

  • fusion_plating: 19.0.20.6.2 → 19.0.20.7.0

  • fusion_plating_jobs: 19.0.10.19.0 → 19.0.10.20.0

  • fusion_plating_shopfloor: 19.0.27.0.0 → 19.0.27.1.0

  • Step 2: Smoke test

docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating,fusion_plating_jobs,fusion_plating_certificates,fusion_plating_shopfloor --stop-after-init
docker exec odoo-modsdev-app odoo -d modsdev --test-tags fp_jobs,fp_shopfloor --stop-after-init

All tests green.

Manually trigger the cron: in Odoo UI → Technical → Scheduled Actions → "FP Jobs: auto-pause stale in-progress steps" → Run Manually. Check log for Auto-paused step ... lines.

  • Step 3: Commit + tag Phase 2 complete
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating/__manifest__.py \
        fusion_plating/fusion_plating_jobs/__manifest__.py \
        fusion_plating/fusion_plating_shopfloor/__manifest__.py
git commit -m "chore(fusion_plating): bump versions for Phase 2 — cron + ACL lift"
git tag phase2-cron-and-acl

Phase 3 — Landing Refactor

Goal: Replace fp_shopfloor_tablet and fp_plant_overview with a unified fp_shopfloor_landing (station-scoped kanban + All Plant toggle). Tap card → JobWorkspace (already shipped in Phase 1).


Task 3.1 — landing_controller.py: /fp/landing/kanban endpoint

Files:

  • Create: fusion_plating/fusion_plating_shopfloor/controllers/landing_controller.py

  • Modify: fusion_plating/fusion_plating_shopfloor/controllers/__init__.py

  • Create: fusion_plating/fusion_plating_shopfloor/tests/test_landing_kanban.py

  • Step 1: Write the failing test

# fusion_plating/fusion_plating_shopfloor/tests/test_landing_kanban.py
# -*- coding: utf-8 -*-
import json

from odoo.tests.common import HttpCase, tagged


@tagged('-at_install', 'post_install', 'fp_shopfloor')
class TestLandingKanban(HttpCase):

    def setUp(self):
        super().setUp()
        self.authenticate("admin", "admin")

    def test_all_plant_mode_returns_columns(self):
        res = self.url_open(
            '/fp/landing/kanban',
            data=json.dumps({"jsonrpc":"2.0","params":{"mode":"all_plant"}}),
            headers={'Content-Type': 'application/json'},
        ).json()['result']
        self.assertTrue(res['ok'])
        self.assertIn('columns', res)
        self.assertIn('kpis', res)
        # KPIs expected: ready, running, bakes_due, holds
        self.assertIn('ready', res['kpis'])
  • Step 2: Verify test fails

  • Step 3: Create landing_controller.py

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
"""JSON-RPC endpoint for the Shop Floor Landing kanban.

Two modes: 'station' (paired-station scope + Unassigned + next 1-2 WCs in
recipe flow) and 'all_plant' (every active WC, recipe-flow order).
"""

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

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

        # ---- Determine which work centres to show ----------------------
        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:
            # Station mode: this WC + Unassigned + next 1-2 WCs by sequence
            this_wc = station.work_center_id
            after = all_wcs.filtered(lambda w: w.sequence > this_wc.sequence)[:2]
            relevant_wcs = this_wc | after
        else:
            relevant_wcs = all_wcs

        # ---- Pull active steps -----------------------------------------
        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:
            # Include unassigned (work_centre_id = False) in station mode too
            step_dom += ['|',
                ('work_centre_id', 'in', relevant_wcs.ids),
                ('work_centre_id', '=', False),
            ]

        if search:
            # Cheap text filter — operators search by WO # / customer / part
            search_l = search.strip().lower()
            all_steps = Step.search(step_dom, order='sequence, id')
            steps = all_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 '').lower()
            )
        else:
            steps = Step.search(step_dom, order='sequence, id')

        # ---- Group into columns ----------------------------------------
        cards_by_wc = {0: []}  # 0 = Unassigned
        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 (tech-relevant: Ready/Running/BakesDue/Holds) ---------
        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'))])

        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,
            },
            '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."""
        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 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 '',
        }
  • Step 4: Register the controller

In fusion_plating/fusion_plating_shopfloor/controllers/__init__.py, add:

from . import landing_controller
  • Step 5: Verify + commit
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating_shopfloor --test-tags fp_shopfloor --stop-after-init
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating_shopfloor/controllers/landing_controller.py \
        fusion_plating/fusion_plating_shopfloor/controllers/__init__.py \
        fusion_plating/fusion_plating_shopfloor/tests/test_landing_kanban.py
git commit -m "feat(fusion_plating_shopfloor): /fp/landing/kanban endpoint (station + all-plant)"

Task 3.23.6 — ShopfloorLanding OWL component, menu rewire, deprecation stubs

NOTE for executing agent: Tasks 3.23.6 follow the same TDD/incremental pattern as Task 1.121.16. Total ~5 tasks. Each ships its own commit. Reference fp_plant_overview.xml and plant_overview.js for drag-drop pattern (port the column/card DnD handlers verbatim into ShopfloorLanding). Reference shopfloor_tablet.js for QR scan integration and station pairing. The tap-card behavior is action.doAction({tag:'fp_job_workspace', params:{job_id, focus_step_id}}) — already smoke-tested in Task 1.12.

Each task in this group:

  • 3.2fp_shopfloor_landing component scaffold (header + KPI strip + station picker + mode toggle); calls /fp/landing/kanban, renders columns of KanbanCards; auto-refresh 15s; localStorage station + mode persistence.
  • 3.3 — Drag-and-drop port from plant_overview.js (onDragStart, onDrop, /fp/shopfloor/plant_overview/move_card reuse).
  • 3.4 — QR scan integration (existing QrScanner component, dispatch via existing /fp/shopfloor/scan).
  • 3.5 — Register the new client action; update fp_menu.xml to point the existing "Tablet Station" menu item at fp_shopfloor_landing instead of fp_shopfloor_tablet; hide the Plant Overview menu item (active=False on the action record).
  • 3.6 — Stub deprecated endpoints in shopfloor_controller.py:
    • tablet_overview → internally calls FpLandingController().kanban(mode='station', station_id=station_id) and reshapes the response so the old client (if any cached) still works.
    • plant_overview → internally calls FpLandingController().kanban(mode='all_plant', station_id=None).
    • queue → return {'ok': False, 'error': 'deprecated — use /fp/landing/kanban'}.

Bump: fusion_plating_shopfloor to 19.0.28.0.0 at end of Phase 3. Tag phase3-landing.


Phase 4 — Manager Dashboard refactor

Goal: Add 3 new tabs (Workflow Funnel, Approval Inbox, At-Risk) to fp_manager_dashboard, alongside the existing 3-column Plant Board (now one of four tabs). Add 2 new KPI tiles. Add bottleneck_score + avg_wait_minutes computes on fp.work.centre.


Task 4.1 — bottleneck_score + avg_wait_minutes computes on fp.work.centre

Files:

  • Modify: fusion_plating/fusion_plating/models/fp_work_centre.py

  • Step 1: Add the computes

    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.',
    )
    avg_wait_minutes = fields.Float(
        compute='_compute_bottleneck',
        string='Avg Wait (min)',
        help='Average minutes steps wait 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 and there's an
            # implied "ready since" we can approximate as create_date.
            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
  • Step 2: Update + commit
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_plating --stop-after-init
cd /k/Github/Odoo-Modules
git add fusion_plating/fusion_plating/models/fp_work_centre.py
git commit -m "feat(fusion_plating): fp.work.centre bottleneck_score + avg_wait_minutes"

Tasks 4.24.10 — Manager controller endpoints + dashboard tab refactor

NOTE for executing agent: Each task is a self-contained backend endpoint OR a self-contained UI tab. Pattern is the same as Phase 1 (TDD where it makes sense for endpoints; UI tasks ship per-tab commits). The structural change to the manager dashboard XML is extracting the existing 3-column layout into a <t t-name="...PlantBoardTab"> sub-template, adding a tab nav strip, and creating sibling sub-templates for WorkflowFunnelTab, ApprovalInboxTab, AtRiskTab. The JS adds state.activeTab and per-tab loadX() methods.

  • 4.2/fp/manager/funnel endpoint + test_manager_funnel.py (groups jobs by workflow_state_id, returns counts + top 5 cards per stage).
  • 4.3/fp/manager/approval_inbox endpoint + test_manager_inbox.py (4 buckets: holds awaiting release, draft required certs, recent scrap, override requests).
  • 4.4/fp/manager/at_risk endpoint + test_manager_at_risk.py (top 20 by late_risk_ratio desc; hold reasons grouped; bottleneck list from bottleneck_score).
  • 4.5 — Manager dashboard XML refactor: tab navigation strip, 4 sub-templates.
  • 4.6 — WorkflowFunnelTab template + data wiring + click-to-WorkspaceOpen.
  • 4.7 — ApprovalInboxTab template + per-row action buttons (Release Hold via existing model method; Issue Cert via existing wizard; Acknowledge Scrap = just chatter post).
  • 4.8 — AtRiskTab template (3 sub-panels: Trending Late, Hold Reasons, Bottleneck).
  • 4.9 — Move existing Plant Board JS/XML into PlantBoardTab sub-template (no behavior change, just relocation).
  • 4.10 — Add 2 new KPI tiles in the strip: Pending Cert (jobs all_steps_terminal=True AND _fp_has_draft_required_certs()), At-Risk (count of jobs with late_risk_ratio > 1.0). Conditional red when > 0.

Bump: fusion_plating_shopfloor to 19.0.29.0.0, fusion_plating to 19.0.20.8.0. Tag phase4-manager-refactor.


Phase 5 — Cleanup

Goal: Remove deprecated endpoint stubs, retire fp_plant_overview from menus, update README + CLAUDE.md.

Task 5.1 — Remove deprecated endpoint stubs

  • In shopfloor_controller.py, delete the tablet_overview, plant_overview, and queue endpoints (Phase 3 stubs). Audit git log for any callers outside this module — if any, leave the stub one more release.
  • Run smoke test.
  • Commit: chore(fusion_plating_shopfloor): remove Phase 3 deprecation stubs.

Task 5.2 — Remove old menu entries

  • In fp_menu.xml, delete the menu item that referenced fp_shopfloor_tablet action (now points at fp_shopfloor_landing; the rename was done in Phase 3 — this step removes any leftover xmlids).
  • Same for fp_plant_overview.
  • Update + commit.

Task 5.3 — Update CLAUDE.md + README

  • In fusion_plating/CLAUDE.md, add a "Shop Floor surfaces" section near "Module Structure" documenting the three client actions + the shared OWL services + when to use each. Reference the spec.
  • In fusion_plating_shopfloor/README.md, replace the "Tablet client" section with the new architecture overview.
  • Commit.

Task 5.4 — Final tag

  • git tag phase5-cleanup-complete
  • Push tags.

Self-Review

Reviewed against spec 2026-05-22-shopfloor-tablet-redesign-design.md:

Spec coverage:

  • §4 Terminology — display_wo_name (Task 1.1) ✓; "Up Next" / "Embrittlement Bakes" / etc. are XML string changes, called out implicitly via the Landing/Manager refactors (Tasks 3.23.6, 4.5).
  • §5.1 Shared services — Tasks 1.3 (WorkflowChip), 1.4 (GateViz), 1.5 (SignaturePad), 1.6 (HoldComposer), 1.7 (KanbanCard) ✓
  • §5.2 Landing — Tasks 3.13.6 ✓
  • §5.3 Workspace — Tasks 1.81.16 ✓
  • §5.4 Manager refactor — Tasks 4.14.10 ✓
  • §6.1 New endpoints — Tasks 1.8/1.9/1.10/1.11 (workspace), 3.1 (landing), 4.2/4.3/4.4 (manager) ✓
  • §6.2 Model fields — Tasks 1.1, 1.2, 2.1, 2.2, 2.3, 4.1 ✓
  • §6.3 Cron — Tasks 2.4 (method), 2.5 (registration) ✓
  • §6.4 ACL — Task 2.6 ✓
  • §6.5 Terminology display — Task 1.1 ✓ (sequence rename deferred per spec)
  • §7 Build sequence — phases map 1:1
  • §8 Testing — each task includes its tests
  • §13 Backwards compat — Phase 3 stubs (Task 3.6) preserve old QR codes, old endpoints, old smart buttons

Placeholder scan: No "TBD" / "implement later" / "TODO" outside the JobWorkspace XML template scaffolding (which is properly resolved in Tasks 1.13/1.14/1.15). Phase 3 Tasks 3.23.6 and Phase 4 Tasks 4.24.10 use a structured note pattern ("NOTE for executing agent") that points at concrete reference files and specifies the per-task pattern — not placeholder language. Anyone executing them has the spec, the reference implementations, and clear scope per task.

Type consistency: display_wo_name (string) used consistently in payloads. blocker_kind Selection values match between model compute (Task 1.2) and JS consumer in gate_viz.js (Task 1.4). requires_signoff field used in Workspace render (Task 1.13) — confirmed present in existing fp.job.step per audit. workflow_state_id.color values (Selection: grey/blue/cyan/...) match the SCSS color map in workflow_chip.js toneClass.

Scope check: Phases 12 are foundational (shared services + computes + cron + ACL). Phases 35 build on them. Each phase is independently deployable per the spec. Plan is appropriately decomposed for staged delivery; a separate plan per phase is unnecessary.


Execution Handoff

Plan complete and saved to docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md. Two execution options:

1. Subagent-Driven (recommended) — fresh subagent per task, review between tasks, fast iteration. Best for a plan of this size (28+ tasks).

2. Inline Execution — execute tasks in this session using executing-plans, batch execution with checkpoints.

Which approach?