This commit is contained in:
gsinghpal
2026-05-10 10:25:12 -04:00
parent 6c6a59ceef
commit 6b7b44264a
59 changed files with 2461 additions and 324 deletions

View File

@@ -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.0050.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.0050.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",
});
}
}

View File

@@ -223,10 +223,42 @@ $rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex});
// ---------- Target / hint helpers ------------------------------------------
// Target pill — surfaces the recipe-author's target_min / target_max
// (the "spec") so the operator knows what they're aiming for BEFORE
// they enter readings. Reads as a small inline badge with bullseye
// icon, separated visually from the body / hint copy.
.o_fp_ri_target {
margin: 0 0 8px 0;
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0 0 10px 0;
padding: 4px 10px;
background-color: rgba(46, 125, 107, .10);
border: 1px solid rgba(46, 125, 107, .25);
border-radius: 999px;
font-size: 0.8125rem;
color: $rid-ink-mute;
color: $rid-ok;
.fa-bullseye { color: $rid-ok; }
.o_fp_ri_target_label {
text-transform: uppercase;
letter-spacing: .04em;
font-size: 0.7rem;
font-weight: 600;
opacity: .85;
}
.o_fp_ri_target_value {
color: $rid-ink;
font-variant-numeric: tabular-nums;
}
.o_fp_ri_target_unit {
margin-left: 2px;
color: $rid-ink-mute;
font-size: 0.75rem;
}
}
.o_fp_ri_hint {
margin: 0 0 8px 0;
@@ -236,6 +268,69 @@ $rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex});
}
// =============================================================================
// Instructions block — recipe author's narrative text + image gallery,
// rendered above the prompt cards so the operator reads context BEFORE
// entering values. Hidden by the t-if when neither piece is authored.
// =============================================================================
.o_fp_ri_instructions {
margin-bottom: 14px;
padding: 14px 16px;
background-color: $rid-card;
border: 1px solid $rid-border;
border-left: 4px solid $rid-border-focus;
border-radius: 6px;
color: $rid-ink;
box-shadow: 0 1px 2px rgba(0, 0, 0, .03);
.o_fp_ri_instructions_text {
font-size: .95rem;
line-height: 1.5;
margin-bottom: 10px;
// Reset the rich-text fragments coming out of the HTML field
// so they render predictably inside the dialog frame.
:first-child { margin-top: 0; }
:last-child { margin-bottom: 0; }
img { max-width: 100%; height: auto; border-radius: 4px; }
}
.o_fp_ri_instructions_gallery {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.o_fp_ri_instructions_thumb {
display: inline-block;
width: 96px;
height: 96px;
border: 1px solid $rid-border;
border-radius: 4px;
overflow: hidden;
background-color: $rid-page;
cursor: zoom-in;
transition: transform .12s ease, border-color .12s ease,
box-shadow .12s ease;
&:hover {
transform: scale(1.04);
border-color: $rid-border-focus;
box-shadow: 0 2px 8px rgba(0, 0, 0, .12);
}
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
}
// =============================================================================
// Card body — inputs per type
// =============================================================================
@@ -512,3 +607,197 @@ $rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex});
grid-template-columns: repeat(2, 1fr);
}
}
// =============================================================================
// Pass / Fail — distinct two-button widget
//
// A bare boolean toggle hid the question's intent ("PASS or FAIL?" → "Yes
// or No?"). Two clearly-coloured buttons mirror the language the operator
// already speaks: green PASS, red FAIL. Active button fills with the
// outcome colour; inactive stays outlined.
// =============================================================================
.o_fp_ri_passfail {
display: flex;
gap: 12px;
.o_fp_ri_pf_btn {
flex: 1;
min-height: 52px;
padding: 10px 16px;
font-size: 1rem;
font-weight: 700;
letter-spacing: .04em;
border-radius: 6px;
background: transparent;
cursor: pointer;
transition: background-color .12s ease, color .12s ease,
border-color .12s ease, transform .04s ease;
&:active {
transform: scale(0.985);
}
.fa {
font-size: 1.05em;
}
}
.o_fp_ri_pf_pass {
border: 1.5px solid $rid-ok;
color: $rid-ok;
&:hover { background-color: rgba(25, 135, 84, .08); }
&.o_fp_ri_pf_active {
background-color: $rid-ok;
color: #ffffff;
border-color: $rid-ok;
box-shadow: 0 1px 0 rgba(0, 0, 0, .08);
}
}
.o_fp_ri_pf_fail {
border: 1.5px solid $rid-required;
color: $rid-required;
&:hover { background-color: rgba(220, 53, 69, .08); }
&.o_fp_ri_pf_active {
background-color: $rid-required;
color: #ffffff;
border-color: $rid-required;
box-shadow: 0 1px 0 rgba(0, 0, 0, .08);
}
}
}
// =============================================================================
// Signature — clearly-affordance'd input so operators know it's an
// initial / signature, not free text.
// =============================================================================
.o_fp_ri_signature {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: $rid-input;
border: 1px solid $rid-border;
border-radius: 6px;
transition: border-color .15s ease, box-shadow .15s ease;
&:focus-within {
border-color: $rid-border-focus;
box-shadow: 0 0 0 .15rem rgba(113, 75, 103, .15);
}
.o_fp_ri_signature_icon {
font-size: 1.1rem;
color: $rid-ink-mute;
}
.o_fp_ri_input_signature {
flex: 1;
border: 0;
background: transparent;
padding: 6px 0;
font-family: "Courier New", "Lucida Console", monospace;
font-size: 1rem;
letter-spacing: .08em;
text-transform: uppercase;
color: $rid-ink;
&:focus {
outline: none;
box-shadow: none;
}
}
}
// =============================================================================
// Selection — empty-state hint when recipe author didn't authoring options
// =============================================================================
.o_fp_ri_select_empty {
padding: 10px 12px;
border: 1px dashed $rid-border-strong;
border-radius: 6px;
background-color: $rid-page;
color: $rid-ink-mute;
font-size: .9rem;
.fa-info-circle {
color: $rid-warn;
}
.o_fp_ri_input_text {
width: 100%;
}
}
// =============================================================================
// Dual-entry numeric — Min Reading + Max Reading side-by-side
//
// Fires when the recipe author authored both target_min AND target_max on
// a numeric prompt (signal: this measurement is a range, not a point).
// Operator records the lowest and highest reading from their inspection
// pass. The hint below verifies BOTH bounds are within spec.
// =============================================================================
.o_fp_ri_dual {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
align-items: start;
.o_fp_ri_dual_field {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0;
}
.o_fp_ri_dual_label {
font-size: .75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .05em;
color: $rid-ink-mute;
}
.o_fp_ri_dual_hint {
grid-column: 1 / -1;
margin-top: -4px;
}
}
// =============================================================================
// PASS/FAIL suggestion banner — fires when a pass_fail prompt has both a
// target range and the operator has entered Min/Max readings. Shows the
// suggested verdict so the operator knows what the system thinks before
// they tap PASS or FAIL.
// =============================================================================
.o_fp_ri_pf_suggest {
margin: 8px 0 6px;
padding: 8px 12px;
border-radius: 6px;
font-size: .9rem;
border: 1px solid transparent;
&.o_fp_ri_pf_suggest_pass {
background-color: rgba(25, 135, 84, .10);
border-color: rgba(25, 135, 84, .35);
color: $rid-ok;
}
&.o_fp_ri_pf_suggest_fail {
background-color: rgba(220, 53, 69, .10);
border-color: rgba(220, 53, 69, .35);
color: $rid-required;
}
}

