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,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)}
|
||||
Reference in New Issue
Block a user