diff --git a/fusion_helpdesk/__init__.py b/fusion_helpdesk/__init__.py
new file mode 100644
index 00000000..9e5827f9
--- /dev/null
+++ b/fusion_helpdesk/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+from . import controllers
+from . import models
diff --git a/fusion_helpdesk/__manifest__.py b/fusion_helpdesk/__manifest__.py
new file mode 100644
index 00000000..8560f27c
--- /dev/null
+++ b/fusion_helpdesk/__manifest__.py
@@ -0,0 +1,47 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+{
+ 'name': 'Fusion Helpdesk Reporter',
+ 'version': '19.0.1.2.0',
+ 'category': 'Productivity',
+ 'summary': 'One-click in-app bug reporting & feature requesting — '
+ 'auto-creates a helpdesk.ticket on a central Odoo Helpdesk.',
+ 'description': """
+Fusion Helpdesk Reporter
+========================
+A standalone module that gives every backend user a quick "Report an
+Issue / Request a Feature" button in the Odoo top systray.
+
+Submissions are forwarded over JSON-RPC / XML-RPC to a central Odoo
+instance (typically the support shop's main Odoo) and become
+`helpdesk.ticket` records with screenshots, file attachments, and
+diagnostic context (URL, user agent, current user/company, error
+code) automatically captured.
+
+Designed to ship alongside any other Fusion / Nexa Systems client
+module bundle. No dependencies on the rest of Fusion Plating.
+ """,
+ 'author': 'Nexa Systems Inc.',
+ 'website': 'https://www.nexasystems.ca',
+ 'license': 'OPL-1',
+ 'depends': ['base', 'web', 'mail'],
+ 'data': [
+ 'security/ir.model.access.csv',
+ 'data/ir_config_parameter_data.xml',
+ 'views/res_config_settings_views.xml',
+ ],
+ 'assets': {
+ 'web.assets_backend': [
+ 'fusion_helpdesk/static/src/scss/fusion_helpdesk.scss',
+ 'fusion_helpdesk/static/src/xml/fusion_helpdesk_systray.xml',
+ 'fusion_helpdesk/static/src/xml/fusion_helpdesk_dialog.xml',
+ 'fusion_helpdesk/static/src/js/fusion_helpdesk_systray.js',
+ 'fusion_helpdesk/static/src/js/fusion_helpdesk_dialog.js',
+ ],
+ },
+ 'images': ['static/description/icon.png'],
+ 'installable': True,
+ 'auto_install': False,
+ 'application': False,
+}
diff --git a/fusion_helpdesk/controllers/__init__.py b/fusion_helpdesk/controllers/__init__.py
new file mode 100644
index 00000000..757b12a1
--- /dev/null
+++ b/fusion_helpdesk/controllers/__init__.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+from . import main
diff --git a/fusion_helpdesk/controllers/main.py b/fusion_helpdesk/controllers/main.py
new file mode 100644
index 00000000..b5ad514f
--- /dev/null
+++ b/fusion_helpdesk/controllers/main.py
@@ -0,0 +1,402 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1
+"""HTTP routes for the Fusion Helpdesk Reporter.
+
+`/fusion_helpdesk/submit` accepts the dialog payload, forwards it to a
+remote Odoo Helpdesk over XML-RPC, attaches uploaded files +
+screenshot, and returns the resulting ticket id/url to the OWL dialog.
+
+Why XML-RPC and not JSON-RPC? Helpdesk's external API surface is
+exposed via Odoo's standard `/xmlrpc/2/object` endpoint which is
+the most stable cross-version contract. JSON-RPC `/jsonrpc` works
+too but historically had quirkier error handling on file payloads.
+"""
+import base64
+import logging
+import socket
+import ssl
+import xmlrpc.client
+from urllib.parse import urljoin, urlparse
+
+from odoo import _, http
+from odoo.exceptions import UserError
+from odoo.http import request
+
+_logger = logging.getLogger(__name__)
+
+
+class FusionHelpdeskController(http.Controller):
+
+ @http.route(
+ '/fusion_helpdesk/submit',
+ type='jsonrpc', auth='user', methods=['POST'],
+ )
+ def submit(self, kind, subject, description,
+ error_code=None, attachments=None,
+ page_url=None, user_agent=None):
+ """Forward a bug report or feature request to the central Odoo
+ Helpdesk and return {ok, ticket_id, ticket_url, error}.
+
+ Args:
+ kind: 'bug' or 'feature'.
+ subject: short title.
+ description: long-form. May contain HTML or plain text.
+ error_code: optional traceback / error string.
+ attachments: list of {name, mimetype, data_b64} dicts.
+ page_url: client-side window.location.href at submit time.
+ user_agent: client-side navigator.userAgent.
+ """
+ cfg = self._read_config()
+ if not all([cfg['url'], cfg['db'], cfg['login'], cfg['password']]):
+ return {
+ 'ok': False,
+ 'error': 'config_missing',
+ 'message': _(
+ 'Fusion Helpdesk is not fully configured. Ask an '
+ 'administrator to fill in the remote URL, database, '
+ 'login and password under Settings → Fusion Helpdesk.'
+ ),
+ }
+
+ # ---- Build the ticket payload ---------------------------------
+ prefix = ('[%s] ' % cfg['client_label']) if cfg['client_label'] else ''
+ kind_label = 'Bug Report' if kind == 'bug' else 'Feature Request'
+ full_subject = '%s%s: %s' % (prefix, kind_label, subject or '(untitled)')
+
+ body_parts = []
+ if description:
+ body_parts.append(
+ '
Description %s
' % _html_escape(description)
+ )
+ if error_code:
+ body_parts.append(
+ 'Error Code / Traceback '
+ '%s ' % _html_escape(error_code)
+ )
+ body_parts.append(self._build_diag_block(page_url, user_agent))
+
+ ticket_vals = {
+ 'name': full_subject,
+ 'description': '\n'.join(body_parts),
+ }
+ if cfg['team_id']:
+ ticket_vals['team_id'] = cfg['team_id']
+
+ # ---- Talk to remote Odoo --------------------------------------
+ try:
+ uid, models_proxy = self._authenticate(cfg)
+ except _RemoteError as e:
+ return e.to_response()
+
+ try:
+ ticket_id = models_proxy.execute_kw(
+ cfg['db'], uid, cfg['password'],
+ 'helpdesk.ticket', 'create', [ticket_vals],
+ )
+ except xmlrpc.client.Fault as e:
+ fault = (e.faultString or '').strip()
+ _logger.warning(
+ 'fusion_helpdesk: helpdesk.ticket.create failed: %s',
+ fault,
+ )
+ # The Helpdesk app might not be installed on the remote.
+ if 'helpdesk.ticket' in fault and 'does not exist' in fault.lower():
+ return {
+ 'ok': False, 'error': 'helpdesk_not_installed',
+ 'message': _(
+ 'The Helpdesk app is not installed on the central '
+ 'Odoo at %s. Install the Helpdesk app there before '
+ 'submitting tickets.'
+ ) % cfg['url'],
+ }
+ if 'access' in fault.lower() or 'rights' in fault.lower():
+ return {
+ 'ok': False, 'error': 'permission_denied',
+ 'message': _(
+ 'The remote service account "%s" does not have '
+ 'permission to create helpdesk tickets. Ask a '
+ 'central Odoo admin to grant Helpdesk Officer '
+ 'rights to that user.'
+ ) % cfg['login'],
+ }
+ return {
+ 'ok': False, 'error': 'create_failed',
+ 'message': _('The remote Helpdesk rejected the ticket: %s'
+ ) % fault,
+ }
+ except (socket.timeout, OSError, ssl.SSLError) as e:
+ _logger.warning(
+ 'fusion_helpdesk: ticket create network error: %s', e,
+ )
+ return _network_error_response(cfg['url'], e)
+
+ # ---- Push attachments -----------------------------------------
+ attached = 0
+ for att in attachments or []:
+ data_b64 = (att or {}).get('data_b64')
+ name = (att or {}).get('name') or 'attachment.bin'
+ mimetype = (att or {}).get('mimetype') or 'application/octet-stream'
+ if not data_b64:
+ continue
+ try:
+ models_proxy.execute_kw(
+ cfg['db'], uid, cfg['password'],
+ 'ir.attachment', 'create', [{
+ 'name': name,
+ 'datas': data_b64,
+ 'res_model': 'helpdesk.ticket',
+ 'res_id': ticket_id,
+ 'mimetype': mimetype,
+ }],
+ )
+ attached += 1
+ except xmlrpc.client.Fault as e:
+ _logger.warning(
+ 'fusion_helpdesk: attachment "%s" upload failed: %s',
+ name, e.faultString,
+ )
+
+ ticket_url = urljoin(
+ cfg['url'].rstrip('/') + '/',
+ 'odoo/helpdesk/%s' % ticket_id,
+ )
+ _logger.info(
+ 'fusion_helpdesk: created remote ticket #%s (%s attachments) '
+ 'on %s for user %s',
+ ticket_id, attached, cfg['url'],
+ request.env.user.login,
+ )
+ return {
+ 'ok': True,
+ 'ticket_id': ticket_id,
+ 'ticket_url': ticket_url,
+ 'attached': attached,
+ }
+
+ # ------------------------------------------------------------------
+ def _read_config(self):
+ """Return the active config as a plain dict. Run as sudo so
+ regular users can submit even without read-access on
+ ir.config_parameter (the password row is system-write but
+ readable by anyone with backend access)."""
+ ICP = request.env['ir.config_parameter'].sudo()
+ return {
+ 'url': (ICP.get_param('fusion_helpdesk.remote_url') or '').strip(),
+ 'db': (ICP.get_param('fusion_helpdesk.remote_db') or '').strip(),
+ 'login': (ICP.get_param('fusion_helpdesk.remote_login') or '').strip(),
+ 'password': ICP.get_param('fusion_helpdesk.remote_password') or '',
+ 'team_id': int(
+ ICP.get_param('fusion_helpdesk.remote_team_id') or 0
+ ) or False,
+ 'client_label': (
+ ICP.get_param('fusion_helpdesk.client_label') or ''
+ ).strip(),
+ }
+
+ def _authenticate(self, cfg):
+ """Authenticate against the remote and return (uid, models_proxy).
+
+ Raises _RemoteError with a granular `error` code and a friendly
+ end-user message so the dialog can show what actually broke
+ (network vs. credentials vs. server problem).
+ """
+ url = cfg['url'].rstrip('/')
+ try:
+ common = xmlrpc.client.ServerProxy(
+ '%s/xmlrpc/2/common' % url, allow_none=True,
+ )
+ try:
+ uid = common.authenticate(
+ cfg['db'], cfg['login'], cfg['password'], {},
+ )
+ except xmlrpc.client.ProtocolError as e:
+ # Server returned an HTTP error status.
+ _logger.warning(
+ 'fusion_helpdesk: HTTP %s from %s during authenticate: %s',
+ e.errcode, url, e.errmsg,
+ )
+ if e.errcode in (401, 403):
+ raise _RemoteError(
+ 'auth_failed',
+ _('The central Odoo at %s rejected the login. '
+ 'Check the Service Login and API Key in '
+ 'Settings → Fusion Helpdesk.') % url,
+ )
+ if e.errcode == 404:
+ raise _RemoteError(
+ 'endpoint_not_found',
+ _('The XML-RPC endpoint at %s/xmlrpc/2/common '
+ 'returned 404. Verify the Remote URL points '
+ 'at an Odoo server (no trailing path).') % url,
+ )
+ raise _RemoteError(
+ 'remote_http_error',
+ _('The central Odoo returned HTTP %(code)s while '
+ 'authenticating. Try again, or check that '
+ '%(url)s is reachable from a browser.'
+ ) % {'code': e.errcode, 'url': url},
+ )
+ except xmlrpc.client.Fault as e:
+ # Server-side application error — usually wrong DB name.
+ fault = (e.faultString or '').strip()
+ _logger.warning(
+ 'fusion_helpdesk: XML-RPC fault during authenticate: %s',
+ fault,
+ )
+ if 'database' in fault.lower() and (
+ 'not exist' in fault.lower() or 'unknown' in fault.lower()
+ ):
+ raise _RemoteError(
+ 'wrong_database',
+ _('Database "%(db)s" does not exist on %(url)s. '
+ 'Verify the Remote DB in Settings → Fusion '
+ 'Helpdesk.') % {'db': cfg['db'], 'url': url},
+ )
+ raise _RemoteError(
+ 'auth_failed',
+ _('The central Odoo rejected authentication: %s'
+ ) % fault,
+ )
+ except ssl.SSLError as e:
+ _logger.warning(
+ 'fusion_helpdesk: TLS error against %s: %s', url, e,
+ )
+ raise _RemoteError(
+ 'tls_error',
+ _('TLS / SSL handshake with %(url)s failed: '
+ '%(msg)s. Either the server cert is invalid or '
+ 'the system cert store on this host is out of '
+ 'date.') % {'url': url, 'msg': str(e)},
+ )
+ except socket.gaierror as e:
+ _logger.warning(
+ 'fusion_helpdesk: DNS lookup failed for %s: %s', url, e,
+ )
+ raise _RemoteError(
+ 'dns_error',
+ _('Could not resolve "%(host)s". Check Settings → '
+ 'Fusion Helpdesk → Remote URL, or your server\'s '
+ 'DNS / outbound network.'
+ ) % {'host': urlparse(url).hostname or url},
+ )
+ except (ConnectionRefusedError, socket.timeout) as e:
+ _logger.warning(
+ 'fusion_helpdesk: connection problem to %s: %s', url, e,
+ )
+ raise _RemoteError(
+ 'unreachable',
+ _('Could not reach %(url)s — connection refused or '
+ 'timed out. Check that this server can make '
+ 'outbound HTTPS to %(host)s (firewall, proxy).'
+ ) % {'url': url, 'host': urlparse(url).hostname or url},
+ )
+ except OSError as e:
+ # Catch-all for socket / network issues we didn't classify.
+ _logger.warning(
+ 'fusion_helpdesk: network error to %s: %s', url, e,
+ )
+ raise _RemoteError(
+ 'unreachable',
+ _('Network error reaching %(url)s: %(msg)s. Verify '
+ 'this server has outbound internet access.'
+ ) % {'url': url, 'msg': str(e)},
+ )
+ except _RemoteError:
+ raise
+ except Exception as e:
+ _logger.exception(
+ 'fusion_helpdesk: unexpected error during authenticate '
+ 'against %s', url,
+ )
+ raise _RemoteError(
+ 'unknown_error',
+ _('Unexpected error contacting the central Helpdesk: %s'
+ ) % str(e),
+ )
+
+ if not uid:
+ raise _RemoteError(
+ 'auth_failed',
+ _('The central Odoo at %(url)s did not accept the '
+ 'login "%(login)s". Verify the Service Login and '
+ 'API Key in Settings → Fusion Helpdesk.'
+ ) % {'url': url, 'login': cfg['login']},
+ )
+
+ models_proxy = xmlrpc.client.ServerProxy(
+ '%s/xmlrpc/2/object' % url, allow_none=True,
+ )
+ return uid, models_proxy
+
+ def _build_diag_block(self, page_url, user_agent):
+ env = request.env
+ company = env.company
+ user = env.user
+ rows = [
+ ('User', '%s (#%s, %s)' % (user.name, user.id, user.login)),
+ ('Company', '%s (#%s)' % (company.name, company.id)),
+ ('Source page', page_url or '—'),
+ ('User agent', user_agent or '—'),
+ ('Source DB', request.env.cr.dbname),
+ ('Source host', request.httprequest.host_url),
+ ]
+ body = 'Diagnostic context '
+ for k, v in rows:
+ body += (
+ '%s '
+ '%s '
+ ) % (_html_escape(k), _html_escape(str(v)))
+ body += '
'
+ return body
+
+
+def _html_escape(s):
+ return (
+ (s or '')
+ .replace('&', '&')
+ .replace('<', '<')
+ .replace('>', '>')
+ )
+
+
+class _RemoteError(Exception):
+ """Typed wrapper that carries an `error` code + a friendly message
+ so the dialog can show the right thing."""
+ def __init__(self, code, message):
+ super().__init__(message)
+ self.code = code
+ self.message = message
+
+ def to_response(self):
+ return {'ok': False, 'error': self.code, 'message': self.message}
+
+
+def _network_error_response(url, err):
+ """Map a raw network exception raised mid-RPC into the same
+ response shape the dialog already knows how to render."""
+ host = urlparse(url).hostname or url
+ if isinstance(err, ssl.SSLError):
+ return {
+ 'ok': False, 'error': 'tls_error',
+ 'message': _('TLS / SSL error talking to %(url)s: %(msg)s'
+ ) % {'url': url, 'msg': str(err)},
+ }
+ if isinstance(err, socket.gaierror):
+ return {
+ 'ok': False, 'error': 'dns_error',
+ 'message': _('Could not resolve "%s" — check DNS / outbound network.'
+ ) % host,
+ }
+ if isinstance(err, socket.timeout):
+ return {
+ 'ok': False, 'error': 'unreachable',
+ 'message': _('Timed out talking to %s — check the firewall '
+ 'allows outbound HTTPS.') % url,
+ }
+ return {
+ 'ok': False, 'error': 'unreachable',
+ 'message': _('Network error talking to %(url)s: %(msg)s'
+ ) % {'url': url, 'msg': str(err)},
+ }
diff --git a/fusion_helpdesk/data/ir_config_parameter_data.xml b/fusion_helpdesk/data/ir_config_parameter_data.xml
new file mode 100644
index 00000000..0ef084e8
--- /dev/null
+++ b/fusion_helpdesk/data/ir_config_parameter_data.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+ fusion_helpdesk.remote_url
+ https://erp.nexasystems.ca
+
+
+
+ fusion_helpdesk.remote_db
+ nexamain
+
+
+
+ fusion_helpdesk.remote_login
+ helpdesk_bot@nexasystems.ca
+
+
+
+ fusion_helpdesk.client_label
+
+
+
+
diff --git a/fusion_helpdesk/models/__init__.py b/fusion_helpdesk/models/__init__.py
new file mode 100644
index 00000000..6084d2ca
--- /dev/null
+++ b/fusion_helpdesk/models/__init__.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+from . import res_config_settings
diff --git a/fusion_helpdesk/models/res_config_settings.py b/fusion_helpdesk/models/res_config_settings.py
new file mode 100644
index 00000000..91aef7e6
--- /dev/null
+++ b/fusion_helpdesk/models/res_config_settings.py
@@ -0,0 +1,52 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1
+"""Configuration for the Fusion Helpdesk Reporter.
+
+Stores the central Odoo Helpdesk endpoint that submissions are
+forwarded to. Defaults point at erp.nexasystems.ca / nexamain;
+each client deployment can override per system parameter.
+"""
+from odoo import api, fields, models
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = 'res.config.settings'
+
+ fhd_remote_url = fields.Char(
+ string='Helpdesk Remote URL',
+ config_parameter='fusion_helpdesk.remote_url',
+ help='Base URL of the central Odoo running the Helpdesk app, '
+ 'e.g. https://erp.nexasystems.ca',
+ )
+ fhd_remote_db = fields.Char(
+ string='Helpdesk Remote DB',
+ config_parameter='fusion_helpdesk.remote_db',
+ help='Database name on the remote Odoo (e.g. nexamain).',
+ )
+ fhd_remote_login = fields.Char(
+ string='Helpdesk Remote Login',
+ config_parameter='fusion_helpdesk.remote_login',
+ help='Service-account login on the remote Odoo. Needs create '
+ 'rights on helpdesk.ticket and ir.attachment.',
+ )
+ fhd_remote_password = fields.Char(
+ string='Helpdesk Remote Password / API Key',
+ config_parameter='fusion_helpdesk.remote_password',
+ help='Service-account password or API key. Stored in '
+ 'ir.config_parameter — restrict read access if needed.',
+ )
+ fhd_remote_team_id = fields.Integer(
+ string='Helpdesk Team ID',
+ config_parameter='fusion_helpdesk.remote_team_id',
+ help='Optional. ID of the helpdesk.team on the remote that '
+ 'should own all incoming tickets. Leave blank to use '
+ 'the remote default routing.',
+ )
+ fhd_client_label = fields.Char(
+ string='Client Label (auto-prepended to subject)',
+ config_parameter='fusion_helpdesk.client_label',
+ help='Short tag prefixed onto the ticket subject so support '
+ 'can tell which client deployment a ticket came from. '
+ 'e.g. "ENTECH" → "[ENTECH] My subject"',
+ )
diff --git a/fusion_helpdesk/security/ir.model.access.csv b/fusion_helpdesk/security/ir.model.access.csv
new file mode 100644
index 00000000..97dd8b91
--- /dev/null
+++ b/fusion_helpdesk/security/ir.model.access.csv
@@ -0,0 +1 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
diff --git a/fusion_helpdesk/static/description/icon.png b/fusion_helpdesk/static/description/icon.png
new file mode 100644
index 00000000..20f0f3f6
Binary files /dev/null and b/fusion_helpdesk/static/description/icon.png differ
diff --git a/fusion_helpdesk/static/src/js/fusion_helpdesk_dialog.js b/fusion_helpdesk/static/src/js/fusion_helpdesk_dialog.js
new file mode 100644
index 00000000..486e5351
--- /dev/null
+++ b/fusion_helpdesk/static/src/js/fusion_helpdesk_dialog.js
@@ -0,0 +1,244 @@
+/** @odoo-module **/
+// Fusion Helpdesk — submission dialog. Lets the user pick Bug or
+// Feature, fill in subject + description, paste an error code, attach
+// files, and capture a screenshot via the browser's getDisplayMedia
+// API. On submit, the payload is POSTed to /fusion_helpdesk/submit
+// which forwards it (XML-RPC) to a central Odoo Helpdesk.
+
+import { Component, 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 { _t } from "@web/core/l10n/translation";
+
+const MAX_BYTES_PER_FILE = 10 * 1024 * 1024; // 10 MB hard cap per file
+
+export class FusionHelpdeskDialog extends Component {
+ static template = "fusion_helpdesk.Dialog";
+ static components = { Dialog };
+ static props = {
+ close: Function,
+ };
+
+ setup() {
+ this.notification = useService("notification");
+ this.state = useState({
+ kind: "bug", // 'bug' | 'feature'
+ subject: "",
+ description: "",
+ errorCode: "",
+ attachments: [], // [{name, mimetype, sizeLabel, iconClass, data_b64}]
+ capturing: false,
+ submitting: false,
+ error: "",
+ success: false,
+ ticketId: null,
+ ticketUrl: "",
+ attached: 0,
+ });
+ }
+
+ get dialogTitle() {
+ return this.state.kind === "bug"
+ ? _t("Report a Bug")
+ : _t("Request a Feature");
+ }
+
+ setKind(kind) {
+ this.state.kind = kind;
+ }
+
+ // ------------------------------------------------------------------
+ // File input → b64
+ async onFilesPicked(ev) {
+ const files = Array.from(ev.target.files || []);
+ for (const f of files) {
+ if (f.size > MAX_BYTES_PER_FILE) {
+ this.notification.add(
+ _t("File '%s' is over 10 MB and was skipped.").replace("%s", f.name),
+ { type: "warning" }
+ );
+ continue;
+ }
+ try {
+ const b64 = await this._fileToB64(f);
+ this._addAttachment({
+ name: f.name,
+ mimetype: f.type || "application/octet-stream",
+ data_b64: b64,
+ rawSize: f.size,
+ });
+ } catch (err) {
+ this.notification.add(
+ _t("Could not read '%s'.").replace("%s", f.name),
+ { type: "danger" }
+ );
+ }
+ }
+ // Reset the input so picking the same file again re-fires onchange.
+ ev.target.value = "";
+ }
+
+ _fileToB64(file) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => {
+ const result = reader.result || "";
+ const idx = result.indexOf(",");
+ resolve(idx >= 0 ? result.slice(idx + 1) : result);
+ };
+ reader.onerror = reject;
+ reader.readAsDataURL(file);
+ });
+ }
+
+ // ------------------------------------------------------------------
+ // Screenshot capture via getDisplayMedia
+ async onTakeScreenshot() {
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
+ this.notification.add(
+ _t("Your browser doesn't support screen capture. Use Attach files instead."),
+ { type: "warning" }
+ );
+ return;
+ }
+ this.state.capturing = true;
+ let stream;
+ try {
+ stream = await navigator.mediaDevices.getDisplayMedia({
+ video: { displaySurface: "browser" },
+ audio: false,
+ });
+ const blob = await this._streamToBlob(stream);
+ const b64 = await this._blobToB64(blob);
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
+ this._addAttachment({
+ name: `screenshot-${ts}.png`,
+ mimetype: "image/png",
+ data_b64: b64,
+ rawSize: blob.size,
+ });
+ } catch (err) {
+ // User cancelled the picker — silently swallow. Other errors → notify.
+ if (err && err.name !== "NotAllowedError" && err.name !== "AbortError") {
+ this.notification.add(
+ _t("Screenshot failed: %s").replace("%s", err.message || err),
+ { type: "danger" }
+ );
+ }
+ } finally {
+ if (stream) {
+ stream.getTracks().forEach((t) => t.stop());
+ }
+ this.state.capturing = false;
+ }
+ }
+
+ async _streamToBlob(stream) {
+ const video = document.createElement("video");
+ video.srcObject = stream;
+ await video.play();
+ // Give the browser one frame to settle the picker chrome.
+ await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
+ const canvas = document.createElement("canvas");
+ canvas.width = video.videoWidth;
+ canvas.height = video.videoHeight;
+ const ctx = canvas.getContext("2d");
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
+ return await new Promise((resolve) =>
+ canvas.toBlob((b) => resolve(b), "image/png", 0.92)
+ );
+ }
+
+ _blobToB64(blob) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => {
+ const result = reader.result || "";
+ const idx = result.indexOf(",");
+ resolve(idx >= 0 ? result.slice(idx + 1) : result);
+ };
+ reader.onerror = reject;
+ reader.readAsDataURL(blob);
+ });
+ }
+
+ _addAttachment({ name, mimetype, data_b64, rawSize }) {
+ this.state.attachments.push({
+ name,
+ mimetype,
+ data_b64,
+ sizeLabel: this._formatBytes(rawSize),
+ iconClass: this._iconForMime(mimetype),
+ });
+ }
+
+ removeAttachment(idx) {
+ this.state.attachments.splice(idx, 1);
+ }
+
+ _formatBytes(n) {
+ if (!n) return "";
+ if (n < 1024) return n + " B";
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
+ return (n / (1024 * 1024)).toFixed(1) + " MB";
+ }
+
+ _iconForMime(mt) {
+ mt = (mt || "").toLowerCase();
+ if (mt.startsWith("image/")) return "fa fa-file-image-o";
+ if (mt.startsWith("video/")) return "fa fa-file-video-o";
+ if (mt.startsWith("audio/")) return "fa fa-file-audio-o";
+ if (mt.includes("pdf")) return "fa fa-file-pdf-o";
+ if (mt.includes("zip") || mt.includes("tar") || mt.includes("rar")) return "fa fa-file-archive-o";
+ if (mt.includes("text")) return "fa fa-file-text-o";
+ return "fa fa-file-o";
+ }
+
+ // ------------------------------------------------------------------
+ // Submit
+ async onSubmit() {
+ if (this.state.submitting) return;
+ const subject = (this.state.subject || "").trim();
+ if (!subject) {
+ this.state.error = _t("Subject is required.");
+ return;
+ }
+ this.state.error = "";
+ this.state.success = false;
+ this.state.submitting = true;
+ try {
+ const payload = {
+ kind: this.state.kind,
+ subject,
+ description: this.state.description || "",
+ error_code: this.state.kind === "bug" ? this.state.errorCode || "" : "",
+ attachments: this.state.attachments.map((a) => ({
+ name: a.name,
+ mimetype: a.mimetype,
+ data_b64: a.data_b64,
+ })),
+ page_url: window.location.href,
+ user_agent: navigator.userAgent,
+ };
+ const res = await rpc("/fusion_helpdesk/submit", payload);
+ if (!res.ok) {
+ this.state.error = res.message || _t("Submission failed.");
+ } else {
+ this.state.success = true;
+ this.state.ticketId = res.ticket_id;
+ this.state.ticketUrl = res.ticket_url;
+ this.state.attached = res.attached || 0;
+ // Reset the editable fields so user can file another if they want.
+ this.state.subject = "";
+ this.state.description = "";
+ this.state.errorCode = "";
+ this.state.attachments = [];
+ }
+ } catch (err) {
+ this.state.error = (err && err.message) || _t("Network error.");
+ } finally {
+ this.state.submitting = false;
+ }
+ }
+}
diff --git a/fusion_helpdesk/static/src/js/fusion_helpdesk_systray.js b/fusion_helpdesk/static/src/js/fusion_helpdesk_systray.js
new file mode 100644
index 00000000..16761db7
--- /dev/null
+++ b/fusion_helpdesk/static/src/js/fusion_helpdesk_systray.js
@@ -0,0 +1,33 @@
+/** @odoo-module **/
+// Fusion Helpdesk — top systray icon. Sequence chosen so the icon
+// appears to the LEFT of the attendance check-in button. Odoo
+// systray ordering is by sequence ascending (lower = leftmost in the
+// systray bar). hr_attendance ships at sequence 100, so we use 99.
+
+import { Component } from "@odoo/owl";
+import { registry } from "@web/core/registry";
+import { useService } from "@web/core/utils/hooks";
+import { FusionHelpdeskDialog } from "./fusion_helpdesk_dialog";
+
+class FusionHelpdeskSystray extends Component {
+ static template = "fusion_helpdesk.SystrayItem";
+ static props = {};
+
+ setup() {
+ this.dialog = useService("dialog");
+ }
+
+ onClick() {
+ this.dialog.add(FusionHelpdeskDialog, {});
+ }
+}
+
+export const fusionHelpdeskSystrayItem = {
+ Component: FusionHelpdeskSystray,
+};
+
+registry
+ .category("systray")
+ .add("fusion_helpdesk.report_button", fusionHelpdeskSystrayItem, {
+ sequence: 99,
+ });
diff --git a/fusion_helpdesk/static/src/scss/fusion_helpdesk.scss b/fusion_helpdesk/static/src/scss/fusion_helpdesk.scss
new file mode 100644
index 00000000..0fa58361
--- /dev/null
+++ b/fusion_helpdesk/static/src/scss/fusion_helpdesk.scss
@@ -0,0 +1,172 @@
+// Fusion Helpdesk Reporter — systray button + dialog styling.
+// Dark-mode aware via Odoo's $o-webclient-color-scheme branch.
+
+$o-webclient-color-scheme: bright !default;
+
+$_fhd-text-hex: #21252b;
+$_fhd-muted-hex: #6c757d;
+$_fhd-bg-hex: #ffffff;
+$_fhd-border-hex: #d8dadd;
+$_fhd-hover-hex: #f3f4f6;
+$_fhd-accent-hex: #2c89e9;
+
+@if $o-webclient-color-scheme == dark {
+ $_fhd-text-hex: #e6e9ef !global;
+ $_fhd-muted-hex: #9aa3ad !global;
+ $_fhd-bg-hex: #22262d !global;
+ $_fhd-border-hex: #3a3f47 !global;
+ $_fhd-hover-hex: #2c313a !global;
+ $_fhd-accent-hex: #4ea3ff !global;
+}
+
+$fhd-text: var(--fhd-text, $_fhd-text-hex);
+$fhd-muted: var(--fhd-muted, $_fhd-muted-hex);
+$fhd-bg: var(--fhd-bg, $_fhd-bg-hex);
+$fhd-border: var(--fhd-border, $_fhd-border-hex);
+$fhd-hover: var(--fhd-hover, $_fhd-hover-hex);
+$fhd-accent: var(--fhd-accent, $_fhd-accent-hex);
+
+// Systray icon
+.o_fhd_systray {
+ .o_fhd_systray_btn {
+ background: transparent;
+ border: none;
+ padding: 0 0.5rem;
+ line-height: 1;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+
+ &:hover .o_fhd_systray_img {
+ transform: scale(1.08);
+ filter: drop-shadow(0 0 2px rgba(78, 163, 255, 0.45));
+ }
+ }
+
+ .o_fhd_systray_img {
+ height: 22px;
+ width: 22px;
+ object-fit: contain;
+ transition: transform 120ms ease, filter 120ms ease;
+ }
+
+ // Hide the dropdown caret Bootstrap injects.
+ .dropdown-toggle::after { display: none; }
+}
+
+// Dialog
+.o_fhd_dialog {
+ color: $fhd-text;
+
+ .o_fhd_kind_row {
+ display: flex;
+ gap: 0.5rem;
+ margin-bottom: 1rem;
+ }
+
+ .o_fhd_kind_chip {
+ flex: 1;
+ padding: 0.6rem 0.75rem;
+ background-color: $fhd-bg;
+ border: 1px solid $fhd-border;
+ border-radius: 6px;
+ color: $fhd-text;
+ cursor: pointer;
+ font-weight: 500;
+
+ &:hover { background-color: $fhd-hover; }
+
+ &.o_fhd_kind_active {
+ border-color: $fhd-accent;
+ color: $fhd-accent;
+ background-color: rgba(44, 137, 233, 0.10);
+ }
+ }
+
+ .o_fhd_field {
+ margin-bottom: 0.85rem;
+
+ > label {
+ display: block;
+ font-weight: 500;
+ margin-bottom: 0.25rem;
+ color: $fhd-text;
+ }
+ }
+
+ .o_fhd_hint {
+ font-weight: 400;
+ font-size: 0.8rem;
+ color: $fhd-muted;
+ margin-left: 0.4rem;
+ }
+
+ .o_fhd_mono {
+ font-family: ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace;
+ font-size: 0.85rem;
+ }
+
+ .o_fhd_actions_row {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ }
+
+ .o_fhd_btn {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.4rem 0.8rem;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 0.9rem;
+ border: 1px solid $fhd-border;
+ background-color: $fhd-bg;
+ color: $fhd-text;
+
+ &:hover:not(:disabled) { background-color: $fhd-hover; }
+
+ &:disabled { opacity: 0.6; cursor: default; }
+ }
+
+ .o_fhd_attach_list {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin-top: 0.5rem;
+ }
+
+ .o_fhd_attach_item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.35rem 0.6rem;
+ background-color: $fhd-hover;
+ border: 1px solid $fhd-border;
+ border-radius: 4px;
+ font-size: 0.85rem;
+ }
+
+ .o_fhd_attach_name {
+ flex: 1;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .o_fhd_attach_size {
+ color: $fhd-muted;
+ font-variant-numeric: tabular-nums;
+ }
+
+ .o_fhd_attach_remove {
+ background: transparent;
+ border: none;
+ color: $fhd-muted;
+ cursor: pointer;
+ font-size: 1.1rem;
+ line-height: 1;
+ padding: 0 0.25rem;
+
+ &:hover { color: #d32f2f; }
+ }
+}
diff --git a/fusion_helpdesk/static/src/xml/fusion_helpdesk_dialog.xml b/fusion_helpdesk/static/src/xml/fusion_helpdesk_dialog.xml
new file mode 100644
index 00000000..f53497f8
--- /dev/null
+++ b/fusion_helpdesk/static/src/xml/fusion_helpdesk_dialog.xml
@@ -0,0 +1,110 @@
+
+
+
+
+
+
+
+
+
+ Report a Bug
+
+
+ Request a Feature
+
+
+
+
+
+ Subject *
+
+
+
+
+
+
+
+
+
+
+
+
+ Error code / traceback
+ paste any error message or stack trace
+
+
+
+
+
+
+
Attachments
+
+
+ Attach files
+
+
+
+
+ Capturing…
+ Capture screenshot
+
+
+
+
+
+
+
+
+
+
+
+ Thanks — ticket
+
+ #
+ created
with attachment(s) .
+
+
+
+
+
+
+
+ Submit
+
+
+ Close
+
+
+
+
+
+
diff --git a/fusion_helpdesk/static/src/xml/fusion_helpdesk_systray.xml b/fusion_helpdesk/static/src/xml/fusion_helpdesk_systray.xml
new file mode 100644
index 00000000..550435bf
--- /dev/null
+++ b/fusion_helpdesk/static/src/xml/fusion_helpdesk_systray.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fusion_helpdesk/views/res_config_settings_views.xml b/fusion_helpdesk/views/res_config_settings_views.xml
new file mode 100644
index 00000000..77e64b04
--- /dev/null
+++ b/fusion_helpdesk/views/res_config_settings_views.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+ res.config.settings.view.form.fusion.helpdesk
+ res.config.settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fusion_helpdesk_central/__init__.py b/fusion_helpdesk_central/__init__.py
new file mode 100644
index 00000000..a0fdc10f
--- /dev/null
+++ b/fusion_helpdesk_central/__init__.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+from . import models
diff --git a/fusion_helpdesk_central/__manifest__.py b/fusion_helpdesk_central/__manifest__.py
new file mode 100644
index 00000000..e1199021
--- /dev/null
+++ b/fusion_helpdesk_central/__manifest__.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1
+{
+ 'name': 'Fusion Helpdesk Central — Client API Keys',
+ 'version': '19.0.1.0.2',
+ 'category': 'Productivity',
+ 'summary': 'Admin UI on the central Odoo for issuing per-client API '
+ 'keys used by fusion_helpdesk client deployments.',
+ 'description': """
+Fusion Helpdesk Central
+=======================
+Companion to `fusion_helpdesk`. Install on the central Odoo (the one
+running the Helpdesk app) to manage **per-client API keys** instead of
+shipping a shared bot password to every client deployment.
+
+Each row in *Helpdesk → Configuration → Client API Keys* maps a client
+label (e.g. ENTECH, MOBILITY) to a real `res.users.apikeys` row on the
+shared bot user. The plaintext key is shown ONCE on creation; revoke
+in one click if a deployment is compromised.
+
+Depends only on `helpdesk`. No client-side install needed.
+ """,
+ 'author': 'Nexa Systems Inc.',
+ 'website': 'https://www.nexasystems.ca',
+ 'license': 'OPL-1',
+ 'depends': ['helpdesk'],
+ 'data': [
+ 'security/ir.model.access.csv',
+ 'data/ir_config_parameter_data.xml',
+ 'views/fusion_helpdesk_client_key_views.xml',
+ ],
+ 'installable': True,
+ 'auto_install': False,
+ 'application': False,
+}
diff --git a/fusion_helpdesk_central/data/ir_config_parameter_data.xml b/fusion_helpdesk_central/data/ir_config_parameter_data.xml
new file mode 100644
index 00000000..00ff1f7a
--- /dev/null
+++ b/fusion_helpdesk_central/data/ir_config_parameter_data.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+ fusion_helpdesk.bot_login
+ helpdesk_bot@nexasystems.ca
+
+
+
diff --git a/fusion_helpdesk_central/models/__init__.py b/fusion_helpdesk_central/models/__init__.py
new file mode 100644
index 00000000..91073a9b
--- /dev/null
+++ b/fusion_helpdesk_central/models/__init__.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+from . import fusion_helpdesk_client_key
diff --git a/fusion_helpdesk_central/models/fusion_helpdesk_client_key.py b/fusion_helpdesk_central/models/fusion_helpdesk_client_key.py
new file mode 100644
index 00000000..e4637f44
--- /dev/null
+++ b/fusion_helpdesk_central/models/fusion_helpdesk_client_key.py
@@ -0,0 +1,186 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1
+"""Per-client API key registry for fusion_helpdesk.
+
+Each row links a client deployment label (e.g. ENTECH) to a real
+`res.users.apikeys` row on a shared bot account. The plaintext key is
+shown ONCE on creation, then cleared. Revoking a row deletes the
+underlying API key so the client deployment can no longer authenticate.
+"""
+import logging
+from datetime import datetime, timedelta
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError
+
+_logger = logging.getLogger(__name__)
+
+
+class FusionHelpdeskClientKey(models.Model):
+ _name = 'fusion.helpdesk.client.key'
+ _description = 'Fusion Helpdesk — Client API Key'
+ _order = 'create_date desc'
+ _rec_name = 'client_label'
+
+ client_label = fields.Char(
+ string='Client Label', required=True, index=True,
+ help='Short tag identifying this client deployment '
+ '(e.g. ENTECH, MOBILITY). Free text — used for your '
+ 'reference and to find keys quickly when revoking.',
+ )
+ notes = fields.Text(
+ string='Notes',
+ help='Optional. Stamp deployment URL, contact, install date.',
+ )
+ bot_user_id = fields.Many2one(
+ 'res.users', string='Bot User', readonly=True,
+ ondelete='restrict',
+ )
+ apikey_id = fields.Many2one(
+ 'res.users.apikeys', string='API Key Record',
+ readonly=True, ondelete='set null',
+ help='Underlying res.users.apikeys row. Cleared if the key '
+ 'is revoked or deleted out-of-band.',
+ )
+ apikey_name = fields.Char(
+ string='Key Name', related='apikey_id.name', store=True,
+ readonly=True,
+ )
+ plaintext_key = fields.Char(
+ string='Plaintext Key (one-time display)',
+ readonly=True, copy=False,
+ help='Shown ONCE right after creation. Copy it now — once you '
+ 'click "Mark Stored" it is wiped from this record forever. '
+ 'The key keeps working; we just stop showing it.',
+ )
+ is_revoked = fields.Boolean(
+ string='Revoked', compute='_compute_is_revoked', store=True,
+ )
+ create_date = fields.Datetime(readonly=True)
+ display_name = fields.Char(
+ string='Display Name', compute='_compute_display_name', store=True,
+ )
+
+ @api.depends('client_label', 'is_revoked')
+ def _compute_display_name(self):
+ for rec in self:
+ base = rec.client_label or _('(unnamed)')
+ rec.display_name = ('%s [REVOKED]' % base) if rec.is_revoked else base
+
+ _sql_constraints = [
+ ('fhc_client_label_unique',
+ 'unique(client_label)',
+ 'Client label must be unique — one row per client deployment.'),
+ ]
+
+ @api.depends('apikey_id')
+ def _compute_is_revoked(self):
+ for rec in self:
+ rec.is_revoked = not rec.apikey_id
+
+ # ----------------------------------------------------------------------
+ @api.model
+ def _default_expiration_date(self):
+ """Odoo 19 requires API keys to carry an expiration. Default to
+ 10 years out so client deployments don't break unexpectedly.
+ Override per row by writing to apikey_id.expiration_date later."""
+ years = int(
+ self.env['ir.config_parameter'].sudo().get_param(
+ 'fusion_helpdesk.key_expiration_years', '10',
+ ) or 10
+ )
+ return datetime.utcnow() + timedelta(days=365 * years)
+
+ @api.model
+ def _get_bot_user(self):
+ ICP = self.env['ir.config_parameter'].sudo()
+ login = (ICP.get_param('fusion_helpdesk.bot_login') or '').strip()
+ if not login:
+ raise UserError(_(
+ 'No bot user configured. Set `fusion_helpdesk.bot_login` '
+ 'in System Parameters first.'
+ ))
+ user = self.env['res.users'].search([('login', '=', login)], limit=1)
+ if not user:
+ raise UserError(_(
+ 'Bot user "%s" not found. Create it first or update '
+ '`fusion_helpdesk.bot_login`.', login
+ ))
+ return user
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ ApiKey = self.env['res.users.apikeys'].with_context(
+ mail_create_nosubscribe=True,
+ )
+ bot = self._get_bot_user()
+ records = super().create(vals_list)
+ for rec in records:
+ # Generate the apikey AS the bot user — the key is bound to
+ # the user that creates it.
+ label = 'fusion_helpdesk:%s' % (rec.client_label or rec.id)
+ key = ApiKey.with_user(bot)._generate(
+ 'rpc', label, self._default_expiration_date(),
+ )
+ # Find the apikey row that was just created so we can link it.
+ apikey_row = self.env['res.users.apikeys'].sudo().search(
+ [('user_id', '=', bot.id), ('name', '=', label)],
+ limit=1, order='id desc',
+ )
+ rec.write({
+ 'bot_user_id': bot.id,
+ 'apikey_id': apikey_row.id if apikey_row else False,
+ 'plaintext_key': key,
+ })
+ _logger.info(
+ 'fusion_helpdesk_central: issued API key for client "%s" '
+ '(apikey_id=%s, bot_user=%s)',
+ rec.client_label, apikey_row.id if apikey_row else '?',
+ bot.login,
+ )
+ return records
+
+ def action_mark_stored(self):
+ """User confirms they have copied the plaintext key. Wipe it
+ from the DB so a later read can't recover it. The underlying
+ res.users.apikeys row keeps working — only the plaintext copy
+ on this row is destroyed."""
+ self.write({'plaintext_key': False})
+ return True
+
+ def action_revoke(self):
+ """Delete the underlying res.users.apikeys row. The client
+ deployment's next request will get an auth_failed error."""
+ for rec in self:
+ if rec.apikey_id:
+ rec.apikey_id.sudo().unlink()
+ _logger.warning(
+ 'fusion_helpdesk_central: REVOKED API key for "%s" '
+ '(was apikey_id=%s)',
+ rec.client_label, rec.apikey_id.id,
+ )
+ rec.write({'plaintext_key': False})
+ return True
+
+ def action_rotate(self):
+ """One-shot: revoke the existing key + issue a new one. Plaintext
+ is shown once on the same row."""
+ ApiKey = self.env['res.users.apikeys']
+ bot = self._get_bot_user()
+ for rec in self:
+ if rec.apikey_id:
+ rec.apikey_id.sudo().unlink()
+ label = 'fusion_helpdesk:%s' % rec.client_label
+ key = ApiKey.with_user(bot)._generate(
+ 'rpc', label, self._default_expiration_date(),
+ )
+ apikey_row = self.env['res.users.apikeys'].sudo().search(
+ [('user_id', '=', bot.id), ('name', '=', label)],
+ limit=1, order='id desc',
+ )
+ rec.write({
+ 'apikey_id': apikey_row.id if apikey_row else False,
+ 'plaintext_key': key,
+ })
+ return True
diff --git a/fusion_helpdesk_central/security/ir.model.access.csv b/fusion_helpdesk_central/security/ir.model.access.csv
new file mode 100644
index 00000000..7bda33a7
--- /dev/null
+++ b/fusion_helpdesk_central/security/ir.model.access.csv
@@ -0,0 +1,2 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_fhc_client_key_admin,fusion.helpdesk.client.key.admin,model_fusion_helpdesk_client_key,base.group_system,1,1,1,1
diff --git a/fusion_helpdesk_central/views/fusion_helpdesk_client_key_views.xml b/fusion_helpdesk_central/views/fusion_helpdesk_client_key_views.xml
new file mode 100644
index 00000000..c737203c
--- /dev/null
+++ b/fusion_helpdesk_central/views/fusion_helpdesk_client_key_views.xml
@@ -0,0 +1,136 @@
+
+
+
+
+
+ fusion.helpdesk.client.key.list
+ fusion.helpdesk.client.key
+
+
+
+
+
+
+
+
+
+
+
+
+
+ fusion.helpdesk.client.key.form
+ fusion.helpdesk.client.key
+
+
+
+
+
+
+ fusion.helpdesk.client.key.search
+ fusion.helpdesk.client.key
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Helpdesk Client API Keys
+ fusion.helpdesk.client.key
+ list,form
+ {'search_default_active': 1}
+
+
+ Issue a new API key
+
+
+ Each row maps a client deployment (e.g. ENTECH, MOBILITY)
+ to a real API key on the shared bot user. The plaintext
+ key is shown ONCE on creation — copy it then click
+ "Mark Key Stored". Revoke any key in one click if a
+ deployment is compromised.
+
+
+
+
+
+
+
diff --git a/fusion_plating/docs/superpowers/specs/2026-05-04-fp-step-kind-model.md b/fusion_plating/docs/superpowers/specs/2026-05-04-fp-step-kind-model.md
new file mode 100644
index 00000000..5f9dd458
--- /dev/null
+++ b/fusion_plating/docs/superpowers/specs/2026-05-04-fp-step-kind-model.md
@@ -0,0 +1,96 @@
+# FP Step Kind — User-Extensible Model
+
+Date: 2026-05-04
+Status: design + implementation
+
+## Problem
+`default_kind` on `fp.step.template` is a hardcoded `Selection` of 24 entries. Users can't add new kinds (e.g. "Shot Peen", "Passivation"). The same Selection list is duplicated on `fusion.plating.process.node`. Default-input seeding (`DEFAULT_INPUTS_BY_KIND`) is also locked in Python — adding a new kind would require a code change + module update.
+
+## Solution
+Convert `default_kind` from `Selection` → `Many2one('fp.step.kind')`. Move the default-input templates from a Python dict into seeded data records on a new `fp.step.kind.default.input` child model. Users add new kinds through the standard Odoo CRUD form.
+
+## Models
+
+### `fp.step.kind`
+| Field | Type | Notes |
+|---|---|---|
+| `code` | Char, required, unique per company | Technical key (lowercase). Stable XML IDs use this. |
+| `name` | Char, required, translated | UI label (e.g. "Cleaning") |
+| `sequence` | Integer | Order in dropdown |
+| `active` | Boolean default True | Archive instead of delete |
+| `icon` | Selection (reuses 24-icon list from `fp.process.node`) | Optional |
+| `description` | Html | Optional ops note |
+| `company_id` | Many2one res.company | Multi-co support |
+| `default_input_ids` | One2many → `fp.step.kind.default.input` | The seed list |
+
+### `fp.step.kind.default.input`
+Same shape as `fp.step.template.input`: `name`, `input_type`, `target_unit`, `sequence`, `required`, `hint`, `selection_options`. Plus `kind_id` parent FK.
+
+## Field changes
+
+### `fp.step.template`
+- **add** `kind_id = Many2one('fp.step.kind', ondelete='restrict', string='Step Kind')` — user-facing input
+- **change** existing `default_kind` from `Selection(...)` to `default_kind = fields.Char(related='kind_id.code', store=True, readonly=True)` — back-compat shim. Lets every legacy `node.default_kind == 'cleaning'` comparison keep working without touching 30+ sites. Stored=True so existing search domains (`('default_kind', '=', 'foo')`) still work.
+
+### `fusion.plating.process.node`
+- Same: add `kind_id`, convert `default_kind` to `related='kind_id.code'` stored Char.
+
+### `fp.job.workflow.state.trigger_default_kinds`
+- **No change in Phase 1.** Stays a Char of comma-separated codes. The codes still match `kind_id.code` after migration. Phase 2 deferred convert to m2m.
+
+## DEFAULT_INPUTS_BY_KIND
+Removed from `fp_step_template.py`. Lives in seed data XML. `action_seed_default_inputs` reads `tpl.kind_id.default_input_ids` instead of the dict.
+
+## Migration `19.0.18.13.0/post-migrate.py`
+1. SQL-read existing `default_kind` text values from `fp_step_template` and `fusion_plating_process_node` BEFORE Odoo recomputes the related field.
+2. Build map `code → fp.step.kind.id` from seeded records.
+3. SQL UPDATE `kind_id` per row.
+4. Trigger recompute of stored related `default_kind` (or leave — values are already there from step 1).
+5. Log counts.
+
+## Why not drop `default_kind` entirely?
+- Comma-CSV `trigger_default_kinds` on workflow state still searches it.
+- 30+ comparison sites and existing search domains in views.
+- Stored related Char keeps it a single source of truth (m2o-derived) without a churn-PR through every dependent.
+
+## View changes
+- `fp_step_template_views.xml` — replace ` ` with ` `. Same for search filter and `group_by="default_kind"` → `group_by="kind_id"`.
+- `fp_process_node_views.xml` — same swap on the one occurrence.
+- New `fp_step_kind_views.xml` — list/form/menu so admins can manage kinds. Form embeds `default_input_ids` as inline list.
+
+## Controller / JS changes
+- `simple_recipe_controller.py`:
+ - Add `kind_id` (id) and `kind_name` (label) to all step-payload responses.
+ - Accept `kind_id` int on `_save_recipe`/template-create endpoints.
+ - Keep returning `default_kind` (the code Char) for back-compat with deployed editor sessions until cache flushes.
+- `simple_recipe_editor.js`:
+ - Replace the `` over selection options with a Many2one-style typeahead. Fetch list from new endpoint `/fusion_plating/recipe/kinds` returning `[{id, code, name, icon}]`.
+ - Submit `kind_id` (int) instead of `default_kind` (string).
+- `simple_recipe_editor.xml`:
+ - Replace `` block with a `` populated from fetched list, plus a "+ New kind…" link that opens a small inline form via `/fusion_plating/recipe/kinds/create`.
+
+## ACLs (`ir.model.access.csv`)
+```
+access_fp_step_kind_operator,fp.step.kind.operator,model_fp_step_kind,group_fusion_plating_operator,1,0,0,0
+access_fp_step_kind_supervisor,fp.step.kind.supervisor,model_fp_step_kind,group_fusion_plating_supervisor,1,1,1,0
+access_fp_step_kind_manager,fp.step.kind.manager,model_fp_step_kind,group_fusion_plating_manager,1,1,1,1
+access_fp_step_kind_default_input_* … same triplet
+```
+
+## Manifest
+- Bump `version` to `19.0.18.13.0`
+- Add to `data`:
+ - `data/fp_step_kind_data.xml` (BEFORE `fp_step_template_data.xml` since templates reference kinds)
+ - `views/fp_step_kind_views.xml`
+
+## Out of scope (Phase 2)
+- Convert `trigger_default_kinds` Char-CSV to m2m on `fp.job.workflow.state`
+- Remove DEFAULT_INPUTS_BY_KIND dict from migration scripts (one-shot dev scripts — no harm leaving)
+- Migrate stored `default_kind` Char to a non-stored related once all callers refactored
+
+## Test plan
+1. `odoo -d admin -u fusion_plating --stop-after-init` (entech) — install + migration runs cleanly
+2. UI smoke: open a step template, kind dropdown shows 24 seeded kinds, dropdown allows quick-create
+3. Create new kind "Passivation" with 3 default inputs → save → return to step template → "Seed defaults" button populates the 3 inputs
+4. Open recipe editor (simple): kind dropdown matches; existing recipes show correct kind label
+5. Run a step that's wired through workflow-state trigger (e.g. completion of a `receiving` step → state changes to "On Floor") to confirm CSV-trigger backwards compat still works
diff --git a/fusion_plating/fusion_plating/__init__.py b/fusion_plating/fusion_plating/__init__.py
index 9883be38..3982819e 100644
--- a/fusion_plating/fusion_plating/__init__.py
+++ b/fusion_plating/fusion_plating/__init__.py
@@ -32,6 +32,18 @@ def post_init_hook(env):
_migrate_legacy_uom_columns(env)
+def _resolve_kind_id(env, code):
+ """Look up an fp.step.kind id by code. Returns False if not found.
+ Cheap helper used during seeding so legacy code paths that referenced
+ string codes can keep their semantics."""
+ if not code:
+ return False
+ rec = env['fp.step.kind'].search(
+ [('code', '=', code)], limit=1,
+ )
+ return rec.id or False
+
+
def _backfill_contract_review_template(env):
"""Idempotent — ensure the Contract Review library template exists.
@@ -45,7 +57,7 @@ def _backfill_contract_review_template(env):
return # already there
tpl = Tpl.create({
'name': 'Contract Review',
- 'default_kind': 'contract_review',
+ 'kind_id': _resolve_kind_id(env, 'contract_review'),
})
tpl.action_seed_default_inputs()
_logger.info(
@@ -236,7 +248,7 @@ def _create_template_from_node(env, node, seen):
'process_type_id': node.process_type_id.id,
'requires_signoff': node.requires_signoff,
'requires_predecessor_done': node.requires_predecessor_done,
- 'default_kind': kind,
+ 'kind_id': _resolve_kind_id(env, kind),
}
# Snapshot tank_ids if the node has them (added by Sub 12a;
# existing nodes may not).
@@ -275,7 +287,10 @@ def _seed_minimal_library(env):
('Shipping', 'ship'),
]
for name, kind in minimal:
- tpl = Tpl.create({'name': name, 'default_kind': kind})
+ tpl = Tpl.create({
+ 'name': name,
+ 'kind_id': _resolve_kind_id(env, kind),
+ })
tpl.action_seed_default_inputs()
_logger.info(
'Fusion Plating: seeded minimal step library (%s entries)',
diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py
index 717e7429..113e254c 100644
--- a/fusion_plating/fusion_plating/__manifest__.py
+++ b/fusion_plating/fusion_plating/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
- 'version': '19.0.18.12.4',
+ 'version': '19.0.18.13.8',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """
@@ -98,6 +98,12 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'views/fp_facility_views.xml',
'views/fp_bath_views.xml',
'views/fp_process_node_views.xml',
+ # Sub 14b — fp.step.kind catalog. MUST load before
+ # fp_step_template_data.xml (templates reference kinds via
+ # kind_id) AND before fp_step_template_views.xml (the form
+ # references the kind action menu).
+ 'views/fp_step_kind_views.xml',
+ 'data/fp_step_kind_data.xml',
'views/fp_step_template_views.xml',
'views/fp_rack_tag_views.xml',
'views/fp_job_step_move_views.xml',
@@ -128,10 +134,14 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating/static/src/scss/recipe_tree_editor.scss',
'fusion_plating/static/src/scss/fp_chatter_dark.scss',
'fusion_plating/static/src/scss/simple_recipe_editor.scss',
+ # Sub 14b — visual icon picker for fp.step.kind etc.
+ 'fusion_plating/static/src/scss/fp_icon_picker.scss',
'fusion_plating/static/src/xml/recipe_tree_editor.xml',
'fusion_plating/static/src/xml/simple_recipe_editor.xml',
+ 'fusion_plating/static/src/xml/fp_icon_picker.xml',
'fusion_plating/static/src/js/recipe_tree_editor.js',
'fusion_plating/static/src/js/simple_recipe_editor.js',
+ 'fusion_plating/static/src/js/fp_icon_picker.js',
],
},
'demo': [
diff --git a/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py b/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py
index 6e2c43f4..3101bb04 100644
--- a/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py
+++ b/fusion_plating/fusion_plating/controllers/simple_recipe_controller.py
@@ -26,7 +26,7 @@ _SNAPSHOT_FIELDS = [
'parallel_start',
'triggers_workflow_state_id', # Sub 14 — workflow milestone trigger
'requires_rack_assignment', 'requires_transition_form',
- 'default_kind',
+ 'kind_id', # Sub 14b — replaces default_kind (now a related Char)
]
# Fields on fp.step.template.input that copy 1:1 into
@@ -90,6 +90,8 @@ class SimpleRecipeController(http.Controller):
'sequence': step.sequence,
'icon': step.icon,
'default_kind': step.default_kind,
+ 'kind_id': step.kind_id.id if step.kind_id else False,
+ 'kind_name': step.kind_id.name if step.kind_id else '',
'requires_signoff': step.requires_signoff,
'requires_rack_assignment': step.requires_rack_assignment,
'requires_transition_form': step.requires_transition_form,
@@ -150,6 +152,8 @@ class SimpleRecipeController(http.Controller):
'code': t.code,
'icon': t.icon,
'default_kind': t.default_kind,
+ 'kind_id': t.kind_id.id if t.kind_id else False,
+ 'kind_name': t.kind_id.name if t.kind_id else '',
'station_count': len(t.tank_ids),
}
for t in records
@@ -201,6 +205,8 @@ class SimpleRecipeController(http.Controller):
'code': tpl.code or '',
'icon': tpl.icon or 'fa-cog',
'default_kind': tpl.default_kind or '',
+ 'kind_id': tpl.kind_id.id if tpl.kind_id else False,
+ 'kind_name': tpl.kind_id.name if tpl.kind_id else '',
'description': tpl.description or '',
'requires_signoff': tpl.requires_signoff,
'requires_predecessor_done': tpl.requires_predecessor_done,
@@ -245,8 +251,11 @@ class SimpleRecipeController(http.Controller):
"""
Tpl = request.env['fp.step.template']
# Whitelist — never trust client-provided write_uid / id / etc.
+ # Sub 14b: `default_kind` is now a related read-only Char. The
+ # client may still send it as a string code for back-compat — we
+ # translate it to kind_id below.
allowed = {
- 'name', 'code', 'icon', 'default_kind', 'description',
+ 'name', 'code', 'icon', 'kind_id', 'description',
'requires_signoff', 'requires_predecessor_done',
'parallel_start',
'triggers_workflow_state_id', # Sub 14
@@ -254,6 +263,11 @@ class SimpleRecipeController(http.Controller):
'tank_ids',
}
clean = {k: v for k, v in (vals or {}).items() if k in allowed}
+ # Back-compat: accept default_kind (string code) and resolve to kind_id.
+ if 'kind_id' not in clean and (vals or {}).get('default_kind'):
+ clean['kind_id'] = self._resolve_kind_id_from_code(
+ vals['default_kind'],
+ )
# 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:
@@ -266,6 +280,15 @@ class SimpleRecipeController(http.Controller):
tpl = Tpl.create(clean)
return {'ok': True, 'template': self._library_payload(tpl)}
+ def _resolve_kind_id_from_code(self, code):
+ """Look up fp.step.kind id by code. Empty string → False."""
+ if not code:
+ return False
+ rec = request.env['fp.step.kind'].search(
+ [('code', '=', code)], limit=1,
+ )
+ return rec.id or False
+
@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 —
@@ -340,6 +363,55 @@ class SimpleRecipeController(http.Controller):
],
}
+ @http.route('/fp/simple_recipe/kinds/list',
+ type='jsonrpc', auth='user')
+ def kinds_list(self):
+ """Sub 14b — Step Kind dropdown options for the inline library
+ form. User-extensible via /fp/simple_recipe/kinds/create."""
+ Kind = request.env['fp.step.kind']
+ return {
+ 'kinds': [
+ {
+ 'id': k.id,
+ 'code': k.code or '',
+ 'name': k.name or '',
+ 'icon': k.icon or '',
+ 'sequence': k.sequence,
+ }
+ for k in Kind.search(
+ [('active', '=', True)], order='sequence, name',
+ )
+ ],
+ }
+
+ @http.route('/fp/simple_recipe/kinds/create',
+ type='jsonrpc', auth='user')
+ def kinds_create(self, name, code=''):
+ """Sub 14b — Inline create for "+ New kind…" in the library
+ form. Auto-derives a code from the name if blank."""
+ Kind = request.env['fp.step.kind']
+ if not name or not name.strip():
+ return {'ok': False, 'error': 'name_required'}
+ # check_access via create attempt — supervisors+ allowed (ACL).
+ if not code:
+ code = name.strip().lower().replace(' ', '_').replace('/', '_')
+ existing = Kind.search([('code', '=', code)], limit=1)
+ if existing:
+ return {
+ 'ok': True, 'id': existing.id,
+ 'name': existing.name, 'code': existing.code,
+ 'duplicate': True,
+ }
+ rec = Kind.create({
+ 'name': name.strip(),
+ 'code': code,
+ })
+ return {
+ 'ok': True, 'id': rec.id,
+ 'name': rec.name, 'code': rec.code,
+ 'duplicate': False,
+ }
+
@http.route('/fp/simple_recipe/workflow_states/list',
type='jsonrpc', auth='user')
def workflow_states_list(self):
@@ -457,7 +529,7 @@ class SimpleRecipeController(http.Controller):
node.check_access('write')
allowed = {
'name', 'description', 'icon',
- 'default_kind',
+ 'kind_id', # Sub 14b — replaces default_kind
'requires_signoff', 'requires_predecessor_done',
'parallel_start', # Sub 13
'triggers_workflow_state_id', # Sub 14
@@ -467,6 +539,11 @@ class SimpleRecipeController(http.Controller):
'collect_measurements',
}
clean = {k: v for k, v in (vals or {}).items() if k in allowed}
+ # Back-compat: accept default_kind (string code) and resolve.
+ if 'kind_id' not in clean and (vals or {}).get('default_kind'):
+ clean['kind_id'] = self._resolve_kind_id_from_code(
+ vals['default_kind'],
+ )
if clean:
node.write(clean)
return {'ok': True}
diff --git a/fusion_plating/fusion_plating/data/fp_step_kind_data.xml b/fusion_plating/fusion_plating/data/fp_step_kind_data.xml
new file mode 100644
index 00000000..a6da0c30
--- /dev/null
+++ b/fusion_plating/fusion_plating/data/fp_step_kind_data.xml
@@ -0,0 +1,928 @@
+
+
+
+
+
+
+ receiving
+ Receiving / Incoming Inspection
+ 10
+ fa-truck
+
+
+ contract_review
+ Contract Review (QA-005)
+ 20
+ fa-file-text-o
+
+
+ racking
+ Racking
+ 30
+ fa-server
+
+
+ mask
+ Masking
+ 40
+ fa-eye-slash
+
+
+ cleaning
+ Cleaning
+ 50
+ fa-tint
+
+
+ electroclean
+ Electroclean
+ 60
+ fa-bolt
+
+
+ etch
+ Etch / Activation
+ 70
+ fa-flask
+
+
+ rinse
+ Rinse
+ 80
+ fa-tint
+
+
+ strike
+ Strike (Wood's Nickel / Activation)
+ 90
+ fa-bolt
+
+
+ plate
+ Plating
+ 100
+ fa-shield
+
+
+ replenishment
+ Tank Replenishment
+ 110
+ fa-plus-circle
+
+
+ wbf_test
+ Water Break Free Test
+ 120
+ fa-check-square-o
+
+
+ dry
+ Drying
+ 130
+ fa-sun-o
+
+
+ bake
+ Bake (HE Relief / Stress Relief)
+ 140
+ fa-fire
+
+
+ demask
+ De-Masking
+ 150
+ fa-eye
+
+
+ derack
+ De-Racking
+ 160
+ fa-server
+
+
+ inspect
+ Inspection
+ 170
+ fa-search
+
+
+ hardness_test
+ Hardness Test (HV / HK / HRC)
+ 180
+ fa-tachometer
+
+
+ adhesion_test
+ Adhesion Test
+ 190
+ fa-link
+
+
+ salt_spray
+ Salt Spray / Corrosion Test
+ 200
+ fa-cloud
+
+
+ final_inspect
+ Final Inspection
+ 210
+ fa-check-circle
+
+
+ packaging
+ Packaging / Pre-Ship
+ 220
+ fa-archive
+
+
+ ship
+ Shipping
+ 230
+ fa-paper-plane
+
+
+ gating
+ Gating
+ 240
+ fa-pause-circle
+
+
+
+
+
+
+
+ Qty Received
+ number
+ each
+ 10
+ True
+
+
+
+ Qty Rejected
+ number
+ each
+ 20
+
+
+
+ Customer PO# Verified
+ boolean
+ 30
+
+
+
+ Packing Slip #
+ text
+ 40
+
+
+
+ Condition Notes
+ text
+ 50
+
+
+
+ Damage Photo
+ photo
+ 60
+
+
+
+ Inspector Initials
+ signature
+ 70
+ True
+
+
+
+
+
+ Actual Time
+ time_seconds
+ s
+ 10
+
+
+
+ Actual Temperature
+ temperature
+ f
+ 20
+
+
+
+ Bath ID
+ text
+ 30
+
+
+
+ Ultrasonic On
+ boolean
+ 40
+
+
+
+ Titration Done
+ boolean
+ 50
+
+
+
+
+
+ Actual Time
+ time_seconds
+ s
+ 10
+
+
+
+ Actual Temperature
+ temperature
+ f
+ 20
+
+
+
+ Amperage
+ number
+ A
+ 30
+
+
+
+ Voltage
+ number
+ V
+ 40
+
+
+
+ Current Density
+ number
+ ASF (A per sq ft)
+ 50
+
+
+
+ Polarity
+ selection
+ anodic,cathodic,periodic
+ 60
+
+
+
+ Bath ID
+ text
+ 70
+
+
+
+
+
+ Actual Time
+ time_seconds
+ s
+ 10
+
+
+
+ Actual Temperature
+ temperature
+ f
+ 20
+
+
+
+ Acid Concentration
+ number
+ % or g/L
+ 30
+
+
+
+ Bath ID
+ text
+ 40
+
+
+
+ HE Risk Flag
+ boolean
+ Hydrogen Embrittlement risk for high-strength steel
+ 50
+
+
+
+
+
+ Rinse Type
+ selection
+ cascade,spray,DI,city
+ 10
+
+
+
+ Conductivity
+ number
+ µS/cm — required for DI rinses
+ 20
+
+
+
+ Actual Time
+ time_seconds
+ s
+ 30
+
+
+
+
+
+ Actual Time
+ time_seconds
+ s
+ 10
+
+
+
+ Actual Temperature
+ temperature
+ f
+ 20
+
+
+
+ Amperage
+ number
+ A
+ 30
+
+
+
+ Voltage
+ number
+ V
+ 40
+
+
+
+ Current Density
+ number
+ ASF
+ 50
+
+
+
+ Bath ID
+ text
+ 60
+
+
+
+
+
+ Actual Time
+ time_hms
+ min
+ 10
+
+
+
+ Actual Temperature
+ temperature
+ f
+ 20
+
+
+
+ Bath ID
+ text
+ 30
+
+
+
+ pH
+ ph
+ 40
+
+
+
+ Bath Concentration
+ number
+ g/L
+ 50
+
+
+
+ Current Density
+ number
+ ASF — electroplate only
+ 60
+
+
+
+ Plating Thickness
+ multi_point_thickness
+ in
+ 70
+
+
+
+
+
+ Bath ID
+ text
+ 10
+ True
+
+
+
+ Chemistry Added
+ text
+ name + amount, e.g. "Nickel sulfamate 500mL"
+ 20
+
+
+
+ pH Before
+ ph
+ 30
+
+
+
+ pH After
+ ph
+ 40
+
+
+
+ Concentration Before
+ number
+ 50
+
+
+
+ Concentration After
+ number
+ 60
+
+
+
+ Operator Initials
+ signature
+ 70
+ True
+
+
+
+
+
+ Result
+ pass_fail
+ 10
+ True
+
+
+
+ Retest Count
+ number
+ 20
+
+
+
+ Photo on FAIL
+ photo
+ 30
+
+
+
+
+
+ Dry Method
+ selection
+ hot air,oven,spin
+ 10
+
+
+
+ Actual Time
+ time_seconds
+ s
+ 20
+
+
+
+ Actual Temperature
+ temperature
+ f
+ 30
+
+
+
+
+
+ Time In
+ date
+ 10
+
+
+
+ Time Out
+ date
+ 20
+
+
+
+ Actual Temperature
+ temperature
+ f
+ 30
+
+
+
+ Oven ID
+ text
+ 40
+
+
+
+ Chart Recorder File
+ photo
+ Attach AMS-2759 chart-recorder file
+ 50
+
+
+
+
+
+ Actual Qty
+ number
+ each
+ 10
+ True
+
+
+
+ Rack ID
+ text
+ 20
+
+
+
+ Masking Applied
+ boolean
+ 30
+
+
+
+ Photo of Racked Load
+ photo
+ 40
+
+
+
+
+
+ Actual Qty
+ number
+ each
+ 10
+
+
+
+ Mask Removal Method
+ selection
+ mechanical,solvent,thermal,not applicable
+ 20
+
+
+
+ Residue Check
+ pass_fail
+ 30
+
+
+
+
+
+ Actual Qty
+ number
+ each
+ 10
+
+
+
+ Mask Material
+ selection
+ Microshield,latex tape,vinyl plugs,wax,other
+ 20
+
+
+
+ Photo of Masked Parts
+ photo
+ 30
+
+
+
+
+
+ Residue Check
+ pass_fail
+ 10
+
+
+
+ Surface Condition
+ selection
+ clean,marks,needs rework
+ 20
+
+
+
+
+
+ Result
+ pass_fail
+ 10
+ True
+
+
+
+ Defect Type
+ selection
+ pitting,burn,blister,peel,missing coverage,none
+ 20
+
+
+
+ Thickness Sample
+ thickness
+ in
+ 30
+
+
+
+ Photo
+ photo
+ 40
+
+
+
+ Inspector Signature
+ signature
+ 50
+
+
+
+
+
+ Test Load
+ number
+ gf
+ 10
+
+
+
+ Readings (HV/HK/HRC)
+ multi_point_thickness
+ Three indents minimum
+ 20
+
+
+
+ Equipment ID
+ text
+ 30
+
+
+
+ Last Calibration Date
+ date
+ 40
+
+
+
+
+
+ Test Method
+ selection
+ bend,tape,burnish,file
+ 10
+
+
+
+ Result
+ pass_fail
+ 20
+ True
+
+
+
+ Photo of Coupon
+ photo
+ 30
+
+
+
+
+
+ Test Duration
+ number
+ hours
+ 10
+
+
+
+ Result
+ pass_fail
+ 20
+ True
+
+
+
+ Red Rust %
+ number
+ 30
+
+
+
+ White Corrosion %
+ number
+ 40
+
+
+
+ Lab Report
+ photo
+ Attach scanned lab report
+ 50
+
+
+
+
+
+ Outgoing Part Count Verified
+ boolean
+ 10
+
+
+
+ Qty Accepted
+ number
+ each
+ 20
+
+
+
+ Qty Rejected
+ number
+ each
+ 30
+
+
+
+ Defect Categorization
+ selection
+ pitting,burn,blister,peel,missing coverage,dimensional,none
+ 35
+
+
+
+ Actual Coating Thickness
+ multi_point_thickness
+ in
+ 40
+
+
+
+ Dimensional Verification
+ pass_fail
+ 45
+
+
+
+ Surface Finish (Ra)
+ number
+ µin
+ 47
+
+
+
+ Pass/Fail
+ pass_fail
+ 50
+ True
+
+
+
+ Inspector Signature
+ signature
+ 60
+
+
+
+
+
+ Packaging Type
+ selection
+ VCI bag,bubble wrap,separator paper,custom crate,other
+ 10
+
+
+
+ Qty Per Package
+ number
+ each
+ 20
+
+
+
+ Package Count
+ number
+ 30
+
+
+
+ Cert Package Included
+ boolean
+ 40
+
+
+
+ Customer-Supplied Packaging
+ boolean
+ 50
+
+
+
+
+
+ Outgoing Qty
+ number
+ each
+ 10
+ True
+
+
+
+ Carrier
+ selection
+ UPS,FedEx,Purolator,Customer Pickup,Other
+ 20
+
+
+
+ Tracking #
+ text
+ 30
+
+
+
+ BoL #
+ text
+ 40
+
+
+
+ Photo of Sealed Shipment
+ photo
+ 50
+
+
+
+
+
+ Reviewer Initials
+ signature
+ 10
+
+
+
+ Date Reviewed
+ date
+ 20
+
+
+
+ QA-005 Approved
+ pass_fail
+ 30
+
+
+
+
+
+
diff --git a/fusion_plating/fusion_plating/data/fp_step_template_data.xml b/fusion_plating/fusion_plating/data/fp_step_template_data.xml
index 5dc901d2..5dc76a46 100644
--- a/fusion_plating/fusion_plating/data/fp_step_template_data.xml
+++ b/fusion_plating/fusion_plating/data/fp_step_template_data.xml
@@ -13,7 +13,7 @@
Incoming Inspection (Standard)
RECV_STD
- receiving
+
fa-inbox
Verify quantity received against packing slip. Visually inspect
@@ -25,7 +25,7 @@
Electroclean (Standard)
ELEC_CLEAN_STD
- electroclean
+
fa-bolt
Submerge rack and energise. Record actual amperage, voltage,
@@ -36,7 +36,7 @@
Wood's Nickel Strike (Standard)
STRIKE_STD
- strike
+
fa-flash
Apply thin nickel strike to ensure adhesion before main plate.
@@ -47,7 +47,7 @@
Salt Spray Test (ASTM B117)
SALT_SPRAY_STD
- salt_spray
+
fa-tint
Submit test panel to salt spray cabinet for the specified
@@ -59,7 +59,7 @@
Adhesion Test (Bend / Tape)
ADHESION_STD
- adhesion_test
+
fa-link
Perform adhesion test per spec (bend, tape, burnish, or file).
@@ -70,7 +70,7 @@
Microhardness Test
HARDNESS_STD
- hardness_test
+
fa-cube
Take three indentations minimum on the test coupon. Record
@@ -82,7 +82,7 @@
Packaging (Standard)
PKG_STD
- packaging
+
fa-archive
Wrap parts per customer spec (VCI bag, bubble wrap, separator
@@ -94,7 +94,7 @@
Tank Replenishment
REPL_STD
- replenishment
+
fa-flask
Mid-shift bath top-up. Record bath ID, chemistry added (name
diff --git a/fusion_plating/fusion_plating/migrations/19.0.18.13.0/post-migrate.py b/fusion_plating/fusion_plating/migrations/19.0.18.13.0/post-migrate.py
new file mode 100644
index 00000000..b260034d
--- /dev/null
+++ b/fusion_plating/fusion_plating/migrations/19.0.18.13.0/post-migrate.py
@@ -0,0 +1,126 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+# Part of the Fusion Plating product family.
+"""19.0.18.13.0 — Backfill kind_id from legacy default_kind text values.
+
+Sub 14b — `default_kind` was a Selection on fp.step.template and
+fusion.plating.process.node. It is now a stored related Char that
+reads from kind_id.code on a new fp.step.kind catalog.
+
+When a Selection field is converted into a Many2one in code, Odoo's
+ORM does NOT auto-migrate the data — the new column starts empty. This
+script reads the (still-present) text values out of the OLD `default_kind`
+column and points kind_id at the seeded fp.step.kind record whose code
+matches.
+
+Idempotent — running it twice is a no-op.
+"""
+
+import logging
+
+_logger = logging.getLogger(__name__)
+
+
+def migrate(cr, version):
+ """Run by the Odoo migration framework on -u to 19.0.18.13.0."""
+ # ---- Build code → kind_id map from seeded data --------------------
+ cr.execute("SELECT id, code FROM fp_step_kind")
+ code_to_id = {code: kid for kid, code in cr.fetchall()}
+ if not code_to_id:
+ _logger.warning(
+ '19.0.18.13.0: fp_step_kind is empty — seed data did not '
+ 'load before post-migrate. Skipping backfill.',
+ )
+ return
+
+ # ---- 1. fp.step.template.kind_id ----------------------------------
+ _backfill(
+ cr,
+ table='fp_step_template',
+ text_col='x_default_kind_legacy',
+ m2o_col='kind_id',
+ code_to_id=code_to_id,
+ )
+
+ # ---- 2. fusion.plating.process.node.kind_id -----------------------
+ _backfill(
+ cr,
+ table='fusion_plating_process_node',
+ text_col='x_default_kind_legacy',
+ m2o_col='kind_id',
+ code_to_id=code_to_id,
+ )
+
+ # ---- 3. Force recompute of stored related default_kind --------------
+ # Now that kind_id is populated, the related Char will read the right
+ # value on next access. Trigger an explicit recompute via SQL so the
+ # column reflects current state immediately (faster than waiting for
+ # ORM to do it lazily on each row).
+ for table in ('fp_step_template', 'fusion_plating_process_node'):
+ cr.execute(f"""
+ UPDATE {table} t
+ SET default_kind = k.code
+ FROM fp_step_kind k
+ WHERE t.kind_id = k.id
+ """)
+ _logger.info(
+ '19.0.18.13.0: synced default_kind from kind_id.code on '
+ '%s rows of %s', cr.rowcount, table,
+ )
+
+
+def _backfill(cr, table, text_col, m2o_col, code_to_id):
+ """Generic backfill helper. Reads `text_col` text values, updates
+ `m2o_col` per row using the supplied code→id lookup table.
+ """
+ # Defensive — if either column is missing (fresh install, never had
+ # default_kind data) skip silently.
+ cr.execute("""
+ SELECT column_name FROM information_schema.columns
+ WHERE table_name = %s AND column_name IN (%s, %s)
+ """, (table, text_col, m2o_col))
+ cols = {row[0] for row in cr.fetchall()}
+ if text_col not in cols or m2o_col not in cols:
+ _logger.info(
+ '19.0.18.13.0: %s missing %s or %s, skipping backfill',
+ table, text_col, m2o_col,
+ )
+ return
+
+ # Pull every row with a non-null default_kind that doesn't yet have
+ # a kind_id — leaves manually-edited rows alone if migration is
+ # rerun.
+ cr.execute(f"""
+ SELECT id, {text_col} FROM {table}
+ WHERE {text_col} IS NOT NULL AND {m2o_col} IS NULL
+ """)
+ rows = cr.fetchall()
+ if not rows:
+ _logger.info(
+ '19.0.18.13.0: %s has no rows needing backfill', table,
+ )
+ return
+
+ updated = 0
+ skipped = 0
+ by_kind = {}
+ for rec_id, code in rows:
+ kid = code_to_id.get(code)
+ if not kid:
+ skipped += 1
+ continue
+ by_kind.setdefault(kid, []).append(rec_id)
+
+ for kid, ids in by_kind.items():
+ cr.execute(
+ f"UPDATE {table} SET {m2o_col} = %s WHERE id = ANY(%s)",
+ (kid, ids),
+ )
+ updated += len(ids)
+
+ _logger.info(
+ '19.0.18.13.0: backfilled %s.%s on %s rows (%s skipped — code '
+ 'not found in fp_step_kind seed data)',
+ table, m2o_col, updated, skipped,
+ )
diff --git a/fusion_plating/fusion_plating/migrations/19.0.18.13.0/pre-migrate.py b/fusion_plating/fusion_plating/migrations/19.0.18.13.0/pre-migrate.py
new file mode 100644
index 00000000..e27e5407
--- /dev/null
+++ b/fusion_plating/fusion_plating/migrations/19.0.18.13.0/pre-migrate.py
@@ -0,0 +1,57 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+# Part of the Fusion Plating product family.
+"""19.0.18.13.0 — Snapshot legacy default_kind values BEFORE the field
+type change wipes them.
+
+Sub 14b — `default_kind` was a Selection on fp.step.template and
+fusion.plating.process.node. The new model code defines it as a
+stored related Char (`related='kind_id.code', store=True`). On first
+ORM access after upgrade, Odoo recomputes the stored related from
+kind_id (which is NULL → wipes the column). We must snapshot the
+original text values now, so post-migrate can read them and resolve
+kind_id.
+
+Idempotent — running it twice is a no-op (snapshot column gets
+re-written with the same data).
+"""
+
+import logging
+
+_logger = logging.getLogger(__name__)
+
+
+def migrate(cr, version):
+ for table in ('fp_step_template', 'fusion_plating_process_node'):
+ _snapshot(cr, table)
+
+
+def _snapshot(cr, table):
+ cr.execute("""
+ SELECT column_name FROM information_schema.columns
+ WHERE table_name = %s AND column_name = 'default_kind'
+ """, (table,))
+ if not cr.fetchone():
+ _logger.info(
+ '19.0.18.13.0/pre: %s has no default_kind column, skip',
+ table,
+ )
+ return
+
+ # Add the snapshot column if missing.
+ cr.execute(f"""
+ ALTER TABLE {table}
+ ADD COLUMN IF NOT EXISTS x_default_kind_legacy VARCHAR
+ """)
+ # Copy the live data.
+ cr.execute(f"""
+ UPDATE {table}
+ SET x_default_kind_legacy = default_kind
+ WHERE default_kind IS NOT NULL
+ """)
+ _logger.info(
+ '19.0.18.13.0/pre: snapshotted %s rows from %s.default_kind '
+ 'to x_default_kind_legacy',
+ cr.rowcount, table,
+ )
diff --git a/fusion_plating/fusion_plating/models/__init__.py b/fusion_plating/fusion_plating/models/__init__.py
index e8ce4962..1ba46997 100644
--- a/fusion_plating/fusion_plating/models/__init__.py
+++ b/fusion_plating/fusion_plating/models/__init__.py
@@ -36,6 +36,7 @@ from . import hr_employee
from . import fp_process_node_inherit
# Sub 12a — Simple Recipe Editor + Step Library
+from . import fp_step_kind # MUST load before fp_step_template (dependency)
from . import fp_step_template
from . import fp_step_template_input
from . import fp_step_template_transition_input
diff --git a/fusion_plating/fusion_plating/models/fp_process_node.py b/fusion_plating/fusion_plating/models/fp_process_node.py
index 757e371e..3204fdf4 100644
--- a/fusion_plating/fusion_plating/models/fp_process_node.py
+++ b/fusion_plating/fusion_plating/models/fp_process_node.py
@@ -373,34 +373,16 @@ class FpProcessNode(models.Model):
string='Requires Transition Form',
help='Sub 12b — opens the transition form before Mark Done.',
)
- default_kind = fields.Selection(
- [
- ('receiving', 'Receiving / Incoming Inspection'),
- ('contract_review', 'Contract Review (QA-005)'),
- ('racking', 'Racking'),
- ('mask', 'Masking'),
- ('cleaning', 'Cleaning'),
- ('electroclean', 'Electroclean'),
- ('etch', 'Etch / Activation'),
- ('rinse', 'Rinse'),
- ('strike', 'Strike (Wood\'s Nickel / Activation)'),
- ('plate', 'Plating'),
- ('replenishment', 'Tank Replenishment'),
- ('wbf_test', 'Water Break Free Test'),
- ('dry', 'Drying'),
- ('bake', 'Bake (HE Relief / Stress Relief)'),
- ('demask', 'De-Masking'),
- ('derack', 'De-Racking'),
- ('inspect', 'Inspection'),
- ('hardness_test', 'Hardness Test (HV / HK / HRC)'),
- ('adhesion_test', 'Adhesion Test'),
- ('salt_spray', 'Salt Spray / Corrosion Test'),
- ('final_inspect', 'Final Inspection'),
- ('packaging', 'Packaging / Pre-Ship'),
- ('ship', 'Shipping'),
- ('gating', 'Gating'),
- ],
- string='Step Kind',
+ # Sub 14b — User-extensible Step Kinds (was Selection of 24).
+ kind_id = fields.Many2one(
+ 'fp.step.kind', string='Step Kind', ondelete='set null', index=True,
+ help='Pick from the catalog or create a new kind.',
+ )
+ # Back-compat: code-string accessor that all legacy
+ # `node.default_kind == "cleaning"` comparisons keep using.
+ default_kind = fields.Char(
+ related='kind_id.code', store=True, readonly=True, index=True,
+ string='Step Kind Code',
)
preferred_editor = fields.Selection(
[
diff --git a/fusion_plating/fusion_plating/models/fp_step_kind.py b/fusion_plating/fusion_plating/models/fp_step_kind.py
new file mode 100644
index 00000000..a863c218
--- /dev/null
+++ b/fusion_plating/fusion_plating/models/fp_step_kind.py
@@ -0,0 +1,282 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+# Part of the Fusion Plating product family.
+
+from odoo import _, api, fields, models
+
+from ._fp_uom_selection import FP_UOM_SELECTION
+
+
+class FpStepKind(models.Model):
+ """User-extensible Step Kind catalog.
+
+ Replaces the hardcoded `default_kind` Selection on fp.step.template
+ and fusion.plating.process.node. Each kind carries a list of default
+ inputs that get seeded onto a step template when the kind is picked.
+ """
+ _name = 'fp.step.kind'
+ _description = 'Fusion Plating — Step Kind'
+ _order = 'sequence, name'
+
+ code = fields.Char(
+ string='Code', required=True, index=True,
+ help='Stable lowercase technical key. Used by automation rules and '
+ 'workflow-state triggers (e.g. "cleaning", "plate"). Lower-case'
+ ' enforced; underscores allowed.',
+ )
+ name = fields.Char(string='Name', required=True, translate=True)
+ sequence = fields.Integer(string='Sequence', default=10)
+ active = fields.Boolean(string='Active', default=True)
+ description = fields.Html(string='Description')
+ icon = fields.Selection(
+ selection='_get_icon_selection',
+ string='Icon',
+ default='fa-cog',
+ )
+ company_id = fields.Many2one(
+ 'res.company', string='Company',
+ default=lambda self: self.env.company,
+ )
+ default_input_ids = fields.One2many(
+ 'fp.step.kind.default.input', 'kind_id',
+ string='Default Inputs', copy=True,
+ help='Auto-seeded onto a step template when this kind is picked '
+ '(via the "Seed Defaults" action).',
+ )
+ template_count = fields.Integer(
+ string='Templates', compute='_compute_template_count')
+
+ _sql_constraints = [
+ ('fp_step_kind_code_company_uniq',
+ 'unique(code, company_id)',
+ 'Step kind code must be unique within a company.'),
+ ]
+
+ # Curated FontAwesome 4 icon catalog for the visual icon picker.
+ # Bigger than the 24 historical fp.process.node list — covers
+ # manufacturing, lab, quality, shipping, safety, time, status etc.
+ # FA4 ships with Odoo (no extra deps). Key = CSS class, Value = label.
+ _ICON_SELECTION = [
+ # Process / chemistry
+ ('fa-flask', 'Flask / Chemistry'),
+ ('fa-tint', 'Drop / Liquid'),
+ ('fa-tachometer', 'Gauge / Measure'),
+ ('fa-thermometer-half', 'Temp / Heat'),
+ ('fa-fire', 'Fire / Bake'),
+ ('fa-snowflake-o', 'Cold / Freeze'),
+ ('fa-bolt', 'Bolt / Electric'),
+ ('fa-plug', 'Plug / Power'),
+ ('fa-magnet', 'Magnet'),
+ ('fa-bullseye', 'Target / Blast'),
+ ('fa-shower', 'Shower / Clean'),
+ ('fa-bathtub', 'Bathtub / Soak'),
+ ('fa-tachometer', 'Tachometer'),
+
+ # Equipment / shop
+ ('fa-industry', 'Industry / Line'),
+ ('fa-wrench', 'Wrench / Operation'),
+ ('fa-cog', 'Gear / General'),
+ ('fa-cogs', 'Gears / System'),
+ ('fa-sitemap', 'Sitemap / Process'),
+ ('fa-cubes', 'Cubes / Batch'),
+ ('fa-cube', 'Cube / Part'),
+ ('fa-th', 'Grid / Racking'),
+ ('fa-th-large', 'Large Grid'),
+ ('fa-server', 'Server / Rack'),
+ ('fa-database', 'Database / Tank'),
+ ('fa-archive', 'Archive / Box'),
+ ('fa-recycle', 'Recycle / Reuse'),
+ ('fa-balance-scale', 'Scale / Balance'),
+
+ # Masking / surface
+ ('fa-paint-brush', 'Paint / Masking'),
+ ('fa-eraser', 'Eraser / De-Masking'),
+ ('fa-shield', 'Shield / Protect'),
+ ('fa-diamond', 'Diamond / Plating'),
+ ('fa-circle-o-notch', 'Coating Layer'),
+
+ # Inspection / quality
+ ('fa-search', 'Search / Inspect'),
+ ('fa-search-plus', 'Search Plus'),
+ ('fa-eye', 'Eye / Visual'),
+ ('fa-eye-slash', 'Eye Slash / Hidden'),
+ ('fa-camera', 'Camera / Photo'),
+ ('fa-check', 'Check'),
+ ('fa-check-circle', 'Check / Approve'),
+ ('fa-check-square-o', 'Checkbox'),
+ ('fa-times', 'Reject / Cancel'),
+ ('fa-times-circle', 'Reject Circle'),
+ ('fa-exclamation-triangle', 'Warning'),
+ ('fa-exclamation-circle', 'Alert'),
+ ('fa-info-circle', 'Info'),
+ ('fa-question-circle', 'Question'),
+ ('fa-bug', 'Bug / Defect'),
+ ('fa-flag', 'Flag'),
+ ('fa-flag-checkered', 'Finish / Done'),
+ ('fa-trophy', 'Trophy / Pass'),
+ ('fa-thumbs-up', 'Thumbs Up'),
+ ('fa-thumbs-down', 'Thumbs Down'),
+ ('fa-star', 'Star'),
+ ('fa-bookmark', 'Bookmark'),
+ ('fa-certificate', 'Certificate'),
+
+ # Time
+ ('fa-clock-o', 'Clock / Wait'),
+ ('fa-hourglass-half', 'Hourglass'),
+ ('fa-hourglass-end', 'Hourglass End'),
+ ('fa-calendar', 'Calendar'),
+ ('fa-calendar-check-o', 'Scheduled'),
+ ('fa-history', 'History'),
+
+ # Safety / handling
+ ('fa-hand-paper-o', 'Hand / Manual'),
+ ('fa-hand-stop-o', 'Stop Hand'),
+ ('fa-life-ring', 'Safety / Life Ring'),
+ ('fa-medkit', 'First Aid'),
+ ('fa-user-md', 'Inspector'),
+ ('fa-lock', 'Lock / Hold'),
+ ('fa-unlock', 'Unlock / Release'),
+ ('fa-key', 'Key'),
+
+ # Documentation / certs
+ ('fa-file-text-o', 'Document'),
+ ('fa-file-pdf-o', 'PDF'),
+ ('fa-file-image-o', 'Image File'),
+ ('fa-clipboard', 'Clipboard'),
+ ('fa-list-alt', 'Checklist'),
+ ('fa-list-ul', 'List'),
+ ('fa-tags', 'Tags'),
+ ('fa-tag', 'Tag'),
+ ('fa-barcode', 'Barcode'),
+ ('fa-qrcode', 'QR Code'),
+ ('fa-pencil', 'Pencil'),
+ ('fa-edit', 'Edit'),
+ ('fa-print', 'Print'),
+ ('fa-paperclip', 'Attach'),
+
+ # Shipping / logistics
+ ('fa-truck', 'Truck / Receiving'),
+ ('fa-paper-plane', 'Ship / Send'),
+ ('fa-plane', 'Plane / Airfreight'),
+ ('fa-ship', 'Ship'),
+ ('fa-shopping-cart', 'Cart'),
+ ('fa-shopping-bag', 'Bag / Pack'),
+ ('fa-gift', 'Gift / Package'),
+ ('fa-suitcase', 'Suitcase'),
+ ('fa-globe', 'Global'),
+ ('fa-map-marker', 'Location'),
+ ('fa-road', 'In Transit'),
+
+ # Status / process flow
+ ('fa-play-circle', 'Start'),
+ ('fa-pause-circle', 'Pause / Hold'),
+ ('fa-stop-circle', 'Stop'),
+ ('fa-step-forward', 'Step Forward'),
+ ('fa-fast-forward', 'Fast Forward'),
+ ('fa-refresh', 'Refresh / Repeat'),
+ ('fa-undo', 'Undo / Rework'),
+ ('fa-share', 'Hand-off'),
+ ('fa-arrow-right', 'Arrow Right'),
+ ('fa-arrow-down', 'Arrow Down'),
+ ('fa-long-arrow-right', 'Long Arrow Right'),
+ ('fa-random', 'Random / Mix'),
+ ('fa-exchange', 'Exchange'),
+ ('fa-sort-amount-asc', 'Sort Asc'),
+ ('fa-sort-amount-desc', 'Sort Desc'),
+ ('fa-tasks', 'Tasks'),
+
+ # Misc useful
+ ('fa-sun-o', 'Sun / Dry'),
+ ('fa-moon-o', 'Moon / Night'),
+ ('fa-cloud', 'Cloud'),
+ ('fa-leaf', 'Leaf / Eco'),
+ ('fa-tree', 'Tree'),
+ ('fa-bell', 'Bell / Alert'),
+ ('fa-bullhorn', 'Announce'),
+ ('fa-trash', 'Trash / Discard'),
+ ('fa-plus-circle', 'Add'),
+ ('fa-minus-circle', 'Remove'),
+ ('fa-circle', 'Circle'),
+ ('fa-square', 'Square'),
+ ('fa-asterisk', 'Asterisk'),
+ ('fa-cutlery', 'Cutlery / Bend'),
+ ('fa-link', 'Link / Adhesion'),
+ ('fa-chain-broken', 'Broken Chain'),
+ ('fa-anchor', 'Anchor'),
+ ('fa-ban', 'Ban / Forbidden'),
+ ]
+
+ @api.model
+ def _get_icon_selection(self):
+ return self._ICON_SELECTION
+
+ @api.depends()
+ def _compute_template_count(self):
+ Tpl = self.env['fp.step.template']
+ for k in self:
+ k.template_count = Tpl.search_count([('kind_id', '=', k.id)])
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ for v in vals_list:
+ if v.get('code'):
+ v['code'] = v['code'].lower().strip().replace(' ', '_')
+ return super().create(vals_list)
+
+ def write(self, vals):
+ if vals.get('code'):
+ vals['code'] = vals['code'].lower().strip().replace(' ', '_')
+ return super().write(vals)
+
+ def action_open_templates(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Step Templates — %s') % self.name,
+ 'res_model': 'fp.step.template',
+ 'view_mode': 'list,form',
+ 'domain': [('kind_id', '=', self.id)],
+ 'context': {'default_kind_id': self.id},
+ }
+
+
+class FpStepKindDefaultInput(models.Model):
+ """Default input prototype attached to a step kind.
+
+ When a recipe author picks a kind on a step template and clicks
+ 'Seed Defaults', these get copied into the template's input list
+ (idempotent — skips by name).
+ """
+ _name = 'fp.step.kind.default.input'
+ _description = 'Fusion Plating — Step Kind Default Input'
+ _order = 'sequence, name'
+
+ name = fields.Char(string='Name', required=True, translate=True)
+ kind_id = fields.Many2one(
+ 'fp.step.kind', string='Kind',
+ required=True, ondelete='cascade', index=True,
+ )
+ input_type = fields.Selection([
+ ('text', 'Text'),
+ ('number', 'Number'),
+ ('boolean', 'Yes/No'),
+ ('selection', 'Selection'),
+ ('date', 'Date / Time'),
+ ('signature', 'Signature'),
+ ('time_hms', 'Time (HH:MM:SS)'),
+ ('time_seconds', 'Time (seconds)'),
+ ('temperature', 'Temperature'),
+ ('thickness', 'Thickness'),
+ ('pass_fail', 'Pass / Fail'),
+ ('photo', 'Photo'),
+ ('multi_point_thickness', 'Multi-Point Thickness (avg)'),
+ ('bath_chemistry_panel', 'Bath Chemistry Panel'),
+ ('ph', 'pH'),
+ ], string='Input Type', required=True, default='text')
+ target_unit = fields.Selection(FP_UOM_SELECTION, string='Target Unit')
+ required = fields.Boolean(string='Required', default=False)
+ hint = fields.Char(string='Hint')
+ selection_options = fields.Text(string='Selection Options',
+ help='Comma-separated when input_type is "selection".')
+ sequence = fields.Integer(string='Sequence', default=10)
diff --git a/fusion_plating/fusion_plating/models/fp_step_template.py b/fusion_plating/fusion_plating/models/fp_step_template.py
index 43fd1f05..fd171722 100644
--- a/fusion_plating/fusion_plating/models/fp_step_template.py
+++ b/fusion_plating/fusion_plating/models/fp_step_template.py
@@ -88,32 +88,21 @@ class FpStepTemplate(models.Model):
requires_transition_form = fields.Boolean(string='Requires Transition Form',
help='Opens the transition form before Mark Done (Sub 12b).')
- default_kind = fields.Selection([
- ('receiving', 'Receiving / Incoming Inspection'),
- ('contract_review', 'Contract Review (QA-005)'),
- ('racking', 'Racking'),
- ('mask', 'Masking'),
- ('cleaning', 'Cleaning'),
- ('electroclean', 'Electroclean'),
- ('etch', 'Etch / Activation'),
- ('rinse', 'Rinse'),
- ('strike', 'Strike (Wood\'s Nickel / Activation)'),
- ('plate', 'Plating'),
- ('replenishment', 'Tank Replenishment'),
- ('wbf_test', 'Water Break Free Test'),
- ('dry', 'Drying'),
- ('bake', 'Bake (HE Relief / Stress Relief)'),
- ('demask', 'De-Masking'),
- ('derack', 'De-Racking'),
- ('inspect', 'Inspection'),
- ('hardness_test', 'Hardness Test (HV / HK / HRC)'),
- ('adhesion_test', 'Adhesion Test'),
- ('salt_spray', 'Salt Spray / Corrosion Test'),
- ('final_inspect', 'Final Inspection'),
- ('packaging', 'Packaging / Pre-Ship'),
- ('ship', 'Shipping'),
- ('gating', 'Gating'),
- ], string='Step Kind', help='Drives sane-default input seeding.')
+ # Sub 14b — User-extensible Step Kinds (was Selection of 24).
+ kind_id = fields.Many2one(
+ 'fp.step.kind', string='Step Kind', ondelete='restrict',
+ index=True, tracking=True,
+ help='Pick from the catalog or create a new kind. Drives sane-'
+ 'default input seeding.',
+ )
+ # Back-compat shim — every legacy `tpl.default_kind == "cleaning"`
+ # call site keeps working without a refactor. Stored=True so existing
+ # search domains [('default_kind', '=', 'cleaning')] still hit an
+ # indexed column.
+ default_kind = fields.Char(
+ related='kind_id.code', store=True, readonly=True, index=True,
+ string='Step Kind Code',
+ )
input_template_ids = fields.One2many(
'fp.step.template.input', 'template_id',
@@ -152,13 +141,11 @@ class FpStepTemplate(models.Model):
return super().write(vals)
# ----- Sane defaults seeding ---------------------------------------------
-
- # NB target_unit must be a valid FP_UOM_SELECTION key — it became a
- # Selection in 19.0.12.1.0 (uom cleanup). Free-text values like
- # 'HH:MM', '°F', 'sec', 'in', 'each' raise ValueError on create.
- # Mapping cheatsheet: sec → 's', °F → 'f', °C → 'c', in → 'in',
- # each → 'each', min → 'min'. Format-only strings ('HH:MM') get
- # left blank since they're not units.
+ # Sub 14b — moved from a Python dict into seeded fp.step.kind records
+ # so users can add new kinds + their default inputs through the
+ # standard UI. The dict below is preserved as a fallback only for
+ # codes that don't have a matching kind_id record (legacy data after
+ # migration). It will be removed in a future version.
DEFAULT_INPUTS_BY_KIND = {
'receiving': [
{'name': 'Qty Received', 'input_type': 'number',
@@ -419,19 +406,37 @@ class FpStepTemplate(models.Model):
)
return True
+ # Mapping from fp.step.kind.default.input fields → fp.step.template.input
+ # spec dict. Keep narrow — copy only the columns both models share.
+ _KIND_DEFAULT_INPUT_FIELDS = (
+ 'name', 'input_type', 'target_unit', 'required',
+ 'hint', 'selection_options', 'sequence',
+ )
+
def action_seed_default_inputs(self):
- """Seed input_template_ids based on default_kind. Idempotent —
- only adds inputs whose names don't already exist on this template.
+ """Seed input_template_ids from kind_id.default_input_ids.
+ Idempotent — only adds inputs whose names don't already exist on
+ this template.
+
+ Falls back to the legacy DEFAULT_INPUTS_BY_KIND dict if the
+ template has no kind_id but still carries a default_kind code
+ (defensive — shouldn't happen post-migration).
Public method (Odoo 19 requires non-underscore-prefixed names
for methods called from a view button).
"""
Input = self.env['fp.step.template.input']
for tpl in self:
- if not tpl.default_kind:
- continue
existing_names = set(tpl.input_template_ids.mapped('name'))
- for spec in self.DEFAULT_INPUTS_BY_KIND.get(tpl.default_kind, []):
+ specs = []
+ if tpl.kind_id:
+ for d in tpl.kind_id.default_input_ids:
+ spec = {f: d[f] for f in self._KIND_DEFAULT_INPUT_FIELDS}
+ specs.append(spec)
+ elif tpl.default_kind:
+ # Legacy fallback — kind_id never got linked.
+ specs = self.DEFAULT_INPUTS_BY_KIND.get(tpl.default_kind, [])
+ for spec in specs:
if spec['name'] in existing_names:
continue
Input.create({
diff --git a/fusion_plating/fusion_plating/security/ir.model.access.csv b/fusion_plating/fusion_plating/security/ir.model.access.csv
index b6b56259..f8abd520 100644
--- a/fusion_plating/fusion_plating/security/ir.model.access.csv
+++ b/fusion_plating/fusion_plating/security/ir.model.access.csv
@@ -70,6 +70,12 @@ access_fp_work_role_manager,fp.work.role.manager,model_fp_work_role,group_fusion
access_fp_proficiency_operator,fp.operator.proficiency.operator,model_fp_operator_proficiency,group_fusion_plating_operator,1,0,0,0
access_fp_proficiency_supervisor,fp.operator.proficiency.supervisor,model_fp_operator_proficiency,group_fusion_plating_supervisor,1,1,1,0
access_fp_proficiency_manager,fp.operator.proficiency.manager,model_fp_operator_proficiency,group_fusion_plating_manager,1,1,1,1
+access_fp_step_kind_operator,fp.step.kind.operator,model_fp_step_kind,group_fusion_plating_operator,1,0,0,0
+access_fp_step_kind_supervisor,fp.step.kind.supervisor,model_fp_step_kind,group_fusion_plating_supervisor,1,1,1,0
+access_fp_step_kind_manager,fp.step.kind.manager,model_fp_step_kind,group_fusion_plating_manager,1,1,1,1
+access_fp_step_kind_default_input_operator,fp.step.kind.default.input.operator,model_fp_step_kind_default_input,group_fusion_plating_operator,1,0,0,0
+access_fp_step_kind_default_input_supervisor,fp.step.kind.default.input.supervisor,model_fp_step_kind_default_input,group_fusion_plating_supervisor,1,1,1,1
+access_fp_step_kind_default_input_manager,fp.step.kind.default.input.manager,model_fp_step_kind_default_input,group_fusion_plating_manager,1,1,1,1
access_fp_step_template_operator,fp.step.template.operator,model_fp_step_template,group_fusion_plating_operator,1,0,0,0
access_fp_step_template_supervisor,fp.step.template.supervisor,model_fp_step_template,group_fusion_plating_supervisor,1,1,1,0
access_fp_step_template_manager,fp.step.template.manager,model_fp_step_template,group_fusion_plating_manager,1,1,1,1
diff --git a/fusion_plating/fusion_plating/static/src/js/fp_icon_picker.js b/fusion_plating/fusion_plating/static/src/js/fp_icon_picker.js
new file mode 100644
index 00000000..b6fd3344
--- /dev/null
+++ b/fusion_plating/fusion_plating/static/src/js/fp_icon_picker.js
@@ -0,0 +1,58 @@
+/** @odoo-module **/
+// Sub 14b — visual FontAwesome icon picker for fp.step.kind.icon and any
+// other Selection field whose values are FA classes (e.g. 'fa-flask').
+//
+// Always-visible compact grid with a Search box. Glyph-only tiles
+// (label appears on hover via title attribute). Designed for the form
+// view; the list shows raw text and opens the form for editing.
+
+import { Component, useState } from "@odoo/owl";
+import { registry } from "@web/core/registry";
+import { standardFieldProps } from "@web/views/fields/standard_field_props";
+
+class FpIconPickerField extends Component {
+ static template = "fusion_plating.FpIconPicker";
+ static props = { ...standardFieldProps };
+
+ setup() {
+ this.state = useState({ filter: "" });
+ }
+
+ get options() {
+ const field = this.props.record.fields[this.props.name];
+ return (field && field.selection) || [];
+ }
+
+ get filteredOptions() {
+ const q = (this.state.filter || "").trim().toLowerCase();
+ if (!q) return this.options;
+ return this.options.filter(([code, label]) =>
+ code.toLowerCase().includes(q) ||
+ (label || "").toLowerCase().includes(q)
+ );
+ }
+
+ get currentValue() {
+ return this.props.record.data[this.props.name] || "";
+ }
+
+ get currentLabel() {
+ const opt = this.options.find((o) => o[0] === this.currentValue);
+ return opt ? opt[1] : "";
+ }
+
+ async onPick(value, ev) {
+ if (this.props.readonly) return;
+ ev.preventDefault();
+ ev.stopPropagation();
+ await this.props.record.update({ [this.props.name]: value });
+ }
+}
+
+export const fpIconPickerField = {
+ component: FpIconPickerField,
+ displayName: "Icon Picker",
+ supportedTypes: ["selection"],
+};
+
+registry.category("fields").add("fp_icon_picker", fpIconPickerField);
diff --git a/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js b/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js
index 4d18e404..b433c0eb 100644
--- a/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js
+++ b/fusion_plating/fusion_plating/static/src/js/simple_recipe_editor.js
@@ -243,6 +243,7 @@ export class FpSimpleRecipeEditor extends Component {
*/
async onOpenLibraryCreate() {
await this._fpEnsureWorkflowStatesLoaded();
+ await this._fpEnsureKindOptionsLoaded();
this.state.libraryEditor = {
id: null, // null = create
name: "",
@@ -282,9 +283,67 @@ export class FpSimpleRecipeEditor extends Component {
}
}
+ /**
+ * Sub 14b — fetch the user-extensible Step Kind catalog once per
+ * editor session, cache on this.state.kindOptions. Used by both
+ * create + edit flows to populate the "Step Kind" dropdown so
+ * user-added kinds appear without a page reload.
+ */
+ async _fpEnsureKindOptionsLoaded() {
+ if (this.state.kindOptions && this.state.kindOptions.length) {
+ return;
+ }
+ try {
+ const data = await rpc("/fp/simple_recipe/kinds/list", {});
+ this.state.kindOptions = data.kinds || [];
+ } catch (err) {
+ this.state.kindOptions = [];
+ }
+ }
+
+ /**
+ * Sub 14b — handler for Step Kind dropdown change. Special-cases
+ * the "+ Add a new kind…" sentinel: prompt the user for a name,
+ * round-trip to /kinds/create, refresh the cached options, then
+ * select the newly-created kind.
+ */
+ async onKindChange(ev) {
+ const code = ev.target.value;
+ if (code !== "__new__") {
+ this.state.libraryEditor.default_kind = code || "";
+ return;
+ }
+ // Reset the dropdown so it doesn't stay on the sentinel if the
+ // user cancels the prompt.
+ ev.target.value = this.state.libraryEditor.default_kind || "";
+ const name = window.prompt(
+ "Name your new Step Kind (e.g. 'Passivation', 'Shot Peen')",
+ ""
+ );
+ if (!name || !name.trim()) {
+ return;
+ }
+ try {
+ const data = await rpc("/fp/simple_recipe/kinds/create", {
+ name: name.trim(),
+ });
+ if (!data.ok) {
+ alert(data.error || "Could not create Step Kind.");
+ return;
+ }
+ // Drop the cached list so the next ensure() refetches it.
+ this.state.kindOptions = null;
+ await this._fpEnsureKindOptionsLoaded();
+ this.state.libraryEditor.default_kind = data.code;
+ } catch (err) {
+ alert("Could not create Step Kind: " + (err.message || err));
+ }
+ }
+
async onOpenLibraryEdit(templateId) {
this.state.libraryEditorBusy = true;
await this._fpEnsureWorkflowStatesLoaded();
+ await this._fpEnsureKindOptionsLoaded();
const data = await rpc("/fp/simple_recipe/library/load", {
template_id: templateId,
});
diff --git a/fusion_plating/fusion_plating/static/src/scss/fp_icon_picker.scss b/fusion_plating/fusion_plating/static/src/scss/fp_icon_picker.scss
new file mode 100644
index 00000000..819b916c
--- /dev/null
+++ b/fusion_plating/fusion_plating/static/src/scss/fp_icon_picker.scss
@@ -0,0 +1,150 @@
+// Sub 14b — Visual icon picker for fp.step.kind.icon and similar
+// Selection fields whose values are FontAwesome class names.
+//
+// Compact 12-column grid with Search filter. Glyph-only tiles
+// (label appears on hover via the browser tooltip). Capped at ~280px
+// scrollable height so it doesn't dominate the form.
+//
+// Dark-mode aware via $o-webclient-color-scheme branch (see CLAUDE.md).
+
+$o-webclient-color-scheme: bright !default;
+
+$_fp-icon-picker-bg-hex: #ffffff;
+$_fp-icon-picker-border-hex: #d8dadd;
+$_fp-icon-picker-hover-hex: #f3f4f6;
+$_fp-icon-picker-active-hex: #2c89e9;
+$_fp-icon-picker-text-hex: #21252b;
+$_fp-icon-picker-muted-hex: #6c757d;
+
+@if $o-webclient-color-scheme == dark {
+ $_fp-icon-picker-bg-hex: #22262d !global;
+ $_fp-icon-picker-border-hex: #3a3f47 !global;
+ $_fp-icon-picker-hover-hex: #2c313a !global;
+ $_fp-icon-picker-active-hex: #4ea3ff !global;
+ $_fp-icon-picker-text-hex: #e6e9ef !global;
+ $_fp-icon-picker-muted-hex: #9aa3ad !global;
+}
+
+$fp-icon-picker-bg: var(--fp-icon-picker-bg, $_fp-icon-picker-bg-hex);
+$fp-icon-picker-border: var(--fp-icon-picker-border, $_fp-icon-picker-border-hex);
+$fp-icon-picker-hover: var(--fp-icon-picker-hover, $_fp-icon-picker-hover-hex);
+$fp-icon-picker-active: var(--fp-icon-picker-active, $_fp-icon-picker-active-hex);
+$fp-icon-picker-text: var(--fp-icon-picker-text, $_fp-icon-picker-text-hex);
+$fp-icon-picker-muted: var(--fp-icon-picker-muted, $_fp-icon-picker-muted-hex);
+
+// Force full sheet width even when Odoo wraps the field in a fixed-width
+// .o_field_widget cell. Selecting both the wrapper and the inline root
+// belt-and-suspenders any group container that tries to clip us.
+.o_field_widget[name="icon"]:has(.o_fp_icon_picker_inline) {
+ width: 100% !important;
+ max-width: none !important;
+ flex: 1 1 100% !important;
+}
+
+.o_fp_icon_picker_inline {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ width: 100%;
+
+ .o_fp_icon_picker_top {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ }
+
+ .o_fp_icon_picker_current {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ padding: 0.25rem 0.6rem;
+ background-color: $fp-icon-picker-bg;
+ border: 1px solid $fp-icon-picker-border;
+ border-radius: 4px;
+ color: $fp-icon-picker-text;
+ font-size: 0.85rem;
+ flex: 1;
+ min-width: 0;
+ }
+
+ .o_fp_icon_picker_current_glyph {
+ font-size: 1rem;
+ color: $fp-icon-picker-active;
+ width: 1.2em;
+ text-align: center;
+ }
+
+ .o_fp_icon_picker_current_empty {
+ color: $fp-icon-picker-muted;
+ }
+
+ .o_fp_icon_picker_current_label {
+ flex: 1;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .o_fp_icon_picker_filter {
+ flex: 0 0 140px;
+ height: 28px;
+ font-size: 0.8rem;
+ padding: 0.2rem 0.5rem;
+ }
+
+ .o_fp_icon_picker_inline_grid {
+ // Auto-fill tracks of ~36px each → grid expands to fill
+ // whatever width the form column gives it.
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(36px, 1fr));
+ gap: 2px;
+ padding: 0.4rem;
+ background-color: $fp-icon-picker-bg;
+ border: 1px solid $fp-icon-picker-border;
+ border-radius: 4px;
+ max-height: 240px;
+ overflow-y: auto;
+ }
+
+ .o_fp_icon_picker_tile {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 3px;
+ cursor: pointer;
+ color: $fp-icon-picker-text;
+ height: 30px;
+ width: 100%;
+
+ &:hover:not(:disabled) {
+ background-color: $fp-icon-picker-hover;
+ border-color: $fp-icon-picker-border;
+ }
+
+ &.o_fp_icon_picker_active {
+ border-color: $fp-icon-picker-active;
+ color: $fp-icon-picker-active;
+ background-color: rgba(44, 137, 233, 0.10);
+ }
+
+ &:disabled {
+ cursor: default;
+ opacity: 0.55;
+ }
+ }
+
+ .o_fp_icon_picker_tile_glyph {
+ font-size: 1rem;
+ }
+
+ .o_fp_icon_picker_empty_results {
+ grid-column: 1 / -1;
+ padding: 0.5rem;
+ text-align: center;
+ color: $fp-icon-picker-muted;
+ font-size: 0.8rem;
+ }
+}
diff --git a/fusion_plating/fusion_plating/static/src/xml/fp_icon_picker.xml b/fusion_plating/fusion_plating/static/src/xml/fp_icon_picker.xml
new file mode 100644
index 00000000..af75d724
--- /dev/null
+++ b/fusion_plating/fusion_plating/static/src/xml/fp_icon_picker.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+ No icons match " "
+
+
+
+
+
+
diff --git a/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml b/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml
index a2fd1a09..4e0b4435 100644
--- a/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml
+++ b/fusion_plating/fusion_plating/static/src/xml/simple_recipe_editor.xml
@@ -414,32 +414,15 @@
title="Picking a kind auto-seeds prompts and turns on workflow gates (Contract Review, Racking, Bake). Leave blank for plain generic steps."/>
+ t-on-change="(ev) => this.onKindChange(ev)"
+ t-att-value="state.libraryEditor.default_kind">
Generic — no automatic behaviour
- Receiving / Incoming Inspection
- Contract Review (QA-005)
- Racking
- Masking
- Cleaning
- Electroclean
- Etch / Activation
- Rinse
- Strike (Wood's Nickel / Activation)
- Plating
- Tank Replenishment
- Water Break Free Test
- Drying
- Bake (HE Relief / Stress Relief)
- De-Masking
- De-Racking
- Inspection
- Hardness Test
- Adhesion Test
- Salt Spray / Corrosion Test
- Final Inspection
- Packaging / Pre-Ship
- Shipping
- Gating
+
+
+
+
+
+ + Add a new kind…
diff --git a/fusion_plating/fusion_plating/views/fp_process_node_views.xml b/fusion_plating/fusion_plating/views/fp_process_node_views.xml
index 17652b3f..af005ac1 100644
--- a/fusion_plating/fusion_plating/views/fp_process_node_views.xml
+++ b/fusion_plating/fusion_plating/views/fp_process_node_views.xml
@@ -179,7 +179,8 @@
-
+
diff --git a/fusion_plating/fusion_plating/views/fp_step_kind_views.xml b/fusion_plating/fusion_plating/views/fp_step_kind_views.xml
new file mode 100644
index 00000000..2524144f
--- /dev/null
+++ b/fusion_plating/fusion_plating/views/fp_step_kind_views.xml
@@ -0,0 +1,127 @@
+
+
+
+
+
+ fp.step.kind.list
+ fp.step.kind
+
+
+
+
+
+
+
+
+
+
+
+
+
+ fp.step.kind.form
+ fp.step.kind
+
+
+
+
+
+
+ fp.step.kind.search
+ fp.step.kind
+
+
+
+
+
+
+
+
+
+
+
+ Step Kinds
+ fp.step.kind
+ list,form
+
+
+
+ Add a new Step Kind
+
+
+ Step Kinds drive the dropdown shown when a recipe author
+ picks the type of a step (Cleaning, Plating, Bake…). Each
+ kind can carry default inputs that get auto-seeded onto
+ templates.
+
+
+
+
+
+
+
diff --git a/fusion_plating/fusion_plating/views/fp_step_template_views.xml b/fusion_plating/fusion_plating/views/fp_step_template_views.xml
index f0e71466..d90d528e 100644
--- a/fusion_plating/fusion_plating/views/fp_step_template_views.xml
+++ b/fusion_plating/fusion_plating/views/fp_step_template_views.xml
@@ -14,7 +14,7 @@
-
+
@@ -32,7 +32,7 @@
+ invisible="not kind_id"/>
-
+
@@ -133,13 +134,13 @@
-
+
+ context="{'group_by':'kind_id'}"/>