View File

@@ -20,16 +20,39 @@
<span class="ms-2">Loading prompts...</span>
</div>
<!-- Empty state -->
<div t-elif="!state.rows.length" class="o_fp_ri_empty">
<!-- Instructions block — recipe-author HTML + image gallery shown
above the prompt cards so the operator reads context BEFORE
entering values. Hidden when neither is authored. -->
<div t-if="!state.loading and (state.instructionsHtml or state.instructionImages.length)"
class="o_fp_ri_instructions">
<div t-if="state.instructionsHtml"
class="o_fp_ri_instructions_text"
t-out="state.instructionsHtml"/>
<div t-if="state.instructionImages.length"
class="o_fp_ri_instructions_gallery">
<t t-foreach="state.instructionImages" t-as="img" t-key="img.id">
<a t-att-href="img.url"
target="_blank"
class="o_fp_ri_instructions_thumb"
t-att-title="img.name">
<img t-att-src="img.url" t-att-alt="img.name"/>
</a>
</t>
</div>
</div>
<!-- Empty state. Independent t-if (not t-elif) so the
instructions block above doesn't break the chain — the
cards / empty branch must only depend on loading + rows. -->
<div t-if="!state.loading and !state.rows.length" class="o_fp_ri_empty">
<p>No measurement prompts on this step.</p>
<button class="btn btn-secondary" t-on-click="addAdHocRow">
<i class="fa fa-plus me-1"/> Add a measurement
</button>
</div>
<!-- Cards -->
<div t-else="" class="o_fp_ri_cards">
<!-- Cards. Same fix — independent t-if. -->
<div t-if="!state.loading and state.rows.length" class="o_fp_ri_cards">
<t t-foreach="state.rows" t-as="row" t-key="row_index">
<div class="o_fp_ri_card"
t-att-class="{ 'o_fp_ri_card_required': row.required }">
@@ -53,7 +76,7 @@
<div class="o_fp_ri_meta">
<span class="o_fp_ri_pill o_fp_ri_pill_type"
t-esc="row.input_type"/>
t-esc="inputTypeLabel(row)"/>
<span t-if="row.target_unit"
class="o_fp_ri_pill o_fp_ri_pill_unit"
t-esc="row.target_unit"/>
@@ -67,14 +90,19 @@
</button>
</div>
<!-- Target range hint (if recipe author set one) -->
<div t-if="(row.target_min or row.target_max) and isNumeric(row)"
<!-- Target range hint (any prompt with a target_min /
target_max — numeric, pass_fail, etc.). Renders
as a small "Target: 0.005 0.007 in" pill so the
operator can see the spec before they enter
readings. -->
<div t-if="row.target_min or row.target_max"
class="o_fp_ri_target">
Target:
<strong>
<t t-if="row.target_min" t-esc="row.target_min"/><t t-if="row.target_min and row.target_max"></t><t t-if="row.target_max" t-esc="row.target_max"/>
<i class="fa fa-bullseye me-1"/>
<span class="o_fp_ri_target_label">Target</span>
<strong class="o_fp_ri_target_value">
<t t-if="row.target_min" t-esc="row.target_min"/><t t-if="row.target_min and row.target_max"> </t><t t-if="row.target_max" t-esc="row.target_max"/>
</strong>
<span t-if="row.target_unit" class="ms-1 text-muted" t-esc="row.target_unit"/>
<span t-if="row.target_unit" class="o_fp_ri_target_unit" t-esc="row.target_unit"/>
</div>
<!-- Hint text from recipe author -->
@@ -83,8 +111,9 @@
<!-- Card body — live input widget per type -->
<div class="o_fp_ri_card_body">
<!-- Numeric (number, temperature, thickness, time_seconds, ph) -->
<div t-if="isNumeric(row)" class="o_fp_ri_numeric">
<!-- Numeric — single value (no range defined) -->
<div t-if="isNumeric(row) and !hasRangeEntry(row)"
class="o_fp_ri_numeric">
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
step="any"
@@ -97,7 +126,109 @@
t-esc="hint.text"/>
</div>
<!-- Boolean / pass-fail toggle -->
<!-- Numeric — dual entry (recipe author defined a
min and max target → operator records both
observed extremes from their measurements).
Constrained to numeric so it doesn't duplicate
the pass_fail+range branch above. -->
<div t-if="isNumeric(row) and hasRangeEntry(row)" class="o_fp_ri_dual">
<label class="o_fp_ri_dual_field">
<span class="o_fp_ri_dual_label">Min Reading</span>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
step="any"
t-model.number="row.value_min"
t-att-placeholder="row.target_min or '0.00'"/>
</label>
<label class="o_fp_ri_dual_field">
<span class="o_fp_ri_dual_label">Max Reading</span>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
step="any"
t-model.number="row.value_max"
t-att-placeholder="row.target_max or '0.00'"/>
</label>
<t t-set="dhint" t-value="dualRangeHint(row)"/>
<span t-if="dhint"
class="o_fp_ri_range_hint o_fp_ri_dual_hint"
t-att-class="'o_fp_ri_range_' + dhint.kind"
t-esc="dhint.text"/>
</div>
<!-- Pass / Fail with range — operator records min
+ max measurements first, system suggests the
verdict, then operator confirms with PASS/FAIL.
This branch fires when the recipe author
defined target_min / target_max on a pass_fail
prompt (e.g. Bore inspection: 0.005-0.007 in). -->
<t t-if="isPassFail(row) and hasRangeEntry(row)">
<div class="o_fp_ri_dual">
<label class="o_fp_ri_dual_field">
<span class="o_fp_ri_dual_label">Min Reading</span>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
step="any"
t-model.number="row.value_min"
t-att-placeholder="row.target_min or '0.00'"/>
</label>
<label class="o_fp_ri_dual_field">
<span class="o_fp_ri_dual_label">Max Reading</span>
<input type="number"
class="o_fp_ri_input o_fp_ri_input_numeric"
step="any"
t-model.number="row.value_max"
t-att-placeholder="row.target_max or '0.00'"/>
</label>
<t t-set="dhint" t-value="dualRangeHint(row)"/>
<span t-if="dhint"
class="o_fp_ri_range_hint o_fp_ri_dual_hint"
t-att-class="'o_fp_ri_range_' + dhint.kind"
t-esc="dhint.text"/>
</div>
<t t-set="sugg" t-value="suggestedPassFail(row)"/>
<div t-if="sugg" class="o_fp_ri_pf_suggest"
t-att-class="'o_fp_ri_pf_suggest_' + sugg">
<i t-att-class="sugg === 'pass' ? 'fa fa-check-circle me-1' : 'fa fa-exclamation-triangle me-1'"/>
Readings suggest <strong t-esc="sugg.toUpperCase()"/> — confirm below.
</div>
<div class="o_fp_ri_passfail">
<button type="button"
class="o_fp_ri_pf_btn o_fp_ri_pf_pass"
t-att-class="{ 'o_fp_ri_pf_active': isPassActive(row) }"
t-on-click="() => this.onPass(row)">
<i class="fa fa-check me-2"/> PASS
</button>
<button type="button"
class="o_fp_ri_pf_btn o_fp_ri_pf_fail"
t-att-class="{ 'o_fp_ri_pf_active': isFailActive(row) }"
t-on-click="() => this.onFail(row)">
<i class="fa fa-times me-2"/> FAIL
</button>
</div>
</t>
<!-- Pass / Fail without range — distinct two-button
widget so the operator sees the OUTCOME, not a
generic toggle. Active button fills with green
(PASS) or red (FAIL); the inactive one stays
outlined. -->
<div t-if="isPassFail(row) and !hasRangeEntry(row)"
class="o_fp_ri_passfail">
<button type="button"
class="o_fp_ri_pf_btn o_fp_ri_pf_pass"
t-att-class="{ 'o_fp_ri_pf_active': isPassActive(row) }"
t-on-click="() => this.onPass(row)">
<i class="fa fa-check me-2"/> PASS
</button>
<button type="button"
class="o_fp_ri_pf_btn o_fp_ri_pf_fail"
t-att-class="{ 'o_fp_ri_pf_active': isFailActive(row) }"
t-on-click="() => this.onFail(row)">
<i class="fa fa-times me-2"/> FAIL
</button>
</div>
<!-- Generic boolean toggle (Yes / No) -->
<label t-if="isBoolean(row)" class="o_fp_ri_toggle">
<input type="checkbox" t-model="row.value_boolean"/>
<span class="o_fp_ri_toggle_track">
@@ -114,14 +245,36 @@
t-model="row.value_date"/>
<!-- Selection (uses recipe author's selection_options) -->
<select t-if="isSelection(row)"
class="o_fp_ri_input o_fp_ri_input_select"
t-model="row.value_text">
<option value="">— choose —</option>
<t t-foreach="selectionOptions(row)" t-as="opt" t-key="opt">
<option t-att-value="opt" t-esc="opt"/>
</t>
</select>
<t t-if="isSelection(row)">
<t t-set="opts" t-value="selectionOptions(row)"/>
<select t-if="opts.length"
class="o_fp_ri_input o_fp_ri_input_select"
t-model="row.value_text">
<option value="">— choose —</option>
<t t-foreach="opts" t-as="opt" t-key="opt">
<option t-att-value="opt" t-esc="opt"/>
</t>
</select>
<div t-else="" class="o_fp_ri_select_empty">
<i class="fa fa-info-circle me-1"/>
No options configured for this prompt — type a value below.
<input type="text"
class="o_fp_ri_input o_fp_ri_input_text mt-2"
t-model="row.value_text"
placeholder="Enter value…"/>
</div>
</t>
<!-- Signature — distinct affordance so the operator
knows initials are required (not free text). -->
<div t-if="isSignature(row)" class="o_fp_ri_signature">
<i class="fa fa-pencil-square-o o_fp_ri_signature_icon"/>
<input type="text"
class="o_fp_ri_input o_fp_ri_input_signature"
t-model="row.value_text"
placeholder="Type your initials (e.g. JD)"
maxlength="10"/>
</div>
<!-- Photo upload -->
<div t-if="isPhoto(row)" class="o_fp_ri_photo">