feat(configurator): Express form polish — 4 fixes per user review

1. Blanket Sales Order — match legacy field shape. Renamed label from
   'Blanket SO' to 'Blanket Sales Order' (matches legacy view), removed
   the boolean_toggle widget (defaults to checkbox), and added the
   sibling 'block_partial_shipments' field inline (only visible when
   blanket is checked, with 'Block partial shipments' helper text).

2. Column widths — give roomier columns where data needs space, tighten
   numeric columns. Part Number 230px, Specification min 220px,
   Internal Notes min 140px, Qty 60px, Price 80px, Subtotal 90px,
   Mask 55px, Bake 130px, Action stack 60px.

3. Stacked DWG / OPEN buttons — new OWL widget FpExpressActionBtns
   (express_action_btns.js + .xml) renders both buttons vertically in
   ONE column to save horizontal space. Widget binds to a new
   action_btns_anchor field (related from part_catalog_id) on the
   line. Each button shows tooltip + disabled state when no part is
   picked; DWG triggers the native file picker, OPEN navigates to the
   part record.

4. Field activation — clicking the cell anywhere now focuses the
   input, not just clicking the label. Achieved via:
   - cursor: text on .o_fp_xpr_cell
   - cursor: pointer on labels
   - min-height: 30px on all inputs (larger click target)
   - width: 100% propagated through Many2One wrappers (.o-dropdown,
     .o-autocomplete) so the input genuinely fills the cell
   - box-sizing: border-box so widths are predictable
   - Background tint on focus for visual feedback
This commit is contained in:
gsinghpal
2026-05-26 22:35:00 -04:00
parent c71e61da77
commit 15e25ca50b
6 changed files with 276 additions and 20 deletions

View File

@@ -79,10 +79,13 @@ Provides:
'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
# + stacked DWG/OPEN action buttons
'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/js/express_action_btns.js',
'fusion_plating_configurator/static/src/xml/express_part_cell.xml',
'fusion_plating_configurator/static/src/xml/express_bake_pill.xml',
'fusion_plating_configurator/static/src/xml/express_action_btns.xml',
],
# Register colour-aware SCSS in both bundles so the
# `@if $o-webclient-color-scheme == dark` branch compiles for
@@ -94,8 +97,10 @@ Provides:
'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/js/express_action_btns.js',
'fusion_plating_configurator/static/src/xml/express_part_cell.xml',
'fusion_plating_configurator/static/src/xml/express_bake_pill.xml',
'fusion_plating_configurator/static/src/xml/express_action_btns.xml',
],
},
'installable': True,

View File

@@ -0,0 +1,101 @@
/** @odoo-module **/
// Express Orders — stacked DWG / OPEN action buttons widget (2026-05-26)
//
// Renders BOTH the upload-drawing and open-part buttons stacked
// vertically in one list cell, saving horizontal width. The widget
// binds to part_catalog_id (read-only — picker is owned by the
// FpExpressPartCell widget on the same field; this widget is
// declared on a separate dummy column).
import { Component } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
export class FpExpressActionBtns extends Component {
static template = "fusion_plating_configurator.FpExpressActionBtns";
static props = { ...standardFieldProps };
setup() {
this.action = useService("action");
this.orm = useService("orm");
this.notification = useService("notification");
}
get hasPart() {
return !!this.props.record.data.part_catalog_id;
}
async _ensureSaved() {
if (!this.props.record.resId) {
await this.props.record.save();
}
return !!this.props.record.resId;
}
async onUpload(ev) {
ev.stopPropagation();
ev.preventDefault();
if (!this.hasPart) {
this.notification.add("Pick a part first.", { type: "warning" });
return;
}
// Trigger native file picker via hidden input
const input = document.createElement("input");
input.type = "file";
input.accept = ".pdf,.dwg,.dxf,.png,.jpg,.jpeg,application/pdf,image/*";
input.onchange = async () => {
const file = input.files && input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async () => {
const base64 = reader.result.split(",")[1];
if (!(await this._ensureSaved())) return;
try {
await this.orm.call(
this.props.record.resModel,
"action_upload_drawing",
[[this.props.record.resId]],
{
context: {
fp_drawing_file: base64,
fp_drawing_filename: file.name,
},
},
);
this.notification.add(`Drawing "${file.name}" uploaded.`, { type: "success" });
await this.props.record.load();
} catch (e) {
this.notification.add(`Upload failed: ${e.message || e}`, { type: "danger" });
}
};
reader.readAsDataURL(file);
};
input.click();
}
async onOpen(ev) {
ev.stopPropagation();
ev.preventDefault();
if (!this.hasPart) {
this.notification.add("Pick a part first.", { type: "warning" });
return;
}
if (!(await this._ensureSaved())) return;
const action = await this.orm.call(
this.props.record.resModel,
"action_open_part",
[[this.props.record.resId]],
);
if (action) await this.action.doAction(action);
}
}
export const fpExpressActionBtns = {
component: FpExpressActionBtns,
supportedTypes: ["many2one"],
};
registry.category("fields").add("fp_express_action_btns", fpExpressActionBtns);

