From 0f2ed5cc168a3f6bd6c37fccba8974f0461099b4 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 26 May 2026 22:13:54 -0400 Subject: [PATCH] feat(configurator): OWL widgets + Express form polish to match mockup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NEW OWL widgets: - FpExpressPartCell (static/src/js/express_part_cell.js + .xml) — multi-row Part cell. Wraps Many2OneField for the part picker (row 1: part# / rev, bold). Below it: row 2 part description (italic muted), row 3 serial #s joined + '+ bulk' button that triggers the existing bulk-add wizard. - FpExpressBakePill (static/src/js/express_bake_pill.js + .xml) — click- to-edit Bake pill. Renders amber pill when set, italic muted 'no bake' when empty. Click swaps to inline textarea + Save / Clear / Cancel. NEW fields: - fp.direct.order.line.part_number_display / part_revision_display / part_name_display (related Char from fp.part.catalog) — fed to the Part cell widget so it can render multi-row without RPC. - fp.direct.order.wizard.tooling_charge (Monetary) — surfaced in the Totals card on the Express form. - fp.direct.order.wizard.po_status (computed Selection received/pending/missing) — drives the PO Block status badge. - sale.order.x_fc_tooling_charge (Monetary) — receives wizard.tooling_charge at confirm. View updates (fp_express_order_views.xml): - PO block header now shows the PO status pill (green Received, amber Pending, red Missing) - Order Lines legend bar (Mask / Bake pill / DWG / OPEN explainers) - Part Number column uses widget='fp_express_part_cell' — single cell with 3 internal rows - Bake column uses widget='fp_express_bake_pill' — interactive pill - Totals card now has Subtotal / Tooling Charge / Total Lines / Total Quantity / Grand Total + currency pill SCSS adds: - Multi-row part cell styles (internal borders, bold/italic/muted rows) - Bake pill (has-bake amber, no-bake italic muted) + inline editor - Legend bar (section background, gap-spaced explainer chips) - PO status pill colour scheme - Bulk button styling Wizard's action_create_order now carries tooling_charge to the SO at confirm so it persists on the resulting sale.order. --- .../__manifest__.py | 9 + .../models/sale_order.py | 6 + .../static/src/js/express_bake_pill.js | 86 ++++++++ .../static/src/js/express_part_cell.js | 110 ++++++++++ .../static/src/scss/express_order.scss | 201 ++++++++++++++++++ .../static/src/xml/express_bake_pill.xml | 30 +++ .../static/src/xml/express_part_cell.xml | 35 +++ .../views/fp_express_order_views.xml | 68 ++++-- .../wizard/fp_direct_order_line.py | 18 ++ .../wizard/fp_direct_order_wizard.py | 29 +++ 10 files changed, 577 insertions(+), 15 deletions(-) create mode 100644 fusion_plating/fusion_plating_configurator/static/src/js/express_bake_pill.js create mode 100644 fusion_plating/fusion_plating_configurator/static/src/js/express_part_cell.js create mode 100644 fusion_plating/fusion_plating_configurator/static/src/xml/express_bake_pill.xml create mode 100644 fusion_plating/fusion_plating_configurator/static/src/xml/express_part_cell.xml diff --git a/fusion_plating/fusion_plating_configurator/__manifest__.py b/fusion_plating/fusion_plating_configurator/__manifest__.py index ffa2b745..502600c1 100644 --- a/fusion_plating/fusion_plating_configurator/__manifest__.py +++ b/fusion_plating/fusion_plating_configurator/__manifest__.py @@ -78,6 +78,11 @@ Provides: # $xpr-* vars are in scope for the consumer SCSS below. 'fusion_plating_configurator/static/src/scss/_express_tokens.scss', 'fusion_plating_configurator/static/src/scss/express_order.scss', + # OWL widgets — multi-row Part cell + click-to-edit Bake pill + 'fusion_plating_configurator/static/src/js/express_part_cell.js', + 'fusion_plating_configurator/static/src/js/express_bake_pill.js', + 'fusion_plating_configurator/static/src/xml/express_part_cell.xml', + 'fusion_plating_configurator/static/src/xml/express_bake_pill.xml', ], # Register colour-aware SCSS in both bundles so the # `@if $o-webclient-color-scheme == dark` branch compiles for @@ -87,6 +92,10 @@ Provides: 'fusion_plating_configurator/static/src/scss/fp_job_status_pill.scss', 'fusion_plating_configurator/static/src/scss/_express_tokens.scss', 'fusion_plating_configurator/static/src/scss/express_order.scss', + 'fusion_plating_configurator/static/src/js/express_part_cell.js', + 'fusion_plating_configurator/static/src/js/express_bake_pill.js', + 'fusion_plating_configurator/static/src/xml/express_part_cell.xml', + 'fusion_plating_configurator/static/src/xml/express_bake_pill.xml', ], }, 'installable': True, diff --git a/fusion_plating/fusion_plating_configurator/models/sale_order.py b/fusion_plating/fusion_plating_configurator/models/sale_order.py index ac7361d2..670d3446 100644 --- a/fusion_plating/fusion_plating_configurator/models/sale_order.py +++ b/fusion_plating/fusion_plating_configurator/models/sale_order.py @@ -140,6 +140,12 @@ class SaleOrder(models.Model): help='When False, the Terms & Conditions (sale.order.note) is ' 'suppressed on quote / SO / invoice / packing slip PDFs.', ) + x_fc_tooling_charge = fields.Monetary( + string='Tooling Charge', + currency_field='currency_id', + help='Optional one-time tooling fee from the Express Orders form. ' + 'Surfaced on the invoice as a separate line.', + ) x_fc_planned_start_date = fields.Date( string='Planned Start Date', tracking=True, ) diff --git a/fusion_plating/fusion_plating_configurator/static/src/js/express_bake_pill.js b/fusion_plating/fusion_plating_configurator/static/src/js/express_bake_pill.js new file mode 100644 index 00000000..f3ccaa57 --- /dev/null +++ b/fusion_plating/fusion_plating_configurator/static/src/js/express_bake_pill.js @@ -0,0 +1,86 @@ +/** @odoo-module **/ + +// Express Orders — Bake pill widget (2026-05-26) +// +// Renders the `bake_instructions` Text field as a coloured pill: +// - Non-empty → amber pill showing the text ("350°F × 4 hr") +// - Empty → italic muted pill "no bake" +// +// Click → swaps to inline edit (small textarea + Save / Clear / Cancel +// buttons). Save persists to the field; Clear empties it (so the +// override flow at SO confirm will opt out of baking nodes). + +import { Component, useRef, useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { standardFieldProps } from "@web/views/fields/standard_field_props"; + + +export class FpExpressBakePill extends Component { + static template = "fusion_plating_configurator.FpExpressBakePill"; + static props = { ...standardFieldProps }; + + setup() { + this.state = useState({ + editing: false, + draft: this.value, + }); + this.textareaRef = useRef("textarea"); + } + + get value() { + return this.props.record.data[this.props.name] || ""; + } + + get hasBake() { + return !!(this.value && this.value.trim()); + } + + get pillLabel() { + return this.hasBake ? this.value : "no bake"; + } + + openEditor(ev) { + ev?.stopPropagation(); + this.state.draft = this.value; + this.state.editing = true; + // Focus the textarea on next tick + Promise.resolve().then(() => { + if (this.textareaRef.el) { + this.textareaRef.el.focus(); + this.textareaRef.el.select(); + } + }); + } + + async save() { + const v = (this.state.draft || "").trim(); + await this.props.record.update({ [this.props.name]: v || false }); + this.state.editing = false; + } + + async clear() { + await this.props.record.update({ [this.props.name]: false }); + this.state.draft = ""; + this.state.editing = false; + } + + cancel() { + this.state.draft = this.value; + this.state.editing = false; + } + + onKeyDown(ev) { + if (ev.key === "Escape") { + this.cancel(); + } else if (ev.key === "Enter" && (ev.ctrlKey || ev.metaKey)) { + this.save(); + } + } +} + +export const fpExpressBakePill = { + component: FpExpressBakePill, + supportedTypes: ["text", "char"], +}; + +registry.category("fields").add("fp_express_bake_pill", fpExpressBakePill); diff --git a/fusion_plating/fusion_plating_configurator/static/src/js/express_part_cell.js b/fusion_plating/fusion_plating_configurator/static/src/js/express_part_cell.js new file mode 100644 index 00000000..78d9ddac --- /dev/null +++ b/fusion_plating/fusion_plating_configurator/static/src/js/express_part_cell.js @@ -0,0 +1,110 @@ +/** @odoo-module **/ + +// Express Orders — multi-row Part cell widget (2026-05-26) +// +// Replaces the standard Many2One renderer for `part_catalog_id` in +// the Express Orders line list. Shows three stacked rows in ONE +// cell, mirroring the brainstorm mockup: +// +// Row 1: Part # / Revision (bold; part # is the active picker) +// Row 2: Part description (italic, muted) +// Row 3: Serial #(s) (small grey, joined by ", ") +// + small "+ bulk" button +// +// Pre-fetched fields used (read off the line record, no RPC needed): +// - part_catalog_id (M2O, the actual picker) +// - part_number_display (related Char from part.part_number) +// - part_revision_display (related Char from part.revision) +// - part_name_display (related Char from part.name) +// - serial_ids (M2M list) +// +// Bulk button calls action_open_serial_bulk_add on the line which +// opens the existing fp.serial.bulk.add.wizard. + +import { Component } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { Many2OneField, many2OneField } from "@web/views/fields/many2one/many2one_field"; + + +export class FpExpressPartCell extends Component { + static template = "fusion_plating_configurator.FpExpressPartCell"; + static components = { Many2OneField }; + static props = { ...Many2OneField.props }; + + setup() { + this.action = useService("action"); + this.orm = useService("orm"); + this.notification = useService("notification"); + } + + get partRev() { + return this.props.record.data.part_revision_display || ""; + } + + get partName() { + return this.props.record.data.part_name_display || ""; + } + + get serialsList() { + const serials = this.props.record.data.serial_ids; + if (!serials || !serials.records) return []; + return serials.records + .map(r => r.data && (r.data.display_name || r.data.name)) + .filter(Boolean); + } + + get serialsDisplay() { + const list = this.serialsList; + if (!list.length) return ""; + if (list.length <= 3) return list.join(", "); + return list.slice(0, 3).join(", ") + ` +${list.length - 3} more`; + } + + get hasPart() { + return !!this.props.record.data.part_catalog_id; + } + + async onBulkClick(ev) { + ev.stopPropagation(); + ev.preventDefault(); + + if (!this.hasPart) { + this.notification.add( + "Pick a part first, then click + bulk to add serials.", + { type: "warning" } + ); + return; + } + + // If the record is brand-new (no resId yet), persist it first so + // the bulk-add wizard has a valid target id to write into. + if (!this.props.record.resId) { + await this.props.record.save(); + } + if (!this.props.record.resId) { + this.notification.add( + "Save the order first before bulk-adding serials.", + { type: "warning" } + ); + return; + } + + const action = await this.orm.call( + this.props.record.resModel, + "action_open_serial_bulk_add", + [[this.props.record.resId]], + ); + await this.action.doAction(action); + } +} + +// Register the field widget. supportedTypes=many2one because the field +// it binds to (part_catalog_id) is a Many2One. +export const fpExpressPartCell = { + ...many2OneField, + component: FpExpressPartCell, + supportedTypes: ["many2one"], +}; + +registry.category("fields").add("fp_express_part_cell", fpExpressPartCell); diff --git a/fusion_plating/fusion_plating_configurator/static/src/scss/express_order.scss b/fusion_plating/fusion_plating_configurator/static/src/scss/express_order.scss index 7217f171..1d5a1fea 100644 --- a/fusion_plating/fusion_plating_configurator/static/src/scss/express_order.scss +++ b/fusion_plating/fusion_plating_configurator/static/src/scss/express_order.scss @@ -346,6 +346,207 @@ } } + // ============================================================ + // LEGEND bar above the lines table + // ============================================================ + .o_fp_xpr_legend { + display: flex; + flex-wrap: wrap; + gap: 24px; + padding: 8px 16px; + background: $xpr-section-bg; + border: 1px solid $xpr-border; + border-radius: 4px; + margin-bottom: 8px; + font-size: 12px; + color: $xpr-text-muted; + + strong { color: $xpr-text; } + } + + // ============================================================ + // PO Block — status pill (received / pending / missing) + // ============================================================ + .o_fp_xpr_po_block .o_fp_xpr_po_head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + + > span:first-child { + color: $xpr-accent; + font-weight: 700; + font-size: 11px; + letter-spacing: 0.5px; + text-transform: uppercase; + } + .badge { + font-size: 10px; + letter-spacing: 0.5px; + } + } + + // ============================================================ + // PART CELL — multi-row content (FpExpressPartCell widget) + // ============================================================ + .o_fp_xpr_part_cell { + display: flex; + flex-direction: column; + gap: 0; + min-width: 220px; + margin: -4px -8px; // hug the td edges so internal borders extend full width + + > .o_fp_xpr_part_row { + padding: 4px 8px; + display: flex; + align-items: baseline; + gap: 4px; + } + > .o_fp_xpr_part_row:not(:last-child) { + border-bottom: 1px solid $xpr-border-table; + } + + .o_fp_xpr_part_id { + font-weight: 600; + font-size: 13px; + + // The embedded Many2OneField input — strip its chrome so it + // looks like inline-bold text. Restore the dropdown affordance + // on hover/focus. + .o_field_widget, + .o_field_widget input, + .o_field_many2one input { + border: none; + background: transparent; + padding: 1px 4px; + font-weight: 600; + font-size: 13px; + color: $xpr-text; + } + .o_field_widget input:focus { + background: $xpr-cell-focus; + outline: none; + } + .o_fp_xpr_part_sep { + color: $xpr-text-dim; + font-weight: 400; + padding: 0 2px; + } + .o_fp_xpr_part_rev { + font-weight: 600; + color: $xpr-text; + min-width: 30px; + } + .o_fp_xpr_part_rev_empty { + color: $xpr-text-dim; + font-weight: 400; + font-style: italic; + } + } + .o_fp_xpr_part_name { + font-style: italic; + font-size: 12px; + color: $xpr-text; + + &.o_fp_xpr_part_name_empty { color: $xpr-text-dim; } + } + .o_fp_xpr_part_serial { + font-size: 11px; + color: $xpr-text-muted; + justify-content: space-between; + + .o_fp_xpr_serials { letter-spacing: 0.2px; } + .o_fp_xpr_serials_empty { + font-style: italic; + color: $xpr-text-dim; + } + .o_fp_xpr_bulk_btn { + background: $xpr-section-bg; + border: 1px solid $xpr-border-strong; + color: $xpr-text-muted; + padding: 1px 7px; + border-radius: 3px; + font-size: 11px; + font-weight: 600; + cursor: pointer; + + &:hover:not(:disabled) { + background: $xpr-accent-bg; + color: $xpr-accent; + border-color: $xpr-accent; + } + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + } + } + } + + // ============================================================ + // BAKE PILL — click-to-edit (FpExpressBakePill widget) + // ============================================================ + .o_fp_xpr_bake_wrap { + display: inline-block; + + .o_fp_xpr_bake_pill { + display: inline-flex; + align-items: center; + padding: 4px 12px; + border-radius: 3px; + font-size: 12px; + font-weight: 500; + border: 1px solid; + cursor: pointer; + transition: background 0.15s; + + &.has-bake { + background: $xpr-bake-bg; + color: $xpr-bake-text; + border-color: $xpr-bake-border; + } + &.no-bake { + background: $xpr-card-soft; + color: $xpr-text-muted; + border-color: $xpr-border-strong; + font-style: italic; + } + &:hover { + filter: brightness(0.97); + } + } + .o_fp_xpr_bake_editor { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 200px; + + textarea { + width: 100%; + border: 1px solid $xpr-border; + border-radius: 3px; + padding: 6px 8px; + font-size: 12px; + font-family: inherit; + resize: vertical; + background: $xpr-card; + color: $xpr-text; + + &:focus { + border-color: $xpr-accent; + outline: none; + } + } + .o_fp_xpr_bake_actions { + display: flex; + gap: 4px; + + .btn { padding: 2px 8px; font-size: 11px; } + } + } + } +} + // view_source badge column on drafts list — accent-coloured for Express .o_list_view .badge.text-bg-info { background-color: $xpr-accent !important; diff --git a/fusion_plating/fusion_plating_configurator/static/src/xml/express_bake_pill.xml b/fusion_plating/fusion_plating_configurator/static/src/xml/express_bake_pill.xml new file mode 100644 index 00000000..415f8210 --- /dev/null +++ b/fusion_plating/fusion_plating_configurator/static/src/xml/express_bake_pill.xml @@ -0,0 +1,30 @@ + + + + + +
+ + + + +
+