feat(configurator): OWL widgets + Express form polish to match mockup

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.
This commit is contained in:
gsinghpal
2026-05-26 22:13:54 -04:00
parent 1d674e587c
commit 0f2ed5cc16
10 changed files with 577 additions and 15 deletions

View File

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

View File

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