feat(jobs): Record Inputs OWL Dialog (v4) — replaces list-as-cards hack

Scrapped the v2/v3 form-view + list-as-cards CSS approach after
extensive failure to make Odoo's editable list look like cards.
Built a proper OWL Dialog component instead, mirroring the pattern
used by fusion_plating_shopfloor's move_parts_dialog.js.

What changed
============
* New OWL Dialog: fp_record_inputs_dialog.js
  - Loads step + prompt definitions via /fp/record_inputs/load
  - Renders each prompt as a semantic <div class="o_fp_ri_card">
  - Per-row widget chosen by input_type:
      numeric/temperature/thickness/time_seconds/ph -> number input
      boolean/pass_fail   -> custom CSS toggle (clearer than Bootstrap)
      date                -> datetime-local input
      photo               -> file picker w/ preview + clear
      multi_point_thickness -> 5-cell grid + live average
      bath_chemistry_panel  -> pH/Conc/Temp/Bath grid
      selection           -> dropdown sourced from selection_options
      text/signature/...  -> text input
  - Live in-range hint for numeric prompts
      ("in range" / "below target" / "above target")
  - Save validates ad-hoc rows have a Prompt label
  - Save dispatches the next_action returned by the wizard model
    (e.g. action_finish_and_advance for the Finish & Next flow)

* New XML template: fp_record_inputs_dialog.xml
  Full DOM control. No fighting Odoo's list view, no class-stripping
  bugs from canUseFormatter, no read-mode-vs-edit-mode CSS dance.

* New SCSS: fp_record_inputs_dialog.scss
  - Dark mode aware (compile-time @if $o-webclient-color-scheme==dark)
  - Pure semantic selectors (.o_fp_ri_card, .o_fp_ri_input, etc.)
  - 14 surface tokens with light/dark hex pairs
  - Tablet polish via @media (max-width: 768px)
  - Custom toggle widget (no <input type="checkbox"> hidden trick)

* New controller: controllers/record_inputs.py
  - /fp/record_inputs/load: returns step + prompts payload
  - /fp/record_inputs/commit: creates a wizard, populates lines,
    calls action_commit (reuses existing audit-trail / synthetic
    move semantics — no commit logic duplicated)

* fp_job_step.py wired to dispatch the new action
  - _fp_open_input_wizard returns
    { type: 'ir.actions.client', tag: 'fp_record_inputs_dialog' }
  - action_open_input_wizard same
  - Contract-review redirect gate preserved (Sub 4 work intact)

* Manifest registers JS/XML/SCSS in BOTH backend + dark bundles
  per the dark-mode pattern in CLAUDE.md.

What was kept
=============
* fp.job.step.input.wizard TransientModel — UNCHANGED. The new
  controller's commit endpoint creates a wizard record and calls
  action_commit() on it, so all the audit-trail / synthetic-move
  / chatter logic stays in Python where it belongs.
* v2 + v3 form views still exist in the XML file. If the OWL
  dialog ever fails, switch action_open_input_wizard back to
  ir.actions.act_window with view_id=v2 or v3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-03 22:17:30 -04:00
parent 328599d539
commit d53fd53b80
7 changed files with 1186 additions and 36 deletions

View File

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

View File

@@ -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

View 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)}

View File

@@ -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,
},
}

View File