View File

@@ -33,6 +33,7 @@
flex-direction: column;
gap: 2px;
min-width: 0; // allow Many2One to shrink within grid track
cursor: text; // hint that clicking the cell focuses the input
> label {
font-size: 11px;
@@ -41,36 +42,80 @@
letter-spacing: 0.3px;
font-weight: 500;
margin-bottom: 0;
cursor: pointer; // label is explicitly clickable to focus input
}
// The actual field input — kill Odoo's default block-level chrome
// The actual field input — kill Odoo's default block-level chrome.
// Aggressive width 100% on all known wrappers so the click target
// matches the cell's visible width.
> .o_field_widget,
> .o_field_widget > div,
> .o_field_widget input,
> .o_field_widget select,
> .o_field_widget > .o-dropdown,
> .o_field_widget > .o-autocomplete,
> .o_field_many2one,
> .o_field_char,
> .o_field_date {
> .o_field_date,
> .o_field_text,
> .o_field_integer,
> .o_field_selection {
width: 100%;
display: block;
}
.o-dropdown, .o-autocomplete {
width: 100%;
display: block;
}
// Field input visual — underline style like the mockup
.o_input, .o_field_widget input {
.o_input, .o_field_widget input,
.o_field_widget select {
border: none;
border-bottom: 1px solid $xpr-border-strong;
background: transparent;
padding: 4px 0;
padding: 5px 4px;
border-radius: 0;
font-size: 14px;
color: $xpr-text;
width: 100%;
min-height: 30px;
box-sizing: border-box;
cursor: text;
&:focus {
border-bottom-color: $xpr-accent;
border-bottom-width: 2px;
outline: none;
box-shadow: none;
background: $xpr-cell-focus;
}
}
.o_field_widget select { cursor: pointer; }
// Ensure Boolean/toggle aligns visually with the underline
.o_field_boolean,
.o_field_boolean_toggle {
padding: 5px 0;
min-height: 30px;
display: flex;
align-items: center;
}
}
// Inline pair (Blanket SO toggle + Block partial checkbox)
.o_fp_xpr_inline_pair {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 5px 0;
min-height: 30px;
.o_field_widget { width: auto !important; }
}
.o_fp_xpr_inline_help {
font-size: 11px;
color: $xpr-text-muted;
font-style: italic;
}
.o_fp_xpr_cell.span-2 { grid-column: span 2; }
@@ -168,6 +213,50 @@
.o_fp_xpr_lines .o_list_view table {
border-collapse: collapse;
border: 1px solid $xpr-border-table;
table-layout: auto;
}
// Column widths — give Part Number + Specification room,
// tighten Qty/Price/Subtotal/Mask
.o_fp_xpr_lines .o_list_view {
th[data-name="part_catalog_id"],
td[name="part_catalog_id"] {
min-width: 230px;
width: 230px;
}
th[data-name="line_description"],
td[name="line_description"] {
min-width: 220px;
}
th[data-name="customer_line_ref"],
td[name="customer_line_ref"] { width: 75px; }
th[data-name="thickness_range"],
td[name="thickness_range"] { width: 110px; }
th[data-name="masking_enabled"],
td[name="masking_enabled"] {
width: 55px;
text-align: center;
}
th[data-name="bake_instructions"],
td[name="bake_instructions"] { width: 130px; }
th[data-name="internal_description"],
td[name="internal_description"] { min-width: 140px; }
th[data-name="quantity"],
td[name="quantity"] { width: 60px; }
th[data-name="unit_price"],
td[name="unit_price"] { width: 80px; }
th[data-name="line_subtotal"],
td[name="line_subtotal"] {
width: 90px;
font-weight: 600;
}
// Stacked DWG / OPEN action column
th[data-name="action_btns_anchor"],
td[name="action_btns_anchor"] {
width: 60px;
text-align: center;
padding: 2px !important;
}
}
.o_fp_xpr_lines .o_list_view thead th {
background: $xpr-table-head;
@@ -244,6 +333,38 @@
}
}
// Stacked DWG / OPEN buttons widget
.o_fp_xpr_action_stack {
display: flex;
flex-direction: column;
gap: 2px;
align-items: stretch;
.o_fp_xpr_action_stack_btn {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
padding: 2px 6px;
border: 1px solid $xpr-border-strong;
border-radius: 3px;
color: $xpr-text-muted;
background: transparent;
cursor: pointer;
line-height: 1.3;
&:hover:not(:disabled) {
color: $xpr-accent;
border-color: $xpr-accent;
background: $xpr-accent-bg;
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
}
// ============================================================
// FOOTER — Notes/Terms left + Totals right (CSS Grid)
// ============================================================

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<!-- Express Orders — stacked DWG / OPEN action buttons -->
<t t-name="fusion_plating_configurator.FpExpressActionBtns">
<div class="o_fp_xpr_action_stack">
<button class="o_fp_xpr_action_stack_btn"
t-on-click="onUpload"
t-att-disabled="!hasPart"
title="Upload a drawing for this part (saves to part record)">
DWG
</button>
<button class="o_fp_xpr_action_stack_btn"
t-on-click="onOpen"
t-att-disabled="!hasPart"
title="Open the part record in a modal">
OPEN
</button>
</div>
</t>
</templates>

View File

@@ -159,11 +159,16 @@
<field name="delivery_method" nolabel="1"/>
</div>
<!-- ROW 4 — Blanket + Currency + Quote Valid + Invoice -->
<!-- ROW 4 — Blanket (with block partial) + Currency + Quote Valid + Invoice -->
<div class="o_fp_xpr_cell">
<label for="is_blanket_order">Blanket SO</label>
<field name="is_blanket_order" nolabel="1"
widget="boolean_toggle"/>
<label for="is_blanket_order">Blanket Sales Order</label>
<div class="o_fp_xpr_inline_pair">
<field name="is_blanket_order" nolabel="1"/>
<field name="block_partial_shipments" nolabel="1"
invisible="not is_blanket_order"/>
<span class="o_fp_xpr_inline_help"
invisible="not is_blanket_order">Block partial shipments</span>
</div>
</div>
<div class="o_fp_xpr_cell">
<label for="pricelist_id">Currency / Pricelist</label>
@@ -250,16 +255,10 @@
widget="monetary"
options="{'currency_field': 'currency_id'}"
sum="Total"/>
<button name="action_upload_drawing" type="object"
string="DWG"
class="btn-link btn-sm o_fp_xpr_inline_btn"
title="Upload a drawing for this part"
invisible="not part_catalog_id"/>
<button name="action_open_part" type="object"
string="OPEN"
class="btn-link btn-sm o_fp_xpr_inline_btn"
title="Open part record"
invisible="not part_catalog_id"/>
<!-- Stacked DWG / OPEN buttons in ONE column -->
<field name="action_btns_anchor"
string=" "
widget="fp_express_action_btns"/>
<field name="process_variant_id"
string="Process / Recipe"
options="{'no_quick_create': True}"

View File

@@ -478,6 +478,14 @@ class FpDirectOrderLine(models.Model):
string='Part Name (display)',
readonly=True,
)
# Anchor field for the FpExpressActionBtns widget — renders the
# stacked DWG / OPEN buttons in one list column. The widget reads
# part_catalog_id from the line; this field's value is unused.
action_btns_anchor = fields.Many2one(
related='part_catalog_id',
string='Actions',
readonly=True,
)
customer_line_ref = fields.Char(
string='Customer Line Job #',
help='Per-line customer sub-reference (e.g. ABC, DEF). Distinct from '