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:
@@ -78,6 +78,11 @@ Provides:
|
|||||||
# $xpr-* vars are in scope for the consumer SCSS below.
|
# $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_tokens.scss',
|
||||||
'fusion_plating_configurator/static/src/scss/express_order.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
|
# Register colour-aware SCSS in both bundles so the
|
||||||
# `@if $o-webclient-color-scheme == dark` branch compiles for
|
# `@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/fp_job_status_pill.scss',
|
||||||
'fusion_plating_configurator/static/src/scss/_express_tokens.scss',
|
'fusion_plating_configurator/static/src/scss/_express_tokens.scss',
|
||||||
'fusion_plating_configurator/static/src/scss/express_order.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,
|
'installable': True,
|
||||||
|
|||||||
@@ -140,6 +140,12 @@ class SaleOrder(models.Model):
|
|||||||
help='When False, the Terms & Conditions (sale.order.note) is '
|
help='When False, the Terms & Conditions (sale.order.note) is '
|
||||||
'suppressed on quote / SO / invoice / packing slip PDFs.',
|
'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(
|
x_fc_planned_start_date = fields.Date(
|
||||||
string='Planned Start Date', tracking=True,
|
string='Planned Start Date', tracking=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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
|
// view_source badge column on drafts list — accent-coloured for Express
|
||||||
.o_list_view .badge.text-bg-info {
|
.o_list_view .badge.text-bg-info {
|
||||||
background-color: $xpr-accent !important;
|
background-color: $xpr-accent !important;
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<!-- Express Orders — Bake pill (click-to-edit) -->
|
||||||
|
<t t-name="fusion_plating_configurator.FpExpressBakePill">
|
||||||
|
<div class="o_fp_xpr_bake_wrap">
|
||||||
|
<t t-if="!state.editing">
|
||||||
|
<span class="o_fp_xpr_bake_pill"
|
||||||
|
t-att-class="{ 'has-bake': hasBake, 'no-bake': !hasBake }"
|
||||||
|
t-on-click="openEditor"
|
||||||
|
t-esc="pillLabel"
|
||||||
|
title="Click to edit"/>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<div class="o_fp_xpr_bake_editor" t-on-keydown="onKeyDown">
|
||||||
|
<textarea t-ref="textarea"
|
||||||
|
t-model="state.draft"
|
||||||
|
rows="2"
|
||||||
|
placeholder="e.g. 350°F × 4 hr"/>
|
||||||
|
<div class="o_fp_xpr_bake_actions">
|
||||||
|
<button class="btn btn-sm btn-primary" t-on-click="save">Save</button>
|
||||||
|
<button class="btn btn-sm btn-secondary" t-on-click="clear">Clear</button>
|
||||||
|
<button class="btn btn-sm btn-link" t-on-click="cancel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<!-- Express Orders — Part cell template (3-row stacked) -->
|
||||||
|
<t t-name="fusion_plating_configurator.FpExpressPartCell">
|
||||||
|
<div class="o_fp_xpr_part_cell">
|
||||||
|
<!-- Row 1: Part Number picker + Revision -->
|
||||||
|
<div class="o_fp_xpr_part_row o_fp_xpr_part_id">
|
||||||
|
<Many2OneField t-props="props"/>
|
||||||
|
<span class="o_fp_xpr_part_sep">/</span>
|
||||||
|
<span class="o_fp_xpr_part_rev"
|
||||||
|
t-esc="partRev or ''"
|
||||||
|
t-att-class="{ 'o_fp_xpr_part_rev_empty': !partRev }"/>
|
||||||
|
</div>
|
||||||
|
<!-- Row 2: Part description / name (italic, muted) -->
|
||||||
|
<div class="o_fp_xpr_part_row o_fp_xpr_part_name"
|
||||||
|
t-att-class="{ 'o_fp_xpr_part_name_empty': !partName }">
|
||||||
|
<t t-esc="partName or '— no description —'"/>
|
||||||
|
</div>
|
||||||
|
<!-- Row 3: Serial #(s) + bulk button -->
|
||||||
|
<div class="o_fp_xpr_part_row o_fp_xpr_part_serial">
|
||||||
|
<span class="o_fp_xpr_serials"
|
||||||
|
t-att-class="{ 'o_fp_xpr_serials_empty': !serialsList.length }"
|
||||||
|
t-esc="serialsDisplay or 'no serials yet'"/>
|
||||||
|
<button class="o_fp_xpr_bulk_btn"
|
||||||
|
t-on-click="onBulkClick"
|
||||||
|
t-att-disabled="!hasPart"
|
||||||
|
title="Bulk add serials (paste list or range fill)">
|
||||||
|
+ bulk
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
@@ -88,7 +88,14 @@
|
|||||||
|
|
||||||
<!-- ROW 2 — PO block (span 2) + Job# + Job Sorting -->
|
<!-- ROW 2 — PO block (span 2) + Job# + Job Sorting -->
|
||||||
<div class="o_fp_xpr_cell span-2 o_fp_xpr_po_block">
|
<div class="o_fp_xpr_cell span-2 o_fp_xpr_po_block">
|
||||||
<div class="o_fp_xpr_po_head">CUSTOMER PO</div>
|
<div class="o_fp_xpr_po_head">
|
||||||
|
<span>CUSTOMER PO</span>
|
||||||
|
<field name="po_status" widget="badge"
|
||||||
|
decoration-success="po_status == 'received'"
|
||||||
|
decoration-warning="po_status == 'pending'"
|
||||||
|
decoration-danger="po_status == 'missing'"
|
||||||
|
nolabel="1"/>
|
||||||
|
</div>
|
||||||
<div class="o_fp_xpr_po_row">
|
<div class="o_fp_xpr_po_row">
|
||||||
<label for="po_number">PO #</label>
|
<label for="po_number">PO #</label>
|
||||||
<field name="po_number" nolabel="1"
|
<field name="po_number" nolabel="1"
|
||||||
@@ -179,6 +186,15 @@
|
|||||||
ORDER LINES — spreadsheet
|
ORDER LINES — spreadsheet
|
||||||
========================================================= -->
|
========================================================= -->
|
||||||
<div class="o_fp_xpr_section_title">Order Lines</div>
|
<div class="o_fp_xpr_section_title">Order Lines</div>
|
||||||
|
|
||||||
|
<!-- Legend bar — like the mockup -->
|
||||||
|
<div class="o_fp_xpr_legend">
|
||||||
|
<span><strong>Mask ✓</strong> include all masking + de-masking recipe steps</span>
|
||||||
|
<span><strong>Bake pill</strong> click to type bake instruction (empty = skip bake)</span>
|
||||||
|
<span><strong>DWG</strong> upload drawing to part</span>
|
||||||
|
<span><strong>OPEN</strong> open the part record</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-2 d-flex gap-2">
|
<div class="mb-2 d-flex gap-2">
|
||||||
<button name="action_add_from_prior_so"
|
<button name="action_add_from_prior_so"
|
||||||
type="object"
|
type="object"
|
||||||
@@ -196,32 +212,41 @@
|
|||||||
decoration-warning="is_missing_info">
|
decoration-warning="is_missing_info">
|
||||||
<field name="is_missing_info" column_invisible="1"/>
|
<field name="is_missing_info" column_invisible="1"/>
|
||||||
<field name="sequence" widget="handle"/>
|
<field name="sequence" widget="handle"/>
|
||||||
|
<!-- The multi-row Part cell. Owns part_catalog_id picker
|
||||||
|
PLUS displays part_revision_display, part_name_display,
|
||||||
|
serial_ids + the inline + bulk button. -->
|
||||||
<field name="part_catalog_id"
|
<field name="part_catalog_id"
|
||||||
string="Part Number"
|
string="Part Number"
|
||||||
|
widget="fp_express_part_cell"
|
||||||
context="{'default_partner_id': parent.partner_id, 'default_revision': 'A'}"
|
context="{'default_partner_id': parent.partner_id, 'default_revision': 'A'}"
|
||||||
domain="[('partner_id', '=', parent.partner_id), ('is_latest_revision', '=', True)]"
|
domain="[('partner_id', '=', parent.partner_id), ('is_latest_revision', '=', True)]"
|
||||||
options="{'no_quick_create': True}"/>
|
options="{'no_quick_create': True}"/>
|
||||||
<field name="line_description" string="Specification"/>
|
<!-- Hidden related fields the widget reads. Must be on the
|
||||||
<field name="customer_line_ref" string="Line Job #" placeholder="ABC"/>
|
list so they're prefetched per row. -->
|
||||||
<field name="thickness_range" string="Thickness" placeholder=".0005-.0010"/>
|
<field name="part_number_display" column_invisible="1"/>
|
||||||
<field name="masking_enabled" string="Mask" widget="boolean_toggle"/>
|
<field name="part_revision_display" column_invisible="1"/>
|
||||||
<field name="bake_instructions" string="Bake" placeholder="no bake"/>
|
<field name="part_name_display" column_invisible="1"/>
|
||||||
<field name="internal_description" string="Internal Notes" optional="show"/>
|
|
||||||
<field name="serial_ids"
|
<field name="serial_ids"
|
||||||
widget="many2many_tags"
|
widget="many2many_tags"
|
||||||
options="{'no_quick_create': False, 'color_field': 'state_color'}"
|
options="{'no_quick_create': False, 'color_field': 'state_color'}"
|
||||||
domain="[('part_id', '=', part_catalog_id)]"
|
domain="[('part_id', '=', part_catalog_id)]"
|
||||||
optional="show"/>
|
column_invisible="1"/>
|
||||||
<button name="action_open_serial_bulk_add" type="object"
|
<field name="line_description" string="Specification (Customer-Facing)"/>
|
||||||
string="+ bulk"
|
<field name="customer_line_ref" string="Line Job #" placeholder="ABC"/>
|
||||||
class="btn-link btn-sm o_fp_xpr_inline_btn"
|
<field name="thickness_range" string="Thickness" placeholder=".0005-.0010"/>
|
||||||
title="Bulk-add serial numbers"
|
<field name="masking_enabled" string="Mask" widget="boolean_toggle"/>
|
||||||
invisible="not part_catalog_id"/>
|
<!-- Bake pill — click to edit -->
|
||||||
<field name="quantity"/>
|
<field name="bake_instructions"
|
||||||
|
string="Bake"
|
||||||
|
widget="fp_express_bake_pill"/>
|
||||||
|
<field name="internal_description" string="Internal Notes" optional="show"/>
|
||||||
|
<field name="quantity" string="Qty"/>
|
||||||
<field name="unit_price"
|
<field name="unit_price"
|
||||||
|
string="Price"
|
||||||
widget="monetary"
|
widget="monetary"
|
||||||
options="{'currency_field': 'currency_id'}"/>
|
options="{'currency_field': 'currency_id'}"/>
|
||||||
<field name="line_subtotal"
|
<field name="line_subtotal"
|
||||||
|
string="Subtotal"
|
||||||
widget="monetary"
|
widget="monetary"
|
||||||
options="{'currency_field': 'currency_id'}"
|
options="{'currency_field': 'currency_id'}"
|
||||||
sum="Total"/>
|
sum="Total"/>
|
||||||
@@ -268,7 +293,20 @@
|
|||||||
|
|
||||||
<div class="o_fp_xpr_footer_right">
|
<div class="o_fp_xpr_footer_right">
|
||||||
<div class="o_fp_xpr_card o_fp_xpr_totals">
|
<div class="o_fp_xpr_card o_fp_xpr_totals">
|
||||||
<div class="o_fp_xpr_card_title">Totals</div>
|
<div class="o_fp_xpr_total_row">
|
||||||
|
<span class="o_fp_xpr_total_label">Subtotal</span>
|
||||||
|
<field name="total_amount"
|
||||||
|
widget="monetary"
|
||||||
|
options="{'currency_field': 'currency_id'}"
|
||||||
|
readonly="1" nolabel="1"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_xpr_total_row">
|
||||||
|
<span class="o_fp_xpr_total_label">Tooling Charge</span>
|
||||||
|
<field name="tooling_charge"
|
||||||
|
widget="monetary"
|
||||||
|
options="{'currency_field': 'currency_id'}"
|
||||||
|
nolabel="1"/>
|
||||||
|
</div>
|
||||||
<div class="o_fp_xpr_total_row">
|
<div class="o_fp_xpr_total_row">
|
||||||
<span class="o_fp_xpr_total_label">Total Lines</span>
|
<span class="o_fp_xpr_total_label">Total Lines</span>
|
||||||
<field name="total_line_count" readonly="1" nolabel="1"/>
|
<field name="total_line_count" readonly="1" nolabel="1"/>
|
||||||
|
|||||||
@@ -460,6 +460,24 @@ class FpDirectOrderLine(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ---- Express Orders per-line flags (2026-05-26) ----
|
# ---- Express Orders per-line flags (2026-05-26) ----
|
||||||
|
# Related read-only fields so the FpExpressPartCell OWL widget can
|
||||||
|
# render the multi-row Part cell (part# / revision / name / serials)
|
||||||
|
# without needing extra RPCs.
|
||||||
|
part_number_display = fields.Char(
|
||||||
|
related='part_catalog_id.part_number',
|
||||||
|
string='Part # (display)',
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
part_revision_display = fields.Char(
|
||||||
|
related='part_catalog_id.revision',
|
||||||
|
string='Revision (display)',
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
part_name_display = fields.Char(
|
||||||
|
related='part_catalog_id.name',
|
||||||
|
string='Part Name (display)',
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
customer_line_ref = fields.Char(
|
customer_line_ref = fields.Char(
|
||||||
string='Customer Line Job #',
|
string='Customer Line Job #',
|
||||||
help='Per-line customer sub-reference (e.g. ABC, DEF). Distinct from '
|
help='Per-line customer sub-reference (e.g. ABC, DEF). Distinct from '
|
||||||
|
|||||||
@@ -274,6 +274,34 @@ class FpDirectOrderWizard(models.Model):
|
|||||||
help='Which view created this draft. Drafts list routes click-action '
|
help='Which view created this draft. Drafts list routes click-action '
|
||||||
'to the matching form. Dropped at phase-out Phase 4.',
|
'to the matching form. Dropped at phase-out Phase 4.',
|
||||||
)
|
)
|
||||||
|
tooling_charge = fields.Monetary(
|
||||||
|
string='Tooling Charge',
|
||||||
|
currency_field='currency_id',
|
||||||
|
help='Optional one-time tooling fee added to the order total. '
|
||||||
|
'Carried to the SO and appears as a separate line on the '
|
||||||
|
'customer invoice.',
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- PO status pill (computed, display-only) ----
|
||||||
|
po_status = fields.Selection(
|
||||||
|
[('received', 'Received'),
|
||||||
|
('pending', 'Pending'),
|
||||||
|
('missing', 'Missing')],
|
||||||
|
string='PO Status',
|
||||||
|
compute='_compute_po_status',
|
||||||
|
help='Computed: Received if PDF attached, Pending if po_pending=True, '
|
||||||
|
'Missing otherwise.',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('po_attachment_file', 'po_pending')
|
||||||
|
def _compute_po_status(self):
|
||||||
|
for rec in self:
|
||||||
|
if rec.po_attachment_file:
|
||||||
|
rec.po_status = 'received'
|
||||||
|
elif rec.po_pending:
|
||||||
|
rec.po_status = 'pending'
|
||||||
|
else:
|
||||||
|
rec.po_status = 'missing'
|
||||||
|
|
||||||
# ---- Lines ----
|
# ---- Lines ----
|
||||||
line_ids = fields.One2many(
|
line_ids = fields.One2many(
|
||||||
@@ -674,6 +702,7 @@ class FpDirectOrderWizard(models.Model):
|
|||||||
# Express Orders header (2026-05-26)
|
# Express Orders header (2026-05-26)
|
||||||
'x_fc_internal_notes': self.internal_notes or False,
|
'x_fc_internal_notes': self.internal_notes or False,
|
||||||
'x_fc_material_process': self.material_process or False,
|
'x_fc_material_process': self.material_process or False,
|
||||||
|
'x_fc_tooling_charge': self.tooling_charge or 0.0,
|
||||||
'pricelist_id': self.pricelist_id.id if self.pricelist_id else False,
|
'pricelist_id': self.pricelist_id.id if self.pricelist_id else False,
|
||||||
'validity_date': self.validity_date or False,
|
'validity_date': self.validity_date or False,
|
||||||
'order_line': [],
|
'order_line': [],
|
||||||
|
|||||||
Reference in New Issue
Block a user