From d53fd53b8002ecf822b6c95fb7c2ffd3f9bfe1f8 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 3 May 2026 22:17:30 -0400 Subject: [PATCH] =?UTF-8?q?feat(jobs):=20Record=20Inputs=20OWL=20Dialog=20?= =?UTF-8?q?(v4)=20=E2=80=94=20replaces=20list-as-cards=20hack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
- 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 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) --- .../fusion_plating_jobs/__manifest__.py | 20 +- .../controllers/__init__.py | 1 + .../controllers/record_inputs.py | 157 ++++++ .../fusion_plating_jobs/models/fp_job_step.py | 53 +- .../static/src/js/fp_record_inputs_dialog.js | 258 +++++++++ .../src/scss/fp_record_inputs_dialog.scss | 514 ++++++++++++++++++ .../src/xml/fp_record_inputs_dialog.xml | 219 ++++++++ 7 files changed, 1186 insertions(+), 36 deletions(-) create mode 100644 fusion_plating/fusion_plating_jobs/controllers/record_inputs.py create mode 100644 fusion_plating/fusion_plating_jobs/static/src/js/fp_record_inputs_dialog.js create mode 100644 fusion_plating/fusion_plating_jobs/static/src/scss/fp_record_inputs_dialog.scss create mode 100644 fusion_plating/fusion_plating_jobs/static/src/xml/fp_record_inputs_dialog.xml diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index a77d1965..a415b7b1 100644 --- a/fusion_plating/fusion_plating_jobs/__manifest__.py +++ b/fusion_plating/fusion_plating_jobs/__manifest__.py @@ -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, diff --git a/fusion_plating/fusion_plating_jobs/controllers/__init__.py b/fusion_plating/fusion_plating_jobs/controllers/__init__.py index 24fad823..ff1a6a5a 100644 --- a/fusion_plating/fusion_plating_jobs/controllers/__init__.py +++ b/fusion_plating/fusion_plating_jobs/controllers/__init__.py @@ -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 diff --git a/fusion_plating/fusion_plating_jobs/controllers/record_inputs.py b/fusion_plating/fusion_plating_jobs/controllers/record_inputs.py new file mode 100644 index 00000000..74772f9f --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/controllers/record_inputs.py @@ -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)} diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py index 583a7a75..5a4ccbe1 100644 --- a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py +++ b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py @@ -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, }, } diff --git a/fusion_plating/fusion_plating_jobs/static/src/js/fp_record_inputs_dialog.js b/fusion_plating/fusion_plating_jobs/static/src/js/fp_record_inputs_dialog.js new file mode 100644 index 00000000..1b0281a3 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/static/src/js/fp_record_inputs_dialog.js @@ -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, +); diff --git a/fusion_plating/fusion_plating_jobs/static/src/scss/fp_record_inputs_dialog.scss b/fusion_plating/fusion_plating_jobs/static/src/scss/fp_record_inputs_dialog.scss new file mode 100644 index 00000000..99a44dde --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/static/src/scss/fp_record_inputs_dialog.scss @@ -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); + } +} diff --git a/fusion_plating/fusion_plating_jobs/static/src/xml/fp_record_inputs_dialog.xml b/fusion_plating/fusion_plating_jobs/static/src/xml/fp_record_inputs_dialog.xml new file mode 100644 index 00000000..f3566d49 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/static/src/xml/fp_record_inputs_dialog.xml @@ -0,0 +1,219 @@ + + + + + + +
+
+

+ + Job + +

+
+
+ + +
+ + Loading prompts... +
+ + +
+

No measurement prompts on this step.

+ +
+ + +
+ +
+ + +
+
+ + + + * + + + +
+ +
+ + +
+ + +
+ + +
+ Target: + + + + +
+ + +
+ + +
+ + +
+ + + +
+ + + + + + + + + + + +
+
+ +
+
+ Captured photo + +
+
+ + +
+
+ + + + + +
+ Avg + +
+
+
+ + +
+ + + + +
+ + + +
+
+ + + + +
+ + + + + +
+
+ +