feat(configurator): Part cell rows 2-3 now editable — type to save
Customer feedback: rows 2 (description) and 3 (serials) in the Part cell rendered as read-only spans. User wanted to edit directly. New writable computed fields on fp.direct.order.line: - part_name_editable: compute reads part_catalog_id.name, inverse writes back to part.name on the linked catalog record - serials_text: compute joins serial_ids names with commas; inverse parses the typed string and find-or-creates fp.serial records, updates the line's serial_ids M2M Removed the redundant rev separator (display_name already includes '(Rev X)' so showing it twice was clutter). Rev edits happen by editing the part record directly via the OPEN button. OWL widget templates updated: - Row 2: <input> bound to part_name_editable, t-on-change saves - Row 3: <input> bound to serials_text, t-on-change parses + saves SCSS: - Row 2 input: italic, transparent border, focus tints background yellow - Row 3 input: small grey text, comma-separated friendly placeholder - Both disabled-look when no part is picked Both inputs trigger the inverse method on blur. The G4 sync chain takes over from there to push line.line_description etc. back to the part as before — so editing in the line keeps the part defaults fresh for future orders.
This commit is contained in:
@@ -1,25 +1,17 @@
|
|||||||
/** @odoo-module **/
|
/** @odoo-module **/
|
||||||
|
|
||||||
// Express Orders — multi-row Part cell widget (2026-05-26)
|
// Express Orders — multi-row Part cell widget (2026-05-26, revised 2026-05-27)
|
||||||
//
|
//
|
||||||
// Replaces the standard Many2One renderer for `part_catalog_id` in
|
// Row 1: Many2OneField picker (shows part_catalog_id.display_name —
|
||||||
// the Express Orders line list. Shows three stacked rows in ONE
|
// which is just the part_number when fp_express_part_picker
|
||||||
// cell, mirroring the brainstorm mockup:
|
// context flag is set).
|
||||||
//
|
// Row 2: editable input bound to part_name_editable (writable compute
|
||||||
// Row 1: Part # / Revision (bold; part # is the active picker)
|
// with inverse that writes part.name on the linked catalog
|
||||||
// Row 2: Part description (italic, muted)
|
// record — see fp_direct_order_line.py).
|
||||||
// Row 3: Serial #(s) (small grey, joined by ", ")
|
// Row 3: editable input bound to serials_text (parses comma-separated
|
||||||
// + small "+ bulk" button
|
// names, finds-or-creates fp.serial records, updates the line's
|
||||||
//
|
// serial_ids M2M) + small "+ bulk" button that opens the existing
|
||||||
// Pre-fetched fields used (read off the line record, no RPC needed):
|
// fp.serial.bulk.add.wizard for paste-list / range-fill entry.
|
||||||
// - 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 { Component } from "@odoo/owl";
|
||||||
import { registry } from "@web/core/registry";
|
import { registry } from "@web/core/registry";
|
||||||
@@ -38,37 +30,40 @@ export class FpExpressPartCell extends Component {
|
|||||||
this.notification = useService("notification");
|
this.notification = useService("notification");
|
||||||
}
|
}
|
||||||
|
|
||||||
get partRev() {
|
get hasPart() {
|
||||||
return this.props.record.data.part_revision_display || "";
|
return !!this.props.record.data.part_catalog_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
get partName() {
|
get partName() {
|
||||||
return this.props.record.data.part_name_display || "";
|
return this.props.record.data.part_name_editable || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
get serialsList() {
|
get serialsText() {
|
||||||
const serials = this.props.record.data.serial_ids;
|
return this.props.record.data.serials_text || "";
|
||||||
if (!serials || !serials.records) return [];
|
|
||||||
return serials.records
|
|
||||||
.map(r => r.data && (r.data.display_name || r.data.name))
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get serialsDisplay() {
|
async onNameChange(ev) {
|
||||||
const list = this.serialsList;
|
const newName = (ev.target.value || "").trim();
|
||||||
if (!list.length) return "";
|
if (newName === this.partName.trim()) return;
|
||||||
if (list.length <= 3) return list.join(", ");
|
await this.props.record.update({ part_name_editable: newName });
|
||||||
return list.slice(0, 3).join(", ") + ` +${list.length - 3} more`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get hasPart() {
|
async onSerialsChange(ev) {
|
||||||
return !!this.props.record.data.part_catalog_id;
|
const newVal = (ev.target.value || "").trim();
|
||||||
|
if (newVal === this.serialsText.trim()) return;
|
||||||
|
await this.props.record.update({ serials_text: newVal });
|
||||||
|
}
|
||||||
|
|
||||||
|
async _ensureSaved() {
|
||||||
|
if (!this.props.record.resId) {
|
||||||
|
await this.props.record.save();
|
||||||
|
}
|
||||||
|
return !!this.props.record.resId;
|
||||||
}
|
}
|
||||||
|
|
||||||
async onBulkClick(ev) {
|
async onBulkClick(ev) {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
|
||||||
if (!this.hasPart) {
|
if (!this.hasPart) {
|
||||||
this.notification.add(
|
this.notification.add(
|
||||||
"Pick a part first, then click + bulk to add serials.",
|
"Pick a part first, then click + bulk to add serials.",
|
||||||
@@ -76,20 +71,13 @@ export class FpExpressPartCell extends Component {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!(await this._ensureSaved())) {
|
||||||
// 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(
|
this.notification.add(
|
||||||
"Save the order first before bulk-adding serials.",
|
"Save the order first before bulk-adding serials.",
|
||||||
{ type: "warning" }
|
{ type: "warning" }
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const action = await this.orm.call(
|
const action = await this.orm.call(
|
||||||
this.props.record.resModel,
|
this.props.record.resModel,
|
||||||
"action_open_serial_bulk_add",
|
"action_open_serial_bulk_add",
|
||||||
@@ -99,8 +87,6 @@ export class FpExpressPartCell extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register the field widget. supportedTypes=many2one because the field
|
|
||||||
// it binds to (part_catalog_id) is a Many2One.
|
|
||||||
export const fpExpressPartCell = {
|
export const fpExpressPartCell = {
|
||||||
...many2OneField,
|
...many2OneField,
|
||||||
component: FpExpressPartCell,
|
component: FpExpressPartCell,
|
||||||
|
|||||||
@@ -582,23 +582,62 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.o_fp_xpr_part_name {
|
.o_fp_xpr_part_name {
|
||||||
font-style: italic;
|
.o_fp_xpr_part_name_input {
|
||||||
font-size: 12px;
|
width: 100%;
|
||||||
color: $xpr-text;
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 12px;
|
||||||
|
color: $xpr-text;
|
||||||
|
padding: 2px 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
&.o_fp_xpr_part_name_empty { color: $xpr-text-dim; }
|
&::placeholder {
|
||||||
|
color: $xpr-text-dim;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
background: $xpr-cell-focus;
|
||||||
|
}
|
||||||
|
&:disabled {
|
||||||
|
background: transparent;
|
||||||
|
color: $xpr-text-dim;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.o_fp_xpr_part_serial {
|
.o_fp_xpr_part_serial {
|
||||||
font-size: 11px;
|
|
||||||
color: $xpr-text-muted;
|
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
.o_fp_xpr_serials { letter-spacing: 0.2px; }
|
.o_fp_xpr_serial_input {
|
||||||
.o_fp_xpr_serials_empty {
|
flex: 1;
|
||||||
font-style: italic;
|
min-width: 0;
|
||||||
color: $xpr-text-dim;
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 11px;
|
||||||
|
color: $xpr-text-muted;
|
||||||
|
padding: 2px 4px;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
font-style: italic;
|
||||||
|
color: $xpr-text-dim;
|
||||||
|
}
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
background: $xpr-cell-focus;
|
||||||
|
color: $xpr-text;
|
||||||
|
}
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.o_fp_xpr_bulk_btn {
|
.o_fp_xpr_bulk_btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
background: $xpr-section-bg;
|
background: $xpr-section-bg;
|
||||||
border: 1px solid $xpr-border-strong;
|
border: 1px solid $xpr-border-strong;
|
||||||
color: $xpr-text-muted;
|
color: $xpr-text-muted;
|
||||||
|
|||||||
@@ -1,27 +1,39 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<templates xml:space="preserve">
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
<!-- Express Orders — Part cell template (3-row stacked) -->
|
<!-- Express Orders — Part cell template (3 stacked rows)
|
||||||
|
|
||||||
|
Row 1: Part picker (Many2OneField — display_name is just the
|
||||||
|
part_number when fp_express_part_picker context is set)
|
||||||
|
Row 2: editable part description (writes to part.name on blur)
|
||||||
|
Row 3: editable serial #s (parses comma-separated, creates fp.serial
|
||||||
|
records as needed) + small + bulk button
|
||||||
|
-->
|
||||||
<t t-name="fusion_plating_configurator.FpExpressPartCell">
|
<t t-name="fusion_plating_configurator.FpExpressPartCell">
|
||||||
<div class="o_fp_xpr_part_cell">
|
<div class="o_fp_xpr_part_cell">
|
||||||
<!-- Row 1: Part Number picker + Revision -->
|
<!-- Row 1 — Part picker -->
|
||||||
<div class="o_fp_xpr_part_row o_fp_xpr_part_id">
|
<div class="o_fp_xpr_part_row o_fp_xpr_part_id">
|
||||||
<Many2OneField t-props="props"/>
|
<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>
|
</div>
|
||||||
<!-- Row 2: Part description / name (italic, muted) -->
|
<!-- Row 2 — Editable part description (saves to part.name) -->
|
||||||
<div class="o_fp_xpr_part_row o_fp_xpr_part_name"
|
<div class="o_fp_xpr_part_row o_fp_xpr_part_name">
|
||||||
t-att-class="{ 'o_fp_xpr_part_name_empty': !partName }">
|
<input class="o_fp_xpr_part_name_input"
|
||||||
<t t-esc="partName or '— no description —'"/>
|
type="text"
|
||||||
|
t-att-value="partName"
|
||||||
|
t-att-disabled="!hasPart"
|
||||||
|
t-on-change="onNameChange"
|
||||||
|
placeholder="— part description —"
|
||||||
|
title="Edit the part's name — saves to the part record"/>
|
||||||
</div>
|
</div>
|
||||||
<!-- Row 3: Serial #(s) + bulk button -->
|
<!-- Row 3 — Editable serial list + bulk-add button -->
|
||||||
<div class="o_fp_xpr_part_row o_fp_xpr_part_serial">
|
<div class="o_fp_xpr_part_row o_fp_xpr_part_serial">
|
||||||
<span class="o_fp_xpr_serials"
|
<input class="o_fp_xpr_serial_input"
|
||||||
t-att-class="{ 'o_fp_xpr_serials_empty': !serialsList.length }"
|
type="text"
|
||||||
t-esc="serialsDisplay or 'no serials yet'"/>
|
t-att-value="serialsText"
|
||||||
|
t-att-disabled="!hasPart"
|
||||||
|
t-on-change="onSerialsChange"
|
||||||
|
placeholder="serial #(s) — comma separated"
|
||||||
|
title="Type serials separated by commas — creates fp.serial records as needed"/>
|
||||||
<button class="o_fp_xpr_bulk_btn"
|
<button class="o_fp_xpr_bulk_btn"
|
||||||
t-on-click="onBulkClick"
|
t-on-click="onBulkClick"
|
||||||
t-att-disabled="!hasPart"
|
t-att-disabled="!hasPart"
|
||||||
|
|||||||
@@ -248,6 +248,10 @@
|
|||||||
<field name="part_number_display" column_invisible="1"/>
|
<field name="part_number_display" column_invisible="1"/>
|
||||||
<field name="part_revision_display" column_invisible="1"/>
|
<field name="part_revision_display" column_invisible="1"/>
|
||||||
<field name="part_name_display" column_invisible="1"/>
|
<field name="part_name_display" column_invisible="1"/>
|
||||||
|
<!-- Writable bridges used by the Part cell widget for
|
||||||
|
editable rows 2 (description) and 3 (serials). -->
|
||||||
|
<field name="part_name_editable" column_invisible="1"/>
|
||||||
|
<field name="serials_text" column_invisible="1"/>
|
||||||
<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'}"
|
||||||
|
|||||||
@@ -478,6 +478,66 @@ class FpDirectOrderLine(models.Model):
|
|||||||
string='Part Name (display)',
|
string='Part Name (display)',
|
||||||
readonly=True,
|
readonly=True,
|
||||||
)
|
)
|
||||||
|
# Writable bridges so the Part cell widget's row-2 description input
|
||||||
|
# and row-3 serials input save back to the underlying records
|
||||||
|
# (part.name and the line's serial_ids M2M) on blur.
|
||||||
|
part_name_editable = fields.Char(
|
||||||
|
string='Part Name (editable)',
|
||||||
|
compute='_compute_part_name_editable',
|
||||||
|
inverse='_inverse_part_name_editable',
|
||||||
|
store=False,
|
||||||
|
)
|
||||||
|
serials_text = fields.Char(
|
||||||
|
string='Serials (text)',
|
||||||
|
compute='_compute_serials_text',
|
||||||
|
inverse='_inverse_serials_text',
|
||||||
|
store=False,
|
||||||
|
help='Comma-separated list of serial numbers — typing here parses, '
|
||||||
|
'creates new fp.serial records as needed, and updates the M2M.',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('part_catalog_id', 'part_catalog_id.name')
|
||||||
|
def _compute_part_name_editable(self):
|
||||||
|
for rec in self:
|
||||||
|
rec.part_name_editable = rec.part_catalog_id.name or ''
|
||||||
|
|
||||||
|
def _inverse_part_name_editable(self):
|
||||||
|
for rec in self:
|
||||||
|
if rec.part_catalog_id:
|
||||||
|
new_name = (rec.part_name_editable or '').strip()
|
||||||
|
if new_name and new_name != rec.part_catalog_id.name:
|
||||||
|
rec.part_catalog_id.sudo().write({'name': new_name})
|
||||||
|
|
||||||
|
@api.depends('serial_ids', 'serial_ids.name')
|
||||||
|
def _compute_serials_text(self):
|
||||||
|
for rec in self:
|
||||||
|
rec.serials_text = ', '.join(rec.serial_ids.mapped('name'))
|
||||||
|
|
||||||
|
def _inverse_serials_text(self):
|
||||||
|
Serial = self.env['fp.serial']
|
||||||
|
company_id = self.env.company.id
|
||||||
|
for rec in self:
|
||||||
|
raw = (rec.serials_text or '').strip()
|
||||||
|
if not raw:
|
||||||
|
rec.serial_ids = [(5, 0)]
|
||||||
|
continue
|
||||||
|
names = [n.strip() for n in raw.replace(';', ',').split(',') if n.strip()]
|
||||||
|
if not names:
|
||||||
|
rec.serial_ids = [(5, 0)]
|
||||||
|
continue
|
||||||
|
existing = Serial.search([
|
||||||
|
('name', 'in', names),
|
||||||
|
('company_id', '=', company_id),
|
||||||
|
])
|
||||||
|
existing_by_name = {s.name: s for s in existing}
|
||||||
|
ids = []
|
||||||
|
for name in names:
|
||||||
|
if name in existing_by_name:
|
||||||
|
ids.append(existing_by_name[name].id)
|
||||||
|
else:
|
||||||
|
new = Serial.sudo().create({'name': name})
|
||||||
|
ids.append(new.id)
|
||||||
|
rec.serial_ids = [(6, 0, ids)]
|
||||||
# Anchor field for the FpExpressActionBtns widget — renders the
|
# Anchor field for the FpExpressActionBtns widget — renders the
|
||||||
# stacked DWG / OPEN buttons in one list column. The widget reads
|
# stacked DWG / OPEN buttons in one list column. The widget reads
|
||||||
# part_catalog_id from the line; this field's value is unused.
|
# part_catalog_id from the line; this field's value is unused.
|
||||||
|
|||||||
Reference in New Issue
Block a user