changes
This commit is contained in:
@@ -29,7 +29,31 @@ import { _t } from "@web/core/l10n/translation";
|
||||
const NUMERIC_TYPES = new Set([
|
||||
"number", "temperature", "thickness", "time_seconds", "ph",
|
||||
]);
|
||||
const BOOLEAN_TYPES = new Set(["boolean", "pass_fail"]);
|
||||
// Generic boolean only — pass_fail gets its own dedicated PASS/FAIL widget
|
||||
// because a bare Yes/No toggle gives the operator no context about which
|
||||
// state is the good outcome.
|
||||
const BOOLEAN_TYPES = new Set(["boolean"]);
|
||||
|
||||
// Human-friendly labels for the type pill in the card header. Without
|
||||
// this map the pill shows the raw key (e.g. "pass_fail") which looks like
|
||||
// a developer field name. The recipe author shouldn't see code identifiers.
|
||||
const TYPE_LABELS = {
|
||||
text: "Text",
|
||||
number: "Number",
|
||||
boolean: "Yes / No",
|
||||
selection: "Selection",
|
||||
date: "Date / Time",
|
||||
signature: "Signature",
|
||||
time_hms: "Time (HH:MM:SS)",
|
||||
time_seconds: "Time (sec)",
|
||||
temperature: "Temperature",
|
||||
thickness: "Thickness",
|
||||
pass_fail: "Pass / Fail",
|
||||
photo: "Photo",
|
||||
multi_point_thickness: "Thickness (5 readings)",
|
||||
bath_chemistry_panel: "Bath Chemistry",
|
||||
ph: "pH",
|
||||
};
|
||||
|
||||
|
||||
export class FpRecordInputsDialog extends Component {
|
||||
@@ -46,6 +70,18 @@ export class FpRecordInputsDialog extends Component {
|
||||
stepName: "",
|
||||
jobName: "",
|
||||
rows: [],
|
||||
// Operator's persisted initials — pre-filled into signature
|
||||
// / "Reviewer Initials" prompts on load. When the operator
|
||||
// edits and saves a different value, the controller persists
|
||||
// it back to res.users.x_fc_initials so it sticks for every
|
||||
// future step / job.
|
||||
userInitials: "",
|
||||
// Recipe-author instructions: the description text and the
|
||||
// attached reference images (photos / screenshots / diagrams).
|
||||
// Surfaced at the top of the dialog before the prompt cards
|
||||
// so the operator sees them BEFORE entering values.
|
||||
instructionsHtml: "",
|
||||
instructionImages: [],
|
||||
});
|
||||
onWillStart(async () => {
|
||||
await this.loadPrompts();
|
||||
@@ -67,23 +103,86 @@ export class FpRecordInputsDialog extends Component {
|
||||
}
|
||||
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.userInitials = data.user_initials || "";
|
||||
this.state.instructionsHtml = data.instructions_html || "";
|
||||
this.state.instructionImages = data.instruction_images || [];
|
||||
const nowDt = this._fpNowForDatetimeLocal();
|
||||
this.state.rows = data.prompts.map((p) => {
|
||||
const row = {
|
||||
...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: "",
|
||||
// Pass/Fail explicit choice tracking — see onPass/onFail.
|
||||
_passfail_chosen: "",
|
||||
// Min / max range entry — see hasRangeEntry().
|
||||
value_min: 0,
|
||||
value_max: 0,
|
||||
};
|
||||
// ---- Sensible per-type defaults ------------------------------
|
||||
// Date / time → now. The operator can still adjust before save.
|
||||
if (this.isDate(row)) {
|
||||
row.value_date = nowDt;
|
||||
}
|
||||
// Pass / Fail defaults:
|
||||
// - Simple pass_fail (no target range) → default PASS so the
|
||||
// common "everything good" path is one less click.
|
||||
// - Range-based pass_fail (Bore A 0.005–0.007 etc.) → DO NOT
|
||||
// pre-select. The verdict must reflect the readings the
|
||||
// operator enters; pre-selecting PASS would silently
|
||||
// record PASS even when readings are out of spec.
|
||||
if (this.isPassFail(row) && !this.hasRangeEntry(row)) {
|
||||
row.value_boolean = true;
|
||||
row._passfail_chosen = "pass";
|
||||
}
|
||||
// Signature / "Reviewer Initials" / "Inspector Initials" /
|
||||
// similar prompts → pre-fill with the operator's persisted
|
||||
// initials so they don't retype the same letters on every
|
||||
// step. Heuristic: input_type=='signature' OR prompt name
|
||||
// contains 'initial' (case-insensitive).
|
||||
if (this._fpIsInitialsField(row)) {
|
||||
row.value_text = this.state.userInitials;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
this.state.loading = false;
|
||||
}
|
||||
|
||||
// True when this row should be auto-populated from
|
||||
// ``state.userInitials``. Driven by input_type or a name keyword
|
||||
// so it works for "Reviewer Initials" (text), "Inspector Signature"
|
||||
// (signature), "Operator Initials" (text), etc.
|
||||
_fpIsInitialsField(row) {
|
||||
if (this.isSignature(row)) return true;
|
||||
if ((row.input_type || "") === "text") {
|
||||
const name = (row.name || "").toLowerCase();
|
||||
return name.includes("initial");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Current local datetime as "YYYY-MM-DDTHH:MM" (the format the
|
||||
// <input type="datetime-local"> widget accepts in t-model).
|
||||
_fpNowForDatetimeLocal() {
|
||||
const d = new Date();
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return [
|
||||
d.getFullYear(),
|
||||
"-", pad(d.getMonth() + 1),
|
||||
"-", pad(d.getDate()),
|
||||
"T", pad(d.getHours()),
|
||||
":", pad(d.getMinutes()),
|
||||
].join("");
|
||||
}
|
||||
|
||||
// ---- 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); }
|
||||
@@ -92,12 +191,91 @@ export class FpRecordInputsDialog extends Component {
|
||||
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, ...)
|
||||
isPassFail(row) { return row.input_type === "pass_fail"; }
|
||||
isSignature(row) { return row.input_type === "signature"; }
|
||||
// Fallback to text for anything else (text, 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);
|
||||
&& !this.isSelection(row) && !this.isPassFail(row)
|
||||
&& !this.isSignature(row);
|
||||
}
|
||||
|
||||
// Friendly label for the type pill — defaults to the raw key when no
|
||||
// mapping exists so a future input_type still renders something.
|
||||
inputTypeLabel(row) {
|
||||
return TYPE_LABELS[row.input_type] || row.input_type || "Text";
|
||||
}
|
||||
|
||||
// True when the recipe author defined BOTH target_min and target_max
|
||||
// on the prompt — the signal that the operator is expected to capture
|
||||
// a range (multiple readings → record their min and max observation).
|
||||
//
|
||||
// Fires for numeric AND pass_fail types: a Bore inspection is a
|
||||
// canonical example where the prompt is "PASS/FAIL" but the recipe
|
||||
// sets a target range (e.g. 0.005–0.007 in) — operator records the
|
||||
// observed min and max bore reading AND marks pass/fail.
|
||||
hasRangeEntry(row) {
|
||||
if (!row.target_min || !row.target_max) return false;
|
||||
if (row.target_min === row.target_max) return false;
|
||||
return this.isNumeric(row) || this.isPassFail(row);
|
||||
}
|
||||
|
||||
// Range hint for the dual-entry case — both bounds must be within
|
||||
// spec for a green "in range" verdict; otherwise call out which one
|
||||
// is the offender.
|
||||
dualRangeHint(row) {
|
||||
const lo = parseFloat(row.value_min);
|
||||
const hi = parseFloat(row.value_max);
|
||||
if (!lo && !hi) return null;
|
||||
if (hi && lo && hi < lo) {
|
||||
return { kind: "low", text: _t("max < min — check entry") };
|
||||
}
|
||||
if (lo && row.target_min && lo < row.target_min) {
|
||||
return { kind: "low", text: _t("min below target") };
|
||||
}
|
||||
if (hi && row.target_max && hi > row.target_max) {
|
||||
return { kind: "high", text: _t("max above target") };
|
||||
}
|
||||
if (lo && hi) {
|
||||
return { kind: "ok", text: _t("both in range") };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Pass/Fail handlers — set value_boolean explicitly per button.
|
||||
// Three states: undecided (false + nothing chosen yet), passed, failed.
|
||||
// We track the operator's CHOICE separately from the underlying boolean
|
||||
// so the buttons can show "FAIL" as the active state (which would
|
||||
// otherwise be indistinguishable from "not yet answered" in a plain
|
||||
// boolean field).
|
||||
onPass(row) {
|
||||
row.value_boolean = true;
|
||||
row._passfail_chosen = "pass";
|
||||
}
|
||||
onFail(row) {
|
||||
row.value_boolean = false;
|
||||
row._passfail_chosen = "fail";
|
||||
}
|
||||
isPassActive(row) { return row._passfail_chosen === "pass"; }
|
||||
isFailActive(row) { return row._passfail_chosen === "fail"; }
|
||||
|
||||
// Auto-suggested PASS/FAIL outcome when a pass_fail prompt has both
|
||||
// a target range and at least one reading entered. Returns 'pass',
|
||||
// 'fail', or '' (no suggestion). Drives the visual hint under the
|
||||
// dual-entry widget; the operator still has to click a button.
|
||||
suggestedPassFail(row) {
|
||||
if (!this.isPassFail(row) || !this.hasRangeEntry(row)) return "";
|
||||
const lo = parseFloat(row.value_min);
|
||||
const hi = parseFloat(row.value_max);
|
||||
if (!lo && !hi) return "";
|
||||
const tmin = row.target_min;
|
||||
const tmax = row.target_max;
|
||||
const minOk = !lo || lo >= tmin;
|
||||
const maxOk = !hi || hi <= tmax;
|
||||
const sane = !lo || !hi || hi >= lo;
|
||||
return (minOk && maxOk && sane) ? "pass" : "fail";
|
||||
}
|
||||
|
||||
// ---- Selection options — recipe author may store as comma-sep ------
|
||||
@@ -125,7 +303,23 @@ export class FpRecordInputsDialog extends Component {
|
||||
return { kind: "ok", text: _t("in range") };
|
||||
}
|
||||
|
||||
// ---- Photo upload — file → base64 ----------------------------------
|
||||
// Convert HTML5 datetime-local "YYYY-MM-DDTHH:MM[:SS]" to Odoo's
|
||||
// "YYYY-MM-DD HH:MM:SS". Returns false for empty / falsy input so
|
||||
// the field clears cleanly on the server side.
|
||||
_fpFormatDatetime(v) {
|
||||
if (!v) return false;
|
||||
let s = String(v).replace("T", " ");
|
||||
if (s.endsWith("Z")) {
|
||||
s = s.slice(0, -1);
|
||||
}
|
||||
// datetime-local without step gives "HH:MM" — pad to "HH:MM:SS".
|
||||
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(s)) {
|
||||
s += ":00";
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---- Photo upload — file -> base64 ----------------------------------
|
||||
async onPhotoChange(row, ev) {
|
||||
const file = ev.target.files[0];
|
||||
if (!file) return;
|
||||
@@ -171,6 +365,7 @@ export class FpRecordInputsDialog extends Component {
|
||||
point_4: 0, point_5: 0,
|
||||
panel_ph: 0, panel_concentration: 0,
|
||||
panel_temperature: 0, panel_bath_id: "",
|
||||
_passfail_chosen: "",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -178,6 +373,21 @@ export class FpRecordInputsDialog extends Component {
|
||||
this.state.rows.splice(idx, 1);
|
||||
}
|
||||
|
||||
// The "current" initials value across all rows — a row counts as a
|
||||
// signature/initials field when ``_fpIsInitialsField`` is true.
|
||||
// Returns the most-recently-set value (last write wins) or empty.
|
||||
// The commit endpoint persists this back to res.users.x_fc_initials
|
||||
// when it differs from what was loaded.
|
||||
_fpCollectInitials() {
|
||||
let latest = "";
|
||||
for (const r of this.state.rows) {
|
||||
if (!this._fpIsInitialsField(r)) continue;
|
||||
const v = (r.value_text || "").trim();
|
||||
if (v) latest = v;
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
// ---- Save ----------------------------------------------------------
|
||||
async onSave() {
|
||||
// Validate ad-hoc rows have a prompt name
|
||||
@@ -190,31 +400,74 @@ export class FpRecordInputsDialog extends Component {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Validate range-based pass_fail rows: when readings are entered
|
||||
// (or the prompt is required), the operator must explicitly pick
|
||||
// PASS or FAIL. Otherwise readings would be recorded with no
|
||||
// verdict — silent ambiguity that breaks the audit trail.
|
||||
for (const row of this.state.rows) {
|
||||
if (!this.isPassFail(row) || !this.hasRangeEntry(row)) continue;
|
||||
const hasReadings = row.value_min || row.value_max;
|
||||
const noChoice = !row._passfail_chosen;
|
||||
if ((hasReadings || row.required) && noChoice) {
|
||||
this.notification.add(
|
||||
_t("Mark PASS or FAIL on \"%s\" before saving.")
|
||||
.replace("%s", row.name || _t("the inspection prompt")),
|
||||
{ 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 payload = this.state.rows.map((r) => {
|
||||
// When the prompt expects a range entry (min + max readings),
|
||||
// pack both into value_text for the audit trail and set
|
||||
// value_number to the larger reading so existing range checks
|
||||
// continue to work without a backend schema change. For
|
||||
// pass_fail prompts with range, the verdict (PASS or FAIL)
|
||||
// is appended too so the CoC shows the full inspection.
|
||||
let valueText = r.value_text || false;
|
||||
let valueNumber = r.value_number || 0;
|
||||
if (this.hasRangeEntry(r)
|
||||
&& (r.value_min || r.value_max)) {
|
||||
const lo = r.value_min || 0;
|
||||
const hi = r.value_max || 0;
|
||||
const unit = r.target_unit ? ` ${r.target_unit}` : "";
|
||||
let txt = `Min: ${lo}, Max: ${hi}${unit}`;
|
||||
if (this.isPassFail(r) && r._passfail_chosen) {
|
||||
txt += ` — ${r._passfail_chosen.toUpperCase()}`;
|
||||
}
|
||||
valueText = txt;
|
||||
valueNumber = hi || lo;
|
||||
}
|
||||
return {
|
||||
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: valueText,
|
||||
value_number: valueNumber,
|
||||
value_boolean: r.value_boolean,
|
||||
// datetime-local emits "YYYY-MM-DDTHH:MM" (or "...:SS")
|
||||
// Odoo's Datetime field needs "YYYY-MM-DD HH:MM:SS".
|
||||
// Normalise here so the wire payload is always valid.
|
||||
value_date: this._fpFormatDatetime(r.value_date),
|
||||
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,
|
||||
user_initials: this._fpCollectInitials(),
|
||||
});
|
||||
this.state.saving = false;
|
||||
if (!result.ok) {
|
||||
@@ -229,9 +482,23 @@ export class FpRecordInputsDialog extends Component {
|
||||
{ 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);
|
||||
// Dispatch a meaningful next action when the backend returns one
|
||||
// (e.g. opening another form). Otherwise — and for the no-op
|
||||
// ir.actions.act_window_close case — soft-reload so the job form
|
||||
// behind the dialog re-fetches and the operator sees the step
|
||||
// state flip from In Progress -> Done without manually refreshing.
|
||||
const next = result.next_action;
|
||||
const isReal =
|
||||
next &&
|
||||
typeof next === "object" &&
|
||||
next.type !== "ir.actions.act_window_close";
|
||||
if (isReal) {
|
||||
await this.action.doAction(next);
|
||||
} else {
|
||||
await this.action.doAction({
|
||||
type: "ir.actions.client",
|
||||
tag: "soft_reload",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user