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:
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.8.16.3',
|
||||
'version': '19.0.8.17.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
@@ -71,18 +71,22 @@ full design rationale and §6.2 of the implementation plan for task list.
|
||||
'report/report_fp_job_margin.xml',
|
||||
],
|
||||
'assets': {
|
||||
# Sub 12d — Step Details quick-look modal styles + Sub 12e — Record
|
||||
# Inputs Wizard v3 card layout. Registered in both bundles so
|
||||
# light + dark mode each compile correctly
|
||||
# ($o-webclient-color-scheme branches at compile time per
|
||||
# CLAUDE.md note).
|
||||
# Sub 12d — Step Details quick-look modal styles
|
||||
# Sub 12e v4 — Record Inputs OWL Dialog (replaces v2/v3)
|
||||
# Both registered in both bundles so light + dark mode each
|
||||
# compile correctly ($o-webclient-color-scheme branches at
|
||||
# compile time per CLAUDE.md note).
|
||||
'web.assets_backend': [
|
||||
'fusion_plating_jobs/static/src/scss/fp_step_quick_look.scss',
|
||||
'fusion_plating_jobs/static/src/scss/fp_job_step_input_wizard_v3.scss',
|
||||
'fusion_plating_jobs/static/src/scss/fp_record_inputs_dialog.scss',
|
||||
'fusion_plating_jobs/static/src/js/fp_record_inputs_dialog.js',
|
||||
'fusion_plating_jobs/static/src/xml/fp_record_inputs_dialog.xml',
|
||||
],
|
||||
'web.assets_web_dark': [
|
||||
'fusion_plating_jobs/static/src/scss/fp_step_quick_look.scss',
|
||||
'fusion_plating_jobs/static/src/scss/fp_job_step_input_wizard_v3.scss',
|
||||
'fusion_plating_jobs/static/src/scss/fp_record_inputs_dialog.scss',
|
||||
'fusion_plating_jobs/static/src/js/fp_record_inputs_dialog.js',
|
||||
'fusion_plating_jobs/static/src/xml/fp_record_inputs_dialog.xml',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
# removed. job_scan is the only controller retained — it powers the
|
||||
# QR-sticker scan redirect for fp.job records.
|
||||
from . import job_scan
|
||||
from . import record_inputs
|
||||
|
||||
157
fusion_plating/fusion_plating_jobs/controllers/record_inputs.py
Normal file
157
fusion_plating/fusion_plating_jobs/controllers/record_inputs.py
Normal file
@@ -0,0 +1,157 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""Record Inputs Dialog (OWL) — JSONRPC backend.
|
||||
|
||||
Replaces the v3 form-based wizard with a custom OWL dialog. The dialog
|
||||
loads step + prompt metadata via /fp/record_inputs/load, then commits
|
||||
operator-entered values via /fp/record_inputs/commit.
|
||||
|
||||
Both endpoints reuse the existing fp.job.step.input.wizard TransientModel
|
||||
so the commit semantics (synthetic move row, value persistence, advance-
|
||||
after-save) match exactly what the form-based wizard did.
|
||||
"""
|
||||
|
||||
from odoo import http, _
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class FpRecordInputsController(http.Controller):
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Load — return the prompt definitions + an empty values payload
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/record_inputs/load', type='jsonrpc', auth='user')
|
||||
def load(self, step_id):
|
||||
Step = request.env['fp.job.step']
|
||||
step = Step.browse(int(step_id))
|
||||
if not step.exists():
|
||||
return {'ok': False, 'error': 'Step not found.'}
|
||||
step.check_access('read')
|
||||
|
||||
# Mirror the wizard's default_get logic — build prompts from
|
||||
# the recipe node's input_ids filtered to step_input + collect.
|
||||
prompts = []
|
||||
node = step.recipe_node_id
|
||||
if node and (
|
||||
not hasattr(node, 'collect_measurements')
|
||||
or node.collect_measurements
|
||||
):
|
||||
inputs = node.input_ids
|
||||
if 'kind' in inputs._fields:
|
||||
inputs = inputs.filtered(lambda i: i.kind == 'step_input')
|
||||
if 'collect' in inputs._fields:
|
||||
inputs = inputs.filtered(lambda i: i.collect)
|
||||
for inp in inputs.sorted('sequence'):
|
||||
prompts.append({
|
||||
'node_input_id': inp.id,
|
||||
'name': inp.name or '',
|
||||
'input_type': inp.input_type or 'text',
|
||||
'required': bool(inp.required),
|
||||
'target_min': inp.target_min or 0.0,
|
||||
'target_max': inp.target_max or 0.0,
|
||||
'target_unit': inp.target_unit or '',
|
||||
'hint': getattr(inp, 'hint', '') or '',
|
||||
'selection_options': inp.selection_options or '',
|
||||
'is_authored': True,
|
||||
})
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'step': {
|
||||
'id': step.id,
|
||||
'name': step.name,
|
||||
},
|
||||
'job': {
|
||||
'id': step.job_id.id,
|
||||
'name': step.job_id.name,
|
||||
},
|
||||
'prompts': prompts,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Commit — write values via the existing wizard (reuse semantics)
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/record_inputs/commit', type='jsonrpc', auth='user')
|
||||
def commit(self, step_id, values, advance_after=False):
|
||||
"""Commit operator-entered values for this step.
|
||||
|
||||
Args:
|
||||
step_id: fp.job.step id
|
||||
values: list of dicts with shape:
|
||||
{
|
||||
'node_input_id': int (or False for ad-hoc),
|
||||
'name': str,
|
||||
'input_type': str,
|
||||
'target_unit': str,
|
||||
'value_text': str | False,
|
||||
'value_number': float | 0.0,
|
||||
'value_boolean': bool,
|
||||
'value_date': str (ISO) | False,
|
||||
'photo_value': str (base64) | False,
|
||||
'photo_filename': str | False,
|
||||
'point_1' .. 'point_5': float,
|
||||
'panel_ph', 'panel_concentration',
|
||||
'panel_temperature', 'panel_bath_id',
|
||||
}
|
||||
advance_after: when True, re-enter action_finish_and_advance
|
||||
with fp_after_inputs=True so the step finishes + auto-
|
||||
starts the next.
|
||||
|
||||
Returns: {ok: bool, error: str?, next_action: dict?}
|
||||
"""
|
||||
Step = request.env['fp.job.step']
|
||||
step = Step.browse(int(step_id))
|
||||
if not step.exists():
|
||||
return {'ok': False, 'error': 'Step not found.'}
|
||||
step.check_access('write')
|
||||
|
||||
# Build the wizard exactly as the form-based path would, then
|
||||
# call action_commit so the audit-trail / chatter / synthetic
|
||||
# move semantics match. Pass values via line_ids so the wizard's
|
||||
# validation kicks in identically.
|
||||
Wizard = request.env['fp.job.step.input.wizard']
|
||||
line_vals = []
|
||||
for v in (values or []):
|
||||
line_vals.append((0, 0, {
|
||||
'node_input_id': v.get('node_input_id') or False,
|
||||
'name': v.get('name') or '',
|
||||
'input_type': v.get('input_type') or 'text',
|
||||
'target_unit': v.get('target_unit') or False,
|
||||
'target_min': v.get('target_min') or 0.0,
|
||||
'target_max': v.get('target_max') or 0.0,
|
||||
'value_text': v.get('value_text') or False,
|
||||
'value_number': v.get('value_number') or 0.0,
|
||||
'value_boolean': bool(v.get('value_boolean')),
|
||||
'value_date': v.get('value_date') or False,
|
||||
'photo_value': v.get('photo_value') or False,
|
||||
'photo_filename': v.get('photo_filename') or False,
|
||||
'point_1': v.get('point_1') or 0.0,
|
||||
'point_2': v.get('point_2') or 0.0,
|
||||
'point_3': v.get('point_3') or 0.0,
|
||||
'point_4': v.get('point_4') or 0.0,
|
||||
'point_5': v.get('point_5') or 0.0,
|
||||
'panel_ph': v.get('panel_ph') or 0.0,
|
||||
'panel_concentration': v.get('panel_concentration') or 0.0,
|
||||
'panel_temperature': v.get('panel_temperature') or 0.0,
|
||||
'panel_bath_id': v.get('panel_bath_id') or '',
|
||||
}))
|
||||
|
||||
wizard = Wizard.create({
|
||||
'step_id': step.id,
|
||||
'line_ids': line_vals,
|
||||
})
|
||||
|
||||
try:
|
||||
ctx = dict(request.env.context)
|
||||
if advance_after:
|
||||
ctx['fp_advance_after_save'] = True
|
||||
result = wizard.with_context(**ctx).action_commit()
|
||||
return {
|
||||
'ok': True,
|
||||
'next_action': result if isinstance(result, dict) else False,
|
||||
}
|
||||
except Exception as exc:
|
||||
request.env.cr.rollback()
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
@@ -481,26 +481,24 @@ class FpJobStep(models.Model):
|
||||
return already == 0
|
||||
|
||||
def _fp_open_input_wizard(self, advance_after=False):
|
||||
"""Open the simplified Record Inputs dialog. When advance_after
|
||||
is True, the wizard's Save button finishes the step and starts
|
||||
the next one as a single atomic flow."""
|
||||
"""Open the Record Inputs OWL dialog (Sub 12e v4).
|
||||
|
||||
Replaces the form-view-based wizard with a custom OWL Dialog
|
||||
component (fp_record_inputs_dialog.js). The dialog renders
|
||||
each prompt as a proper card with semantic HTML — no more
|
||||
list-cell-as-card CSS hacks.
|
||||
|
||||
When advance_after is True, the dialog's Save button commits
|
||||
values then dispatches the result of action_finish_and_advance
|
||||
so the step finishes + auto-starts the next step in one flow.
|
||||
"""
|
||||
self.ensure_one()
|
||||
view = self.env.ref(
|
||||
'fusion_plating_jobs.view_fp_job_step_input_wizard_form_v3'
|
||||
)
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fp.job.step.input.wizard',
|
||||
'view_mode': 'form',
|
||||
'view_id': view.id,
|
||||
'views': [(view.id, 'form')],
|
||||
'target': 'new',
|
||||
'name': _('Record Inputs — %s') % self.name,
|
||||
'context': {
|
||||
**dict(self.env.context),
|
||||
'default_step_id': self.id,
|
||||
'active_id': self.id,
|
||||
'fp_advance_after_save': advance_after,
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_record_inputs_dialog',
|
||||
'params': {
|
||||
'step_id': self.id,
|
||||
'advance_after': bool(advance_after),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -916,24 +914,23 @@ class FpJobStep(models.Model):
|
||||
}
|
||||
|
||||
def action_open_input_wizard(self):
|
||||
"""Open the Input Recording wizard for this step.
|
||||
"""Open the Record Inputs OWL dialog from the per-row Record
|
||||
button on the job form.
|
||||
|
||||
Contract-review steps redirect to the QA-005 form (same gate as
|
||||
action_finish_and_advance) so the per-row "Record" button stays
|
||||
consistent with "Finish & Next".
|
||||
action_finish_and_advance) so the per-row Record button stays
|
||||
consistent with Finish & Next.
|
||||
"""
|
||||
self.ensure_one()
|
||||
cr_action = self._fp_contract_review_redirect()
|
||||
if cr_action:
|
||||
return cr_action
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fp.job.step.input.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'name': _('Record Inputs — %s') % self.name,
|
||||
'context': {
|
||||
'default_step_id': self.id,
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_record_inputs_dialog',
|
||||
'params': {
|
||||
'step_id': self.id,
|
||||
'advance_after': False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -0,0 +1,514 @@
|
||||
// =============================================================================
|
||||
// Record Inputs Dialog — Sub 12e v4 (proper OWL component)
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
//
|
||||
// Pure semantic HTML inside a Dialog. No list view to fight, no
|
||||
// table-cell unwinding, no class-stripping bugs. Just cards.
|
||||
//
|
||||
// Dark mode: branched at compile time on $o-webclient-color-scheme,
|
||||
// per fusion-plating/CLAUDE.md. Registered in BOTH backend + dark
|
||||
// asset bundles.
|
||||
// =============================================================================
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
// ---------- Surface tokens ---------------------------------------------------
|
||||
|
||||
$_fp-rid-card-hex : #ffffff;
|
||||
$_fp-rid-card-hover-hex: #f8f9fa;
|
||||
$_fp-rid-page-hex : #f3f4f6;
|
||||
$_fp-rid-input-hex : #ffffff;
|
||||
$_fp-rid-pill-hex : #f1f3f5;
|
||||
$_fp-rid-border-hex : #d8dadd;
|
||||
$_fp-rid-border-strong-hex: #b6babf;
|
||||
$_fp-rid-border-focus-hex : #714B67;
|
||||
$_fp-rid-ink-hex : #1f2937;
|
||||
$_fp-rid-ink-soft-hex : #4b5563;
|
||||
$_fp-rid-ink-mute-hex : #6b7280;
|
||||
$_fp-rid-ink-faint-hex : #9ca3af;
|
||||
$_fp-rid-required-hex : #dc3545;
|
||||
$_fp-rid-ok-hex : #198754;
|
||||
$_fp-rid-warn-hex : #b18307;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_fp-rid-card-hex : #2a2f37 !global;
|
||||
$_fp-rid-card-hover-hex: #323843 !global;
|
||||
$_fp-rid-page-hex : #1a1d21 !global;
|
||||
$_fp-rid-input-hex : #1a1d21 !global;
|
||||
$_fp-rid-pill-hex : #353a44 !global;
|
||||
$_fp-rid-border-hex : #3f4651 !global;
|
||||
$_fp-rid-border-strong-hex: #5a606b !global;
|
||||
$_fp-rid-border-focus-hex : #a78bca !global;
|
||||
$_fp-rid-ink-hex : #e5e7eb !global;
|
||||
$_fp-rid-ink-soft-hex : #c8ccd2 !global;
|
||||
$_fp-rid-ink-mute-hex : #8a909a !global;
|
||||
$_fp-rid-ink-faint-hex : #6a707b !global;
|
||||
$_fp-rid-required-hex : #ea868f !global;
|
||||
$_fp-rid-ok-hex : #75b798 !global;
|
||||
$_fp-rid-warn-hex : #ffd866 !global;
|
||||
}
|
||||
|
||||
$rid-card : var(--fp-rid-card-bg, #{$_fp-rid-card-hex});
|
||||
$rid-card-hover : var(--fp-rid-card-hover-bg, #{$_fp-rid-card-hover-hex});
|
||||
$rid-page : var(--fp-rid-page-bg, #{$_fp-rid-page-hex});
|
||||
$rid-input : var(--fp-rid-input-bg, #{$_fp-rid-input-hex});
|
||||
$rid-pill : var(--fp-rid-pill-bg, #{$_fp-rid-pill-hex});
|
||||
$rid-border : var(--fp-rid-border, #{$_fp-rid-border-hex});
|
||||
$rid-border-strong: var(--fp-rid-border-strong, #{$_fp-rid-border-strong-hex});
|
||||
$rid-border-focus: var(--fp-rid-border-focus, #{$_fp-rid-border-focus-hex});
|
||||
$rid-ink : var(--fp-rid-ink, #{$_fp-rid-ink-hex});
|
||||
$rid-ink-soft : var(--fp-rid-ink-soft, #{$_fp-rid-ink-soft-hex});
|
||||
$rid-ink-mute : var(--fp-rid-ink-mute, #{$_fp-rid-ink-mute-hex});
|
||||
$rid-ink-faint : var(--fp-rid-ink-faint, #{$_fp-rid-ink-faint-hex});
|
||||
$rid-required : var(--fp-rid-required, #{$_fp-rid-required-hex});
|
||||
$rid-ok : var(--fp-rid-ok, #{$_fp-rid-ok-hex});
|
||||
$rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex});
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Dialog frame — generous body, scrollable card stack
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_ri_dialog_content {
|
||||
.modal-body {
|
||||
padding: 16px 20px;
|
||||
background-color: $rid-page;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_ri_header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
.o_fp_ri_step_name {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: $rid-ink;
|
||||
}
|
||||
.o_fp_ri_job_label {
|
||||
font-size: 0.875rem;
|
||||
color: $rid-ink-mute;
|
||||
}
|
||||
|
||||
.o_fp_ri_loading,
|
||||
.o_fp_ri_empty {
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
color: $rid-ink-mute;
|
||||
background-color: $rid-card;
|
||||
border: 1px dashed $rid-border;
|
||||
border-radius: 12px;
|
||||
|
||||
p { margin-bottom: 12px; }
|
||||
i.fa-spinner { color: $rid-border-focus; font-size: 1.5rem; }
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Card stack
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_ri_cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.o_fp_ri_card {
|
||||
padding: 16px 20px;
|
||||
background-color: $rid-card;
|
||||
border: 1px solid $rid-border;
|
||||
border-radius: 12px;
|
||||
transition: border-color 120ms ease, background-color 120ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: $rid-border-strong;
|
||||
}
|
||||
&:focus-within {
|
||||
border-color: $rid-border-focus;
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, #{$rid-border-focus} 18%, transparent);
|
||||
}
|
||||
|
||||
&.o_fp_ri_card_required {
|
||||
border-left: 3px solid $rid-border-focus;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------- Card header — prompt + meta + remove ----------------------------
|
||||
|
||||
.o_fp_ri_card_head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.o_fp_ri_prompt {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.o_fp_ri_prompt_label {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: $rid-ink;
|
||||
}
|
||||
.o_fp_ri_required_mark {
|
||||
color: $rid-required;
|
||||
font-weight: 700;
|
||||
}
|
||||
.o_fp_ri_prompt_input {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
background: $rid-input;
|
||||
color: $rid-ink;
|
||||
border: 1px solid $rid-border;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
|
||||
&:focus {
|
||||
border-color: $rid-border-focus;
|
||||
outline: none;
|
||||
}
|
||||
&::placeholder { color: $rid-ink-faint; }
|
||||
}
|
||||
|
||||
.o_fp_ri_meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.o_fp_ri_pill {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
background-color: $rid-pill;
|
||||
color: $rid-ink-soft;
|
||||
border: 1px solid $rid-border;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
text-transform: lowercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.o_fp_ri_pill_unit {
|
||||
background-color: transparent;
|
||||
border-color: transparent;
|
||||
color: $rid-ink-mute;
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.o_fp_ri_remove {
|
||||
flex-shrink: 0;
|
||||
color: $rid-ink-faint;
|
||||
padding: 4px 8px;
|
||||
opacity: 0.5;
|
||||
transition: opacity 120ms ease, color 120ms ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
color: $rid-required;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------- Target / hint helpers ------------------------------------------
|
||||
|
||||
.o_fp_ri_target {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 0.8125rem;
|
||||
color: $rid-ink-mute;
|
||||
}
|
||||
.o_fp_ri_hint {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 0.8125rem;
|
||||
color: $rid-ink-faint;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Card body — inputs per type
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_ri_card_body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
|
||||
// ---------- Common input chrome --------------------------------------------
|
||||
|
||||
.o_fp_ri_input {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 10px 14px;
|
||||
min-height: 48px;
|
||||
background-color: $rid-input;
|
||||
color: $rid-ink;
|
||||
border: 1px solid $rid-border-strong;
|
||||
border-radius: 8px;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
|
||||
&::placeholder { color: $rid-ink-faint; font-weight: 400; }
|
||||
|
||||
&:hover {
|
||||
border-color: $rid-ink-mute;
|
||||
}
|
||||
&:focus {
|
||||
border-color: $rid-border-focus;
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, #{$rid-border-focus} 25%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_ri_input_select {
|
||||
appearance: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
.o_fp_ri_input_numeric {
|
||||
text-align: left;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
|
||||
// ---------- Numeric — input + range hint -----------------------------------
|
||||
|
||||
.o_fp_ri_numeric {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.o_fp_ri_range_hint {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
|
||||
&.o_fp_ri_range_ok {
|
||||
background-color: color-mix(in srgb, #{$rid-ok} 15%, transparent);
|
||||
color: $rid-ok;
|
||||
}
|
||||
&.o_fp_ri_range_low,
|
||||
&.o_fp_ri_range_high {
|
||||
background-color: color-mix(in srgb, #{$rid-warn} 18%, transparent);
|
||||
color: $rid-warn;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------- Boolean toggle (custom — bigger + clearer than Bootstrap) ------
|
||||
|
||||
.o_fp_ri_toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
> input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
}
|
||||
.o_fp_ri_toggle_track {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 56px;
|
||||
height: 32px;
|
||||
background-color: $rid-pill;
|
||||
border: 1px solid $rid-border-strong;
|
||||
border-radius: 999px;
|
||||
transition: background-color 150ms ease, border-color 150ms ease;
|
||||
}
|
||||
.o_fp_ri_toggle_thumb {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: $rid-ink-mute;
|
||||
border-radius: 50%;
|
||||
transition: transform 150ms ease, background-color 150ms ease;
|
||||
}
|
||||
.o_fp_ri_toggle > input:checked ~ .o_fp_ri_toggle_track {
|
||||
background-color: $rid-border-focus;
|
||||
border-color: $rid-border-focus;
|
||||
|
||||
.o_fp_ri_toggle_thumb {
|
||||
transform: translateX(24px);
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
.o_fp_ri_toggle_label {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: $rid-ink-soft;
|
||||
}
|
||||
|
||||
|
||||
// ---------- Photo upload — modest size, semantic ---------------------------
|
||||
|
||||
.o_fp_ri_photo {
|
||||
display: inline-block;
|
||||
}
|
||||
.o_fp_ri_photo_placeholder .btn {
|
||||
background-color: $rid-pill;
|
||||
color: $rid-ink-soft;
|
||||
border: 1px dashed $rid-border-strong;
|
||||
padding: 12px 18px;
|
||||
|
||||
&:hover {
|
||||
border-color: $rid-border-focus;
|
||||
color: $rid-border-focus;
|
||||
}
|
||||
}
|
||||
.o_fp_ri_photo_preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
img {
|
||||
max-width: 200px;
|
||||
max-height: 150px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid $rid-border;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------- Multi-point thickness ------------------------------------------
|
||||
|
||||
.o_fp_ri_multi_grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(70px, 1fr));
|
||||
gap: 8px;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.75rem;
|
||||
color: $rid-ink-mute;
|
||||
font-weight: 600;
|
||||
|
||||
input {
|
||||
margin-top: 4px;
|
||||
padding: 8px 10px;
|
||||
background: $rid-input;
|
||||
color: $rid-ink;
|
||||
border: 1px solid $rid-border-strong;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&:focus {
|
||||
border-color: $rid-border-focus;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.o_fp_ri_multi_avg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
padding-bottom: 4px;
|
||||
|
||||
strong {
|
||||
font-size: 1.125rem;
|
||||
color: $rid-ink;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------- Bath chemistry panel — same shape as multi ---------------------
|
||||
|
||||
.o_fp_ri_panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.75rem;
|
||||
color: $rid-ink-mute;
|
||||
font-weight: 600;
|
||||
|
||||
input {
|
||||
margin-top: 4px;
|
||||
padding: 8px 10px;
|
||||
background: $rid-input;
|
||||
color: $rid-ink;
|
||||
border: 1px solid $rid-border-strong;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
|
||||
&:focus {
|
||||
border-color: $rid-border-focus;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------- Add-row CTA -----------------------------------------------------
|
||||
|
||||
.o_fp_ri_add_btn {
|
||||
align-self: flex-start;
|
||||
padding: 10px 18px;
|
||||
color: $rid-ink-soft;
|
||||
background-color: $rid-card;
|
||||
border: 1px dashed $rid-border-strong;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: $rid-border-focus;
|
||||
border-color: $rid-border-focus;
|
||||
background-color: $rid-card-hover;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Tablet polish — bigger inputs on narrow screens
|
||||
// =============================================================================
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.o_fp_ri_card_head {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.o_fp_ri_meta {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
}
|
||||
.o_fp_ri_input {
|
||||
max-width: 100%;
|
||||
min-height: 56px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.o_fp_ri_multi_grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.o_fp_ri_panel {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_jobs.FpRecordInputsDialog">
|
||||
<Dialog size="'lg'" contentClass="'o_fp_ri_dialog_content'">
|
||||
<t t-set-slot="header">
|
||||
<div class="o_fp_ri_header">
|
||||
<div class="o_fp_ri_header_titles">
|
||||
<h4 class="o_fp_ri_step_name" t-esc="state.stepName"/>
|
||||
<span class="o_fp_ri_job_label">
|
||||
Job <t t-esc="state.jobName"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div t-if="state.loading" class="o_fp_ri_loading">
|
||||
<i class="fa fa-spinner fa-spin"/>
|
||||
<span class="ms-2">Loading prompts...</span>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div t-elif="!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">
|
||||
<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 }">
|
||||
|
||||
<!-- Card header — prompt name + meta pills -->
|
||||
<div class="o_fp_ri_card_head">
|
||||
<div class="o_fp_ri_prompt">
|
||||
<!-- Authored prompt: read-only label -->
|
||||
<span t-if="row.is_authored"
|
||||
class="o_fp_ri_prompt_label">
|
||||
<span t-esc="row.name"/>
|
||||
<span t-if="row.required" class="o_fp_ri_required_mark" title="Required">*</span>
|
||||
</span>
|
||||
<!-- Ad-hoc prompt: editable -->
|
||||
<input t-else=""
|
||||
type="text"
|
||||
class="o_fp_ri_prompt_input"
|
||||
placeholder="Measurement name…"
|
||||
t-model="row.name"/>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_ri_meta">
|
||||
<span class="o_fp_ri_pill o_fp_ri_pill_type"
|
||||
t-esc="row.input_type"/>
|
||||
<span t-if="row.target_unit"
|
||||
class="o_fp_ri_pill o_fp_ri_pill_unit"
|
||||
t-esc="row.target_unit"/>
|
||||
</div>
|
||||
|
||||
<button t-if="!row.is_authored"
|
||||
class="o_fp_ri_remove btn btn-link"
|
||||
title="Remove this measurement"
|
||||
t-on-click="() => this.removeRow(row_index)">
|
||||
<i class="fa fa-times"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Target range hint (if recipe author set one) -->
|
||||
<div t-if="(row.target_min or row.target_max) and isNumeric(row)"
|
||||
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"/>
|
||||
</strong>
|
||||
<span t-if="row.target_unit" class="ms-1 text-muted" t-esc="row.target_unit"/>
|
||||
</div>
|
||||
|
||||
<!-- Hint text from recipe author -->
|
||||
<div t-if="row.hint" class="o_fp_ri_hint" t-esc="row.hint"/>
|
||||
|
||||
<!-- 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">
|
||||
<input type="number"
|
||||
class="o_fp_ri_input o_fp_ri_input_numeric"
|
||||
step="any"
|
||||
t-model.number="row.value_number"
|
||||
t-att-placeholder="row.target_min or '0.00'"/>
|
||||
<t t-set="hint" t-value="rangeHint(row)"/>
|
||||
<span t-if="hint"
|
||||
class="o_fp_ri_range_hint"
|
||||
t-att-class="'o_fp_ri_range_' + hint.kind"
|
||||
t-esc="hint.text"/>
|
||||
</div>
|
||||
|
||||
<!-- Boolean / pass-fail toggle -->
|
||||
<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">
|
||||
<span class="o_fp_ri_toggle_thumb"/>
|
||||
</span>
|
||||
<span class="o_fp_ri_toggle_label"
|
||||
t-esc="row.value_boolean ? 'Yes' : 'No'"/>
|
||||
</label>
|
||||
|
||||
<!-- Date / time -->
|
||||
<input t-if="isDate(row)"
|
||||
type="datetime-local"
|
||||
class="o_fp_ri_input o_fp_ri_input_date"
|
||||
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>
|
||||
|
||||
<!-- Photo upload -->
|
||||
<div t-if="isPhoto(row)" class="o_fp_ri_photo">
|
||||
<div t-if="!row.photo_value" class="o_fp_ri_photo_placeholder">
|
||||
<label class="btn btn-secondary">
|
||||
<i class="fa fa-camera me-2"/> Take or upload photo
|
||||
<input type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
hidden=""
|
||||
t-on-change="(ev) => this.onPhotoChange(row, ev)"/>
|
||||
</label>
|
||||
</div>
|
||||
<div t-else="" class="o_fp_ri_photo_preview">
|
||||
<img t-att-src="photoPreviewSrc(row)" alt="Captured photo"/>
|
||||
<button class="btn btn-sm btn-link text-danger"
|
||||
t-on-click="() => this.onPhotoClear(row)">
|
||||
<i class="fa fa-trash me-1"/> Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Multi-point thickness — 5 readings + live avg -->
|
||||
<div t-if="isMulti(row)" class="o_fp_ri_multi">
|
||||
<div class="o_fp_ri_multi_grid">
|
||||
<label>R1
|
||||
<input type="number" step="any" t-model.number="row.point_1"/>
|
||||
</label>
|
||||
<label>R2
|
||||
<input type="number" step="any" t-model.number="row.point_2"/>
|
||||
</label>
|
||||
<label>R3
|
||||
<input type="number" step="any" t-model.number="row.point_3"/>
|
||||
</label>
|
||||
<label>R4
|
||||
<input type="number" step="any" t-model.number="row.point_4"/>
|
||||
</label>
|
||||
<label>R5
|
||||
<input type="number" step="any" t-model.number="row.point_5"/>
|
||||
</label>
|
||||
<div class="o_fp_ri_multi_avg">
|
||||
<span class="text-muted">Avg</span>
|
||||
<strong t-esc="multiPointAvg(row)"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bath chemistry panel — pH / conc / temp / bath -->
|
||||
<div t-if="isPanel(row)" class="o_fp_ri_panel">
|
||||
<label>pH
|
||||
<input type="number" step="any" t-model.number="row.panel_ph"/>
|
||||
</label>
|
||||
<label>Concentration
|
||||
<input type="number" step="any" t-model.number="row.panel_concentration"/>
|
||||
</label>
|
||||
<label>Temperature
|
||||
<input type="number" step="any" t-model.number="row.panel_temperature"/>
|
||||
</label>
|
||||
<label>Bath ID
|
||||
<input type="text" t-model="row.panel_bath_id"/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Text fallback (text, signature, time_hms, anything else) -->
|
||||
<input t-if="isText(row)"
|
||||
type="text"
|
||||
class="o_fp_ri_input o_fp_ri_input_text"
|
||||
t-model="row.value_text"
|
||||
placeholder="Enter value…"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Add-row CTA -->
|
||||
<button class="o_fp_ri_add_btn btn btn-link"
|
||||
t-on-click="addAdHocRow">
|
||||
<i class="fa fa-plus me-1"/> Add another measurement
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<t t-set-slot="footer">
|
||||
<button class="btn btn-primary"
|
||||
t-att-disabled="state.saving or state.loading"
|
||||
t-on-click="onSave">
|
||||
<i t-if="state.saving" class="fa fa-spinner fa-spin me-2"/>
|
||||
Save
|
||||
</button>
|
||||
<button class="btn btn-secondary" t-on-click="onCancel">
|
||||
Cancel
|
||||
</button>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
Reference in New Issue
Block a user