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