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