changes
This commit is contained in:
@@ -148,22 +148,17 @@ class IrActionsActWindow(models.Model):
|
|||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def _fp_workstation_action_for_layout(self, company):
|
def _fp_workstation_action_for_layout(self, company):
|
||||||
"""Single source of truth: which Shop Floor surface is active on
|
"""Resolve the Shop Floor surface for technicians + shop managers.
|
||||||
this DB?
|
|
||||||
|
|
||||||
``ir.config_parameter['fusion_plating_shopfloor.layout']`` is the
|
Returns ``action_fp_plant_kanban`` (the 2026-05-23 plant view).
|
||||||
feature flag. Flipping it instantly retargets every Technician /
|
The legacy ``fp_shopfloor_landing`` component was retired
|
||||||
Shop Manager landing on next page load.
|
2026-05-25 (one feature ported across — the inline QR scanner).
|
||||||
|
The ``fusion_plating_shopfloor.layout`` ir.config_parameter
|
||||||
|
survives orphaned for one release cycle so we can ship a
|
||||||
|
settings-UI cleanup separately; flipping it has no effect.
|
||||||
"""
|
"""
|
||||||
param = self.env['ir.config_parameter'].sudo().get_param(
|
|
||||||
'fusion_plating_shopfloor.layout', 'v2')
|
|
||||||
if param == 'v2':
|
|
||||||
return self.env.ref(
|
|
||||||
'fusion_plating_shopfloor.action_fp_plant_kanban',
|
|
||||||
raise_if_not_found=False,
|
|
||||||
)
|
|
||||||
return self.env.ref(
|
return self.env.ref(
|
||||||
'fusion_plating_shopfloor.action_fp_shopfloor_landing',
|
'fusion_plating_shopfloor.action_fp_plant_kanban',
|
||||||
raise_if_not_found=False,
|
raise_if_not_found=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -119,21 +119,19 @@ class TestLandingResolver(TransactionCase):
|
|||||||
self.skipTest('Plant Kanban action not found')
|
self.skipTest('Plant Kanban action not found')
|
||||||
self.assertEqual(self._resolve_xmlid(self.u_tech), expected)
|
self.assertEqual(self._resolve_xmlid(self.u_tech), expected)
|
||||||
|
|
||||||
def test_technician_lands_on_legacy_workstation(self):
|
def test_technician_lands_on_plant_kanban_regardless_of_legacy_flag(self):
|
||||||
|
"""The legacy 'fp_shopfloor_landing' component was retired
|
||||||
|
2026-05-25. The ``fusion_plating_shopfloor.layout`` flag is now
|
||||||
|
orphaned (kept in res.config.settings for one release cycle) and
|
||||||
|
flipping it must NOT change the landing — every technician lands
|
||||||
|
on the plant kanban."""
|
||||||
self.env['ir.config_parameter'].sudo().set_param(
|
self.env['ir.config_parameter'].sudo().set_param(
|
||||||
'fusion_plating_shopfloor.layout', 'legacy')
|
'fusion_plating_shopfloor.layout', 'legacy')
|
||||||
expected = self._xmlid_of('fusion_plating_shopfloor.action_fp_shopfloor_landing')
|
expected = self._xmlid_of('fusion_plating_shopfloor.action_fp_plant_kanban')
|
||||||
if not expected:
|
if not expected:
|
||||||
# The legacy action is currently not defined by that xmlid
|
self.skipTest('Plant Kanban action not found')
|
||||||
# in this codebase — both old XMLIDs (action_fp_shopfloor_tablet
|
|
||||||
# and action_fp_plant_overview) point at the v2 fp_plant_kanban
|
|
||||||
# tag after the 2026-05-23 plant-view redesign. The resolver
|
|
||||||
# falls through to the company default / hardcoded fallback
|
|
||||||
# when no action is found. Skip the assertion here rather
|
|
||||||
# than fail.
|
|
||||||
self.skipTest('Legacy Workstation action not found in this DB')
|
|
||||||
self.assertEqual(self._resolve_xmlid(self.u_tech), expected)
|
self.assertEqual(self._resolve_xmlid(self.u_tech), expected)
|
||||||
# Reset to v2 to avoid bleeding into other tests
|
# Reset for downstream tests
|
||||||
self.env['ir.config_parameter'].sudo().set_param(
|
self.env['ir.config_parameter'].sudo().set_param(
|
||||||
'fusion_plating_shopfloor.layout', 'v2')
|
'fusion_plating_shopfloor.layout', 'v2')
|
||||||
|
|
||||||
|
|||||||
@@ -21,13 +21,13 @@
|
|||||||
</record>
|
</record>
|
||||||
|
|
||||||
<!-- Phase 3 tablet redesign (2026-05-22) — the standalone Plant
|
<!-- Phase 3 tablet redesign (2026-05-22) — the standalone Plant
|
||||||
Overview menu is superseded by Workstation > All Plant toggle.
|
Overview menu was superseded by the single Workstation entry.
|
||||||
The fp_menu.xml record was removed but the database row
|
The original <delete> directive lived here; it was retired
|
||||||
persists (Odoo doesn't auto-delete orphan records). Force-
|
2026-05-25 because the menu row had been gone for weeks and the
|
||||||
delete here so the menu disappears from the Shop Floor tree.
|
re-run of `<delete model="ir.ui.menu" ref="...">` against a
|
||||||
The action record (action_fp_plant_overview) is kept and
|
missing xmlid raises ValueError on every -u. The action record
|
||||||
retargeted to fp_shopfloor_landing for bookmark back-compat. -->
|
(action_fp_plant_overview) is kept and retargeted to
|
||||||
<delete model="ir.ui.menu" id="fusion_plating_shopfloor.menu_fp_shopfloor_plant_overview"/>
|
fp_plant_kanban for bookmark back-compat. -->
|
||||||
|
|
||||||
<!-- bridge_mrp Production Priorities reference removed post-Sub 11
|
<!-- bridge_mrp Production Priorities reference removed post-Sub 11
|
||||||
(the bridge module is uninstalled and its menu xmlid no longer
|
(the bridge module is uninstalled and its menu xmlid no longer
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Shop Floor',
|
'name': 'Fusion Plating — Shop Floor',
|
||||||
'version': '19.0.35.0.0',
|
'version': '19.0.36.0.3',
|
||||||
'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.',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -92,7 +92,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
# browser re-bootstraps under the kiosk.
|
# browser re-bootstraps under the kiosk.
|
||||||
'fusion_plating_shopfloor/static/src/js/services/tablet_session_manager.js',
|
'fusion_plating_shopfloor/static/src/js/services/tablet_session_manager.js',
|
||||||
# Phase 6.3 — fpRpc wrapper. MUST load before any consumer
|
# Phase 6.3 — fpRpc wrapper. MUST load before any consumer
|
||||||
# (job_workspace, shopfloor_landing, manager_dashboard,
|
# (job_workspace, plant_kanban, manager_dashboard,
|
||||||
# hold_composer) so `import { fpRpc }` resolves.
|
# hold_composer) so `import { fpRpc }` resolves.
|
||||||
'fusion_plating_shopfloor/static/src/js/services/fp_rpc.js',
|
'fusion_plating_shopfloor/static/src/js/services/fp_rpc.js',
|
||||||
'fusion_plating_shopfloor/static/src/scss/components/_pin_pad.scss',
|
'fusion_plating_shopfloor/static/src/scss/components/_pin_pad.scss',
|
||||||
@@ -123,11 +123,11 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
# ---- Finish block dialog (required-inputs gate + manager bypass) ----
|
# ---- Finish block dialog (required-inputs gate + manager bypass) ----
|
||||||
'fusion_plating_shopfloor/static/src/xml/fp_finish_block_dialog.xml',
|
'fusion_plating_shopfloor/static/src/xml/fp_finish_block_dialog.xml',
|
||||||
'fusion_plating_shopfloor/static/src/js/fp_finish_block_dialog.js',
|
'fusion_plating_shopfloor/static/src/js/fp_finish_block_dialog.js',
|
||||||
# ---- Shop Floor Landing (Phase 3 — tablet redesign) ----
|
|
||||||
'fusion_plating_shopfloor/static/src/scss/shopfloor_landing.scss',
|
|
||||||
'fusion_plating_shopfloor/static/src/xml/shopfloor_landing.xml',
|
|
||||||
'fusion_plating_shopfloor/static/src/js/shopfloor_landing.js',
|
|
||||||
# ---- Plant View Kanban (2026-05-23 redesign) ---------------
|
# ---- Plant View Kanban (2026-05-23 redesign) ---------------
|
||||||
|
# 2026-05-25 — fp_shopfloor_landing (Phase 3 component) retired.
|
||||||
|
# The inline QR scanner was ported into plant_kanban; every
|
||||||
|
# other surface (kanban layout, mode toggle, search, drag-drop)
|
||||||
|
# is now plant_kanban's exclusive domain.
|
||||||
# Tokens MUST load first (project rule 8: SCSS @import is
|
# Tokens MUST load first (project rule 8: SCSS @import is
|
||||||
# forbidden in Odoo 19 custom code; manifest order is the
|
# forbidden in Odoo 19 custom code; manifest order is the
|
||||||
# concatenation order, and tokens carry the $plant-* vars
|
# concatenation order, and tokens carry the $plant-* vars
|
||||||
|
|||||||
@@ -177,6 +177,37 @@ class PlantKanbanController(http.Controller):
|
|||||||
'cards': cards,
|
'cards': cards,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Pair the current user to a work centre (used by station-QR scan from
|
||||||
|
# plant_kanban). 2026-05-25 — replaces the legacy
|
||||||
|
# localStorage-based pairing that shopfloor_landing used; plant_kanban
|
||||||
|
# reads the pairing from res.users.paired_work_centre_ids server-side,
|
||||||
|
# so QR scans must persist there.
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
@http.route('/fp/landing/pair_work_centre', type='jsonrpc', auth='user')
|
||||||
|
def pair_work_centre(self, work_centre_id=None):
|
||||||
|
"""Set env.user.paired_work_centre_ids to [work_centre_id], or clear.
|
||||||
|
|
||||||
|
MVP holds exactly one row in the M2M (the legacy single-station
|
||||||
|
picker). Pass work_centre_id=None to unpair.
|
||||||
|
"""
|
||||||
|
user = request.env.user.sudo()
|
||||||
|
if 'paired_work_centre_ids' not in user._fields:
|
||||||
|
return {'ok': False, 'error': 'paired_work_centre_ids not supported'}
|
||||||
|
try:
|
||||||
|
if work_centre_id:
|
||||||
|
wc_id = int(work_centre_id)
|
||||||
|
# Verify the work centre exists so we don't write garbage
|
||||||
|
if not request.env['fp.work.centre'].sudo().browse(wc_id).exists():
|
||||||
|
return {'ok': False, 'error': 'work centre not found'}
|
||||||
|
user.write({'paired_work_centre_ids': [(6, 0, [wc_id])]})
|
||||||
|
else:
|
||||||
|
user.write({'paired_work_centre_ids': [(5,)]})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning('pair_work_centre failed: %s', e)
|
||||||
|
return {'ok': False, 'error': str(e)}
|
||||||
|
return {'ok': True}
|
||||||
|
|
||||||
|
|
||||||
# ===== helpers ==========================================================
|
# ===== helpers ==========================================================
|
||||||
|
|
||||||
|
|||||||
@@ -198,9 +198,15 @@ class FpTabletController(http.Controller):
|
|||||||
}
|
}
|
||||||
if not (shop_branch_ids & set(target.all_group_ids.ids)):
|
if not (shop_branch_ids & set(target.all_group_ids.ids)):
|
||||||
return {'ok': False, 'error': 'no_role'}
|
return {'ok': False, 'error': 'no_role'}
|
||||||
# Resolve recipient email — login if email-shaped, else partner.email
|
# Resolve recipient email — MUST mirror the mail.template's
|
||||||
email = (target.login if target.login and '@' in target.login
|
# `{{ object.email or object.login }}` priority so the masked
|
||||||
else (target.partner_id.email or ''))
|
# address shown in the UI matches where the email actually
|
||||||
|
# lands. (Pre-2026-05-25 these two were inverted: the controller
|
||||||
|
# showed login but the template delivered to email. Operators
|
||||||
|
# ended up checking the wrong inbox.)
|
||||||
|
email = (target.email if target.email and '@' in target.email
|
||||||
|
else (target.login if target.login and '@' in target.login
|
||||||
|
else ''))
|
||||||
if not email:
|
if not email:
|
||||||
owner = env['res.company'].browse(env.user.company_id.id).sudo()
|
owner = env['res.company'].browse(env.user.company_id.id).sudo()
|
||||||
owner_name = (owner.x_fc_owner_user_id.name
|
owner_name = (owner.x_fc_owner_user_id.name
|
||||||
@@ -231,9 +237,14 @@ class FpTabletController(http.Controller):
|
|||||||
'error': 'rate_limited',
|
'error': 'rate_limited',
|
||||||
'wait_minutes': wait_min,
|
'wait_minutes': wait_min,
|
||||||
}
|
}
|
||||||
# Render the email directly with code in context — the
|
# Render + send IMMEDIATELY (force_send=True). The legacy
|
||||||
# _dispatch path doesn't yet propagate ctx.code into the
|
# `force_send=False` queued the mail for the `Mail: Email Queue
|
||||||
# mail.template render, so direct send_mail is the safe path.
|
# Manager` cron — which runs every 1 HOUR on entech (not per
|
||||||
|
# minute), so a tech tapping "Send temporary PIN" could wait up
|
||||||
|
# to 60 min for the code. PIN reset is an interactive flow; the
|
||||||
|
# user is staring at the screen. Synchronous send adds ~1s of
|
||||||
|
# latency but the code lands in the inbox before the user can
|
||||||
|
# tab to their email.
|
||||||
try:
|
try:
|
||||||
mail_template = env.ref(
|
mail_template = env.ref(
|
||||||
'fusion_plating_shopfloor.fp_mail_template_tablet_pin_reset',
|
'fusion_plating_shopfloor.fp_mail_template_tablet_pin_reset',
|
||||||
@@ -241,7 +252,7 @@ class FpTabletController(http.Controller):
|
|||||||
)
|
)
|
||||||
if mail_template:
|
if mail_template:
|
||||||
mail_template.sudo().with_context(code=code).send_mail(
|
mail_template.sudo().with_context(code=code).send_mail(
|
||||||
target.id, force_send=False,
|
target.id, force_send=True,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_logger.warning(
|
_logger.warning(
|
||||||
|
|||||||
@@ -15,12 +15,23 @@
|
|||||||
-->
|
-->
|
||||||
<odoo noupdate="1">
|
<odoo noupdate="1">
|
||||||
|
|
||||||
<!-- ===== Mail template ============================================ -->
|
<!-- ===== Mail template ============================================
|
||||||
|
email_from is computed at send time (see _fp_resolve_from_header
|
||||||
|
on res.users) so it ALWAYS matches the active mail server's
|
||||||
|
from_filter — eliminates the "No mail server matches the
|
||||||
|
from_filter" warning and the DMARC misalignment that warning
|
||||||
|
signals. Without this alignment, M365 (nexasystems.ca's host)
|
||||||
|
greylists cross-provider mail with mismatched From for 5–15 min
|
||||||
|
before delivering. Subject dropped the 🔒 emoji — emojis in
|
||||||
|
subject lines bump M365 spam scoring and slow delivery on
|
||||||
|
cross-provider mail.
|
||||||
|
-->
|
||||||
<record id="fp_mail_template_tablet_pin_reset" model="mail.template">
|
<record id="fp_mail_template_tablet_pin_reset" model="mail.template">
|
||||||
<field name="name">FP: Tablet PIN Reset Code</field>
|
<field name="name">FP: Tablet PIN Reset Code</field>
|
||||||
<field name="model_id" ref="base.model_res_users"/>
|
<field name="model_id" ref="base.model_res_users"/>
|
||||||
<field name="subject">🔒 Your ENTECH tablet temporary PIN: {{ ctx.get('code', '----') }}</field>
|
<field name="subject">Your ENTECH tablet temporary PIN: {{ ctx.get('code', '----') }}</field>
|
||||||
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
|
<field name="email_from">{{ object._fp_resolve_from_header() }}</field>
|
||||||
|
<field name="reply_to">{{ object._fp_resolve_from_header() }}</field>
|
||||||
<field name="email_to">{{ object.email or object.login }}</field>
|
<field name="email_to">{{ object.email or object.login }}</field>
|
||||||
<field name="auto_delete" eval="True"/>
|
<field name="auto_delete" eval="True"/>
|
||||||
<field name="body_html" type="html">
|
<field name="body_html" type="html">
|
||||||
|
|||||||
@@ -221,3 +221,30 @@ class ResUsers(models.Model):
|
|||||||
'mfa': 'default',
|
'mfa': 'default',
|
||||||
}
|
}
|
||||||
return super()._check_credentials(credential, env)
|
return super()._check_credentials(credential, env)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# _fp_resolve_from_header — used by mail.template email_from / reply_to
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Picks the From address that matches the active outbound mail server's
|
||||||
|
# from_filter, so the message goes out perfectly aligned for SPF +
|
||||||
|
# DKIM + DMARC. Mismatched From triggers M365 greylisting (5–15 min
|
||||||
|
# delivery delay) on cross-provider mail — the user feels this as
|
||||||
|
# "the email takes a while." Mail-server lookups need sudo; the kiosk
|
||||||
|
# session calling the template has no read on ir.mail_server. Falls
|
||||||
|
# back to res.company.email if no usable mail server is configured.
|
||||||
|
def _fp_resolve_from_header(self):
|
||||||
|
self.ensure_one()
|
||||||
|
Server = self.env['ir.mail_server'].sudo()
|
||||||
|
srv = Server.search([('active', '=', True)],
|
||||||
|
order='sequence asc, id asc', limit=1)
|
||||||
|
if srv and srv.from_filter and '@' in srv.from_filter:
|
||||||
|
# from_filter can be 'user@domain' OR a domain like '*@domain' /
|
||||||
|
# 'domain' — only the exact-address form is safe to use as From.
|
||||||
|
ff = srv.from_filter.strip()
|
||||||
|
if not ff.startswith('*') and ' ' not in ff:
|
||||||
|
return ff
|
||||||
|
if srv and srv.smtp_user and '@' in srv.smtp_user:
|
||||||
|
return srv.smtp_user
|
||||||
|
# Last-ditch fallback — preserves the legacy behaviour for any
|
||||||
|
# environment that has no mail server configured.
|
||||||
|
return self.company_id.email or self.email or ''
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Force-send a PIN reset email synchronously to see the SMTP error.
|
||||||
|
|
||||||
|
Run via odoo-shell on entech.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
_logger = logging.getLogger('bt_pin_send_debug')
|
||||||
|
|
||||||
|
# Garry (uid=2) — has gs@nexasystems.ca
|
||||||
|
u = env['res.users'].sudo().browse(2)
|
||||||
|
print('user:', u.name, '| email:', u.email, '| login:', u.login)
|
||||||
|
|
||||||
|
tpl = env.ref('fusion_plating_shopfloor.fp_mail_template_tablet_pin_reset')
|
||||||
|
print('template found:', tpl.name)
|
||||||
|
print('template email_from raw:', repr(tpl.email_from))
|
||||||
|
print('template email_to raw:', repr(tpl.email_to))
|
||||||
|
|
||||||
|
# Render to see what gets put on mail.mail
|
||||||
|
vals = tpl.with_context(code='5555')._generate_template(
|
||||||
|
[u.id],
|
||||||
|
{'email_to', 'email_from', 'partner_to', 'subject'},
|
||||||
|
)
|
||||||
|
print('--- rendered ---')
|
||||||
|
import json
|
||||||
|
print(json.dumps({str(k): str(v)[:200] if not isinstance(v, dict) else {kk: (list(vv) if hasattr(vv, '__iter__') and not isinstance(vv, str) else str(vv)[:200]) for kk, vv in v.items()} for k, v in vals.items()}, indent=2, default=str))
|
||||||
|
|
||||||
|
# Generate a real reset code + send via the same path the controller uses
|
||||||
|
Reset = env['fp.tablet.pin.reset'].sudo()
|
||||||
|
old = Reset.search([('user_id', '=', u.id), ('used_at', '=', False)])
|
||||||
|
if old:
|
||||||
|
print('purging', len(old), 'stale active reset rows')
|
||||||
|
old.unlink()
|
||||||
|
rec, code = Reset._generate_for_user(u, requester_ip='127.0.0.1')
|
||||||
|
print('generated code:', code, '(reset id', rec.id, ')')
|
||||||
|
|
||||||
|
# Send WITHOUT force_send first (matches controller), then peek at outbox
|
||||||
|
tpl.with_context(code=code).send_mail(u.id, force_send=False)
|
||||||
|
queued = env['mail.mail'].sudo().search(
|
||||||
|
[('mail_message_id.subject', 'like', '%ENTECH tablet temporary PIN%'),
|
||||||
|
('state', '=', 'outgoing')],
|
||||||
|
order='id desc', limit=1,
|
||||||
|
)
|
||||||
|
print('queued mail.mail id:', queued.id if queued else None,
|
||||||
|
'| state:', queued.state if queued else None,
|
||||||
|
'| email_to:', repr(queued.email_to) if queued else None,
|
||||||
|
'| recipients:', queued.recipient_ids.mapped('email') if queued else None,
|
||||||
|
'| email_from:', queued.email_from if queued else None)
|
||||||
|
|
||||||
|
# Now force-send and surface ANY SMTP error
|
||||||
|
if queued:
|
||||||
|
try:
|
||||||
|
print('--- attempting synchronous send ---')
|
||||||
|
queued.send(raise_exception=True)
|
||||||
|
print('queued.send() returned without exception')
|
||||||
|
except Exception as e:
|
||||||
|
print('SEND FAILED:', type(e).__name__, str(e)[:600])
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
# Re-check state (might be deleted on success, or marked exception on fail)
|
||||||
|
try:
|
||||||
|
queued.invalidate_recordset()
|
||||||
|
print('after-send state:', queued.state, '| failure:', queued.failure_reason)
|
||||||
|
except Exception as e:
|
||||||
|
print('row deleted (auto_delete) -- send was treated as success:', type(e).__name__)
|
||||||
|
|
||||||
|
env.cr.commit()
|
||||||
|
print('--- done ---')
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Verify the new _fp_resolve_from_header helper + rewrite the
|
||||||
|
mail.template record so it uses the helper (XML has noupdate=1 so -u
|
||||||
|
doesn't pick up the data-file change on existing installs).
|
||||||
|
|
||||||
|
Run via odoo-shell after `-u fusion_plating_shopfloor`.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
u = env['res.users'].sudo().browse(2)
|
||||||
|
print('===== Helper resolution =====')
|
||||||
|
print('resolved from_header:', u._fp_resolve_from_header())
|
||||||
|
|
||||||
|
print()
|
||||||
|
print('===== Current template state =====')
|
||||||
|
tpl = env.ref('fusion_plating_shopfloor.fp_mail_template_tablet_pin_reset')
|
||||||
|
print(' subject: ', str(tpl.subject))
|
||||||
|
print(' email_from: ', str(tpl.email_from))
|
||||||
|
print(' reply_to: ', str(tpl.reply_to or '<empty>'))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print('===== Rewriting template via ORM =====')
|
||||||
|
new_subject = "Your ENTECH tablet temporary PIN: {{ ctx.get('code', '----') }}"
|
||||||
|
new_from = "{{ object._fp_resolve_from_header() }}"
|
||||||
|
tpl.sudo().write({
|
||||||
|
'subject': new_subject,
|
||||||
|
'email_from': new_from,
|
||||||
|
'reply_to': new_from,
|
||||||
|
})
|
||||||
|
tpl.invalidate_recordset()
|
||||||
|
print(' subject: ', str(tpl.subject))
|
||||||
|
print(' email_from: ', str(tpl.email_from))
|
||||||
|
print(' reply_to: ', str(tpl.reply_to))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print('===== Real send: time end-to-end =====')
|
||||||
|
Reset = env['fp.tablet.pin.reset'].sudo()
|
||||||
|
Reset.search([('user_id', '=', 2), ('used_at', '=', False)]).unlink()
|
||||||
|
t0 = time.time()
|
||||||
|
rec, code = Reset._generate_for_user(u)
|
||||||
|
t_gen = time.time() - t0
|
||||||
|
t1 = time.time()
|
||||||
|
tpl.sudo().with_context(code=code).send_mail(u.id, force_send=True)
|
||||||
|
t_send = time.time() - t1
|
||||||
|
t_total = time.time() - t0
|
||||||
|
print(' new code: ', code)
|
||||||
|
print(' _generate_for_user: {:.3f}s'.format(t_gen))
|
||||||
|
print(' send_mail (force_send): {:.3f}s'.format(t_send))
|
||||||
|
print(' TOTAL (Odoo-side): {:.3f}s'.format(t_total))
|
||||||
|
|
||||||
|
env.cr.commit()
|
||||||
|
print()
|
||||||
|
print('Watch gs@nexasystems.ca — measure wall-clock from now until it lands.')
|
||||||
@@ -162,12 +162,10 @@ export class FpJobWorkspace extends Component {
|
|||||||
onBack() {
|
onBack() {
|
||||||
// The workspace is opened with target: "current" which REPLACES
|
// The workspace is opened with target: "current" which REPLACES
|
||||||
// the current action and wipes the backstack. Navigate explicitly
|
// the current action and wipes the backstack. Navigate explicitly
|
||||||
// to the plant-view kanban — the 2026-05-23 redesigned Shop Floor
|
// to the plant kanban — the sole Shop Floor surface as of
|
||||||
// surface — instead of the deprecated fp_shopfloor_landing OWL
|
// 2026-05-25 (fp_shopfloor_landing was retired the same day).
|
||||||
// component. (Bug caught 2026-05-24: Back used to dump the user
|
// See CLAUDE.md Critical Rule 21 + the "Legacy-action redirect"
|
||||||
// into the old per-step kanban even when they entered via the
|
// section.
|
||||||
// new plant view.) See CLAUDE.md Critical Rule 21 + the
|
|
||||||
// "Legacy-action redirect" section.
|
|
||||||
this.action.doAction({
|
this.action.doAction({
|
||||||
type: "ir.actions.client",
|
type: "ir.actions.client",
|
||||||
tag: "fp_plant_kanban",
|
tag: "fp_plant_kanban",
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
/** @odoo-module **/
|
/** @odoo-module **/
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// FpPlantKanban — top-level OWL action for the 2026-05-23 redesigned
|
// FpPlantKanban — sole Shop Floor OWL action (2026-05-25 onward).
|
||||||
// Shop Floor. Mounts via the fp_plant_kanban client action; landing
|
// Mounts via the fp_plant_kanban client action. fp_shopfloor_landing
|
||||||
// resolver dispatches between this and the legacy fp_shopfloor_landing
|
// was retired the same day — its only unique feature (inline QR
|
||||||
// based on the x_fc_shopfloor_layout config parameter.
|
// scanner) was ported here.
|
||||||
//
|
//
|
||||||
// Architecture:
|
// Architecture:
|
||||||
// - Polls /fp/landing/plant_kanban every 10s
|
// - Polls /fp/landing/plant_kanban every 10s
|
||||||
// - Owns mode + filter + search state (filters persist in localStorage)
|
// - Owns mode + filter + search state (filters persist in localStorage)
|
||||||
// - 9 fixed columns; one card per fp.job
|
// - 9 fixed columns; one card per fp.job
|
||||||
|
// - Inline QR via the QrScanner camera component + a wedge/manual
|
||||||
|
// text drawer driven by /fp/shopfloor/scan
|
||||||
|
// - Station pairing writes res.users.paired_work_centre_ids via
|
||||||
|
// /fp/landing/pair_work_centre (replaces legacy localStorage
|
||||||
|
// pairing)
|
||||||
// - Per project rule 20, no String()/Number()/etc. in templates —
|
// - Per project rule 20, no String()/Number()/etc. in templates —
|
||||||
// all coercion happens here in JS-land.
|
// all coercion happens here in JS-land.
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -23,6 +28,7 @@ import { FpPlantCard } from "./components/plant_card";
|
|||||||
import { FpColumnHeader } from "./components/column_header";
|
import { FpColumnHeader } from "./components/column_header";
|
||||||
import { FpKpiTile } from "./components/kpi_tile";
|
import { FpKpiTile } from "./components/kpi_tile";
|
||||||
import { FpFilterChip } from "./components/filter_chip";
|
import { FpFilterChip } from "./components/filter_chip";
|
||||||
|
import { QrScanner } from "./qr_scanner";
|
||||||
|
|
||||||
const LOCAL_FILTER_KEY = "fp_plant_kanban_filters";
|
const LOCAL_FILTER_KEY = "fp_plant_kanban_filters";
|
||||||
|
|
||||||
@@ -35,6 +41,7 @@ export class FpPlantKanban extends Component {
|
|||||||
FpColumnHeader,
|
FpColumnHeader,
|
||||||
FpKpiTile,
|
FpKpiTile,
|
||||||
FpFilterChip,
|
FpFilterChip,
|
||||||
|
QrScanner,
|
||||||
};
|
};
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
@@ -48,6 +55,10 @@ export class FpPlantKanban extends Component {
|
|||||||
data: null,
|
data: null,
|
||||||
loading: true,
|
loading: true,
|
||||||
search: "",
|
search: "",
|
||||||
|
// QR scan drawer (text/wedge path). Camera path is owned by
|
||||||
|
// the QrScanner component itself — it routes URLs internally.
|
||||||
|
showScan: false,
|
||||||
|
scanInput: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -140,15 +151,114 @@ export class FpPlantKanban extends Component {
|
|||||||
this.tabletSessionManager.lockBack("manual");
|
this.tabletSessionManager.lockBack("manual");
|
||||||
}
|
}
|
||||||
|
|
||||||
onScanQr() {
|
// ---- QR scan (text / wedge / manual paste path) -----------------------
|
||||||
this.action.doAction({
|
// Camera path is rendered by the inline <QrScanner/> component below
|
||||||
type: "ir.actions.client",
|
// the header — it owns its own modal + decoder + URL routing. This
|
||||||
tag: "fp_qr_scanner",
|
// drawer is for FP-STATION:/FP-JOB:/FP-STEP: scanner-wedge codes typed
|
||||||
target: "new",
|
// into the input.
|
||||||
}).catch(() => {
|
toggleScan() {
|
||||||
// QR scanner action may not be registered in all installs
|
this.state.showScan = !this.state.showScan;
|
||||||
this.notification.add("QR scanner not available", { type: "warning" });
|
if (this.state.showScan) {
|
||||||
});
|
// Tiny delay so the input is in the DOM before we focus
|
||||||
|
setTimeout(() => {
|
||||||
|
const el = document.querySelector(".o_fp_plant_scan_input");
|
||||||
|
if (el) el.focus();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onScanSubmit() {
|
||||||
|
const code = (this.state.scanInput || "").trim();
|
||||||
|
if (!code) return;
|
||||||
|
try {
|
||||||
|
const res = await rpc("/fp/shopfloor/scan", { qr_code: code });
|
||||||
|
if (!res || !res.ok) {
|
||||||
|
this.notification.add(
|
||||||
|
(res && res.error) || "Unrecognised QR",
|
||||||
|
{ type: "danger" },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.model === "fusion.plating.shopfloor.station") {
|
||||||
|
// Persist the pairing server-side (paired_work_centre_ids)
|
||||||
|
// so subsequent /fp/landing/plant_kanban refreshes scope
|
||||||
|
// to this station. The new endpoint replaces the legacy
|
||||||
|
// localStorage pairing shopfloor_landing used.
|
||||||
|
const wcId = res.work_center_id;
|
||||||
|
if (wcId) {
|
||||||
|
const pair = await rpc("/fp/landing/pair_work_centre", {
|
||||||
|
work_centre_id: wcId,
|
||||||
|
});
|
||||||
|
if (pair && pair.ok) {
|
||||||
|
this.notification.add(
|
||||||
|
`Paired to ${res.name}`,
|
||||||
|
{ type: "success" },
|
||||||
|
);
|
||||||
|
this.state.mode = "station";
|
||||||
|
} else {
|
||||||
|
this.notification.add(
|
||||||
|
(pair && pair.error) || "Pairing failed",
|
||||||
|
{ type: "warning" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.notification.add(
|
||||||
|
`Station ${res.name} has no work centre assigned`,
|
||||||
|
{ type: "warning" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (res.model === "fp.job") {
|
||||||
|
this.action.doAction({
|
||||||
|
type: "ir.actions.client",
|
||||||
|
tag: "fp_job_workspace",
|
||||||
|
params: { job_id: res.id },
|
||||||
|
target: "current",
|
||||||
|
});
|
||||||
|
return; // navigating away — skip the refresh
|
||||||
|
} else if (res.model === "fp.job.step") {
|
||||||
|
this.action.doAction({
|
||||||
|
type: "ir.actions.client",
|
||||||
|
tag: "fp_job_workspace",
|
||||||
|
params: {
|
||||||
|
job_id: res.job_id || 0,
|
||||||
|
focus_step_id: res.id,
|
||||||
|
},
|
||||||
|
target: "current",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else if (res.action_tag) {
|
||||||
|
// Some QR types (FP-QC) include their own action_tag
|
||||||
|
this.action.doAction({
|
||||||
|
type: "ir.actions.client",
|
||||||
|
tag: res.action_tag,
|
||||||
|
params: res.action_params || {},
|
||||||
|
target: "current",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
this.notification.add(
|
||||||
|
`Scanned ${res.model}`,
|
||||||
|
{ type: "info" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(
|
||||||
|
err.message || String(err),
|
||||||
|
{ type: "danger" },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.state.scanInput = "";
|
||||||
|
this.state.showScan = false;
|
||||||
|
await this.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onScanKey(ev) {
|
||||||
|
if (ev.key === "Enter") this.onScanSubmit();
|
||||||
|
else if (ev.key === "Escape") {
|
||||||
|
this.state.scanInput = "";
|
||||||
|
this.state.showScan = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,277 +0,0 @@
|
|||||||
/** @odoo-module **/
|
|
||||||
// =============================================================================
|
|
||||||
// Fusion Plating — Shop Floor Landing (OWL client action)
|
|
||||||
// Client action: fp_shopfloor_landing
|
|
||||||
//
|
|
||||||
// Replaces fp_shopfloor_tablet AND folds in fp_plant_overview. Single
|
|
||||||
// kanban entry surface for technicians. Two modes:
|
|
||||||
//
|
|
||||||
// station — paired station's work centre + Unassigned + next 1-2
|
|
||||||
// WCs in recipe flow. Default when a station is paired.
|
|
||||||
// all_plant — every active work centre. Default with no station.
|
|
||||||
//
|
|
||||||
// Tap a card → JobWorkspace. QR scan: stations pair, jobs jump.
|
|
||||||
// Drag-and-drop between columns reassigns step.work_centre_id (existing
|
|
||||||
// /fp/shopfloor/plant_overview/move_card endpoint).
|
|
||||||
//
|
|
||||||
// Auto-refresh: 15s. Mode + station_id persist in localStorage.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
|
|
||||||
import { registry } from "@web/core/registry";
|
|
||||||
import { rpc } from "@web/core/network/rpc";
|
|
||||||
import { fpRpc } from "./services/fp_rpc";
|
|
||||||
import { useService } from "@web/core/utils/hooks";
|
|
||||||
import { QrScanner } from "./qr_scanner";
|
|
||||||
import { FpKanbanCard } from "./components/kanban_card";
|
|
||||||
import { FpTabletLock } from "./tablet_lock";
|
|
||||||
|
|
||||||
const LS_STATION_ID = "fp_landing_station_id";
|
|
||||||
const LS_MODE = "fp_landing_mode";
|
|
||||||
const REFRESH_MS = 15000;
|
|
||||||
|
|
||||||
export class FpShopfloorLanding extends Component {
|
|
||||||
static template = "fusion_plating_shopfloor.ShopfloorLanding";
|
|
||||||
static props = ["*"];
|
|
||||||
static components = { QrScanner, FpKanbanCard, FpTabletLock };
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
this.notification = useService("notification");
|
|
||||||
this.action = useService("action");
|
|
||||||
this.tabletSessionManager = useService("fp_tablet_session_manager");
|
|
||||||
|
|
||||||
this.state = useState({
|
|
||||||
mode: localStorage.getItem(LS_MODE) || "all_plant",
|
|
||||||
stationId: parseInt(localStorage.getItem(LS_STATION_ID) || "0") || null,
|
|
||||||
data: null,
|
|
||||||
search: "",
|
|
||||||
scanInput: "",
|
|
||||||
showScan: false,
|
|
||||||
lastRefresh: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
this._draggedCard = null;
|
|
||||||
this._movesInFlight = 0;
|
|
||||||
this._lastDropAt = 0;
|
|
||||||
this._searchTimer = null;
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await this.refresh();
|
|
||||||
this._refreshInterval = setInterval(() => {
|
|
||||||
if (this._movesInFlight > 0) return;
|
|
||||||
if (Date.now() - this._lastDropAt < 5000) return;
|
|
||||||
this.refresh();
|
|
||||||
}, REFRESH_MS);
|
|
||||||
});
|
|
||||||
|
|
||||||
onWillUnmount(() => {
|
|
||||||
if (this._refreshInterval) clearInterval(this._refreshInterval);
|
|
||||||
if (this._searchTimer) clearTimeout(this._searchTimer);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Data load ---------------------------------------------------------
|
|
||||||
async refresh() {
|
|
||||||
try {
|
|
||||||
const res = await rpc("/fp/landing/kanban", {
|
|
||||||
mode: this.state.mode,
|
|
||||||
station_id: this.state.stationId,
|
|
||||||
search: this.state.search || null,
|
|
||||||
});
|
|
||||||
if (res && res.ok) {
|
|
||||||
this.state.data = res;
|
|
||||||
this.state.lastRefresh = res.server_time || new Date().toLocaleTimeString();
|
|
||||||
// If station resolved (e.g. via QR scan), persist its id
|
|
||||||
if (res.station && res.station.id) {
|
|
||||||
this.state.stationId = res.station.id;
|
|
||||||
localStorage.setItem(LS_STATION_ID, String(res.station.id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.notification.add(err.message || String(err), { type: "danger" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Mode toggle -------------------------------------------------------
|
|
||||||
setMode(mode) {
|
|
||||||
if (this.state.mode === mode) return;
|
|
||||||
this.state.mode = mode;
|
|
||||||
localStorage.setItem(LS_MODE, mode);
|
|
||||||
this.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Station picker ----------------------------------------------------
|
|
||||||
onPickStation(ev) {
|
|
||||||
const id = parseInt(ev.target.value) || null;
|
|
||||||
this.state.stationId = id;
|
|
||||||
if (id) {
|
|
||||||
localStorage.setItem(LS_STATION_ID, String(id));
|
|
||||||
// Picking a station naturally switches to station mode
|
|
||||||
this.state.mode = "station";
|
|
||||||
localStorage.setItem(LS_MODE, "station");
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem(LS_STATION_ID);
|
|
||||||
}
|
|
||||||
this.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
onUnpairStation() {
|
|
||||||
this.state.stationId = null;
|
|
||||||
this.state.mode = "all_plant";
|
|
||||||
localStorage.removeItem(LS_STATION_ID);
|
|
||||||
localStorage.setItem(LS_MODE, "all_plant");
|
|
||||||
this.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Hand-Off (Phase 6.2) ---------------------------------------------
|
|
||||||
handOff() {
|
|
||||||
// Tech walking away: lock the tablet so the next operator must PIN in
|
|
||||||
this.tabletSessionManager.lockBack("manual");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Search ------------------------------------------------------------
|
|
||||||
onSearchInput(ev) {
|
|
||||||
this.state.search = ev.target.value;
|
|
||||||
if (this._searchTimer) clearTimeout(this._searchTimer);
|
|
||||||
this._searchTimer = setTimeout(() => this.refresh(), 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
onSearchKey(ev) {
|
|
||||||
if (ev.key === "Enter") {
|
|
||||||
if (this._searchTimer) clearTimeout(this._searchTimer);
|
|
||||||
this.refresh();
|
|
||||||
} else if (ev.key === "Escape") {
|
|
||||||
this.state.search = "";
|
|
||||||
this.refresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Tap card → JobWorkspace ------------------------------------------
|
|
||||||
onCardTap(cardData) {
|
|
||||||
this.action.doAction({
|
|
||||||
type: "ir.actions.client",
|
|
||||||
tag: "fp_job_workspace",
|
|
||||||
params: {
|
|
||||||
job_id: cardData.job_id,
|
|
||||||
focus_step_id: cardData.current_step_id,
|
|
||||||
},
|
|
||||||
target: "current",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- QR scan -----------------------------------------------------------
|
|
||||||
toggleScan() {
|
|
||||||
this.state.showScan = !this.state.showScan;
|
|
||||||
}
|
|
||||||
|
|
||||||
async onScanSubmit() {
|
|
||||||
const code = (this.state.scanInput || "").trim();
|
|
||||||
if (!code) return;
|
|
||||||
try {
|
|
||||||
const res = await rpc("/fp/shopfloor/scan", { qr_code: code });
|
|
||||||
if (!res || !res.ok) {
|
|
||||||
this.notification.add((res && res.error) || "Unrecognised QR", { type: "danger" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (res.model === "fusion.plating.shopfloor.station") {
|
|
||||||
this.state.stationId = res.id;
|
|
||||||
this.state.mode = "station";
|
|
||||||
localStorage.setItem(LS_STATION_ID, String(res.id));
|
|
||||||
localStorage.setItem(LS_MODE, "station");
|
|
||||||
this.notification.add(`Paired to ${res.name}`, { type: "success" });
|
|
||||||
} else if (res.model === "fp.job") {
|
|
||||||
this.action.doAction({
|
|
||||||
type: "ir.actions.client",
|
|
||||||
tag: "fp_job_workspace",
|
|
||||||
params: { job_id: res.id },
|
|
||||||
target: "current",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} else if (res.model === "fp.job.step") {
|
|
||||||
this.action.doAction({
|
|
||||||
type: "ir.actions.client",
|
|
||||||
tag: "fp_job_workspace",
|
|
||||||
params: { job_id: res.job_id || 0, focus_step_id: res.id },
|
|
||||||
target: "current",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
this.notification.add(`Scanned ${res.model}`, { type: "info" });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.notification.add(err.message, { type: "danger" });
|
|
||||||
} finally {
|
|
||||||
this.state.scanInput = "";
|
|
||||||
await this.refresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onScanKey(ev) {
|
|
||||||
if (ev.key === "Enter") this.onScanSubmit();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Drag-and-drop -----------------------------------------------------
|
|
||||||
// Reuses the existing /fp/shopfloor/plant_overview/move_card endpoint,
|
|
||||||
// which still works for re-assigning step.work_centre_id.
|
|
||||||
onCardDragStart(card, col, ev) {
|
|
||||||
this._draggedCard = {
|
|
||||||
id: card.step_id,
|
|
||||||
source_wc_id: col.work_center_id,
|
|
||||||
};
|
|
||||||
ev.dataTransfer.effectAllowed = "move";
|
|
||||||
ev.dataTransfer.setData("text/plain", String(card.step_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
onColDragOver(col, ev) {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.dataTransfer.dropEffect = "move";
|
|
||||||
}
|
|
||||||
|
|
||||||
async onColDrop(col, ev) {
|
|
||||||
ev.preventDefault();
|
|
||||||
const dragged = this._draggedCard;
|
|
||||||
this._draggedCard = null;
|
|
||||||
if (!dragged) return;
|
|
||||||
if (dragged.source_wc_id === col.work_center_id) return;
|
|
||||||
|
|
||||||
// Optimistic move: pop from source, push to target
|
|
||||||
const srcIdx = this.state.data.columns.findIndex(c => c.work_center_id === dragged.source_wc_id);
|
|
||||||
const tgtIdx = this.state.data.columns.findIndex(c => c.work_center_id === col.work_center_id);
|
|
||||||
let movedCard = null;
|
|
||||||
if (srcIdx >= 0 && tgtIdx >= 0) {
|
|
||||||
const src = this.state.data.columns[srcIdx].cards;
|
|
||||||
const idx = src.findIndex(c => c.step_id === dragged.id);
|
|
||||||
if (idx >= 0) {
|
|
||||||
movedCard = src[idx];
|
|
||||||
this.state.data.columns[srcIdx].cards = [
|
|
||||||
...src.slice(0, idx), ...src.slice(idx + 1),
|
|
||||||
];
|
|
||||||
this.state.data.columns[tgtIdx].cards = [
|
|
||||||
movedCard, ...this.state.data.columns[tgtIdx].cards,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this._movesInFlight += 1;
|
|
||||||
this._lastDropAt = Date.now();
|
|
||||||
try {
|
|
||||||
const res = await fpRpc("/fp/shopfloor/plant_overview/move_card", {
|
|
||||||
card_id: dragged.id,
|
|
||||||
target_workcenter_id: col.work_center_id,
|
|
||||||
});
|
|
||||||
if (res && res.ok) {
|
|
||||||
this.notification.add(`Moved to ${col.work_center_name}`, { type: "success" });
|
|
||||||
} else {
|
|
||||||
this.notification.add((res && res.error) || "Move failed", { type: "warning" });
|
|
||||||
await this.refresh(); // server is the source of truth on conflict
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.notification.add(err.message, { type: "danger" });
|
|
||||||
await this.refresh();
|
|
||||||
} finally {
|
|
||||||
this._movesInFlight -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
registry.category("actions").add("fp_shopfloor_landing", FpShopfloorLanding);
|
|
||||||
@@ -125,8 +125,17 @@ export class FpTabletLock extends Component {
|
|||||||
async _loadTiles() {
|
async _loadTiles() {
|
||||||
this.state.loadingTiles = true;
|
this.state.loadingTiles = true;
|
||||||
try {
|
try {
|
||||||
const stationId = parseInt(localStorage.getItem("fp_landing_station_id")) || null;
|
// 2026-05-25 — the legacy fp_landing_station_id localStorage
|
||||||
const res = await rpc("/fp/tablet/tiles", { station_id: stationId });
|
// key (set by the now-deleted fp_shopfloor_landing component)
|
||||||
|
// is purged on read. Sending its stale value to /fp/tablet/tiles
|
||||||
|
// caused the kiosk session to AccessError on shopfloor.station
|
||||||
|
// and return an empty tile list. plant_kanban pairs to
|
||||||
|
// work_centre server-side (paired_work_centre_ids) so the
|
||||||
|
// kiosk-rendered lock screen no longer needs per-tablet pairing
|
||||||
|
// for tile scoping — all shop-branch users render.
|
||||||
|
try { localStorage.removeItem("fp_landing_station_id"); } catch {}
|
||||||
|
try { localStorage.removeItem("fp_landing_mode"); } catch {}
|
||||||
|
const res = await rpc("/fp/tablet/tiles", { station_id: null });
|
||||||
if (res && res.ok) {
|
if (res && res.ok) {
|
||||||
this.state.company = res.company || null;
|
this.state.company = res.company || null;
|
||||||
this.state.kioskUid = res.kiosk_uid || null;
|
this.state.kioskUid = res.kiosk_uid || null;
|
||||||
|
|||||||
@@ -187,3 +187,30 @@
|
|||||||
color: #856404; // keep gold legible on dark
|
color: #856404; // keep gold legible on dark
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Scan drawer (ported from shopfloor_landing 2026-05-25) ============
|
||||||
|
// Inline text/wedge scan input that drops down below the floor-header
|
||||||
|
// when the operator taps the "⌨️ Scan Code" toolbar button. Camera scans
|
||||||
|
// are handled by the inline <QrScanner> component (own modal).
|
||||||
|
.o_fp_plant_kanban {
|
||||||
|
.toolbar-btn.active {
|
||||||
|
background: $plant-mine-border;
|
||||||
|
color: #fff;
|
||||||
|
border-color: $plant-mine-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_plant_scan_drawer {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: $plant-card-bg;
|
||||||
|
border-bottom: 1px solid $plant-card-border;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.o_fp_plant_scan_input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,294 +0,0 @@
|
|||||||
// =============================================================================
|
|
||||||
// Shop Floor Landing — kanban entry surface (Phase 3 tablet redesign)
|
|
||||||
// Replaces fp_shopfloor_tablet + fp_plant_overview.
|
|
||||||
// Dark-mode aware via $o-webclient-color-scheme branch.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
$o-webclient-color-scheme: bright !default;
|
|
||||||
|
|
||||||
$_lan-page-hex: #f3f4f6;
|
|
||||||
$_lan-card-hex: #ffffff;
|
|
||||||
$_lan-border-hex: #d8dadd;
|
|
||||||
$_lan-text-hex: #1d1d1f;
|
|
||||||
|
|
||||||
@if $o-webclient-color-scheme == dark {
|
|
||||||
$_lan-page-hex: #1a1d21 !global;
|
|
||||||
$_lan-card-hex: #22262d !global;
|
|
||||||
$_lan-border-hex: #424245 !global;
|
|
||||||
$_lan-text-hex: #f5f5f7 !global;
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_fp_landing {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
background: $_lan-page-hex;
|
|
||||||
color: $_lan-text-hex;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_fp_landing_loading {
|
|
||||||
margin: auto;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-secondary, #666);
|
|
||||||
|
|
||||||
> div { margin-top: 0.6rem; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- HEADER ------------------------------------------------------------
|
|
||||||
.o_fp_landing_head {
|
|
||||||
background: $_lan-card-hex;
|
|
||||||
border-bottom: 1px solid $_lan-border-hex;
|
|
||||||
padding: 0.55rem 1rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_fp_landing_title_block {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_fp_landing_title {
|
|
||||||
font-size: 1.05rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_fp_landing_station_chip {
|
|
||||||
background: rgba(0, 113, 227, 0.12);
|
|
||||||
color: #0050a0;
|
|
||||||
padding: 0.2rem 0.55rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@if $o-webclient-color-scheme == dark {
|
|
||||||
.o_fp_landing_station_chip { color: #6cb6ff; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_fp_landing_unpair { padding: 0 0.2rem; color: inherit; opacity: 0.6;
|
|
||||||
&:hover { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_fp_landing_head_actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_fp_landing_station_picker { min-width: 180px; }
|
|
||||||
|
|
||||||
.o_fp_landing_refresh {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
color: var(--text-secondary, #999);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Scan drawer -------------------------------------------------------
|
|
||||||
.o_fp_landing_scan_drawer {
|
|
||||||
background: $_lan-card-hex;
|
|
||||||
border-bottom: 1px solid $_lan-border-hex;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- KPI strip ---------------------------------------------------------
|
|
||||||
.o_fp_landing_kpis {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.55rem 1rem;
|
|
||||||
background: $_lan-page-hex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_fp_landing_kpi {
|
|
||||||
background: $_lan-card-hex;
|
|
||||||
border: 1px solid $_lan-border-hex;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.5rem 0.7rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
> i {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.45rem;
|
|
||||||
right: 0.55rem;
|
|
||||||
opacity: 0.4;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_fp_landing_kpi_v {
|
|
||||||
font-size: 1.6rem;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_fp_landing_kpi_l {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
color: var(--text-secondary, #777);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.o_fp_landing_kpi_success { border-color: rgba(52, 199, 89, 0.3); }
|
|
||||||
&.o_fp_landing_kpi_warning {
|
|
||||||
border-color: rgba(255, 159, 10, 0.4);
|
|
||||||
.o_fp_landing_kpi_v { color: #b06600; }
|
|
||||||
}
|
|
||||||
&.o_fp_landing_kpi_danger {
|
|
||||||
border-color: rgba(255, 59, 48, 0.4);
|
|
||||||
background: rgba(255, 59, 48, 0.06);
|
|
||||||
.o_fp_landing_kpi_v { color: #b00018; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@if $o-webclient-color-scheme == dark {
|
|
||||||
.o_fp_landing_kpi_warning .o_fp_landing_kpi_v { color: #ffb84d; }
|
|
||||||
.o_fp_landing_kpi_danger .o_fp_landing_kpi_v { color: #ff7a72; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Search bar --------------------------------------------------------
|
|
||||||
.o_fp_landing_search {
|
|
||||||
background: $_lan-page-hex;
|
|
||||||
padding: 0.3rem 1rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
|
|
||||||
> i { color: var(--text-secondary, #999); font-size: 0.85rem; }
|
|
||||||
> input { max-width: 320px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Kanban board ------------------------------------------------------
|
|
||||||
// Recipe authors keep adding work centres (Anodize, Strip, Etch, Bake,
|
|
||||||
// Mask, Rack, Inspect, Ship…) so the kanban must accommodate both
|
|
||||||
// FEW columns (early-shop layouts) AND MANY columns (mature shops with
|
|
||||||
// 15+ stations). Two design moves to handle both:
|
|
||||||
// 1. Columns use `flex: 1 0 200px` — basis 200px, GROW into spare
|
|
||||||
// space (3 cols on a 1200px screen → each becomes 400px), but
|
|
||||||
// NEVER SHRINK below 200px so 15+ cols stay readable and scroll
|
|
||||||
// horizontally. Max 320px caps the growth so a single-column
|
|
||||||
// kanban doesn't span 1200px of empty whitespace.
|
|
||||||
// 2. Custom-styled horizontal scrollbar — the default browser bar
|
|
||||||
// is invisible until hover on most platforms; users had no idea
|
|
||||||
// more columns existed off-screen. Now there's a persistent thin
|
|
||||||
// bar at the bottom of the board.
|
|
||||||
.o_fp_landing_board {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
gap: 0.6rem;
|
|
||||||
padding: 0.6rem 1rem 1rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
align-items: stretch;
|
|
||||||
|
|
||||||
// Custom scrollbar — visible enough that users notice more columns
|
|
||||||
// exist off-screen without being obnoxiously large.
|
|
||||||
&::-webkit-scrollbar { height: 10px; }
|
|
||||||
&::-webkit-scrollbar-track {
|
|
||||||
background: $_lan-page-hex;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
&::-webkit-scrollbar-thumb {
|
|
||||||
background: $_lan-border-hex;
|
|
||||||
border-radius: 5px;
|
|
||||||
&:hover { background: darken(#d8dadd, 10%); }
|
|
||||||
}
|
|
||||||
scrollbar-width: thin; // Firefox
|
|
||||||
scrollbar-color: $_lan-border-hex $_lan-page-hex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_fp_landing_empty {
|
|
||||||
margin: auto;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-secondary, #999);
|
|
||||||
|
|
||||||
> div { margin-top: 0.6rem; max-width: 280px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_fp_landing_col {
|
|
||||||
flex: 1 0 200px; // grow into spare, never shrink below 200px
|
|
||||||
min-width: 200px;
|
|
||||||
max-width: 320px; // cap growth so single col doesn't span 1200px
|
|
||||||
background: $_lan-card-hex;
|
|
||||||
border: 1px solid $_lan-border-hex;
|
|
||||||
border-radius: 6px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
max-height: 100%;
|
|
||||||
overflow: hidden; // contain inner sticky header within border-radius
|
|
||||||
|
|
||||||
&.o_fp_drop_target {
|
|
||||||
outline: 2px dashed #0071e3;
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_fp_landing_col_head {
|
|
||||||
// Sticky inside the column body so as the operator scrolls through
|
|
||||||
// many cards, they always see WHICH station they're looking at.
|
|
||||||
// (Caught 2026-05-23 — long card lists in Oven Baking made operators
|
|
||||||
// lose track of which column they were scrolling.)
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 2;
|
|
||||||
background: $_lan-card-hex;
|
|
||||||
padding: 0.4rem 0.7rem;
|
|
||||||
border-bottom: 1px solid $_lan-border-hex;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_fp_landing_col_name {
|
|
||||||
flex: 1;
|
|
||||||
// Truncate long work-centre names instead of wrapping (which would
|
|
||||||
// push the count badge to a second line and shift card content).
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_fp_landing_col_count {
|
|
||||||
background: $_lan-page-hex;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 0.1rem 0.5rem;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: var(--text-secondary, #777);
|
|
||||||
flex-shrink: 0; // don't squeeze the count when the name is long
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_fp_landing_col_body {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 0.4rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.4rem;
|
|
||||||
min-height: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_fp_landing_col_empty {
|
|
||||||
color: var(--text-tertiary, #aaa);
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
padding: 1rem 0;
|
|
||||||
}
|
|
||||||
@@ -244,7 +244,7 @@
|
|||||||
margin: 14px auto 0;
|
margin: 14px auto 0;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid $lock-tile-border-rgba;
|
border: 1px solid $lock-tile-border;
|
||||||
color: $lock-muted;
|
color: $lock-muted;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -252,14 +252,14 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.1s ease, color 0.1s ease;
|
transition: background 0.1s ease, color 0.1s ease;
|
||||||
&:hover {
|
&:hover {
|
||||||
background: $lock-tile-hover-bg-rgba;
|
background: $lock-tile-hover-bg;
|
||||||
color: $lock-text;
|
color: $lock-text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.o_fp_lock_wizard {
|
.o_fp_lock_wizard {
|
||||||
background: $lock-frame-bg-rgba;
|
background: $lock-frame-bg;
|
||||||
border: 1px solid $lock-frame-border-rgba;
|
border: 1px solid $lock-frame-border;
|
||||||
box-shadow: $lock-frame-shadow;
|
box-shadow: $lock-frame-shadow;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 32px 36px;
|
padding: 32px 36px;
|
||||||
|
|||||||
@@ -23,11 +23,30 @@
|
|||||||
<button t-att-class="modeClass('manager')"
|
<button t-att-class="modeClass('manager')"
|
||||||
t-on-click="() => this.setMode('manager')">Manager</button>
|
t-on-click="() => this.setMode('manager')">Manager</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="toolbar-btn" t-on-click="onScanQr">📷 Scan QR</button>
|
<!-- Text/wedge scan drawer toggle. Camera path
|
||||||
|
is the QrScanner inline below — it
|
||||||
|
opens its own modal + decoder. -->
|
||||||
|
<button class="toolbar-btn"
|
||||||
|
t-att-class="state.showScan ? 'toolbar-btn active' : 'toolbar-btn'"
|
||||||
|
t-on-click="toggleScan">⌨️ Scan Code</button>
|
||||||
|
<QrScanner cssClass="'toolbar-btn'" label="'📷 Camera'"/>
|
||||||
<button class="toolbar-btn handoff" t-on-click="onHandOff">🔓 Hand Off</button>
|
<button class="toolbar-btn handoff" t-on-click="onHandOff">🔓 Hand Off</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== Inline scan drawer (text / wedge) ===== -->
|
||||||
|
<div t-if="state.showScan" class="o_fp_plant_scan_drawer">
|
||||||
|
<input type="text"
|
||||||
|
class="form-control o_fp_plant_scan_input"
|
||||||
|
placeholder="Scan FP-STATION:… FP-JOB:… FP-STEP:… or paste a code"
|
||||||
|
t-model="state.scanInput"
|
||||||
|
t-on-keydown="onScanKey"/>
|
||||||
|
<button class="btn btn-primary"
|
||||||
|
t-on-click="onScanSubmit">Submit</button>
|
||||||
|
<button class="btn btn-link"
|
||||||
|
t-on-click="toggleScan">Cancel</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- KPI strip -->
|
<!-- KPI strip -->
|
||||||
<div t-if="state.data" class="kpi-strip">
|
<div t-if="state.data" class="kpi-strip">
|
||||||
<FpKpiTile value="state.data.kpis.active_jobs"
|
<FpKpiTile value="state.data.kpis.active_jobs"
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<templates xml:space="preserve">
|
|
||||||
|
|
||||||
<t t-name="fusion_plating_shopfloor.ShopfloorLanding">
|
|
||||||
<FpTabletLock>
|
|
||||||
<t t-set-slot="default">
|
|
||||||
<div class="o_fp_landing">
|
|
||||||
|
|
||||||
<!-- Loading state -->
|
|
||||||
<div t-if="!state.data" class="o_fp_landing_loading">
|
|
||||||
<i class="fa fa-spinner fa-spin fa-2x"/>
|
|
||||||
<div>Loading Shop Floor…</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<t t-if="state.data">
|
|
||||||
|
|
||||||
<!-- ===== HEADER ===== -->
|
|
||||||
<header class="o_fp_landing_head">
|
|
||||||
<div class="o_fp_landing_title_block">
|
|
||||||
<h1 class="o_fp_landing_title">
|
|
||||||
<i class="fa fa-industry"/> Shop Floor
|
|
||||||
</h1>
|
|
||||||
<t t-if="state.data.station">
|
|
||||||
<span class="o_fp_landing_station_chip">
|
|
||||||
@ <t t-esc="state.data.station.work_center_name or state.data.station.name"/>
|
|
||||||
<button class="btn btn-sm btn-link o_fp_landing_unpair"
|
|
||||||
t-on-click="onUnpairStation"
|
|
||||||
title="Unpair this tablet">
|
|
||||||
<i class="fa fa-times"/>
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</t>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="o_fp_landing_head_actions">
|
|
||||||
<!-- Station picker -->
|
|
||||||
<select class="o_fp_landing_station_picker form-select form-select-sm"
|
|
||||||
t-on-change="onPickStation">
|
|
||||||
<option value="">— Pick station —</option>
|
|
||||||
<t t-foreach="state.data.stations" t-as="s" t-key="s.id">
|
|
||||||
<option t-att-value="s.id"
|
|
||||||
t-att-selected="state.stationId === s.id">
|
|
||||||
<t t-esc="s.name"/>
|
|
||||||
<t t-if="s.work_center_name"> · <t t-esc="s.work_center_name"/></t>
|
|
||||||
</option>
|
|
||||||
</t>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<!-- Mode toggle -->
|
|
||||||
<div class="o_fp_landing_mode_toggle btn-group btn-group-sm">
|
|
||||||
<button t-att-class="'btn ' + (state.mode === 'station' ? 'btn-primary' : 'btn-outline-secondary')"
|
|
||||||
t-on-click="() => this.setMode('station')">
|
|
||||||
Station
|
|
||||||
</button>
|
|
||||||
<button t-att-class="'btn ' + (state.mode === 'all_plant' ? 'btn-primary' : 'btn-outline-secondary')"
|
|
||||||
t-on-click="() => this.setMode('all_plant')">
|
|
||||||
All Plant
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Scan controls -->
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" t-on-click="toggleScan">
|
|
||||||
<i class="fa fa-qrcode"/> Code
|
|
||||||
</button>
|
|
||||||
<QrScanner cssClass="'btn btn-sm btn-outline-secondary'" label="'Camera'"/>
|
|
||||||
|
|
||||||
<!-- Phase 6.2 — Hand-Off: lock the tablet for the next operator -->
|
|
||||||
<button class="btn btn-sm btn-warning"
|
|
||||||
t-on-click="handOff"
|
|
||||||
title="Lock the tablet for the next operator">
|
|
||||||
<i class="fa fa-lock"/> Hand Off
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Refresh indicator -->
|
|
||||||
<span class="o_fp_landing_refresh text-muted">
|
|
||||||
<i class="fa fa-clock-o"/> <t t-esc="state.lastRefresh"/>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- ===== Scan drawer ===== -->
|
|
||||||
<div t-if="state.showScan" class="o_fp_landing_scan_drawer">
|
|
||||||
<input type="text"
|
|
||||||
class="form-control"
|
|
||||||
placeholder="Scan FP-STATION:… FP-JOB:… FP-STEP:…"
|
|
||||||
t-model="state.scanInput"
|
|
||||||
t-on-keydown="onScanKey"
|
|
||||||
autofocus="autofocus"/>
|
|
||||||
<button class="btn btn-primary" t-on-click="onScanSubmit">Scan</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ===== KPI strip (4 tech-relevant tiles) ===== -->
|
|
||||||
<div class="o_fp_landing_kpis">
|
|
||||||
<div class="o_fp_landing_kpi">
|
|
||||||
<i class="fa fa-hourglass-half"/>
|
|
||||||
<span class="o_fp_landing_kpi_v"><t t-esc="state.data.kpis.ready"/></span>
|
|
||||||
<span class="o_fp_landing_kpi_l">Ready</span>
|
|
||||||
</div>
|
|
||||||
<div class="o_fp_landing_kpi o_fp_landing_kpi_success">
|
|
||||||
<i class="fa fa-cogs"/>
|
|
||||||
<span class="o_fp_landing_kpi_v"><t t-esc="state.data.kpis.running"/></span>
|
|
||||||
<span class="o_fp_landing_kpi_l">Running</span>
|
|
||||||
</div>
|
|
||||||
<div t-att-class="'o_fp_landing_kpi ' + (state.data.kpis.bakes_due ? 'o_fp_landing_kpi_warning' : '')">
|
|
||||||
<i class="fa fa-fire"/>
|
|
||||||
<span class="o_fp_landing_kpi_v"><t t-esc="state.data.kpis.bakes_due"/></span>
|
|
||||||
<span class="o_fp_landing_kpi_l">Bakes Due</span>
|
|
||||||
</div>
|
|
||||||
<div t-att-class="'o_fp_landing_kpi ' + (state.data.kpis.holds ? 'o_fp_landing_kpi_danger' : '')">
|
|
||||||
<i class="fa fa-pause-circle"/>
|
|
||||||
<span class="o_fp_landing_kpi_v"><t t-esc="state.data.kpis.holds"/></span>
|
|
||||||
<span class="o_fp_landing_kpi_l">Holds</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ===== Search bar ===== -->
|
|
||||||
<div class="o_fp_landing_search">
|
|
||||||
<i class="fa fa-search"/>
|
|
||||||
<input type="text"
|
|
||||||
class="form-control form-control-sm"
|
|
||||||
placeholder="Search WO #, customer, part…"
|
|
||||||
t-model="state.search"
|
|
||||||
t-on-input="onSearchInput"
|
|
||||||
t-on-keydown="onSearchKey"/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ===== Kanban board ===== -->
|
|
||||||
<div class="o_fp_landing_board">
|
|
||||||
<div t-if="!state.data.columns.length" class="o_fp_landing_empty">
|
|
||||||
<i class="fa fa-check-circle fa-2x text-success"/>
|
|
||||||
<div t-if="state.mode === 'station'">
|
|
||||||
No jobs at this station right now. Switch to All Plant
|
|
||||||
to pull one over.
|
|
||||||
</div>
|
|
||||||
<div t-else="">
|
|
||||||
Plant is quiet — nothing in progress.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<t t-foreach="state.data.columns" t-as="col" t-key="col.work_center_id">
|
|
||||||
<div class="o_fp_landing_col"
|
|
||||||
t-on-dragover="(ev) => this.onColDragOver(col, ev)"
|
|
||||||
t-on-drop="(ev) => this.onColDrop(col, ev)">
|
|
||||||
<div class="o_fp_landing_col_head">
|
|
||||||
<span class="o_fp_landing_col_name" t-esc="col.work_center_name"/>
|
|
||||||
<span class="o_fp_landing_col_count"><t t-esc="col.cards.length"/></span>
|
|
||||||
</div>
|
|
||||||
<div class="o_fp_landing_col_body">
|
|
||||||
<t t-foreach="col.cards" t-as="card" t-key="card.step_id">
|
|
||||||
<div draggable="true"
|
|
||||||
t-on-dragstart="(ev) => this.onCardDragStart(card, col, ev)">
|
|
||||||
<FpKanbanCard
|
|
||||||
data="card"
|
|
||||||
density="'normal'"
|
|
||||||
showWorkflowChip="true"
|
|
||||||
showWorkcenter="state.mode === 'all_plant'"
|
|
||||||
onTap.bind="onCardTap"/>
|
|
||||||
</div>
|
|
||||||
</t>
|
|
||||||
<div t-if="!col.cards.length" class="o_fp_landing_col_empty">
|
|
||||||
—
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</t>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</t>
|
|
||||||
</div>
|
|
||||||
</t>
|
|
||||||
</FpTabletLock>
|
|
||||||
</t>
|
|
||||||
|
|
||||||
</templates>
|
|
||||||
@@ -27,10 +27,12 @@
|
|||||||
sequence="3"
|
sequence="3"
|
||||||
groups="fusion_plating.group_fusion_plating_manager"/>
|
groups="fusion_plating.group_fusion_plating_manager"/>
|
||||||
|
|
||||||
<!-- Phase 3 tablet redesign — single Workstation menu entry replaces
|
<!-- Single Workstation menu — action_fp_shopfloor_tablet is the
|
||||||
the legacy "Tablet Station" + "Plant Overview" pair. The new
|
legacy xmlid for back-compat (bookmarks/breadcrumbs); its tag
|
||||||
fp_shopfloor_landing component has a Station/All-Plant toggle
|
was retargeted at the new fp_plant_kanban component in the
|
||||||
so one menu item covers both old surfaces. -->
|
2026-05-23 plant-view redesign. The previous fp_shopfloor_landing
|
||||||
|
OWL component was retired 2026-05-25 — its inline QR scanner
|
||||||
|
was ported into plant_kanban. -->
|
||||||
<menuitem id="menu_fp_shopfloor_tablet"
|
<menuitem id="menu_fp_shopfloor_tablet"
|
||||||
name="Workstation"
|
name="Workstation"
|
||||||
parent="menu_fp_shopfloor"
|
parent="menu_fp_shopfloor"
|
||||||
|
|||||||
@@ -7,12 +7,12 @@
|
|||||||
<odoo>
|
<odoo>
|
||||||
|
|
||||||
<!-- ================================================================== -->
|
<!-- ================================================================== -->
|
||||||
<!-- Client action — was "Plant Overview" (fp_plant_overview). -->
|
<!-- Client action — was "Plant Overview" (legacy fp_plant_overview). -->
|
||||||
<!-- 2026-05-23 plant-view redesign retargets the tag at the new -->
|
<!-- Retargets the tag at fp_plant_kanban so old bookmarks / -->
|
||||||
<!-- fp_plant_kanban component so old bookmarks / breadcrumbs / QR-scan -->
|
<!-- breadcrumbs / QR-scan landings flow into the current view. -->
|
||||||
<!-- landings flow into the new view. The legacy fp_plant_overview AND -->
|
<!-- The legacy fp_plant_overview OWL component is still registered -->
|
||||||
<!-- fp_shopfloor_landing OWL components stay registered (no code -->
|
<!-- but no menu/action XMLID points at it. fp_shopfloor_landing was -->
|
||||||
<!-- removed) but no menu / action XMLID points at them anymore. -->
|
<!-- deleted entirely 2026-05-25. -->
|
||||||
<!-- ================================================================== -->
|
<!-- ================================================================== -->
|
||||||
<record id="action_fp_plant_overview" model="ir.actions.client">
|
<record id="action_fp_plant_overview" model="ir.actions.client">
|
||||||
<field name="name">Plant Overview</field>
|
<field name="name">Plant Overview</field>
|
||||||
@@ -29,12 +29,10 @@
|
|||||||
</record>
|
</record>
|
||||||
|
|
||||||
<!-- ================================================================== -->
|
<!-- ================================================================== -->
|
||||||
<!-- Plant-view kanban (2026-05-23 redesign). -->
|
<!-- Plant-view kanban — sole Shop Floor surface as of 2026-05-25. -->
|
||||||
<!-- One card per fp.job grouped into 9 fixed columns by area_kind. -->
|
<!-- One card per fp.job grouped into 9 fixed columns by area_kind. -->
|
||||||
<!-- Replaces fp_shopfloor_landing when x_fc_shopfloor_layout='v2'. -->
|
<!-- Landing resolver in fusion_plating/data/fp_landing_data.xml -->
|
||||||
<!-- The landing-action resolver in fusion_plating/data/fp_landing_data -->
|
<!-- routes every technician / shop-manager here. -->
|
||||||
<!-- .xml dispatches between this and the legacy action based on the -->
|
|
||||||
<!-- ir.config_parameter set by the new feature-flag setting. -->
|
|
||||||
<!-- ================================================================== -->
|
<!-- ================================================================== -->
|
||||||
<record id="action_fp_plant_kanban" model="ir.actions.client">
|
<record id="action_fp_plant_kanban" model="ir.actions.client">
|
||||||
<field name="name">Shop Floor</field>
|
<field name="name">Shop Floor</field>
|
||||||
|
|||||||
Reference in New Issue
Block a user