feat(jobs): Sub 13 sequential step enforcement + Sub 12e v3 wizard

Two coherent feature drops shipping together because their fp_job_step
edits overlap. Both target operator workflow correctness.

## Sub 13 — Sequential step enforcement (recipe + per-step)

Background:
  Investigation on WH/JOB/00339 showed operators starting Incoming
  Inspection while Contract Review was still in_progress. Audit:
  98.7% of recipe operations system-wide had requires_predecessor_done
  = false (the legacy per-step opt-in defaults off, recipe authors
  rarely tick the box).

Architecture:
  Recipe-level toggle + per-step opt-out (Option A from /investigate).
  * fusion.plating.process.node.enforce_sequential — Boolean on the
    recipe root. Default True. When True, every operation under this
    recipe waits for earlier-sequence steps to finish before it can
    start.
  * fusion.plating.process.node.parallel_start — Boolean on operation
    nodes. When True, this step bypasses the sequential gate (e.g.
    paperwork or QA review that runs alongside production).
  * Mirrored on fp.step.template (parallel_start) so library steps
    carry the flag into snapshots.
  * fp.job.enforce_sequential — related from recipe_id. Snapshotted
    at job creation so a recipe author flipping the recipe's flag
    AFTER job generation does NOT change behaviour mid-run.
  * fp.job.step.parallel_start — related from recipe_node_id.
  * Decision matrix (encapsulated in
    fp.job.step._fp_should_block_predecessors):
        recipe.enforce_sequential | step.parallel_start | step.req_pred_done | block?
        --------------------------|---------------------|--------------------|------
                 True             |       False         |        any         |  YES
                 True             |       True          |        any         |   no
                 False            |        any          |       True         |  YES
                 False            |        any          |       False        |   no
  * Manager bypass via context fp_skip_predecessor_check=True (existing).

Runtime gates:
  * fp.job.step.button_start — calls _fp_should_block_predecessors;
    raises UserError naming the blocking earlier step(s).
  * fp.job.step.can_start — computed Boolean for view-side disable.
  * Move wizard predecessor check
    (fusion_plating_shopfloor/controllers/move_controller.py) — uses
    the same helper so tablet + backend behave identically.

UI surface:
  * Recipe form (fp_process_node_views.xml) — enforce_sequential
    toggle on recipe root, parallel_start checkbox on operations.
  * Step template form — parallel_start checkbox.
  * Simple Recipe Editor (inline library form) — Parallel Start
    checkbox + legacy flag demoted with muted styling + supervisor
    group gate.
  * Recipe Tree Editor (properties panel) — both flags exposed,
    only-show on the right node_type.
  * Controllers updated to allowlist + payload the new fields.

Migration:
  fusion_plating/migrations/19.0.18.12.0/post-migrate.py — sets
  enforce_sequential = TRUE on every existing recipe-root node.
  Idempotent. User confirmed dev-stage data, so retroactive flip
  is safe (no production jobs to disrupt).

Tests:
  TestSequentialEnforcement (10 tests) covering:
    * sequential mode blocks out-of-order start
    * first step always startable
    * predecessor finish/skip unlocks next
    * parallel_start opts out of gate
    * free-flow mode bypasses gate
    * legacy requires_predecessor_done still honoured in free-flow
    * manager bypass via context
    * can_start compute reflects state correctly
    * library template parallel_start snapshots into recipe-node

## Sub 12e — Record Inputs Wizard v3 (card layout, dark-mode aware)

Background:
  v2 wizard was a 17-column wide editable table. Operators got lost
  finding which value column applied to their row's type, horizontal
  scroll required on tablets, composite types crammed into one row.

New layout:
  * Each measurement renders as a stacked card (CSS Grid + display
    transformation on the existing list widget — preserves inline
    editing, no JS rewrite).
  * Card header: prompt name (large, bold) + type/unit pills.
  * Card body: ONLY the value widget for this row's type
    (number / boolean / date / text / photo / multi-point / panel).
  * Composite types (multi-point thickness 5x reading + avg, bath
    panel 4 fields) get inline sub-grid inside the card.
  * Empty state ("no measurement prompts") with friendly CTA.

