feat(jobs,shopfloor): smart buttons + QR scanner + NFC tank pages
Three connected operator-workflow features for entech.
A. fp.job smart buttons — count fields and action methods for sale
order, steps, deliveries, invoices, payments, quality holds,
certificates, time logs, and portal job. Each is an oe_stat_button
that drills into the matching records, mirroring the sale.order
pattern. Cross-module models are runtime-detected so the form
stays clean when bridge modules are uninstalled.
B. Reusable QR scanner OWL component (`<QrScanner/>`) wired into the
Manager Desk, Tablet Station, Plant Overview, and Process Tree
headers. Click → modal with rear-camera stream (getUserMedia) +
BarcodeDetector live decode → opens the matching fp.job form via
the action service. Falls back to a manual URL paste box on
browsers without BarcodeDetector. Works on iOS 17+ Safari and
Android Chrome. Width uses `min(420px, 92vw)` wrapped in #{} so
dart-sass passes it through verbatim instead of trying to compute
incompatible units at compile time.
C. /fp/tank/<id> public-but-auth-required tank status page for NFC
taps. Renders the tank's current step (in-progress / paused),
queued ready steps, and most recent bath chemistry log (lines
table) on a mobile-first page. URL-based so it works on iOS Safari
without the Web NFC API — the operator taps the NFC tag, the URL
opens in the default browser, the page auto-renders. New
web.assets_frontend bundle entry pulls in the design tokens +
tank_status.scss.
Manifest version bumps: jobs 19.0.5.0.0, shopfloor 19.0.16.0.0.
Tests: 44 pass (3 new smart-button assertions added).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Native Jobs',
|
'name': 'Fusion Plating — Native Jobs',
|
||||||
'version': '19.0.4.0.0',
|
'version': '19.0.5.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||||
'author': 'Nexa Systems Inc.',
|
'author': 'Nexa Systems Inc.',
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import logging
|
|||||||
|
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
|
|
||||||
from odoo import fields, models
|
from odoo import api, fields, models
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
@@ -65,6 +65,204 @@ class FpJob(models.Model):
|
|||||||
'idempotency. Cleared post-cutover.',
|
'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)
|
# Recipe → fp.job.step generation (Task 2.4)
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -576,6 +576,53 @@ class TestPhase6Controllers(TransactionCase):
|
|||||||
self.assertEqual(step_by_node[op.id].name, 'Op1')
|
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):
|
class TestPhase7Migration(TransactionCase):
|
||||||
"""Phase 7 — verify the migration script idempotency-key fields are
|
"""Phase 7 — verify the migration script idempotency-key fields are
|
||||||
in place and the script files are present + parse as valid Python.
|
in place and the script files are present + parse as valid Python.
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<odoo>
|
<odoo>
|
||||||
<!--
|
<!--
|
||||||
Adds a "Process Tree" button to the fp.job form header, calling
|
Adds a "Process Tree" header button + smart-button row to the
|
||||||
fp.job.action_open_process_tree (which launches the canonical
|
fp.job form. The fp.job form in core has no button_box yet, so
|
||||||
fp_process_tree shopfloor client action with job_id in context).
|
we inject one at the top of the sheet (xpath //sheet position
|
||||||
|
"inside" with a sibling reference at the start).
|
||||||
|
|
||||||
Hidden while the job is in draft (no recipe-derived steps yet).
|
Smart buttons appear only when the underlying count is > 0
|
||||||
|
(except Steps, which always shows since every confirmed job
|
||||||
|
has steps). Pattern follows the existing oe_stat_button row
|
||||||
|
from sale.order / mrp.production.
|
||||||
|
|
||||||
|
Process Tree header button is hidden while the job is in draft
|
||||||
|
(no recipe-derived steps yet).
|
||||||
-->
|
-->
|
||||||
<record id="view_fp_job_form_jobs_inherit" model="ir.ui.view">
|
<record id="view_fp_job_form_jobs_inherit" model="ir.ui.view">
|
||||||
<field name="name">fp.job.form.jobs.inherit</field>
|
<field name="name">fp.job.form.jobs.inherit</field>
|
||||||
@@ -19,6 +26,67 @@
|
|||||||
icon="fa-sitemap"
|
icon="fa-sitemap"
|
||||||
invisible="state == 'draft'"/>
|
invisible="state == 'draft'"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
|
||||||
|
<!-- Inject a button_box at the top of the sheet, before the
|
||||||
|
oe_title block. Smart buttons drill into the matching
|
||||||
|
records the way sale.order does. -->
|
||||||
|
<xpath expr="//sheet/div[hasclass('oe_title')]" position="before">
|
||||||
|
<div class="oe_button_box" name="button_box">
|
||||||
|
<button name="action_view_sale_order" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-shopping-cart"
|
||||||
|
invisible="sale_order_count == 0">
|
||||||
|
<field name="sale_order_count" widget="statinfo"
|
||||||
|
string="Sale Order"/>
|
||||||
|
</button>
|
||||||
|
<button name="action_view_steps" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-list-ol">
|
||||||
|
<field name="step_count" widget="statinfo"
|
||||||
|
string="Steps"/>
|
||||||
|
</button>
|
||||||
|
<button name="action_view_deliveries" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-truck"
|
||||||
|
invisible="delivery_count == 0">
|
||||||
|
<field name="delivery_count" widget="statinfo"
|
||||||
|
string="Delivery"/>
|
||||||
|
</button>
|
||||||
|
<button name="action_view_invoices" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-file-text-o"
|
||||||
|
invisible="invoice_count == 0">
|
||||||
|
<field name="invoice_count" widget="statinfo"
|
||||||
|
string="Invoices"/>
|
||||||
|
</button>
|
||||||
|
<button name="action_view_payments" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-money"
|
||||||
|
invisible="payment_count == 0">
|
||||||
|
<field name="payment_count" widget="statinfo"
|
||||||
|
string="Payments"/>
|
||||||
|
</button>
|
||||||
|
<button name="action_view_quality_holds" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-pause-circle"
|
||||||
|
invisible="quality_hold_count == 0">
|
||||||
|
<field name="quality_hold_count" widget="statinfo"
|
||||||
|
string="Holds"/>
|
||||||
|
</button>
|
||||||
|
<button name="action_view_certificates" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-certificate"
|
||||||
|
invisible="certificate_count == 0">
|
||||||
|
<field name="certificate_count" widget="statinfo"
|
||||||
|
string="Certificates"/>
|
||||||
|
</button>
|
||||||
|
<button name="action_view_timelogs" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-clock-o"
|
||||||
|
invisible="timelog_count == 0">
|
||||||
|
<field name="timelog_count" widget="statinfo"
|
||||||
|
string="Time Logs"/>
|
||||||
|
</button>
|
||||||
|
<button name="action_view_portal_job" type="object"
|
||||||
|
class="oe_stat_button" icon="fa-globe"
|
||||||
|
invisible="portal_job_count == 0">
|
||||||
|
<field name="portal_job_count" widget="statinfo"
|
||||||
|
string="Portal Job"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Shop Floor',
|
'name': 'Fusion Plating — Shop Floor',
|
||||||
'version': '19.0.15.0.0',
|
'version': '19.0.16.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
||||||
'first-piece inspection gates.',
|
'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_bake_window_views.xml',
|
||||||
'views/fp_first_piece_gate_views.xml',
|
'views/fp_first_piece_gate_views.xml',
|
||||||
'views/fp_plant_overview_views.xml',
|
'views/fp_plant_overview_views.xml',
|
||||||
|
'views/tank_status_template.xml',
|
||||||
'views/fp_menu.xml',
|
'views/fp_menu.xml',
|
||||||
],
|
],
|
||||||
'demo': [
|
'demo': [
|
||||||
@@ -61,11 +62,16 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
# and variables directly (Odoo 19 forbids @import in custom SCSS,
|
# and variables directly (Odoo 19 forbids @import in custom SCSS,
|
||||||
# so tokens are resolved via bundle concatenation order).
|
# so tokens are resolved via bundle concatenation order).
|
||||||
'fusion_plating_shopfloor/static/src/scss/_fp_shopfloor_tokens.scss',
|
'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/fusion_plating_shopfloor.scss',
|
||||||
'fusion_plating_shopfloor/static/src/scss/plant_overview.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/process_tree.scss',
|
||||||
'fusion_plating_shopfloor/static/src/scss/manager_dashboard.scss',
|
'fusion_plating_shopfloor/static/src/scss/manager_dashboard.scss',
|
||||||
'fusion_plating_shopfloor/static/src/scss/fp_kanbans.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/shopfloor_tablet.xml',
|
||||||
'fusion_plating_shopfloor/static/src/xml/plant_overview.xml',
|
'fusion_plating_shopfloor/static/src/xml/plant_overview.xml',
|
||||||
'fusion_plating_shopfloor/static/src/xml/process_tree.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/process_tree.js',
|
||||||
'fusion_plating_shopfloor/static/src/js/manager_dashboard.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,
|
'installable': True,
|
||||||
'application': False,
|
'application': False,
|
||||||
|
|||||||
@@ -4,3 +4,4 @@
|
|||||||
|
|
||||||
from . import shopfloor_controller
|
from . import shopfloor_controller
|
||||||
from . import manager_controller
|
from . import manager_controller
|
||||||
|
from . import tank_status
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
#
|
||||||
|
# /fp/tank/<id> — 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/<int:tank_id>',
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -16,10 +16,12 @@ import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
|
|||||||
import { registry } from "@web/core/registry";
|
import { registry } from "@web/core/registry";
|
||||||
import { rpc } from "@web/core/network/rpc";
|
import { rpc } from "@web/core/network/rpc";
|
||||||
import { useService } from "@web/core/utils/hooks";
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { QrScanner } from "./qr_scanner";
|
||||||
|
|
||||||
export class ManagerDashboard extends Component {
|
export class ManagerDashboard extends Component {
|
||||||
static template = "fusion_plating_shopfloor.ManagerDashboard";
|
static template = "fusion_plating_shopfloor.ManagerDashboard";
|
||||||
static props = ["*"];
|
static props = ["*"];
|
||||||
|
static components = { QrScanner };
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
this.notification = useService("notification");
|
this.notification = useService("notification");
|
||||||
|
|||||||
@@ -22,10 +22,12 @@ import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
|
|||||||
import { registry } from "@web/core/registry";
|
import { registry } from "@web/core/registry";
|
||||||
import { rpc } from "@web/core/network/rpc";
|
import { rpc } from "@web/core/network/rpc";
|
||||||
import { useService } from "@web/core/utils/hooks";
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { QrScanner } from "./qr_scanner";
|
||||||
|
|
||||||
export class PlantOverview extends Component {
|
export class PlantOverview extends Component {
|
||||||
static template = "fusion_plating_shopfloor.PlantOverview";
|
static template = "fusion_plating_shopfloor.PlantOverview";
|
||||||
static props = ["*"];
|
static props = ["*"];
|
||||||
|
static components = { QrScanner };
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
this.notification = useService("notification");
|
this.notification = useService("notification");
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ import { Component, useState, onMounted } from "@odoo/owl";
|
|||||||
import { registry } from "@web/core/registry";
|
import { registry } from "@web/core/registry";
|
||||||
import { rpc } from "@web/core/network/rpc";
|
import { rpc } from "@web/core/network/rpc";
|
||||||
import { useService } from "@web/core/utils/hooks";
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { QrScanner } from "./qr_scanner";
|
||||||
|
|
||||||
export class ProcessTree extends Component {
|
export class ProcessTree extends Component {
|
||||||
static template = "fusion_plating_shopfloor.ProcessTree";
|
static template = "fusion_plating_shopfloor.ProcessTree";
|
||||||
static props = ["*"];
|
static props = ["*"];
|
||||||
|
static components = { QrScanner };
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
this.notification = useService("notification");
|
this.notification = useService("notification");
|
||||||
|
|||||||
@@ -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 <video> 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/<id> (or /fp/wo/<id> 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 <video>
|
||||||
|
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/<id> and /fp/wo/<id>
|
||||||
|
* (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" });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,10 +19,12 @@ import { Component, useState, onMounted, onWillUnmount, useRef } from "@odoo/owl
|
|||||||
import { registry } from "@web/core/registry";
|
import { registry } from "@web/core/registry";
|
||||||
import { rpc } from "@web/core/network/rpc";
|
import { rpc } from "@web/core/network/rpc";
|
||||||
import { useService } from "@web/core/utils/hooks";
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { QrScanner } from "./qr_scanner";
|
||||||
|
|
||||||
export class ShopfloorTablet extends Component {
|
export class ShopfloorTablet extends Component {
|
||||||
static template = "fusion_plating_shopfloor.ShopfloorTablet";
|
static template = "fusion_plating_shopfloor.ShopfloorTablet";
|
||||||
static props = ["*"];
|
static props = ["*"];
|
||||||
|
static components = { QrScanner };
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
this.notification = useService("notification");
|
this.notification = useService("notification");
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ $pt-line-width : 2px;
|
|||||||
top: 0;
|
top: 0;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
}
|
}
|
||||||
|
.o_fp_pt_header_actions {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $fp-space-2;
|
||||||
|
}
|
||||||
.o_fp_pt_back {
|
.o_fp_pt_back {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/<id>. 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@
|
|||||||
t-att-disabled="state.isFetching">
|
t-att-disabled="state.isFetching">
|
||||||
<i t-att-class="'fa fa-refresh' + (state.isFetching ? ' fa-spin' : '')"/>
|
<i t-att-class="'fa fa-refresh' + (state.isFetching ? ' fa-spin' : '')"/>
|
||||||
</button>
|
</button>
|
||||||
|
<QrScanner cssClass="'btn'"/>
|
||||||
<button t-att-class="'btn ' + (state.mode === 'quick' ? 'btn-primary' : '')"
|
<button t-att-class="'btn ' + (state.mode === 'quick' ? 'btn-primary' : '')"
|
||||||
t-on-click="toggleMode">
|
t-on-click="toggleMode">
|
||||||
<t t-if="state.mode === 'quick'">Quick View</t>
|
<t t-if="state.mode === 'quick'">Quick View</t>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
title="Refresh">
|
title="Refresh">
|
||||||
<i t-att-class="state.loading ? 'fa fa-spinner fa-spin' : 'fa fa-refresh'"/>
|
<i t-att-class="state.loading ? 'fa fa-spinner fa-spin' : 'fa fa-refresh'"/>
|
||||||
</button>
|
</button>
|
||||||
|
<QrScanner cssClass="'btn btn-outline-secondary'"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,9 @@
|
|||||||
<span t-if="state.recipe"> · <i class="fa fa-flask me-1"/><t t-esc="state.recipe"/></span>
|
<span t-if="state.recipe"> · <i class="fa fa-flask me-1"/><t t-esc="state.recipe"/></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="o_fp_pt_header_actions">
|
||||||
|
<QrScanner cssClass="'btn btn-outline-secondary'"/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ========== LOADING ========== -->
|
<!-- ========== LOADING ========== -->
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc. · License OPL-1
|
||||||
|
Fusion Plating — Reusable QR Scanner template
|
||||||
|
-->
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
<t t-name="fusion_plating_shopfloor.QrScanner">
|
||||||
|
<button t-att-class="props.cssClass + ' o_fp_qr_btn'"
|
||||||
|
t-on-click="() => this.open()">
|
||||||
|
<i class="fa fa-qrcode me-1"/>
|
||||||
|
<t t-esc="props.label"/>
|
||||||
|
</button>
|
||||||
|
<div t-if="state.open" class="o_fp_qr_modal_backdrop"
|
||||||
|
t-on-click="close">
|
||||||
|
<div class="o_fp_qr_modal" t-on-click.stop="">
|
||||||
|
<div class="o_fp_qr_modal_head">
|
||||||
|
<h3>Scan job QR</h3>
|
||||||
|
<button class="btn btn-sm btn-light" t-on-click="close">
|
||||||
|
<i class="fa fa-times"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_qr_modal_body">
|
||||||
|
<div t-if="!state.supportsBarcode and !state.error"
|
||||||
|
class="o_fp_qr_warn">
|
||||||
|
Live decoding isn't supported on this browser.
|
||||||
|
Paste the URL below.
|
||||||
|
</div>
|
||||||
|
<video t-if="state.supportsBarcode" t-ref="video"
|
||||||
|
class="o_fp_qr_video" muted="true" playsinline="true"/>
|
||||||
|
<div t-if="state.error" class="o_fp_qr_error">
|
||||||
|
<i class="fa fa-exclamation-triangle me-1"/>
|
||||||
|
<span t-esc="state.error"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_qr_manual">
|
||||||
|
<label class="form-label">Or paste sticker URL</label>
|
||||||
|
<input class="form-control" t-model="state.manualUrl"
|
||||||
|
placeholder="https://entech/.../fp/job/123"
|
||||||
|
t-on-keyup="(e) => e.key === 'Enter' && this.onManualSubmit()"/>
|
||||||
|
<button class="btn btn-primary mt-2"
|
||||||
|
t-on-click="() => this.onManualSubmit()">Open</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@@ -42,8 +42,9 @@
|
|||||||
</t>
|
</t>
|
||||||
</select>
|
</select>
|
||||||
<button class="o_fp_scan_toggle" t-on-click="toggleScan">
|
<button class="o_fp_scan_toggle" t-on-click="toggleScan">
|
||||||
<i class="fa fa-qrcode me-1"/>Scan
|
<i class="fa fa-qrcode me-1"/>Code
|
||||||
</button>
|
</button>
|
||||||
|
<QrScanner cssClass="'o_fp_scan_toggle'" label="'Camera'"/>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc. · License OPL-1
|
||||||
|
Fusion Plating — Tank Status (NFC tap-to-view) page
|
||||||
|
|
||||||
|
Rendered by /fp/tank/<id>. Mobile-first layout with big touch
|
||||||
|
targets so an operator can read the tank's current state from a
|
||||||
|
phone after tapping the NFC tag.
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
<template id="tank_status_page">
|
||||||
|
<t t-call="web.frontend_layout">
|
||||||
|
<t t-set="head">
|
||||||
|
<meta name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1, viewport-fit=cover"/>
|
||||||
|
</t>
|
||||||
|
<div class="o_fp_tank_status">
|
||||||
|
<header class="o_fp_tank_head">
|
||||||
|
<h1>
|
||||||
|
<i class="fa fa-flask"/>
|
||||||
|
<span t-esc="tank.name"/>
|
||||||
|
</h1>
|
||||||
|
<div class="o_fp_tank_meta">
|
||||||
|
<span t-if="tank.code"><strong>Code:</strong> <span t-esc="tank.code"/></span>
|
||||||
|
<span t-if="tank.current_bath_id"><strong>Bath:</strong> <span t-esc="tank.current_bath_id.name"/></span>
|
||||||
|
<span t-if="tank.facility_id"><strong>Facility:</strong> <span t-esc="tank.facility_id.name"/></span>
|
||||||
|
<span t-if="tank.work_center_id"><strong>Work Centre:</strong> <span t-esc="tank.work_center_id.name"/></span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="o_fp_tank_section o_fp_tank_section_active">
|
||||||
|
<h2>
|
||||||
|
<i t-if="active_step" class="fa fa-cog fa-spin"/>
|
||||||
|
<i t-if="not active_step" class="fa fa-circle-o"/>
|
||||||
|
Current Job
|
||||||
|
</h2>
|
||||||
|
<div t-if="active_step" class="o_fp_tank_card">
|
||||||
|
<div class="o_fp_tank_card_title">
|
||||||
|
<strong><span t-esc="active_step.job_id.name"/></strong>
|
||||||
|
<span class="o_fp_state_badge"
|
||||||
|
t-att-data-state="active_step.state">
|
||||||
|
<span t-esc="active_step.state"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_tank_card_meta">
|
||||||
|
<span>
|
||||||
|
<strong>Customer:</strong>
|
||||||
|
<span t-esc="active_step.job_id.partner_id.name"/>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong>Step:</strong>
|
||||||
|
<span t-esc="active_step.name"/>
|
||||||
|
</span>
|
||||||
|
<span t-if="active_step.assigned_user_id">
|
||||||
|
<strong>Operator:</strong>
|
||||||
|
<span t-esc="active_step.assigned_user_id.name"/>
|
||||||
|
</span>
|
||||||
|
<span t-if="active_step.duration_expected">
|
||||||
|
<strong>Expected:</strong>
|
||||||
|
<span t-esc="int(active_step.duration_expected)"/> min
|
||||||
|
</span>
|
||||||
|
<span t-if="active_step.thickness_target">
|
||||||
|
<strong>Target thickness:</strong>
|
||||||
|
<span t-esc="active_step.thickness_target"/>
|
||||||
|
<span t-esc="active_step.thickness_uom or ''"/>
|
||||||
|
</span>
|
||||||
|
<span t-if="active_step.date_started">
|
||||||
|
<strong>Started:</strong>
|
||||||
|
<span t-esc="active_step.date_started"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div t-if="not active_step" class="o_fp_tank_empty">
|
||||||
|
Tank is idle.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="o_fp_tank_section">
|
||||||
|
<h2><i class="fa fa-clock-o"/>Up Next</h2>
|
||||||
|
<div t-if="ready_steps" class="o_fp_tank_list">
|
||||||
|
<t t-foreach="ready_steps" t-as="step">
|
||||||
|
<div class="o_fp_tank_card o_fp_tank_card_compact">
|
||||||
|
<strong><span t-esc="step.job_id.name"/></strong>
|
||||||
|
<span class="o_fp_tank_card_sub">
|
||||||
|
<span t-esc="step.job_id.partner_id.name"/>
|
||||||
|
· <span t-esc="step.name"/>
|
||||||
|
<t t-if="step.duration_expected">
|
||||||
|
· <span t-esc="int(step.duration_expected)"/> min
|
||||||
|
</t>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<div t-if="not ready_steps" class="o_fp_tank_empty">
|
||||||
|
No queued work for this tank.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section t-if="bath_log" class="o_fp_tank_section">
|
||||||
|
<h2><i class="fa fa-tint"/>Bath Chemistry</h2>
|
||||||
|
<div class="o_fp_tank_card">
|
||||||
|
<div class="o_fp_tank_card_meta">
|
||||||
|
<span>
|
||||||
|
<strong>Status:</strong>
|
||||||
|
<span class="o_fp_state_badge"
|
||||||
|
t-att-data-state="bath_log.status"
|
||||||
|
t-esc="bath_log.status or '—'"/>
|
||||||
|
</span>
|
||||||
|
<span t-if="bath_log.log_date">
|
||||||
|
<strong>Last sampled:</strong>
|
||||||
|
<span t-esc="bath_log.log_date"/>
|
||||||
|
</span>
|
||||||
|
<span t-if="bath_log.operator_id">
|
||||||
|
<strong>Sampled by:</strong>
|
||||||
|
<span t-esc="bath_log.operator_id.name"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div t-if="bath_log.line_ids"
|
||||||
|
class="o_fp_tank_chem_grid">
|
||||||
|
<t t-foreach="bath_log.line_ids" t-as="line">
|
||||||
|
<div class="o_fp_tank_chem_cell"
|
||||||
|
t-att-data-status="line.status">
|
||||||
|
<div class="o_fp_tank_chem_label">
|
||||||
|
<span t-esc="line.parameter_id.name or line.parameter_code or '—'"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_tank_chem_value">
|
||||||
|
<span t-esc="line.value"/>
|
||||||
|
<small t-if="line.uom" t-esc="line.uom"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_tank_chem_range"
|
||||||
|
t-if="line.target_min or line.target_max">
|
||||||
|
target
|
||||||
|
<t t-if="line.target_min"><span t-esc="line.target_min"/></t>
|
||||||
|
<t t-if="line.target_min and line.target_max"> – </t>
|
||||||
|
<t t-if="line.target_max"><span t-esc="line.target_max"/></t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="o_fp_tank_foot">
|
||||||
|
<p>Tap the NFC tag again or scan a part-box QR for job details.</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template id="tank_status_not_found">
|
||||||
|
<t t-call="web.frontend_layout">
|
||||||
|
<div class="o_fp_tank_status">
|
||||||
|
<header class="o_fp_tank_head">
|
||||||
|
<h1>
|
||||||
|
<i class="fa fa-exclamation-triangle"/>
|
||||||
|
Tank not found
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
<section class="o_fp_tank_section">
|
||||||
|
<p>No tank with id <strong t-esc="tank_id"/>.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</template>
|
||||||
|
</odoo>
|
||||||
Reference in New Issue
Block a user