feat(simple-editor): node_type bug fix + inline library authoring + back nav
Bucket 1 — Generation bug fix
- post-migrate.py for 19.0.18.8.0 promotes flat 'step' children of
recipes to 'operation' so fp.job._generate_steps() picks them up.
Filter is narrow: only direct children of node_type='recipe' get
flipped, tree-editor sub-steps (parent.node_type='operation') are
untouched. Idempotent. Posts an audit chatter note on each affected
recipe.
- Simple Editor controller hardcodes node_type='operation' on insert
+ snapshot-import path so future recipes start correct.
Bucket 2 — Inline library authoring
- 6 new JSONRPC routes (/fp/simple_recipe/library/load + save +
seed_defaults + input/{add,write,remove}, /fp/simple_recipe/tank/list).
- + New Step button in the right pane opens an inline form with name /
kind / icon / instructions / stations / flags / prompts table.
- Pencil icon on each library row reopens the same form prefilled.
- Step Kind picker leads with 'Generic — no automatic behaviour'.
- 'Seed defaults from kind' calls action_seed_default_inputs server-side
for kinds that have curated default prompts.
Bucket 3 — Back nav
- '← Recipes' button in the header (or '← Part' when opened from
Process Composer) mirrors recipe_tree_editor.js, with
clearBreadcrumbs:true to avoid stack pollution.
Verified on entech: LGPS1104's 19 'step' children now show as
'operation', migration chatter note posted on the recipe, asset cache
busted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating',
|
'name': 'Fusion Plating',
|
||||||
'version': '19.0.18.7.4',
|
'version': '19.0.18.8.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -167,6 +167,156 @@ class SimpleRecipeController(http.Controller):
|
|||||||
tpl.unlink()
|
tpl.unlink()
|
||||||
return {'ok': True, 'soft_deleted': False}
|
return {'ok': True, 'soft_deleted': False}
|
||||||
|
|
||||||
|
# ---- Inline authoring (Simple Editor right-pane "+ New Step" / pencil) ----
|
||||||
|
#
|
||||||
|
# Endpoints below let a shop foreman create or edit a library template
|
||||||
|
# (with prompts) without leaving the Simple Editor. Manager-grade
|
||||||
|
# features (transition form, advanced time/temp targets, common-audit
|
||||||
|
# seeding) still live on the dedicated form view.
|
||||||
|
|
||||||
|
@http.route('/fp/simple_recipe/library/load', type='jsonrpc', auth='user')
|
||||||
|
def library_load(self, template_id):
|
||||||
|
"""Return the full payload for one library template."""
|
||||||
|
tpl = request.env['fp.step.template'].browse(int(template_id))
|
||||||
|
if not tpl.exists():
|
||||||
|
return {'ok': False, 'error': 'not_found'}
|
||||||
|
tpl.check_access('read')
|
||||||
|
return {'ok': True, 'template': self._library_payload(tpl)}
|
||||||
|
|
||||||
|
def _library_payload(self, tpl):
|
||||||
|
return {
|
||||||
|
'id': tpl.id,
|
||||||
|
'name': tpl.name or '',
|
||||||
|
'code': tpl.code or '',
|
||||||
|
'icon': tpl.icon or 'fa-cog',
|
||||||
|
'default_kind': tpl.default_kind or '',
|
||||||
|
'description': tpl.description or '',
|
||||||
|
'requires_signoff': tpl.requires_signoff,
|
||||||
|
'requires_predecessor_done': tpl.requires_predecessor_done,
|
||||||
|
'requires_rack_assignment': tpl.requires_rack_assignment,
|
||||||
|
'requires_transition_form': tpl.requires_transition_form,
|
||||||
|
'tank_ids': [
|
||||||
|
{'id': t.id, 'name': t.name, 'code': t.code or ''}
|
||||||
|
for t in tpl.tank_ids
|
||||||
|
],
|
||||||
|
'inputs': [
|
||||||
|
{
|
||||||
|
'id': i.id,
|
||||||
|
'name': i.name or '',
|
||||||
|
'input_type': i.input_type or 'text',
|
||||||
|
'target_min': i.target_min or 0.0,
|
||||||
|
'target_max': i.target_max or 0.0,
|
||||||
|
'target_unit': i.target_unit or '',
|
||||||
|
'required': bool(i.required),
|
||||||
|
'sequence': i.sequence or 0,
|
||||||
|
'hint': i.hint or '',
|
||||||
|
'selection_options': i.selection_options or '',
|
||||||
|
}
|
||||||
|
for i in tpl.input_template_ids.sorted('sequence')
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
@http.route('/fp/simple_recipe/library/save', type='jsonrpc', auth='user')
|
||||||
|
def library_save(self, template_id, vals):
|
||||||
|
"""Upsert: create when template_id is falsy, write otherwise.
|
||||||
|
Returns the full template payload so the OWL component can
|
||||||
|
refresh in one round-trip.
|
||||||
|
"""
|
||||||
|
Tpl = request.env['fp.step.template']
|
||||||
|
# Whitelist — never trust client-provided write_uid / id / etc.
|
||||||
|
allowed = {
|
||||||
|
'name', 'code', 'icon', 'default_kind', 'description',
|
||||||
|
'requires_signoff', 'requires_predecessor_done',
|
||||||
|
'requires_rack_assignment', 'requires_transition_form',
|
||||||
|
'tank_ids',
|
||||||
|
}
|
||||||
|
clean = {k: v for k, v in (vals or {}).items() if k in allowed}
|
||||||
|
# tank_ids comes in as a plain list of ids from the OWL form;
|
||||||
|
# translate into the Odoo (6, 0, ids) command form.
|
||||||
|
if 'tank_ids' in clean:
|
||||||
|
clean['tank_ids'] = [(6, 0, [int(x) for x in clean['tank_ids']])]
|
||||||
|
if template_id:
|
||||||
|
tpl = Tpl.browse(int(template_id))
|
||||||
|
tpl.check_access('write')
|
||||||
|
tpl.write(clean)
|
||||||
|
else:
|
||||||
|
tpl = Tpl.create(clean)
|
||||||
|
return {'ok': True, 'template': self._library_payload(tpl)}
|
||||||
|
|
||||||
|
@http.route('/fp/simple_recipe/library/seed_defaults', type='jsonrpc', auth='user')
|
||||||
|
def library_seed_defaults(self, template_id):
|
||||||
|
"""Run action_seed_default_inputs on this template. Idempotent —
|
||||||
|
only adds prompts whose name doesn't already exist.
|
||||||
|
"""
|
||||||
|
tpl = request.env['fp.step.template'].browse(int(template_id))
|
||||||
|
if not tpl.exists():
|
||||||
|
return {'ok': False, 'error': 'not_found'}
|
||||||
|
tpl.check_access('write')
|
||||||
|
if not tpl.default_kind:
|
||||||
|
return {'ok': False, 'error': 'no_kind',
|
||||||
|
'message': 'Pick a Step Kind first to seed defaults.'}
|
||||||
|
tpl.action_seed_default_inputs()
|
||||||
|
return {'ok': True, 'template': self._library_payload(tpl)}
|
||||||
|
|
||||||
|
@http.route('/fp/simple_recipe/library/input/add', type='jsonrpc', auth='user')
|
||||||
|
def library_input_add(self, template_id, payload):
|
||||||
|
tpl = request.env['fp.step.template'].browse(int(template_id))
|
||||||
|
tpl.check_access('write')
|
||||||
|
Input = request.env['fp.step.template.input']
|
||||||
|
existing_max = max(tpl.input_template_ids.mapped('sequence') or [0])
|
||||||
|
rec = Input.create({
|
||||||
|
'template_id': tpl.id,
|
||||||
|
'name': (payload or {}).get('name') or 'New Prompt',
|
||||||
|
'input_type': (payload or {}).get('input_type') or 'text',
|
||||||
|
'sequence': existing_max + 10,
|
||||||
|
'required': bool((payload or {}).get('required')),
|
||||||
|
})
|
||||||
|
return {'ok': True, 'input_id': rec.id,
|
||||||
|
'template': self._library_payload(tpl)}
|
||||||
|
|
||||||
|
@http.route('/fp/simple_recipe/library/input/write', type='jsonrpc', auth='user')
|
||||||
|
def library_input_write(self, input_id, payload):
|
||||||
|
Input = request.env['fp.step.template.input']
|
||||||
|
rec = Input.browse(int(input_id))
|
||||||
|
if not rec.exists():
|
||||||
|
return {'ok': False, 'error': 'not_found'}
|
||||||
|
rec.template_id.check_access('write')
|
||||||
|
allowed = {
|
||||||
|
'name', 'input_type', 'target_min', 'target_max', 'target_unit',
|
||||||
|
'required', 'sequence', 'selection_options', 'hint',
|
||||||
|
}
|
||||||
|
clean = {k: v for k, v in (payload or {}).items() if k in allowed}
|
||||||
|
if clean:
|
||||||
|
rec.write(clean)
|
||||||
|
return {'ok': True, 'template': self._library_payload(rec.template_id)}
|
||||||
|
|
||||||
|
@http.route('/fp/simple_recipe/library/input/remove', type='jsonrpc', auth='user')
|
||||||
|
def library_input_remove(self, input_id):
|
||||||
|
Input = request.env['fp.step.template.input']
|
||||||
|
rec = Input.browse(int(input_id))
|
||||||
|
if not rec.exists():
|
||||||
|
return {'ok': False, 'error': 'not_found'}
|
||||||
|
tpl = rec.template_id
|
||||||
|
tpl.check_access('write')
|
||||||
|
rec.unlink()
|
||||||
|
return {'ok': True, 'template': self._library_payload(tpl)}
|
||||||
|
|
||||||
|
@http.route('/fp/simple_recipe/tank/list', type='jsonrpc', auth='user')
|
||||||
|
def tank_list(self, query=''):
|
||||||
|
"""Tank picker for the inline library form. Returns active tanks
|
||||||
|
scoped to the current company.
|
||||||
|
"""
|
||||||
|
Tank = request.env['fusion.plating.tank']
|
||||||
|
domain = [('active', '=', True)]
|
||||||
|
if query:
|
||||||
|
domain += ['|', ('name', 'ilike', query), ('code', 'ilike', query)]
|
||||||
|
return {
|
||||||
|
'tanks': [
|
||||||
|
{'id': t.id, 'name': t.name, 'code': t.code or ''}
|
||||||
|
for t in Tank.search(domain, limit=50)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------ step
|
# ------------------------------------------------------------------ step
|
||||||
@http.route('/fp/simple_recipe/step/insert', type='jsonrpc', auth='user')
|
@http.route('/fp/simple_recipe/step/insert', type='jsonrpc', auth='user')
|
||||||
def step_insert(self, recipe_id, template_id=False, position=99, vals=None):
|
def step_insert(self, recipe_id, template_id=False, position=99, vals=None):
|
||||||
@@ -175,7 +325,10 @@ class SimpleRecipeController(http.Controller):
|
|||||||
|
|
||||||
new_vals = {
|
new_vals = {
|
||||||
'parent_id': recipe.id,
|
'parent_id': recipe.id,
|
||||||
'node_type': 'step',
|
# Must be 'operation' — fp.job._generate_steps() only creates
|
||||||
|
# fp.job.step rows for operation nodes. Flat 'step' children
|
||||||
|
# of a recipe were silently skipped pre-19.0.18.8.0.
|
||||||
|
'node_type': 'operation',
|
||||||
'sequence': target_seq,
|
'sequence': target_seq,
|
||||||
}
|
}
|
||||||
tpl = False
|
tpl = False
|
||||||
@@ -291,7 +444,8 @@ class SimpleRecipeController(http.Controller):
|
|||||||
Node = request.env['fusion.plating.process.node']
|
Node = request.env['fusion.plating.process.node']
|
||||||
new_vals = {
|
new_vals = {
|
||||||
'parent_id': target_recipe.id,
|
'parent_id': target_recipe.id,
|
||||||
'node_type': 'step',
|
# See _SNAPSHOT_FIELDS comment — operation, not step.
|
||||||
|
'node_type': 'operation',
|
||||||
'sequence': src_node.sequence,
|
'sequence': src_node.sequence,
|
||||||
'source_template_id': src_node.source_template_id.id or False,
|
'source_template_id': src_node.source_template_id.id or False,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
"""Post-migration for 19.0.18.8.0 — Simple Editor node_type fix.
|
||||||
|
|
||||||
|
Background
|
||||||
|
----------
|
||||||
|
The Simple Recipe Editor's controller used to insert library templates
|
||||||
|
into a recipe with `node_type='step'` directly under the recipe root.
|
||||||
|
But fp.job._generate_steps() (in fusion_plating_jobs/models/fp_job.py)
|
||||||
|
only creates fp.job.step rows for nodes whose node_type is 'operation'.
|
||||||
|
Top-level 'step' children of a recipe were silently skipped, meaning
|
||||||
|
Simple-Editor recipes generated zero job steps — no traveller content,
|
||||||
|
no shopfloor tablet entries, no CoC moves.
|
||||||
|
|
||||||
|
Fix
|
||||||
|
---
|
||||||
|
Promote every `step` node whose direct parent is a `recipe` to
|
||||||
|
`node_type='operation'`. Tree-editor authored 'step' nodes (which sit
|
||||||
|
under `operation` parents) are left untouched — the filter is on
|
||||||
|
`parent.node_type='recipe'`.
|
||||||
|
|
||||||
|
Idempotent: a second run finds no `step` children of recipes and is
|
||||||
|
a no-op. Posts a chatter note on each affected recipe so QA / clients
|
||||||
|
have a paper trail.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_AUDIT_BODY = (
|
||||||
|
'<p><strong>Recipe migrated to v19.0.18.8.0 step layout.</strong></p>'
|
||||||
|
'<p>Step nodes that were direct children of this recipe (Simple '
|
||||||
|
'Editor authoring) have been promoted to operation nodes so they '
|
||||||
|
'generate work-order steps correctly. No data was lost — only '
|
||||||
|
'<code>node_type</code> changed.</p>'
|
||||||
|
'<p>If this recipe was authored via the Tree Editor with explicit '
|
||||||
|
'sub-process / operation hierarchy, this migration was a no-op '
|
||||||
|
'for it.</p>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
if not version:
|
||||||
|
return
|
||||||
|
from odoo.api import Environment, SUPERUSER_ID
|
||||||
|
env = Environment(cr, SUPERUSER_ID, {})
|
||||||
|
|
||||||
|
# Find recipe ids whose direct children include any 'step' rows.
|
||||||
|
# We need this list BEFORE we flip the rows so we can post chatter
|
||||||
|
# afterwards.
|
||||||
|
cr.execute("""
|
||||||
|
SELECT DISTINCT parent.id
|
||||||
|
FROM fusion_plating_process_node parent
|
||||||
|
JOIN fusion_plating_process_node child
|
||||||
|
ON child.parent_id = parent.id
|
||||||
|
WHERE parent.node_type = 'recipe'
|
||||||
|
AND child.node_type = 'step'
|
||||||
|
""")
|
||||||
|
affected_recipe_ids = [r[0] for r in cr.fetchall()]
|
||||||
|
|
||||||
|
if not affected_recipe_ids:
|
||||||
|
_logger.info(
|
||||||
|
"Sub Simple-Editor migration: no flat-step recipes found, "
|
||||||
|
"nothing to do."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Flip the node_type. Filter is intentionally narrow — only direct
|
||||||
|
# children of a recipe get promoted. Tree-editor sub-step rows
|
||||||
|
# (parent.node_type='operation') are untouched.
|
||||||
|
cr.execute("""
|
||||||
|
UPDATE fusion_plating_process_node child
|
||||||
|
SET node_type = 'operation'
|
||||||
|
WHERE child.node_type = 'step'
|
||||||
|
AND child.parent_id IN (
|
||||||
|
SELECT id
|
||||||
|
FROM fusion_plating_process_node
|
||||||
|
WHERE node_type = 'recipe'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
flipped = cr.rowcount
|
||||||
|
_logger.info(
|
||||||
|
"Sub Simple-Editor migration: promoted %s step nodes to "
|
||||||
|
"operation across %s recipes.",
|
||||||
|
flipped, len(affected_recipe_ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Post a chatter note on each affected recipe (best-effort).
|
||||||
|
Node = env['fusion.plating.process.node']
|
||||||
|
for recipe in Node.browse(affected_recipe_ids):
|
||||||
|
try:
|
||||||
|
recipe.message_post(
|
||||||
|
body=_AUDIT_BODY,
|
||||||
|
message_type='notification',
|
||||||
|
subtype_xmlid='mail.mt_note',
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning(
|
||||||
|
"Failed to post audit note on recipe %s: %s",
|
||||||
|
recipe.id, e,
|
||||||
|
)
|
||||||
@@ -44,13 +44,25 @@ export class FpSimpleRecipeEditor extends Component {
|
|||||||
editingStepId: null,
|
editingStepId: null,
|
||||||
editName: "",
|
editName: "",
|
||||||
editInstructions: "",
|
editInstructions: "",
|
||||||
|
// Inline library form — open when authoring or editing a
|
||||||
|
// library template directly from the right pane. null =
|
||||||
|
// closed; otherwise carries the template payload.
|
||||||
|
libraryEditor: null,
|
||||||
|
libraryEditorBusy: false,
|
||||||
|
tankSearchResults: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
this._recipeId = null;
|
this._recipeId = null;
|
||||||
|
this._partId = null;
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const ctx = this.props.action?.context || {};
|
const ctx = this.props.action?.context || {};
|
||||||
this._recipeId = ctx.recipe_id || null;
|
this._recipeId = ctx.recipe_id || null;
|
||||||
|
this._partId = ctx.part_id || null;
|
||||||
|
// Mirror onto state so the OWL template can branch on it
|
||||||
|
// (template scope sees state directly, not arbitrary
|
||||||
|
// instance properties).
|
||||||
|
this.state.fromPart = !!this._partId;
|
||||||
if (this._recipeId) {
|
if (this._recipeId) {
|
||||||
await this.loadAll();
|
await this.loadAll();
|
||||||
} else {
|
} else {
|
||||||
@@ -173,6 +185,229 @@ export class FpSimpleRecipeEditor extends Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------- back navigation
|
||||||
|
/**
|
||||||
|
* Mirror of recipe_tree_editor.onBackToList. When the editor was
|
||||||
|
* opened from the part-scoped Process Composer, return to that part
|
||||||
|
* form; otherwise drop the user back on the Recipes list.
|
||||||
|
*
|
||||||
|
* `clearBreadcrumbs: true` is critical — without it, every part →
|
||||||
|
* composer → editor → back leaves intermediate pages on the
|
||||||
|
* breadcrumb stack so a second visit shows nonsense.
|
||||||
|
*/
|
||||||
|
onBackToList() {
|
||||||
|
if (this._partId) {
|
||||||
|
this.action.doAction(
|
||||||
|
{
|
||||||
|
type: "ir.actions.act_window",
|
||||||
|
res_model: "fp.part.catalog",
|
||||||
|
res_id: this._partId,
|
||||||
|
views: [[false, "form"]],
|
||||||
|
target: "current",
|
||||||
|
},
|
||||||
|
{ clearBreadcrumbs: true }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.action.doAction("fusion_plating.action_fp_process_recipe", {
|
||||||
|
clearBreadcrumbs: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------- inline library form
|
||||||
|
/**
|
||||||
|
* Open the inline form for a NEW library template. Skeleton payload
|
||||||
|
* mirrors the shape returned by `/fp/simple_recipe/library/load` so
|
||||||
|
* the same template renders both create + edit.
|
||||||
|
*/
|
||||||
|
onOpenLibraryCreate() {
|
||||||
|
this.state.libraryEditor = {
|
||||||
|
id: null, // null = create
|
||||||
|
name: "",
|
||||||
|
code: "",
|
||||||
|
icon: "fa-cog",
|
||||||
|
default_kind: "",
|
||||||
|
description: "",
|
||||||
|
requires_signoff: false,
|
||||||
|
requires_predecessor_done: false,
|
||||||
|
requires_rack_assignment: false,
|
||||||
|
requires_transition_form: false,
|
||||||
|
tank_ids: [],
|
||||||
|
inputs: [],
|
||||||
|
};
|
||||||
|
this.state.tankSearchResults = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async onOpenLibraryEdit(templateId) {
|
||||||
|
this.state.libraryEditorBusy = true;
|
||||||
|
const data = await rpc("/fp/simple_recipe/library/load", {
|
||||||
|
template_id: templateId,
|
||||||
|
});
|
||||||
|
if (data.ok) {
|
||||||
|
// Defensive copy — OWL useState wraps top-level fields, but
|
||||||
|
// we want to be able to mutate this.state.libraryEditor.* in
|
||||||
|
// place without triggering library list re-renders.
|
||||||
|
this.state.libraryEditor = JSON.parse(JSON.stringify(data.template));
|
||||||
|
} else {
|
||||||
|
this.notification.add(
|
||||||
|
_t("Could not load library template — it may have been deleted."),
|
||||||
|
{ type: "warning" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.state.libraryEditorBusy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onCancelLibraryEditor() {
|
||||||
|
this.state.libraryEditor = null;
|
||||||
|
this.state.tankSearchResults = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSaveLibraryEditor() {
|
||||||
|
const ed = this.state.libraryEditor;
|
||||||
|
if (!ed) return;
|
||||||
|
if (!(ed.name || "").trim()) {
|
||||||
|
this.notification.add(_t("Name is required."), { type: "warning" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.state.libraryEditorBusy = true;
|
||||||
|
const vals = {
|
||||||
|
name: ed.name,
|
||||||
|
code: ed.code,
|
||||||
|
icon: ed.icon,
|
||||||
|
default_kind: ed.default_kind || false,
|
||||||
|
description: ed.description,
|
||||||
|
requires_signoff: !!ed.requires_signoff,
|
||||||
|
requires_predecessor_done: !!ed.requires_predecessor_done,
|
||||||
|
requires_rack_assignment: !!ed.requires_rack_assignment,
|
||||||
|
requires_transition_form: !!ed.requires_transition_form,
|
||||||
|
tank_ids: (ed.tank_ids || []).map((t) => t.id),
|
||||||
|
};
|
||||||
|
const result = await rpc("/fp/simple_recipe/library/save", {
|
||||||
|
template_id: ed.id || false,
|
||||||
|
vals: vals,
|
||||||
|
});
|
||||||
|
if (result.ok) {
|
||||||
|
// Refresh in place so the Inputs section reflects DB state
|
||||||
|
// (e.g. id assigned to a freshly-created template).
|
||||||
|
this.state.libraryEditor = JSON.parse(JSON.stringify(result.template));
|
||||||
|
// Refresh the library list in the right pane.
|
||||||
|
const libData = await rpc("/fp/simple_recipe/library/list", {
|
||||||
|
query: this.state.librarySearch || "",
|
||||||
|
});
|
||||||
|
this.state.library = libData.templates;
|
||||||
|
this.notification.add(
|
||||||
|
ed.id ? _t("Library step updated") : _t("Library step created"),
|
||||||
|
{ type: "success" }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.notification.add(
|
||||||
|
result.error || _t("Save failed"),
|
||||||
|
{ type: "danger" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.state.libraryEditorBusy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSeedLibraryDefaults() {
|
||||||
|
const ed = this.state.libraryEditor;
|
||||||
|
if (!ed || !ed.id) {
|
||||||
|
this.notification.add(
|
||||||
|
_t("Save the step first, then seed defaults."),
|
||||||
|
{ type: "warning" }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ed.default_kind) {
|
||||||
|
this.notification.add(
|
||||||
|
_t("Pick a Step Kind first to seed defaults."),
|
||||||
|
{ type: "warning" }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await rpc("/fp/simple_recipe/library/seed_defaults", {
|
||||||
|
template_id: ed.id,
|
||||||
|
});
|
||||||
|
if (result.ok) {
|
||||||
|
this.state.libraryEditor = JSON.parse(JSON.stringify(result.template));
|
||||||
|
this.notification.add(_t("Default prompts seeded"), { type: "success" });
|
||||||
|
} else {
|
||||||
|
this.notification.add(
|
||||||
|
result.message || _t("Seed failed"),
|
||||||
|
{ type: "warning" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onAddLibraryInput() {
|
||||||
|
const ed = this.state.libraryEditor;
|
||||||
|
if (!ed || !ed.id) {
|
||||||
|
this.notification.add(
|
||||||
|
_t("Save the step first, then add prompts."),
|
||||||
|
{ type: "warning" }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await rpc("/fp/simple_recipe/library/input/add", {
|
||||||
|
template_id: ed.id,
|
||||||
|
payload: { name: _t("New Prompt"), input_type: "text" },
|
||||||
|
});
|
||||||
|
if (result.ok) {
|
||||||
|
this.state.libraryEditor = JSON.parse(JSON.stringify(result.template));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onLibraryInputBlur(inputId, field, ev) {
|
||||||
|
const value = ev.target.value;
|
||||||
|
if (field === "name" && !value.trim()) return;
|
||||||
|
await this._libraryInputWrite(inputId, field, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onLibraryInputChange(inputId, field, value) {
|
||||||
|
await this._libraryInputWrite(inputId, field, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _libraryInputWrite(inputId, field, value) {
|
||||||
|
const payload = {};
|
||||||
|
payload[field] = value;
|
||||||
|
const result = await rpc("/fp/simple_recipe/library/input/write", {
|
||||||
|
input_id: inputId,
|
||||||
|
payload: payload,
|
||||||
|
});
|
||||||
|
if (result.ok) {
|
||||||
|
this.state.libraryEditor = JSON.parse(JSON.stringify(result.template));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onRemoveLibraryInput(inputId) {
|
||||||
|
const proceed = await this._confirm(_t("Remove this prompt?"));
|
||||||
|
if (!proceed) return;
|
||||||
|
const result = await rpc("/fp/simple_recipe/library/input/remove", {
|
||||||
|
input_id: inputId,
|
||||||
|
});
|
||||||
|
if (result.ok) {
|
||||||
|
this.state.libraryEditor = JSON.parse(JSON.stringify(result.template));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSearchTanks(ev) {
|
||||||
|
const q = ev.target.value;
|
||||||
|
const data = await rpc("/fp/simple_recipe/tank/list", { query: q });
|
||||||
|
this.state.tankSearchResults = data.tanks;
|
||||||
|
}
|
||||||
|
|
||||||
|
onAddTank(tank) {
|
||||||
|
const ed = this.state.libraryEditor;
|
||||||
|
if (!ed) return;
|
||||||
|
if (ed.tank_ids.some((t) => t.id === tank.id)) return;
|
||||||
|
ed.tank_ids.push(tank);
|
||||||
|
}
|
||||||
|
|
||||||
|
onRemoveTank(tankId) {
|
||||||
|
const ed = this.state.libraryEditor;
|
||||||
|
if (!ed) return;
|
||||||
|
ed.tank_ids = ed.tank_ids.filter((t) => t.id !== tankId);
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------- drag & drop
|
// --------------------------------------------------------- drag & drop
|
||||||
|
|
||||||
onSelectedDragStart(stepId, ev) {
|
onSelectedDragStart(stepId, ev) {
|
||||||
|
|||||||
@@ -321,3 +321,174 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
color: $fp-se-muted;
|
color: $fp-se-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Back button + library header (Bucket 3) ----
|
||||||
|
.o_fp_se_back {
|
||||||
|
color: $fp-se-accent;
|
||||||
|
margin-right: 1rem;
|
||||||
|
padding: .25rem .5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $fp-se-accent;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_library_header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
|
||||||
|
h3 { margin: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_library_item {
|
||||||
|
.o_fp_library_edit {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: $fp-se-muted;
|
||||||
|
padding: .25rem .5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .15s;
|
||||||
|
|
||||||
|
&:hover { color: $fp-se-accent; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .o_fp_library_edit { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Inline library editor (Bucket 2) ----
|
||||||
|
.o_fp_library_editor {
|
||||||
|
background: $fp-se-card;
|
||||||
|
border: 1px solid $fp-se-border;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
|
&.o_fp_busy { opacity: .65; pointer-events: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_library_editor_header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: .75rem;
|
||||||
|
border-bottom: 1px solid $fp-se-border;
|
||||||
|
|
||||||
|
h3 { margin: 0; font-size: 1.1rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_library_editor_body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_le_row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
.o_fp_le_field { flex: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_le_field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .25rem;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: .85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $fp-se-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_le_tank_chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .35rem;
|
||||||
|
margin-bottom: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_le_tank_chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .35rem;
|
||||||
|
padding: .15rem .5rem;
|
||||||
|
background: rgba(0, 100, 200, .1);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: .85rem;
|
||||||
|
|
||||||
|
.o_fp_le_tank_remove {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: $fp-se-muted;
|
||||||
|
|
||||||
|
&:hover { color: red; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_le_tank_results {
|
||||||
|
border: 1px solid $fp-se-border;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: .25rem;
|
||||||
|
max-height: 12rem;
|
||||||
|
overflow: auto;
|
||||||
|
background: $fp-se-card;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_le_tank_option {
|
||||||
|
padding: .35rem .5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid $fp-se-border;
|
||||||
|
|
||||||
|
&:last-child { border-bottom: 0; }
|
||||||
|
&:hover { background: rgba(0, 100, 200, .08); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_le_flags {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: .35rem .75rem;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .35rem;
|
||||||
|
font-size: .9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_le_prompts {
|
||||||
|
border-top: 1px solid $fp-se-border;
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_le_prompts_header {
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_le_prompt_actions {
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
margin-top: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_library_editor_actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: .75rem;
|
||||||
|
border-top: 1px solid $fp-se-border;
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,13 @@
|
|||||||
<t t-name="fusion_plating.FpSimpleRecipeEditor">
|
<t t-name="fusion_plating.FpSimpleRecipeEditor">
|
||||||
<div class="o_fp_simple_editor">
|
<div class="o_fp_simple_editor">
|
||||||
<div class="o_fp_simple_editor_header">
|
<div class="o_fp_simple_editor_header">
|
||||||
|
<button class="btn btn-link o_fp_se_back"
|
||||||
|
t-on-click="onBackToList"
|
||||||
|
t-att-title="state.fromPart ? 'Back to part' : 'Back to recipes'">
|
||||||
|
<i class="fa fa-arrow-left me-2"/>
|
||||||
|
<t t-if="state.fromPart">Part</t>
|
||||||
|
<t t-else="">Recipes</t>
|
||||||
|
</button>
|
||||||
<h2 t-if="state.recipe">
|
<h2 t-if="state.recipe">
|
||||||
Recipe: <span t-esc="state.recipe.name"/>
|
Recipe: <span t-esc="state.recipe.name"/>
|
||||||
</h2>
|
</h2>
|
||||||
@@ -256,27 +263,287 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="o_fp_library_panel">
|
<div class="o_fp_library_panel">
|
||||||
<h3>Step Library</h3>
|
<!-- ============== LIBRARY LIST (default view) ============== -->
|
||||||
<input type="text" class="form-control"
|
<t t-if="!state.libraryEditor">
|
||||||
placeholder="Search…"
|
<div class="o_fp_library_header">
|
||||||
t-on-input="onSearchLibrary"
|
<h3>Step Library</h3>
|
||||||
t-att-value="state.librarySearch"/>
|
<button class="btn btn-sm btn-primary o_fp_lib_new"
|
||||||
<div class="o_fp_library_list">
|
t-on-click="onOpenLibraryCreate"
|
||||||
<t t-foreach="state.library" t-as="tpl" t-key="tpl.id">
|
title="Create a new library step (with prompts)">
|
||||||
<div class="o_fp_library_item"
|
<i class="fa fa-plus me-1"/>New Step
|
||||||
draggable="true"
|
</button>
|
||||||
t-on-dragstart="(ev) => this.onLibraryDragStart(tpl.id, ev)">
|
|
||||||
<i t-att-class="'fa ' + (tpl.icon || 'fa-cog')"/>
|
|
||||||
<span class="o_fp_library_name" t-esc="tpl.name"/>
|
|
||||||
<span class="o_fp_library_meta" t-if="tpl.station_count">
|
|
||||||
<t t-esc="tpl.station_count"/> st.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</t>
|
|
||||||
<div class="o_fp_library_empty" t-if="!state.library.length">
|
|
||||||
No library entries match your search.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<input type="text" class="form-control"
|
||||||
|
placeholder="Search…"
|
||||||
|
t-on-input="onSearchLibrary"
|
||||||
|
t-att-value="state.librarySearch"/>
|
||||||
|
<div class="o_fp_library_list">
|
||||||
|
<t t-foreach="state.library" t-as="tpl" t-key="tpl.id">
|
||||||
|
<div class="o_fp_library_item"
|
||||||
|
draggable="true"
|
||||||
|
t-on-dragstart="(ev) => this.onLibraryDragStart(tpl.id, ev)">
|
||||||
|
<i t-att-class="'fa ' + (tpl.icon || 'fa-cog')"/>
|
||||||
|
<span class="o_fp_library_name" t-esc="tpl.name"/>
|
||||||
|
<span class="o_fp_library_meta" t-if="tpl.station_count">
|
||||||
|
<t t-esc="tpl.station_count"/> st.
|
||||||
|
</span>
|
||||||
|
<button class="o_fp_library_edit"
|
||||||
|
title="Edit this library step"
|
||||||
|
t-on-click.stop="() => this.onOpenLibraryEdit(tpl.id)">
|
||||||
|
<i class="fa fa-pencil"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<div class="o_fp_library_empty" t-if="!state.library.length">
|
||||||
|
No library entries match your search.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- ============== INLINE LIBRARY EDITOR ============== -->
|
||||||
|
<t t-if="state.libraryEditor">
|
||||||
|
<div class="o_fp_library_editor"
|
||||||
|
t-att-class="state.libraryEditorBusy ? 'o_fp_busy' : ''">
|
||||||
|
<div class="o_fp_library_editor_header">
|
||||||
|
<h3 t-if="!state.libraryEditor.id">+ New Library Step</h3>
|
||||||
|
<h3 t-else="">Edit: <t t-esc="state.libraryEditor.name"/></h3>
|
||||||
|
<button class="btn btn-link btn-sm"
|
||||||
|
t-on-click="onCancelLibraryEditor"
|
||||||
|
title="Close without saving recent unsaved field">
|
||||||
|
<i class="fa fa-times"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="o_fp_library_editor_body">
|
||||||
|
<div class="o_fp_le_row">
|
||||||
|
<div class="o_fp_le_field">
|
||||||
|
<label>Name *</label>
|
||||||
|
<input type="text" class="form-control"
|
||||||
|
t-model="state.libraryEditor.name"
|
||||||
|
placeholder="e.g. Surface Activation"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_le_field">
|
||||||
|
<label>Code</label>
|
||||||
|
<input type="text" class="form-control"
|
||||||
|
t-model="state.libraryEditor.code"
|
||||||
|
placeholder="e.g. SURF_ACT"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="o_fp_le_row">
|
||||||
|
<div class="o_fp_le_field">
|
||||||
|
<label>Step Kind
|
||||||
|
<i class="fa fa-question-circle ms-1"
|
||||||
|
title="Picking a kind auto-seeds prompts and turns on workflow gates (Contract Review, Racking, Bake). Leave blank for plain generic steps."/>
|
||||||
|
</label>
|
||||||
|
<select class="form-select"
|
||||||
|
t-model="state.libraryEditor.default_kind">
|
||||||
|
<option value="">Generic — no automatic behaviour</option>
|
||||||
|
<option value="receiving">Receiving / Incoming Inspection</option>
|
||||||
|
<option value="contract_review">Contract Review (QA-005)</option>
|
||||||
|
<option value="racking">Racking</option>
|
||||||
|
<option value="mask">Masking</option>
|
||||||
|
<option value="cleaning">Cleaning</option>
|
||||||
|
<option value="electroclean">Electroclean</option>
|
||||||
|
<option value="etch">Etch / Activation</option>
|
||||||
|
<option value="rinse">Rinse</option>
|
||||||
|
<option value="strike">Strike (Wood's Nickel / Activation)</option>
|
||||||
|
<option value="plate">Plating</option>
|
||||||
|
<option value="replenishment">Tank Replenishment</option>
|
||||||
|
<option value="wbf_test">Water Break Free Test</option>
|
||||||
|
<option value="dry">Drying</option>
|
||||||
|
<option value="bake">Bake (HE Relief / Stress Relief)</option>
|
||||||
|
<option value="demask">De-Masking</option>
|
||||||
|
<option value="derack">De-Racking</option>
|
||||||
|
<option value="inspect">Inspection</option>
|
||||||
|
<option value="hardness_test">Hardness Test</option>
|
||||||
|
<option value="adhesion_test">Adhesion Test</option>
|
||||||
|
<option value="salt_spray">Salt Spray / Corrosion Test</option>
|
||||||
|
<option value="final_inspect">Final Inspection</option>
|
||||||
|
<option value="packaging">Packaging / Pre-Ship</option>
|
||||||
|
<option value="ship">Shipping</option>
|
||||||
|
<option value="gating">Gating</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_le_field">
|
||||||
|
<label>Icon</label>
|
||||||
|
<input type="text" class="form-control"
|
||||||
|
t-model="state.libraryEditor.icon"
|
||||||
|
placeholder="e.g. fa-flask"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="o_fp_le_field">
|
||||||
|
<label>Default Operator Instructions</label>
|
||||||
|
<textarea class="form-control" rows="3"
|
||||||
|
t-model="state.libraryEditor.description"
|
||||||
|
placeholder="Standing instructions for this step. Snapshot-copied into recipes when authors drag it in."/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="o_fp_le_field">
|
||||||
|
<label>Allowed Stations</label>
|
||||||
|
<div class="o_fp_le_tank_chips">
|
||||||
|
<t t-foreach="state.libraryEditor.tank_ids" t-as="tnk" t-key="tnk.id">
|
||||||
|
<span class="o_fp_le_tank_chip">
|
||||||
|
<t t-esc="tnk.name"/>
|
||||||
|
<button class="o_fp_le_tank_remove"
|
||||||
|
t-on-click="() => this.onRemoveTank(tnk.id)">×</button>
|
||||||
|
</span>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<input type="text" class="form-control form-control-sm"
|
||||||
|
placeholder="Search tanks to add…"
|
||||||
|
t-on-input="onSearchTanks"/>
|
||||||
|
<div class="o_fp_le_tank_results"
|
||||||
|
t-if="state.tankSearchResults and state.tankSearchResults.length">
|
||||||
|
<t t-foreach="state.tankSearchResults" t-as="tnk" t-key="tnk.id">
|
||||||
|
<div class="o_fp_le_tank_option"
|
||||||
|
t-on-click="() => this.onAddTank(tnk)">
|
||||||
|
+ <t t-esc="tnk.name"/>
|
||||||
|
<small t-if="tnk.code" class="text-muted ms-1">(<t t-esc="tnk.code"/>)</small>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="o_fp_le_flags">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox"
|
||||||
|
t-model="state.libraryEditor.requires_signoff"/>
|
||||||
|
Require QA Sign-off
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox"
|
||||||
|
t-model="state.libraryEditor.requires_predecessor_done"/>
|
||||||
|
Require Predecessor Done
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox"
|
||||||
|
t-model="state.libraryEditor.requires_rack_assignment"/>
|
||||||
|
Requires Rack Assignment
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox"
|
||||||
|
t-model="state.libraryEditor.requires_transition_form"/>
|
||||||
|
Requires Transition Form
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============== PROMPTS ============== -->
|
||||||
|
<div class="o_fp_le_prompts">
|
||||||
|
<div class="o_fp_le_prompts_header">
|
||||||
|
<strong>Operation Measurements</strong>
|
||||||
|
<span class="text-muted ms-2 small">
|
||||||
|
What the operator records during this step. Snapshot-copied into recipes.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<table class="table table-sm o_fp_inputs_table"
|
||||||
|
t-if="state.libraryEditor.inputs and state.libraryEditor.inputs.length">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Prompt</th>
|
||||||
|
<th style="width:160px;">Type</th>
|
||||||
|
<th style="width:90px;">Min</th>
|
||||||
|
<th style="width:90px;">Max</th>
|
||||||
|
<th style="width:80px;">Unit</th>
|
||||||
|
<th style="width:60px;">Req</th>
|
||||||
|
<th style="width:36px;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr t-foreach="state.libraryEditor.inputs" t-as="inp" t-key="inp.id">
|
||||||
|
<td>
|
||||||
|
<input type="text" class="form-control form-control-sm"
|
||||||
|
t-att-value="inp.name"
|
||||||
|
t-on-blur="(ev) => this.onLibraryInputBlur(inp.id, 'name', ev)"/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select class="form-select form-select-sm"
|
||||||
|
t-on-change="(ev) => this.onLibraryInputChange(inp.id, 'input_type', ev.target.value)">
|
||||||
|
<option value="text" t-att-selected="inp.input_type === 'text'">Text</option>
|
||||||
|
<option value="number" t-att-selected="inp.input_type === 'number'">Number</option>
|
||||||
|
<option value="boolean" t-att-selected="inp.input_type === 'boolean'">Yes/No</option>
|
||||||
|
<option value="selection" t-att-selected="inp.input_type === 'selection'">Selection</option>
|
||||||
|
<option value="date" t-att-selected="inp.input_type === 'date'">Date / Time</option>
|
||||||
|
<option value="signature" t-att-selected="inp.input_type === 'signature'">Signature</option>
|
||||||
|
<option value="time_hms" t-att-selected="inp.input_type === 'time_hms'">Time (HH:MM:SS)</option>
|
||||||
|
<option value="time_seconds" t-att-selected="inp.input_type === 'time_seconds'">Time (sec)</option>
|
||||||
|
<option value="temperature" t-att-selected="inp.input_type === 'temperature'">Temperature</option>
|
||||||
|
<option value="thickness" t-att-selected="inp.input_type === 'thickness'">Thickness</option>
|
||||||
|
<option value="pass_fail" t-att-selected="inp.input_type === 'pass_fail'">Pass / Fail</option>
|
||||||
|
<option value="photo" t-att-selected="inp.input_type === 'photo'">Photo</option>
|
||||||
|
<option value="multi_point_thickness" t-att-selected="inp.input_type === 'multi_point_thickness'">Multi-Point Thickness</option>
|
||||||
|
<option value="bath_chemistry_panel" t-att-selected="inp.input_type === 'bath_chemistry_panel'">Bath Chemistry Panel</option>
|
||||||
|
<option value="ph" t-att-selected="inp.input_type === 'ph'">pH</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="number" step="any" class="form-control form-control-sm"
|
||||||
|
t-att-value="inp.target_min"
|
||||||
|
t-on-blur="(ev) => this.onLibraryInputBlur(inp.id, 'target_min', ev)"/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="number" step="any" class="form-control form-control-sm"
|
||||||
|
t-att-value="inp.target_max"
|
||||||
|
t-on-blur="(ev) => this.onLibraryInputBlur(inp.id, 'target_max', ev)"/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" class="form-control form-control-sm"
|
||||||
|
t-att-value="inp.target_unit"
|
||||||
|
placeholder=""
|
||||||
|
t-on-blur="(ev) => this.onLibraryInputBlur(inp.id, 'target_unit', ev)"/>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<input type="checkbox"
|
||||||
|
t-att-checked="inp.required"
|
||||||
|
t-on-change="(ev) => this.onLibraryInputChange(inp.id, 'required', ev.target.checked)"/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-link btn-sm text-danger p-0"
|
||||||
|
title="Remove prompt"
|
||||||
|
t-on-click="() => this.onRemoveLibraryInput(inp.id)">×</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p t-if="!state.libraryEditor.inputs or !state.libraryEditor.inputs.length"
|
||||||
|
class="text-muted small">
|
||||||
|
<t t-if="!state.libraryEditor.id">
|
||||||
|
Save the step first, then add prompts.
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
No prompts yet — operators will not be asked for measurements at runtime.
|
||||||
|
</t>
|
||||||
|
</p>
|
||||||
|
<div class="o_fp_le_prompt_actions"
|
||||||
|
t-if="state.libraryEditor.id">
|
||||||
|
<button class="btn btn-link btn-sm"
|
||||||
|
t-on-click="onAddLibraryInput">
|
||||||
|
<i class="fa fa-plus"/> Add prompt
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-link btn-sm"
|
||||||
|
t-if="state.libraryEditor.default_kind"
|
||||||
|
t-on-click="onSeedLibraryDefaults"
|
||||||
|
title="Append the canonical prompts for this Step Kind. Idempotent — won't duplicate existing prompts.">
|
||||||
|
<i class="fa fa-magic"/> Seed defaults from kind
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="o_fp_library_editor_actions">
|
||||||
|
<button class="btn btn-primary"
|
||||||
|
t-on-click="onSaveLibraryEditor"
|
||||||
|
t-att-disabled="state.libraryEditorBusy">
|
||||||
|
<i class="fa fa-save me-1"/>Save
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary"
|
||||||
|
t-on-click="onCancelLibraryEditor">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user