Dark mode:
  * SCSS branches at compile time on $o-webclient-color-scheme
    (per fusion-plating/CLAUDE.md note).
  * Tokens: 7 surface colours + 4 ink levels with light/dark hex
    pairs, all behind var(--fp-*) custom properties for per-deploy
    override.
  * Registered in BOTH web.assets_backend AND web.assets_web_dark
    so each bundle compiles its own palette.

Tablet polish:
  @media (max-width: 900px) — collapse meta below prompt + bump
  numeric input min-height to 56px.

Defensive:
  * v2 view kept in the XML file (instant rollback by changing one
    view_id ref).
  * `:has(.o_invisible_modifier)` rule drops empty cells out of the
    grid so Odoo's invisible="..." doesn't punch holes in layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-03 21:24:12 -04:00
parent ee80673579
commit 9794a98de9
20 changed files with 1109 additions and 34 deletions

View File

@@ -0,0 +1,422 @@
// =============================================================================
// Record Inputs Wizard — v3 card layout (light + dark mode)
// Copyright 2026 Nexa Systems Inc.
//
// Replaces the long-row table layout (v2) with a stacked card layout —
// one card per measurement prompt, the right input widget rendered per
// type, target range + required indicator visible inline.
//
// Pattern (per fusion-plating/CLAUDE.md):
// * SCSS branches at COMPILE TIME on $o-webclient-color-scheme
// * File is registered in BOTH web.assets_backend AND web.assets_web_dark
// * No reliance on runtime DOM classes (.o_dark_mode etc) — Odoo 19
// does not flip dark mode via runtime; it serves a separate bundle.
// * Tokens fall through to CSS custom properties so deployments can
// override via :root { --fp-card-bg: ... } without touching SCSS.
// =============================================================================
$o-webclient-color-scheme: bright !default;
// ---------- Surface tokens — branched at compile time ------------------------
$_fp-iw-card-hex : #ffffff;
$_fp-iw-card-hover-hex: #f8f9fa;
$_fp-iw-page-hex : #f3f4f6;
$_fp-iw-border-hex : #d8dadd;
$_fp-iw-border-focus-hex: #714B67; // Odoo brand purple
$_fp-iw-ink-hex : #1f2937;
$_fp-iw-ink-soft-hex : #4b5563;
$_fp-iw-ink-mute-hex : #6b7280;
$_fp-iw-ink-faint-hex : #9ca3af;
$_fp-iw-required-hex : #dc3545; // red asterisk
$_fp-iw-success-hex : #198754;
$_fp-iw-pill-bg-hex : #f1f3f5;
@if $o-webclient-color-scheme == dark {
$_fp-iw-card-hex : #22262d !global;
$_fp-iw-card-hover-hex: #2a2f37 !global;
$_fp-iw-page-hex : #1a1d21 !global;
$_fp-iw-border-hex : #343942 !global;
$_fp-iw-border-focus-hex: #a78bca !global; // lighter purple for dark
$_fp-iw-ink-hex : #e5e7eb !global;
$_fp-iw-ink-soft-hex : #c8ccd2 !global;
$_fp-iw-ink-mute-hex : #8a909a !global;
$_fp-iw-ink-faint-hex : #5a606b !global;
$_fp-iw-required-hex : #ea868f !global;
$_fp-iw-success-hex : #75b798 !global;
$_fp-iw-pill-bg-hex : #1c2027 !global;
}
// CSS-custom-property fallbacks so per-deployment overrides still work.
$fp-iw-card : var(--fp-card-bg, #{$_fp-iw-card-hex});
$fp-iw-card-hover : var(--fp-card-hover-bg, #{$_fp-iw-card-hover-hex});
$fp-iw-page : var(--fp-page-bg, #{$_fp-iw-page-hex});
$fp-iw-border : var(--fp-border-color, #{$_fp-iw-border-hex});
$fp-iw-border-focus: var(--fp-border-focus, #{$_fp-iw-border-focus-hex});
$fp-iw-ink : var(--fp-ink, #{$_fp-iw-ink-hex});
$fp-iw-ink-soft : var(--fp-ink-soft, #{$_fp-iw-ink-soft-hex});
$fp-iw-ink-mute : var(--fp-ink-mute, #{$_fp-iw-ink-mute-hex});
$fp-iw-ink-faint : var(--fp-ink-faint, #{$_fp-iw-ink-faint-hex});
$fp-iw-required : var(--fp-required, #{$_fp-iw-required-hex});
$fp-iw-success : var(--fp-success, #{$_fp-iw-success-hex});
$fp-iw-pill-bg : var(--fp-pill-bg, #{$_fp-iw-pill-bg-hex});
// =============================================================================
// Wizard layout — header + section title + card grid + empty state
// =============================================================================
.o_fp_input_wizard_v3 {
background-color: $fp-iw-page;
.o_fp_input_header {
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid $fp-iw-border;
h2 {
margin: 0 0 4px 0;
font-size: 1.5rem;
font-weight: 600;
color: $fp-iw-ink;
}
.o_fp_input_subhead {
margin: 0;
font-size: 0.875rem;
color: $fp-iw-ink-mute;
// The job_id field renders as an inline anchor; keep the
// colour calm so the section title stays the focal point.
a, .o_field_widget {
color: $fp-iw-ink-soft;
}
}
}
.o_fp_input_section_title {
margin: 8px 0 12px 0;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: $fp-iw-ink-mute;
}
.o_fp_input_empty_state {
padding: 32px 24px;
text-align: center;
color: $fp-iw-ink-mute;
background-color: $fp-iw-card;
border: 1px dashed $fp-iw-border;
border-radius: 12px;
strong {
color: $fp-iw-ink-soft;
}
}
}
// =============================================================================
// List → cards transformation
//
// We keep Odoo's <list editable="bottom"> for inline editing semantics
// (operators tab through cells, Enter saves the row) and re-render it
// as a stack of cards via CSS only. No JS, no OWL component — the
// existing wizard model is unchanged.
//
// Strategy: turn each <table>/<tr>/<td> into block-level / grid
// containers. Hide column headers entirely. Use CSS Grid on each row
// to position prompt + meta + value into a card layout.
// =============================================================================
.o_fp_input_card_list {
// Override the default list chrome — no border, no horizontal scroll
.o_list_renderer {
background: transparent;
border: none;
overflow: visible;
}
.o_list_table {
display: block;
width: 100%;
border: none;
border-collapse: separate;
background: transparent;
// No column headers — each card carries its own labels
> thead { display: none; }
> tbody {
display: flex;
flex-direction: column;
gap: 12px;
}
// Each row becomes a card
tr.o_data_row {
display: grid;
grid-template-columns: 1fr auto;
grid-template-areas:
"prompt meta"
"value value"
"extras extras";
gap: 8px 16px;
align-items: start;
padding: 16px 20px;
background-color: $fp-iw-card;
border: 1px solid $fp-iw-border;
border-radius: 12px;
transition: border-color 150ms ease, background-color 150ms ease;
&:hover {
background-color: $fp-iw-card-hover;
}
&:focus-within {
border-color: $fp-iw-border-focus;
box-shadow: 0 0 0 3px
color-mix(in srgb, #{$fp-iw-border-focus} 18%, transparent);
}
// Per-cell rest — strip table styling; we'll re-position via
// grid-area on the cells we actually want visible.
> td {
display: block;
padding: 0;
margin: 0;
border: none !important;
background: transparent !important;
vertical-align: top;
// Inputs inherit row width
.o_field_widget {
width: 100%;
}
}
// Defensive: any cell whose widget is logically invisible
// (Odoo's invisible="..." attr) drops out of the grid so it
// doesn't punch an empty slot in our layout.
> td:has(.o_invisible_modifier),
> td.o_invisible_modifier,
> td:empty {
display: none;
}
// ---------- Card header — prompt name ----------
td.o_fp_iw_prompt {
grid-area: prompt;
input, .o_field_widget {
font-size: 1rem;
font-weight: 600;
color: $fp-iw-ink;
background: transparent !important;
border: none !important;
padding: 0 !important;
box-shadow: none !important;
cursor: text;
&[readonly], &:disabled {
cursor: default;
}
}
// Required asterisk — driven by data-required attribute
// OR a server-side compute. We can't easily inspect the
// model field here, so the asterisk is rendered by the
// XML view via a span sibling (.o_fp_iw_required_marker).
}
// ---------- Meta — type + unit pill, target range ----------
td.o_fp_iw_meta {
grid-area: meta;
display: flex;
gap: 8px;
align-items: center;
justify-content: flex-end;
font-size: 0.75rem;
color: $fp-iw-ink-mute;
.o_field_widget {
width: auto;
}
// Type/unit selection looks like a pill
select, input {
font-size: 0.75rem;
padding: 4px 10px !important;
background-color: $fp-iw-pill-bg !important;
color: $fp-iw-ink-soft !important;
border: 1px solid $fp-iw-border !important;
border-radius: 999px !important;
line-height: 1.2 !important;
height: auto !important;
min-height: 0 !important;
}
}
// ---------- Value — the live widget for this row's type ----------
td.o_fp_iw_value {
grid-area: value;
max-width: 360px;
// Numeric / text / date inputs — large + comfortable
input[type="text"],
input[type="number"],
input[type="datetime-local"],
input:not([type]) {
font-size: 1.125rem;
font-weight: 500;
padding: 10px 14px;
min-height: 48px;
background-color: $fp-iw-card;
color: $fp-iw-ink;
border: 1px solid $fp-iw-border;
border-radius: 8px;
box-shadow: none;
transition: border-color 120ms ease,
box-shadow 120ms ease;
&:focus {
border-color: $fp-iw-border-focus;
box-shadow: 0 0 0 3px
color-mix(in srgb,
#{$fp-iw-border-focus} 25%, transparent);
outline: none;
}
&::placeholder {
color: $fp-iw-ink-faint;
font-weight: 400;
}
}
// Boolean toggle — make the pill bigger, easier to tap
.o_boolean_toggle {
transform: scale(1.4);
transform-origin: left center;
margin: 8px 0;
}
// Image / photo widget
.o_field_image {
img, .o_image, .o_form_uri {
max-width: 240px;
max-height: 180px;
border-radius: 8px;
border: 1px solid $fp-iw-border;
}
}
}
// ---------- Extras — composite types (multi-point, panel) ----------
td.o_fp_iw_extra {
grid-area: extras;
display: inline-flex;
gap: 8px;
align-items: baseline;
margin-right: 8px;
// Compact label-above-input grouping
&::before {
content: attr(data-label);
display: block;
font-size: 0.75rem;
color: $fp-iw-ink-mute;
margin-right: 4px;
}
input {
width: 80px !important;
font-size: 1rem;
padding: 6px 10px;
min-height: 38px;
background-color: $fp-iw-card;
color: $fp-iw-ink;
border: 1px solid $fp-iw-border;
border-radius: 6px;
}
}
// Trash button column — small, right-aligned, low contrast
td.o_list_record_remove {
grid-area: meta;
align-self: start;
justify-self: end;
opacity: 0.4;
&:hover { opacity: 1; }
button {
color: $fp-iw-ink-mute;
background: transparent !important;
border: none !important;
padding: 4px;
&:hover {
color: $fp-iw-required;
}
}
}
}
// "Add a line" footer — make it a tasteful CTA card
tfoot, .o_field_x2many_list_row_add {
display: block;
margin-top: 4px;
a, td {
display: inline-block;
padding: 10px 18px;
font-size: 0.875rem;
font-weight: 600;
color: $fp-iw-ink-soft;
background-color: $fp-iw-card;
border: 1px dashed $fp-iw-border;
border-radius: 10px;
cursor: pointer;
transition: border-color 120ms ease, color 120ms ease;
text-decoration: none;
&:hover {
color: $fp-iw-border-focus;
border-color: $fp-iw-border-focus;
background-color: $fp-iw-card-hover;
text-decoration: none;
}
}
}
}
}
// =============================================================================
// Tablet polish — operators on shop-floor tablets need bigger touch targets
// =============================================================================
@media (max-width: 900px) {
.o_fp_input_card_list .o_list_table tr.o_data_row {
grid-template-columns: 1fr;
grid-template-areas:
"prompt"
"meta"
"value"
"extras";
td.o_fp_iw_meta {
justify-content: flex-start;
}
td.o_fp_iw_value {
max-width: 100%;
input { min-height: 56px; }
}
td.o_list_record_remove {
justify-self: end;
}
}
}