feat(jobs): Record Inputs OWL Dialog (v4) — replaces list-as-cards hack
Scrapped the v2/v3 form-view + list-as-cards CSS approach after
extensive failure to make Odoo's editable list look like cards.
Built a proper OWL Dialog component instead, mirroring the pattern
used by fusion_plating_shopfloor's move_parts_dialog.js.
What changed
============
* New OWL Dialog: fp_record_inputs_dialog.js
- Loads step + prompt definitions via /fp/record_inputs/load
- Renders each prompt as a semantic <div class="o_fp_ri_card">
- Per-row widget chosen by input_type:
numeric/temperature/thickness/time_seconds/ph -> number input
boolean/pass_fail -> custom CSS toggle (clearer than Bootstrap)
date -> datetime-local input
photo -> file picker w/ preview + clear
multi_point_thickness -> 5-cell grid + live average
bath_chemistry_panel -> pH/Conc/Temp/Bath grid
selection -> dropdown sourced from selection_options
text/signature/... -> text input
- Live in-range hint for numeric prompts
("in range" / "below target" / "above target")
- Save validates ad-hoc rows have a Prompt label
- Save dispatches the next_action returned by the wizard model
(e.g. action_finish_and_advance for the Finish & Next flow)
* New XML template: fp_record_inputs_dialog.xml
Full DOM control. No fighting Odoo's list view, no class-stripping
bugs from canUseFormatter, no read-mode-vs-edit-mode CSS dance.
* New SCSS: fp_record_inputs_dialog.scss
- Dark mode aware (compile-time @if $o-webclient-color-scheme==dark)
- Pure semantic selectors (.o_fp_ri_card, .o_fp_ri_input, etc.)
- 14 surface tokens with light/dark hex pairs
- Tablet polish via @media (max-width: 768px)
- Custom toggle widget (no <input type="checkbox"> hidden trick)
* New controller: controllers/record_inputs.py
- /fp/record_inputs/load: returns step + prompts payload
- /fp/record_inputs/commit: creates a wizard, populates lines,
calls action_commit (reuses existing audit-trail / synthetic
move semantics — no commit logic duplicated)
* fp_job_step.py wired to dispatch the new action
- _fp_open_input_wizard returns
{ type: 'ir.actions.client', tag: 'fp_record_inputs_dialog' }
- action_open_input_wizard same
- Contract-review redirect gate preserved (Sub 4 work intact)
* Manifest registers JS/XML/SCSS in BOTH backend + dark bundles
per the dark-mode pattern in CLAUDE.md.
What was kept
=============
* fp.job.step.input.wizard TransientModel — UNCHANGED. The new
controller's commit endpoint creates a wizard record and calls
action_commit() on it, so all the audit-trail / synthetic-move
/ chatter logic stays in Python where it belongs.
* v2 + v3 form views still exist in the XML file. If the OWL
dialog ever fails, switch action_open_input_wizard back to
ir.actions.act_window with view_id=v2 or v3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
/** @odoo-module **/
|
||||
/*
|
||||
* Record Inputs Dialog (Sub 12e v4)
|
||||
*
|
||||
* Replaces the form-view + list-as-cards CSS hack with a proper OWL
|
||||
* Dialog that owns its own DOM. No more fighting Odoo's editable list
|
||||
* renderer — semantic HTML, full visual control, dark-mode aware.
|
||||
*
|
||||
* Backend dispatch:
|
||||
* fp_job_step.action_open_input_wizard / action_finish_and_advance
|
||||
* return ir.actions.client { tag: 'fp_record_inputs_dialog', params }.
|
||||
* The action handler below opens the Dialog and returns nothing
|
||||
* (the action chain ends; the dialog manages itself).
|
||||
*
|
||||
* Dialog flow:
|
||||
* onWillStart → /fp/record_inputs/load → seed prompt rows
|
||||
* onSave → /fp/record_inputs/commit → advance step (optional)
|
||||
*/
|
||||
|
||||
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
|
||||
// Type categories — drives which input widget renders per row.
|
||||
const NUMERIC_TYPES = new Set([
|
||||
"number", "temperature", "thickness", "time_seconds", "ph",
|
||||
]);
|
||||
const BOOLEAN_TYPES = new Set(["boolean", "pass_fail"]);
|
||||
|
||||
|
||||
export class FpRecordInputsDialog extends Component {
|
||||
static template = "fusion_plating_jobs.FpRecordInputsDialog";
|
||||
static components = { Dialog };
|
||||
static props = ["stepId", "advanceAfter?", "close"];
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.action = useService("action");
|
||||
this.state = useState({
|
||||
loading: true,
|
||||
saving: false,
|
||||
stepName: "",
|
||||
jobName: "",
|
||||
rows: [],
|
||||
});
|
||||
onWillStart(async () => {
|
||||
await this.loadPrompts();
|
||||
});
|
||||
}
|
||||
|
||||
async loadPrompts() {
|
||||
this.state.loading = true;
|
||||
const data = await rpc("/fp/record_inputs/load", {
|
||||
step_id: this.props.stepId,
|
||||
});
|
||||
if (!data.ok) {
|
||||
this.notification.add(
|
||||
data.error || _t("Could not load step prompts."),
|
||||
{ type: "danger" },
|
||||
);
|
||||
this.props.close();
|
||||
return;
|
||||
}
|
||||
this.state.stepName = data.step.name;
|
||||
this.state.jobName = data.job.name;
|
||||
this.state.rows = data.prompts.map((p) => ({
|
||||
...p,
|
||||
// value fields — initialized blank, populated as operator types
|
||||
value_text: "",
|
||||
value_number: 0,
|
||||
value_boolean: false,
|
||||
value_date: "",
|
||||
photo_value: false,
|
||||
photo_filename: "",
|
||||
point_1: 0, point_2: 0, point_3: 0,
|
||||
point_4: 0, point_5: 0,
|
||||
panel_ph: 0, panel_concentration: 0,
|
||||
panel_temperature: 0, panel_bath_id: "",
|
||||
}));
|
||||
this.state.loading = false;
|
||||
}
|
||||
|
||||
// ---- Type predicates (used by the OWL template t-if) ----------------
|
||||
isNumeric(row) { return NUMERIC_TYPES.has(row.input_type); }
|
||||
isBoolean(row) { return BOOLEAN_TYPES.has(row.input_type); }
|
||||
isDate(row) { return row.input_type === "date"; }
|
||||
isPhoto(row) { return row.input_type === "photo"; }
|
||||
isMulti(row) { return row.input_type === "multi_point_thickness"; }
|
||||
isPanel(row) { return row.input_type === "bath_chemistry_panel"; }
|
||||
isSelection(row) { return row.input_type === "selection"; }
|
||||
// Fallback to text for anything else (text, signature, time_hms, ...)
|
||||
isText(row) {
|
||||
return !this.isNumeric(row) && !this.isBoolean(row)
|
||||
&& !this.isDate(row) && !this.isPhoto(row)
|
||||
&& !this.isMulti(row) && !this.isPanel(row)
|
||||
&& !this.isSelection(row);
|
||||
}
|
||||
|
||||
// ---- Selection options — recipe author may store as comma-sep ------
|
||||
selectionOptions(row) {
|
||||
const raw = row.selection_options || "";
|
||||
return raw.split(/[\n,]/).map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
// ---- Multi-point: live average of non-zero readings ----------------
|
||||
multiPointAvg(row) {
|
||||
const pts = [row.point_1, row.point_2, row.point_3,
|
||||
row.point_4, row.point_5].filter((v) => v);
|
||||
if (!pts.length) return 0;
|
||||
return (pts.reduce((a, b) => a + b, 0) / pts.length).toFixed(3);
|
||||
}
|
||||
|
||||
// ---- In-range hint for numeric — "in range" / "low" / "high" -------
|
||||
rangeHint(row) {
|
||||
if (!this.isNumeric(row)) return null;
|
||||
if (!row.target_min && !row.target_max) return null;
|
||||
const v = parseFloat(row.value_number);
|
||||
if (!v) return null;
|
||||
if (row.target_min && v < row.target_min) return { kind: "low", text: _t("below target") };
|
||||
if (row.target_max && v > row.target_max) return { kind: "high", text: _t("above target") };
|
||||
return { kind: "ok", text: _t("in range") };
|
||||
}
|
||||
|
||||
// ---- Photo upload — file → base64 ----------------------------------
|
||||
async onPhotoChange(row, ev) {
|
||||
const file = ev.target.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const result = e.target.result;
|
||||
row.photo_value = result.split(",")[1]; // strip data: URL prefix
|
||||
row.photo_filename = file.name;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
onPhotoClear(row) {
|
||||
row.photo_value = false;
|
||||
row.photo_filename = "";
|
||||
}
|
||||
|
||||
photoPreviewSrc(row) {
|
||||
if (!row.photo_value) return "";
|
||||
return "data:image/jpeg;base64," + row.photo_value;
|
||||
}
|
||||
|
||||
// ---- Add an ad-hoc measurement row ---------------------------------
|
||||
addAdHocRow() {
|
||||
this.state.rows.push({
|
||||
node_input_id: false,
|
||||
name: "",
|
||||
input_type: "text",
|
||||
required: false,
|
||||
target_min: 0,
|
||||
target_max: 0,
|
||||
target_unit: "",
|
||||
hint: "",
|
||||
selection_options: "",
|
||||
is_authored: false,
|
||||
value_text: "",
|
||||
value_number: 0,
|
||||
value_boolean: false,
|
||||
value_date: "",
|
||||
photo_value: false,
|
||||
photo_filename: "",
|
||||
point_1: 0, point_2: 0, point_3: 0,
|
||||
point_4: 0, point_5: 0,
|
||||
panel_ph: 0, panel_concentration: 0,
|
||||
panel_temperature: 0, panel_bath_id: "",
|
||||
});
|
||||
}
|
||||
|
||||
removeRow(idx) {
|
||||
this.state.rows.splice(idx, 1);
|
||||
}
|
||||
|
||||
// ---- Save ----------------------------------------------------------
|
||||
async onSave() {
|
||||
// Validate ad-hoc rows have a prompt name
|
||||
for (const row of this.state.rows) {
|
||||
if (!row.is_authored && !row.name.trim()) {
|
||||
this.notification.add(
|
||||
_t("Every ad-hoc measurement needs a Prompt label."),
|
||||
{ type: "warning" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.state.saving = true;
|
||||
const payload = this.state.rows.map((r) => ({
|
||||
node_input_id: r.node_input_id || false,
|
||||
name: r.name,
|
||||
input_type: r.input_type,
|
||||
target_unit: r.target_unit,
|
||||
target_min: r.target_min,
|
||||
target_max: r.target_max,
|
||||
value_text: r.value_text || false,
|
||||
value_number: r.value_number || 0,
|
||||
value_boolean: r.value_boolean,
|
||||
value_date: r.value_date || false,
|
||||
photo_value: r.photo_value || false,
|
||||
photo_filename: r.photo_filename || false,
|
||||
point_1: r.point_1, point_2: r.point_2, point_3: r.point_3,
|
||||
point_4: r.point_4, point_5: r.point_5,
|
||||
panel_ph: r.panel_ph,
|
||||
panel_concentration: r.panel_concentration,
|
||||
panel_temperature: r.panel_temperature,
|
||||
panel_bath_id: r.panel_bath_id,
|
||||
}));
|
||||
const result = await rpc("/fp/record_inputs/commit", {
|
||||
step_id: this.props.stepId,
|
||||
values: payload,
|
||||
advance_after: !!this.props.advanceAfter,
|
||||
});
|
||||
this.state.saving = false;
|
||||
if (!result.ok) {
|
||||
this.notification.add(
|
||||
result.error || _t("Save failed."),
|
||||
{ type: "danger" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.notification.add(
|
||||
_t("Inputs recorded."),
|
||||
{ type: "success" },
|
||||
);
|
||||
this.props.close();
|
||||
// If commit returned an action (e.g. Finish & Advance), dispatch it
|
||||
if (result.next_action && typeof result.next_action === "object") {
|
||||
await this.action.doAction(result.next_action);
|
||||
}
|
||||
}
|
||||
|
||||
onCancel() {
|
||||
this.props.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Register as a client action so backend Python can dispatch via:
|
||||
// { type: 'ir.actions.client', tag: 'fp_record_inputs_dialog', params: {...} }
|
||||
function fpRecordInputsDialogActionHandler(env, action) {
|
||||
env.services.dialog.add(FpRecordInputsDialog, {
|
||||
stepId: action.params.step_id,
|
||||
advanceAfter: action.params.advance_after || false,
|
||||
});
|
||||
// Action chain ends — dialog is self-managed.
|
||||
return { type: "ir.actions.act_window_close" };
|
||||
}
|
||||
|
||||
registry.category("actions").add(
|
||||
"fp_record_inputs_dialog",
|
||||
fpRecordInputsDialogActionHandler,
|
||||
);
|
||||
Reference in New Issue
Block a user