diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index 0cc73b90..655aecf0 100644 --- a/fusion_plating/fusion_plating_jobs/__manifest__.py +++ b/fusion_plating/fusion_plating_jobs/__manifest__.py @@ -3,7 +3,7 @@ # License OPL-1 (Odoo Proprietary License v1.0) { 'name': 'Fusion Plating — Native Jobs', - 'version': '19.0.4.0.0', + 'version': '19.0.5.0.0', 'category': 'Manufacturing/Plating', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'author': 'Nexa Systems Inc.', diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job.py b/fusion_plating/fusion_plating_jobs/models/fp_job.py index dcb1aa93..a29745eb 100644 --- a/fusion_plating/fusion_plating_jobs/models/fp_job.py +++ b/fusion_plating/fusion_plating_jobs/models/fp_job.py @@ -14,7 +14,7 @@ import logging from markupsafe import Markup -from odoo import fields, models +from odoo import api, fields, models from odoo.exceptions import UserError _logger = logging.getLogger(__name__) @@ -65,6 +65,204 @@ class FpJob(models.Model): 'idempotency. Cleared post-cutover.', ) + # ------------------------------------------------------------------ + # Smart-button counts (Feature A — operator workflow) + # + # Compute counts for each downstream model so the form view can + # render an oe_stat_button row similar to sale.order. Cross-module + # models are runtime-detected so this still works when one of the + # bridge modules is uninstalled. + # ------------------------------------------------------------------ + sale_order_count = fields.Integer(compute='_compute_smart_counts') + delivery_count = fields.Integer(compute='_compute_smart_counts') + invoice_count = fields.Integer(compute='_compute_smart_counts') + payment_count = fields.Integer(compute='_compute_smart_counts') + quality_hold_count = fields.Integer(compute='_compute_smart_counts') + certificate_count = fields.Integer(compute='_compute_smart_counts') + timelog_count = fields.Integer(compute='_compute_smart_counts') + portal_job_count = fields.Integer(compute='_compute_smart_counts') + + @api.depends( + 'sale_order_id', 'delivery_id', 'portal_job_id', 'step_ids', + 'step_ids.time_log_ids', 'origin', 'partner_id', + ) + def _compute_smart_counts(self): + AccountMove = self.env.get('account.move') + AccountPayment = self.env.get('account.payment') + QualityHold = self.env.get('fusion.plating.quality.hold') + Certificate = self.env.get('fp.certificate') + for job in self: + job.sale_order_count = 1 if job.sale_order_id else 0 + job.delivery_count = 1 if job.delivery_id else 0 + job.portal_job_count = 1 if job.portal_job_id else 0 + + # Invoices via origin (the SO name) + if AccountMove is not None and job.origin: + job.invoice_count = AccountMove.search_count([ + ('invoice_origin', '=', job.origin), + ('move_type', 'in', ('out_invoice', 'out_refund')), + ]) + else: + job.invoice_count = 0 + + # Payments — find invoices for this SO, then payments + # reconciled against them. + if (AccountMove is not None and AccountPayment is not None + and job.origin): + inv_ids = AccountMove.search([ + ('invoice_origin', '=', job.origin), + ('move_type', 'in', ('out_invoice', 'out_refund')), + ]).ids + if inv_ids: + job.payment_count = AccountPayment.search_count([ + ('reconciled_invoice_ids', 'in', inv_ids), + ]) + else: + job.payment_count = 0 + else: + job.payment_count = 0 + + if QualityHold is not None: + job.quality_hold_count = QualityHold.search_count([ + ('x_fc_job_id', '=', job.id), + ]) + else: + job.quality_hold_count = 0 + + if Certificate is not None: + job.certificate_count = Certificate.search_count([ + ('x_fc_job_id', '=', job.id), + ]) + else: + job.certificate_count = 0 + + job.timelog_count = sum( + len(s.time_log_ids) for s in job.step_ids + ) + + # ------------------------------------------------------------------ + # Smart-button actions + # ------------------------------------------------------------------ + def action_view_sale_order(self): + self.ensure_one() + if not self.sale_order_id: + return {'type': 'ir.actions.act_window_close'} + return { + 'type': 'ir.actions.act_window', + 'res_model': 'sale.order', + 'res_id': self.sale_order_id.id, + 'view_mode': 'form', + 'name': self.sale_order_id.name, + } + + def action_view_steps(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'res_model': 'fp.job.step', + 'view_mode': 'list,form', + 'domain': [('job_id', '=', self.id)], + 'name': 'Steps — %s' % self.name, + 'context': {'default_job_id': self.id}, + } + + def action_view_deliveries(self): + self.ensure_one() + if not self.delivery_id: + return {'type': 'ir.actions.act_window_close'} + return { + 'type': 'ir.actions.act_window', + 'res_model': 'fusion.plating.delivery', + 'res_id': self.delivery_id.id, + 'view_mode': 'form', + 'name': self.delivery_id.name, + } + + def action_view_invoices(self): + self.ensure_one() + if not self.origin: + return {'type': 'ir.actions.act_window_close'} + return { + 'type': 'ir.actions.act_window', + 'res_model': 'account.move', + 'view_mode': 'list,form', + 'domain': [ + ('invoice_origin', '=', self.origin), + ('move_type', 'in', ('out_invoice', 'out_refund')), + ], + 'name': 'Invoices — %s' % self.name, + } + + def action_view_payments(self): + self.ensure_one() + if not self.origin: + return {'type': 'ir.actions.act_window_close'} + AccountMove = self.env.get('account.move') + if AccountMove is None: + return {'type': 'ir.actions.act_window_close'} + inv_ids = AccountMove.search([ + ('invoice_origin', '=', self.origin), + ('move_type', 'in', ('out_invoice', 'out_refund')), + ]).ids + return { + 'type': 'ir.actions.act_window', + 'res_model': 'account.payment', + 'view_mode': 'list,form', + 'domain': ( + [('reconciled_invoice_ids', 'in', inv_ids)] + if inv_ids else [('id', '=', 0)] + ), + 'name': 'Payments — %s' % self.name, + } + + def action_view_quality_holds(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'res_model': 'fusion.plating.quality.hold', + 'view_mode': 'list,form', + 'domain': [('x_fc_job_id', '=', self.id)], + 'name': 'Quality Holds — %s' % self.name, + 'context': {'default_x_fc_job_id': self.id}, + } + + def action_view_certificates(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'res_model': 'fp.certificate', + 'view_mode': 'list,form', + 'domain': [('x_fc_job_id', '=', self.id)], + 'name': 'Certificates — %s' % self.name, + 'context': {'default_x_fc_job_id': self.id}, + } + + def action_view_timelogs(self): + self.ensure_one() + step_ids = self.step_ids.ids + return { + 'type': 'ir.actions.act_window', + 'res_model': 'fp.job.step.timelog', + 'view_mode': 'list,form', + 'domain': ( + [('step_id', 'in', step_ids)] + if step_ids else [('id', '=', 0)] + ), + 'name': 'Time Logs — %s' % self.name, + } + + def action_view_portal_job(self): + self.ensure_one() + if not self.portal_job_id: + return {'type': 'ir.actions.act_window_close'} + return { + 'type': 'ir.actions.act_window', + 'res_model': 'fusion.plating.portal.job', + 'res_id': self.portal_job_id.id, + 'view_mode': 'form', + 'name': self.portal_job_id.name, + } + # ------------------------------------------------------------------ # Recipe → fp.job.step generation (Task 2.4) # diff --git a/fusion_plating/fusion_plating_jobs/tests/test_fp_job_extensions.py b/fusion_plating/fusion_plating_jobs/tests/test_fp_job_extensions.py index adf1b07f..4196c1b4 100644 --- a/fusion_plating/fusion_plating_jobs/tests/test_fp_job_extensions.py +++ b/fusion_plating/fusion_plating_jobs/tests/test_fp_job_extensions.py @@ -576,6 +576,53 @@ class TestPhase6Controllers(TransactionCase): self.assertEqual(step_by_node[op.id].name, 'Op1') +class TestFpJobSmartButtons(TransactionCase): + """Feature A — verify smart-button count fields and action methods + are wired on fp.job. Runtime-detect tests confirm the methods exist + without requiring downstream models to be installed.""" + + def test_smart_count_fields_exist(self): + for f in ( + 'sale_order_count', 'delivery_count', 'invoice_count', + 'payment_count', 'quality_hold_count', 'certificate_count', + 'timelog_count', 'portal_job_count', + ): + self.assertIn(f, self.env['fp.job']._fields) + + def test_smart_action_methods_exist(self): + Job = self.env['fp.job'] + for m in ( + 'action_view_sale_order', 'action_view_steps', + 'action_view_deliveries', 'action_view_invoices', + 'action_view_payments', 'action_view_quality_holds', + 'action_view_certificates', 'action_view_timelogs', + 'action_view_portal_job', + ): + self.assertTrue( + hasattr(Job, m), + 'fp.job missing action method %s' % m, + ) + + def test_smart_counts_compute_for_empty_job(self): + partner = self.env['res.partner'].create({'name': 'C'}) + product = self.env['product.product'].create({'name': 'W'}) + job = self.env['fp.job'].create({ + 'partner_id': partner.id, + 'product_id': product.id, + 'qty': 1.0, + }) + # All counts should be 0 on a freshly-created job (no SO, + # no delivery, no portal job, no holds, etc.) + self.assertEqual(job.sale_order_count, 0) + self.assertEqual(job.delivery_count, 0) + self.assertEqual(job.invoice_count, 0) + self.assertEqual(job.payment_count, 0) + self.assertEqual(job.quality_hold_count, 0) + self.assertEqual(job.certificate_count, 0) + self.assertEqual(job.timelog_count, 0) + self.assertEqual(job.portal_job_count, 0) + + class TestPhase7Migration(TransactionCase): """Phase 7 — verify the migration script idempotency-key fields are in place and the script files are present + parse as valid Python. diff --git a/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml b/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml index 26687d01..8b571a74 100644 --- a/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml +++ b/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml @@ -1,11 +1,18 @@ fp.job.form.jobs.inherit @@ -19,6 +26,67 @@ icon="fa-sitemap" invisible="state == 'draft'"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fusion_plating/fusion_plating_shopfloor/__manifest__.py b/fusion_plating/fusion_plating_shopfloor/__manifest__.py index bbdda141..eafcdd11 100644 --- a/fusion_plating/fusion_plating_shopfloor/__manifest__.py +++ b/fusion_plating/fusion_plating_shopfloor/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Shop Floor', - 'version': '19.0.15.0.0', + 'version': '19.0.16.0.0', 'category': 'Manufacturing/Plating', 'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, ' 'first-piece inspection gates.', @@ -50,6 +50,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved. 'views/fp_bake_window_views.xml', 'views/fp_first_piece_gate_views.xml', 'views/fp_plant_overview_views.xml', + 'views/tank_status_template.xml', 'views/fp_menu.xml', ], 'demo': [ @@ -61,11 +62,16 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved. # and variables directly (Odoo 19 forbids @import in custom SCSS, # so tokens are resolved via bundle concatenation order). 'fusion_plating_shopfloor/static/src/scss/_fp_shopfloor_tokens.scss', + 'fusion_plating_shopfloor/static/src/scss/qr_scanner.scss', 'fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss', 'fusion_plating_shopfloor/static/src/scss/plant_overview.scss', 'fusion_plating_shopfloor/static/src/scss/process_tree.scss', 'fusion_plating_shopfloor/static/src/scss/manager_dashboard.scss', 'fusion_plating_shopfloor/static/src/scss/fp_kanbans.scss', + # qr_scanner.js MUST load before its consumers so the + # `import { QrScanner } from "./qr_scanner"` resolves. + 'fusion_plating_shopfloor/static/src/js/qr_scanner.js', + 'fusion_plating_shopfloor/static/src/xml/qr_scanner.xml', 'fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml', 'fusion_plating_shopfloor/static/src/xml/plant_overview.xml', 'fusion_plating_shopfloor/static/src/xml/process_tree.xml', @@ -75,6 +81,12 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved. 'fusion_plating_shopfloor/static/src/js/process_tree.js', 'fusion_plating_shopfloor/static/src/js/manager_dashboard.js', ], + 'web.assets_frontend': [ + # Tank status page (rendered via web.frontend_layout for + # NFC tap-to-view from a phone). Tokens loaded first. + 'fusion_plating_shopfloor/static/src/scss/_fp_shopfloor_tokens.scss', + 'fusion_plating_shopfloor/static/src/scss/tank_status.scss', + ], }, 'installable': True, 'application': False, diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/__init__.py b/fusion_plating/fusion_plating_shopfloor/controllers/__init__.py index 007ab933..69b5026a 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/__init__.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/__init__.py @@ -4,3 +4,4 @@ from . import shopfloor_controller from . import manager_controller +from . import tank_status diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/tank_status.py b/fusion_plating/fusion_plating_shopfloor/controllers/tank_status.py new file mode 100644 index 00000000..4d671231 --- /dev/null +++ b/fusion_plating/fusion_plating_shopfloor/controllers/tank_status.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +# +# /fp/tank/ — mobile-friendly tank status page. Linked from NFC +# tags on the physical tank. The operator taps the tag with a phone, +# the tag's URL opens this page in their default browser. +# +# Auth is `user` so an operator must be logged in (no public exposure +# of bath chemistry / job-customer data). Operators stay logged in on +# the shopfloor tablet, so this is friction-free in practice. +# +# Why URL-based and not Web NFC API: Web NFC is Chrome-Android only; +# iOS Safari does not expose any NFC API. iOS instead reads the URL +# off the tag's NDEF record and opens it in the default browser. As +# long as the tag stores the URL, both platforms Just Work. + +from odoo import http +from odoo.http import request + + +class FpTankStatusController(http.Controller): + + @http.route( + '/fp/tank/', + type='http', + auth='user', + website=False, + ) + def fp_tank_status(self, tank_id, **kwargs): + Tank = request.env['fusion.plating.tank'].sudo() + tank = Tank.browse(tank_id).exists() + if not tank: + return request.render( + 'fusion_plating_shopfloor.tank_status_not_found', + {'tank_id': tank_id}, + ) + + # Find the active step on this tank (in progress or paused). + # fp.job.step.tank_id was added in fusion_plating core. + Step = request.env['fp.job.step'].sudo() + active_step = Step.search([ + ('tank_id', '=', tank.id), + ('state', 'in', ('in_progress', 'paused')), + ], order='date_started desc', limit=1) + + # Up to 5 ready steps for this tank — the operator's "what's + # coming next" signal. + ready_steps = Step.search([ + ('tank_id', '=', tank.id), + ('state', '=', 'ready'), + ], order='sequence asc', limit=5) + + # Most recent bath log. Readings are line-level + # (fusion.plating.bath.log.line), keyed by parameter_code (pH, + # temperature, nickel, etc.). The template iterates the lines. + bath_log = request.env['fusion.plating.bath.log'].sudo().search( + [('tank_id', '=', tank.id)], + order='log_date desc, create_date desc', + limit=1, + ) + + return request.render( + 'fusion_plating_shopfloor.tank_status_page', + { + 'tank': tank, + 'active_step': active_step, + 'ready_steps': ready_steps, + 'bath_log': bath_log, + }, + ) diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/manager_dashboard.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/manager_dashboard.js index 665b531b..7feac04e 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/js/manager_dashboard.js +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/manager_dashboard.js @@ -16,10 +16,12 @@ import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl"; import { registry } from "@web/core/registry"; import { rpc } from "@web/core/network/rpc"; import { useService } from "@web/core/utils/hooks"; +import { QrScanner } from "./qr_scanner"; export class ManagerDashboard extends Component { static template = "fusion_plating_shopfloor.ManagerDashboard"; static props = ["*"]; + static components = { QrScanner }; setup() { this.notification = useService("notification"); diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/plant_overview.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/plant_overview.js index 4e73d2d5..4f7866a8 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/js/plant_overview.js +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/plant_overview.js @@ -22,10 +22,12 @@ import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl"; import { registry } from "@web/core/registry"; import { rpc } from "@web/core/network/rpc"; import { useService } from "@web/core/utils/hooks"; +import { QrScanner } from "./qr_scanner"; export class PlantOverview extends Component { static template = "fusion_plating_shopfloor.PlantOverview"; static props = ["*"]; + static components = { QrScanner }; setup() { this.notification = useService("notification"); diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/process_tree.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/process_tree.js index 1f78a72d..0d694205 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/js/process_tree.js +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/process_tree.js @@ -24,10 +24,12 @@ import { Component, useState, onMounted } from "@odoo/owl"; import { registry } from "@web/core/registry"; import { rpc } from "@web/core/network/rpc"; import { useService } from "@web/core/utils/hooks"; +import { QrScanner } from "./qr_scanner"; export class ProcessTree extends Component { static template = "fusion_plating_shopfloor.ProcessTree"; static props = ["*"]; + static components = { QrScanner }; setup() { this.notification = useService("notification"); diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/qr_scanner.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/qr_scanner.js new file mode 100644 index 00000000..fd71a6e4 --- /dev/null +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/qr_scanner.js @@ -0,0 +1,158 @@ +/** @odoo-module **/ +// ============================================================================= +// Fusion Plating — Reusable QR Scanner OWL Component +// Copyright 2026 Nexa Systems Inc. · License OPL-1 +// +// Renders a single button. On click, opens a modal that streams the rear +// camera into a element and uses the browser's BarcodeDetector +// API to decode QR codes in real time. When a code is detected, parses +// it as a URL, extracts /fp/job/ (or /fp/wo/ as a legacy alias), +// and opens the matching fp.job form via the action service. +// +// Falls back to a paste-the-URL textbox if BarcodeDetector or +// getUserMedia is unavailable (e.g. on insecure origins, older Safari). +// +// BarcodeDetector is supported on: +// * Android Chrome (since 2019) +// * iOS Safari 17+ (2023) +// * Desktop Chrome / Edge +// +// Used by Manager Desk, Tablet Station, Plant Overview, and Process Tree +// headers — see each component's `static components = { QrScanner }`. +// ============================================================================= + +import { Component, useState, useRef, onWillUnmount } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; + +export class QrScanner extends Component { + static template = "fusion_plating_shopfloor.QrScanner"; + static props = { + label: { type: String, optional: true }, + cssClass: { type: String, optional: true }, + }; + static defaultProps = { + label: "Scan", + cssClass: "btn btn-secondary", + }; + + setup() { + this.action = useService("action"); + this.notification = useService("notification"); + this.videoRef = useRef("video"); + this.state = useState({ + open: false, + error: null, + manualUrl: "", + supportsBarcode: typeof BarcodeDetector !== "undefined", + }); + this.stream = null; + this.decodeLoopActive = false; + + onWillUnmount(() => this._stopCamera()); + } + + async open() { + this.state.open = true; + this.state.error = null; + await this._startCamera(); + } + + close() { + this.state.open = false; + this._stopCamera(); + } + + async _startCamera() { + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + this.state.error = "Camera access not available. Use the URL input below."; + return; + } + try { + this.stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: { ideal: "environment" } }, + audio: false, + }); + // Wait one paint tick so the t-ref resolves to the + await new Promise((r) => requestAnimationFrame(r)); + const v = this.videoRef.el; + if (v) { + v.srcObject = this.stream; + await v.play(); + } + if (this.state.supportsBarcode) { + this._decodeLoop(); + } + } catch (e) { + this.state.error = "Couldn't access camera: " + (e.message || e); + } + } + + _stopCamera() { + this.decodeLoopActive = false; + if (this.stream) { + this.stream.getTracks().forEach((t) => t.stop()); + this.stream = null; + } + } + + async _decodeLoop() { + if (!this.state.supportsBarcode) return; + const detector = new BarcodeDetector({ formats: ["qr_code"] }); + this.decodeLoopActive = true; + const v = this.videoRef.el; + if (!v) return; + const tick = async () => { + if (!this.decodeLoopActive || !this.state.open) return; + try { + if (v.readyState >= 2) { + const codes = await detector.detect(v); + if (codes.length > 0) { + this._handleCode(codes[0].rawValue); + return; + } + } + } catch (e) { + // Decode errors are noisy and recoverable — try again + // next frame. Real failures (camera revoked, etc.) + // surface via _startCamera's catch. + } + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + } + + onManualSubmit() { + if (this.state.manualUrl) { + this._handleCode(this.state.manualUrl); + } + } + + /** + * Parse a decoded value (URL or raw id-bearing string) and route to + * the matching fp.job form. Accepts /fp/job/ and /fp/wo/ + * (legacy alias from older mrp.workorder stickers — both now point + * at fp.job). + */ + _handleCode(rawValue) { + const m = (rawValue || "").match(/\/fp\/(?:job|wo)\/(\d+)/); + if (!m) { + this.state.error = + "QR doesn't look like a job sticker. Got: " + + (rawValue || "").slice(0, 80); + this.state.manualUrl = ""; + return; + } + const jobId = parseInt(m[1], 10); + this._stopCamera(); + this.state.open = false; + this.action.doAction({ + type: "ir.actions.act_window", + res_model: "fp.job", + res_id: jobId, + view_mode: "form", + views: [[false, "form"]], + target: "current", + }); + this.notification.add("Opened job " + jobId, { type: "success" }); + } +} diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_tablet.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_tablet.js index 32d13f7a..98f6ab31 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_tablet.js +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_tablet.js @@ -19,10 +19,12 @@ import { Component, useState, onMounted, onWillUnmount, useRef } from "@odoo/owl import { registry } from "@web/core/registry"; import { rpc } from "@web/core/network/rpc"; import { useService } from "@web/core/utils/hooks"; +import { QrScanner } from "./qr_scanner"; export class ShopfloorTablet extends Component { static template = "fusion_plating_shopfloor.ShopfloorTablet"; static props = ["*"]; + static components = { QrScanner }; setup() { this.notification = useService("notification"); diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/process_tree.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/process_tree.scss index c6a9a2bc..b168c32a 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/scss/process_tree.scss +++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/process_tree.scss @@ -71,6 +71,12 @@ $pt-line-width : 2px; top: 0; z-index: 5; } + .o_fp_pt_header_actions { + margin-left: auto; + display: flex; + align-items: center; + gap: $fp-space-2; + } .o_fp_pt_back { display: inline-flex; align-items: center; diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/qr_scanner.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/qr_scanner.scss new file mode 100644 index 00000000..078fd48b --- /dev/null +++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/qr_scanner.scss @@ -0,0 +1,100 @@ +// ============================================================================= +// Fusion Plating — Reusable QR Scanner Modal +// Copyright 2026 Nexa Systems Inc. · License OPL-1 +// +// Mobile-first modal that overlays the page. The video element fills +// the body with a fixed aspect ratio so the layout doesn't jump as +// the camera initialises. +// +// All surfaces resolve from the shop-floor design tokens +// (_fp_shopfloor_tokens.scss) so light + dark modes both work without +// extra rules. +// ============================================================================= + +.o_fp_qr_modal_backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +.o_fp_qr_modal { + background: $fp-card; + color: $fp-ink; + border-radius: $fp-radius-lg; + box-shadow: $fp-elev-3; + // Wrap min() in #{...} so dart-sass doesn't try to compute it at + // compile time (it can't combine 420px and 92vw — the clamp/min + // functions are CSS-runtime, not SCSS). Pass through verbatim. + width: #{"min(420px, 92vw)"}; + max-width: 92vw; + overflow: hidden; + font-family: $fp-font-stack; +} + +.o_fp_qr_modal_head { + display: flex; + align-items: center; + justify-content: space-between; + padding: $fp-space-3 $fp-space-4; + border-bottom: 1px solid $fp-border; + + h3 { + margin: 0; + font-size: $fp-text-lg; + color: $fp-ink; + } +} + +.o_fp_qr_modal_body { + padding: $fp-space-4; + display: flex; + flex-direction: column; + gap: $fp-space-3; +} + +.o_fp_qr_video { + width: 100%; + aspect-ratio: 4 / 3; + background: #000; + border-radius: $fp-radius-md; + object-fit: cover; +} + +.o_fp_qr_error, +.o_fp_qr_warn { + padding: $fp-space-2 $fp-space-3; + border-radius: $fp-radius-sm; + background: $fp-card-soft; + color: $fp-ink-soft; + font-size: $fp-text-sm; +} + +.o_fp_qr_error { + border-left: 3px solid $fp-bad; +} + +.o_fp_qr_manual { + border-top: 1px solid $fp-border; + padding-top: $fp-space-3; + + label { + font-size: $fp-text-sm; + color: $fp-ink-mute; + margin-bottom: 4px; + } + + .form-control { + background: $fp-card-soft; + border: 1px solid $fp-border; + color: $fp-ink; + + &:focus { + @include fp-focus-ring; + border-color: $fp-accent; + } + } +} diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/tank_status.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/tank_status.scss new file mode 100644 index 00000000..c7f2ae82 --- /dev/null +++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/tank_status.scss @@ -0,0 +1,222 @@ +// ============================================================================= +// Fusion Plating — Tank Status (NFC tap-to-view) +// Copyright 2026 Nexa Systems Inc. · License OPL-1 +// +// Mobile-first stylesheet for /fp/tank/. Renders inside +// web.frontend_layout. Uses the shop-floor design tokens so light + +// dark themes both work without an extra rule set. +// ============================================================================= + +.o_fp_tank_status { + max-width: 720px; + margin: 0 auto; + padding: $fp-space-4; + color: $fp-ink; + font-family: $fp-font-stack; + background: $fp-page; + min-height: 100vh; + box-sizing: border-box; +} + +.o_fp_tank_head { + text-align: center; + margin-bottom: $fp-space-5; + + h1 { + font-size: $fp-text-2xl; + margin: 0 0 $fp-space-2; + color: $fp-ink; + + i { + color: $fp-accent; + margin-right: $fp-space-2; + } + } +} + +.o_fp_tank_meta { + color: $fp-ink-mute; + font-size: $fp-text-sm; + + span + span::before { + content: " · "; + padding: 0 4px; + } + + strong { + color: $fp-ink-soft; + margin-right: 4px; + } +} + +.o_fp_tank_section { + background: $fp-card-soft; + border-radius: $fp-radius-md; + padding: $fp-space-4; + margin-bottom: $fp-space-4; + + h2 { + font-size: $fp-text-md; + margin: 0 0 $fp-space-3; + color: $fp-ink-soft; + + i { + margin-right: $fp-space-2; + color: $fp-accent; + } + } +} + +.o_fp_tank_card { + background: $fp-card; + border: 1px solid $fp-border; + border-radius: $fp-radius-sm; + padding: $fp-space-3 $fp-space-4; + box-shadow: $fp-elev-1; + margin-bottom: $fp-space-2; + color: $fp-ink; +} + +.o_fp_tank_card_compact { + display: flex; + flex-direction: column; + gap: 2px; +} + +.o_fp_tank_card_title { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: $fp-space-2; + gap: $fp-space-2; +} + +.o_fp_tank_card_meta { + display: grid; + grid-template-columns: 1fr; + gap: $fp-space-2; + font-size: $fp-text-sm; + color: $fp-ink-soft; + + span strong { + color: $fp-ink-mute; + margin-right: 6px; + } +} + +.o_fp_tank_card_sub { + color: $fp-ink-mute; + font-size: $fp-text-sm; +} + +.o_fp_tank_empty { + color: $fp-ink-mute; + font-style: italic; + text-align: center; + padding: $fp-space-3 0; +} + +// State / status pills — use the same translucent-tint pattern as the +// other shop-floor surfaces so they read at a glance on a phone. +.o_fp_state_badge { + padding: 2px 8px; + border-radius: $fp-radius-pill; + font-size: $fp-text-xs; + text-transform: uppercase; + font-weight: $fp-weight-semibold; + letter-spacing: 0.5px; + background: color-mix(in srgb, #{$fp-ink-soft} 14%, transparent); + color: $fp-ink-soft; + + &[data-state="in_progress"] { + background: color-mix(in srgb, #{$fp-accent} 18%, transparent); + color: $fp-accent; + } + + &[data-state="paused"] { + background: color-mix(in srgb, #{$fp-warn} 18%, transparent); + color: $fp-warn; + } + + &[data-state="ok"] { + background: color-mix(in srgb, #{$fp-ok} 18%, transparent); + color: $fp-ok; + } + + &[data-state="warning"] { + background: color-mix(in srgb, #{$fp-warn} 18%, transparent); + color: $fp-warn; + } + + &[data-state="out_of_spec"] { + background: color-mix(in srgb, #{$fp-bad} 18%, transparent); + color: $fp-bad; + } +} + +// Bath chemistry grid — one cell per parameter reading. +.o_fp_tank_chem_grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: $fp-space-2; + margin-top: $fp-space-3; + border-top: 1px solid $fp-border; + padding-top: $fp-space-3; +} + +.o_fp_tank_chem_cell { + background: $fp-card-soft; + border-radius: $fp-radius-sm; + padding: $fp-space-2 $fp-space-3; + text-align: center; + + &[data-status="ok"] { + box-shadow: inset 0 0 0 1px color-mix(in srgb, #{$fp-ok} 40%, transparent); + } + + &[data-status="warning"] { + box-shadow: inset 0 0 0 1px color-mix(in srgb, #{$fp-warn} 40%, transparent); + } + + &[data-status="out_of_spec"] { + box-shadow: inset 0 0 0 1px color-mix(in srgb, #{$fp-bad} 50%, transparent); + } +} + +.o_fp_tank_chem_label { + font-size: $fp-text-xs; + color: $fp-ink-mute; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.o_fp_tank_chem_value { + font-size: $fp-text-lg; + font-weight: $fp-weight-semibold; + color: $fp-ink; + margin-top: 2px; + + small { + font-size: $fp-text-xs; + font-weight: $fp-weight-medium; + color: $fp-ink-mute; + margin-left: 2px; + } +} + +.o_fp_tank_chem_range { + font-size: $fp-text-xs; + color: $fp-ink-faint; + margin-top: 2px; +} + +.o_fp_tank_foot { + text-align: center; + color: $fp-ink-faint; + font-size: $fp-text-xs; + margin-top: $fp-space-6; + + p { + margin: 0; + } +} diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/xml/manager_dashboard.xml b/fusion_plating/fusion_plating_shopfloor/static/src/xml/manager_dashboard.xml index 05c013f3..68a14526 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/xml/manager_dashboard.xml +++ b/fusion_plating/fusion_plating_shopfloor/static/src/xml/manager_dashboard.xml @@ -44,6 +44,7 @@ t-att-disabled="state.isFetching"> + Quick View diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/xml/plant_overview.xml b/fusion_plating/fusion_plating_shopfloor/static/src/xml/plant_overview.xml index afd1ed96..d521bc0e 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/xml/plant_overview.xml +++ b/fusion_plating/fusion_plating_shopfloor/static/src/xml/plant_overview.xml @@ -43,6 +43,7 @@ title="Refresh"> + diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/xml/process_tree.xml b/fusion_plating/fusion_plating_shopfloor/static/src/xml/process_tree.xml index 0c549d68..d5420da8 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/xml/process_tree.xml +++ b/fusion_plating/fusion_plating_shopfloor/static/src/xml/process_tree.xml @@ -106,6 +106,9 @@ · + + + diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/xml/qr_scanner.xml b/fusion_plating/fusion_plating_shopfloor/static/src/xml/qr_scanner.xml new file mode 100644 index 00000000..8b532f8c --- /dev/null +++ b/fusion_plating/fusion_plating_shopfloor/static/src/xml/qr_scanner.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + Scan job QR + + + + + + + Live decoding isn't supported on this browser. + Paste the URL below. + + + + + + + + Or paste sticker URL + + Open + + + + + + diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml b/fusion_plating/fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml index 13c8b8f8..3e9cbc67 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml +++ b/fusion_plating/fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml @@ -42,8 +42,9 @@ - Scan + Code + diff --git a/fusion_plating/fusion_plating_shopfloor/views/tank_status_template.xml b/fusion_plating/fusion_plating_shopfloor/views/tank_status_template.xml new file mode 100644 index 00000000..6c8b28f3 --- /dev/null +++ b/fusion_plating/fusion_plating_shopfloor/views/tank_status_template.xml @@ -0,0 +1,165 @@ + + + + + + + + + + + + + + + + Code: + Bath: + Facility: + Work Centre: + + + + + + + + Current Job + + + + + + + + + + + Customer: + + + + Step: + + + + Operator: + + + + Expected: + min + + + Target thickness: + + + + + Started: + + + + + + Tank is idle. + + + + + Up Next + + + + + + + · + + · min + + + + + + + No queued work for this tank. + + + + + Bath Chemistry + + + + Status: + + + + Last sampled: + + + + Sampled by: + + + + + + + + + + + + + + + target + + – + + + + + + + + + + + + + + + + + + + + Tank not found + + + + No tank with id . + + + + +
No tank with id .