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:
gsinghpal
2026-05-03 22:17:30 -04:00
parent 328599d539
commit d53fd53b80
7 changed files with 1186 additions and 36 deletions

View File

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