@@ -0,0 +1,258 @@
/** @odoo-module **/
/*
* Record Inputs Dialog (Sub 12e v4)
*
* Replaces the form-view + list-as-cards CSS hack with a proper OWL
* Dialog that owns its own DOM. No more fighting Odoo's editable list
* renderer — semantic HTML, full visual control, dark-mode aware.
*
* Backend dispatch:
* fp_job_step.action_open_input_wizard / action_finish_and_advance
* return ir.actions.client { tag: 'fp_record_inputs_dialog', params }.
* The action handler below opens the Dialog and returns nothing
* (the action chain ends; the dialog manages itself).
*
* Dialog flow:
* onWillStart → /fp/record_inputs/load → seed prompt rows
* onSave → /fp/record_inputs/commit → advance step (optional)
*/
import { Component, onWillStart, useState } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";
import { _t } from "@web/core/l10n/translation";
// Type categories — drives which input widget renders per row.
const NUMERIC_TYPES = new Set([
"number", "temperature", "thickness", "time_seconds", "ph",
]);
const BOOLEAN_TYPES = new Set(["boolean", "pass_fail"]);
export class FpRecordInputsDialog extends Component {
static template = "fusion_plating_jobs.FpRecordInputsDialog";
static components = { Dialog };
static props = ["stepId", "advanceAfter?", "close"];
setup() {
this.notification = useService("notification");
this.action = useService("action");
this.state = useState({
loading: true,
saving: false,
stepName: "",
jobName: "",
rows: [],
});
onWillStart(async () => {
await this.loadPrompts();
});
}
async loadPrompts() {
this.state.loading = true;
const data = await rpc("/fp/record_inputs/load", {
step_id: this.props.stepId,
});
if (!data.ok) {
this.notification.add(
data.error || _t("Could not load step prompts."),
{ type: "danger" },
);
this.props.close();
return;
}
this.state.stepName = data.step.name;
this.state.jobName = data.job.name;
this.state.rows = data.prompts.map((p) => ({
...p,
// value fields — initialized blank, populated as operator types
value_text: "",
value_number: 0,
value_boolean: false,
value_date: "",
photo_value: false,
photo_filename: "",
point_1: 0, point_2: 0, point_3: 0,
point_4: 0, point_5: 0,
panel_ph: 0, panel_concentration: 0,
panel_temperature: 0, panel_bath_id: "",
}));
this.state.loading = false;
}
// ---- Type predicates (used by the OWL template t-if) ----------------
isNumeric(row) { return NUMERIC_TYPES.has(row.input_type); }
isBoolean(row) { return BOOLEAN_TYPES.has(row.input_type); }
isDate(row) { return row.input_type === "date"; }
isPhoto(row) { return row.input_type === "photo"; }
isMulti(row) { return row.input_type === "multi_point_thickness"; }
isPanel(row) { return row.input_type === "bath_chemistry_panel"; }
isSelection(row) { return row.input_type === "selection"; }
// Fallback to text for anything else (text, signature, time_hms, ...)
isText(row) {
return !this.isNumeric(row) && !this.isBoolean(row)
&& !this.isDate(row) && !this.isPhoto(row)
&& !this.isMulti(row) && !this.isPanel(row)
&& !this.isSelection(row);
}
// ---- Selection options — recipe author may store as comma-sep ------
selectionOptions(row) {
const raw = row.selection_options || "";
return raw.split(/[\n,]/).map((s) => s.trim()).filter(Boolean);
}
// ---- Multi-point: live average of non-zero readings ----------------
multiPointAvg(row) {
const pts = [row.point_1, row.point_2, row.point_3,
row.point_4, row.point_5].filter((v) => v);
if (!pts.length) return 0;
return (pts.reduce((a, b) => a + b, 0) / pts.length).toFixed(3);
}
// ---- In-range hint for numeric — "in range" / "low" / "high" -------
rangeHint(row) {
if (!this.isNumeric(row)) return null;
if (!row.target_min && !row.target_max) return null;
const v = parseFloat(row.value_number);
if (!v) return null;
if (row.target_min && v < row.target_min) return { kind: "low", text: _t("below target") };
if (row.target_max && v > row.target_max) return { kind: "high", text: _t("above target") };
return { kind: "ok", text: _t("in range") };
}
// ---- Photo upload — file → base64 ----------------------------------
async onPhotoChange(row, ev) {
const file = ev.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const result = e.target.result;
row.photo_value = result.split(",")[1]; // strip data: URL prefix
row.photo_filename = file.name;
};
reader.readAsDataURL(file);
}
onPhotoClear(row) {
row.photo_value = false;
row.photo_filename = "";
}
photoPreviewSrc(row) {
if (!row.photo_value) return "";
return "data:image/jpeg;base64," + row.photo_value;
}
// ---- Add an ad-hoc measurement row ---------------------------------
addAdHocRow() {
this.state.rows.push({
node_input_id: false,
name: "",
input_type: "text",
required: false,
target_min: 0,
target_max: 0,
target_unit: "",
hint: "",
selection_options: "",
is_authored: false,
value_text: "",
value_number: 0,
value_boolean: false,
value_date: "",
photo_value: false,
photo_filename: "",
point_1: 0, point_2: 0, point_3: 0,
point_4: 0, point_5: 0,
panel_ph: 0, panel_concentration: 0,
panel_temperature: 0, panel_bath_id: "",
});
}
removeRow(idx) {
this.state.rows.splice(idx, 1);
}
// ---- Save ----------------------------------------------------------
async onSave() {
// Validate ad-hoc rows have a prompt name
for (const row of this.state.rows) {
if (!row.is_authored && !row.name.trim()) {
this.notification.add(
_t("Every ad-hoc measurement needs a Prompt label."),
{ type: "warning" },
);
return;
}
}
this.state.saving = true;
const payload = this.state.rows.map((r) => ({
node_input_id: r.node_input_id || false,
name: r.name,
input_type: r.input_type,
target_unit: r.target_unit,
target_min: r.target_min,
target_max: r.target_max,
value_text: r.value_text || false,
value_number: r.value_number || 0,
value_boolean: r.value_boolean,
value_date: r.value_date || false,
photo_value: r.photo_value || false,
photo_filename: r.photo_filename || false,
point_1: r.point_1, point_2: r.point_2, point_3: r.point_3,
point_4: r.point_4, point_5: r.point_5,
panel_ph: r.panel_ph,
panel_concentration: r.panel_concentration,
panel_temperature: r.panel_temperature,
panel_bath_id: r.panel_bath_id,
}));
const result = await rpc("/fp/record_inputs/commit", {
step_id: this.props.stepId,
values: payload,
advance_after: !!this.props.advanceAfter,
});
this.state.saving = false;
if (!result.ok) {
this.notification.add(
result.error || _t("Save failed."),
{ type: "danger" },
);
return;
}
this.notification.add(
_t("Inputs recorded."),
{ type: "success" },
);
this.props.close();
// If commit returned an action (e.g. Finish & Advance), dispatch it
if (result.next_action && typeof result.next_action === "object") {
await this.action.doAction(result.next_action);
}
}
onCancel() {
this.props.close();
}
}
// Register as a client action so backend Python can dispatch via:
// { type: 'ir.actions.client', tag: 'fp_record_inputs_dialog', params: {...} }
function fpRecordInputsDialogActionHandler(env, action) {
env.services.dialog.add(FpRecordInputsDialog, {
stepId: action.params.step_id,
advanceAfter: action.params.advance_after || false,
});
// Action chain ends — dialog is self-managed.
return { type: "ir.actions.act_window_close" };
}
registry.category("actions").add(
"fp_record_inputs_dialog",
fpRecordInputsDialogActionHandler,
);

View File

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

View File

@@ -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>