diff --git a/fusion_plating/fusion_plating/models/fp_landing.py b/fusion_plating/fusion_plating/models/fp_landing.py index 465e5d70..db9df423 100644 --- a/fusion_plating/fusion_plating/models/fp_landing.py +++ b/fusion_plating/fusion_plating/models/fp_landing.py @@ -148,22 +148,17 @@ class IrActionsActWindow(models.Model): @api.model def _fp_workstation_action_for_layout(self, company): - """Single source of truth: which Shop Floor surface is active on - this DB? + """Resolve the Shop Floor surface for technicians + shop managers. - ``ir.config_parameter['fusion_plating_shopfloor.layout']`` is the - feature flag. Flipping it instantly retargets every Technician / - Shop Manager landing on next page load. + Returns ``action_fp_plant_kanban`` (the 2026-05-23 plant view). + The legacy ``fp_shopfloor_landing`` component was retired + 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( - 'fusion_plating_shopfloor.action_fp_shopfloor_landing', + 'fusion_plating_shopfloor.action_fp_plant_kanban', raise_if_not_found=False, ) diff --git a/fusion_plating/fusion_plating/tests/test_landing_resolver.py b/fusion_plating/fusion_plating/tests/test_landing_resolver.py index 0c8e2139..b0fa3817 100644 --- a/fusion_plating/fusion_plating/tests/test_landing_resolver.py +++ b/fusion_plating/fusion_plating/tests/test_landing_resolver.py @@ -119,21 +119,19 @@ class TestLandingResolver(TransactionCase): self.skipTest('Plant Kanban action not found') 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( '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: - # The legacy action is currently not defined by that xmlid - # 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.skipTest('Plant Kanban action not found') 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( 'fusion_plating_shopfloor.layout', 'v2') diff --git a/fusion_plating/fusion_plating_jobs/views/legacy_menu_hide.xml b/fusion_plating/fusion_plating_jobs/views/legacy_menu_hide.xml index 757753da..8f51a3da 100644 --- a/fusion_plating/fusion_plating_jobs/views/legacy_menu_hide.xml +++ b/fusion_plating/fusion_plating_jobs/views/legacy_menu_hide.xml @@ -21,13 +21,13 @@ - + Overview menu was superseded by the single Workstation entry. + The original directive lived here; it was retired + 2026-05-25 because the menu row had been gone for weeks and the + re-run of `` against a + missing xmlid raises ValueError on every -u. The action record + (action_fp_plant_overview) is kept and retargeted to + fp_plant_kanban for bookmark back-compat. --> - + FP: Tablet PIN Reset Code - 🔒 Your ENTECH tablet temporary PIN: {{ ctx.get('code', '----') }} - {{ (object.company_id.email or user.email) }} + Your ENTECH tablet temporary PIN: {{ ctx.get('code', '----') }} + {{ object._fp_resolve_from_header() }} + {{ object._fp_resolve_from_header() }} {{ object.email or object.login }} diff --git a/fusion_plating/fusion_plating_shopfloor/models/res_users.py b/fusion_plating/fusion_plating_shopfloor/models/res_users.py index 352f6ce3..f5d398a0 100644 --- a/fusion_plating/fusion_plating_shopfloor/models/res_users.py +++ b/fusion_plating/fusion_plating_shopfloor/models/res_users.py @@ -221,3 +221,30 @@ class ResUsers(models.Model): 'mfa': 'default', } 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 '' diff --git a/fusion_plating/fusion_plating_shopfloor/scripts/bt_pin_send_debug.py b/fusion_plating/fusion_plating_shopfloor/scripts/bt_pin_send_debug.py new file mode 100644 index 00000000..d61e84de --- /dev/null +++ b/fusion_plating/fusion_plating_shopfloor/scripts/bt_pin_send_debug.py @@ -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 ---') diff --git a/fusion_plating/fusion_plating_shopfloor/scripts/bt_pin_template_fix.py b/fusion_plating/fusion_plating_shopfloor/scripts/bt_pin_template_fix.py new file mode 100644 index 00000000..888b0e87 --- /dev/null +++ b/fusion_plating/fusion_plating_shopfloor/scripts/bt_pin_template_fix.py @@ -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 '')) + +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.') diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js index 15466b26..56342341 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js @@ -162,12 +162,10 @@ export class FpJobWorkspace extends Component { onBack() { // The workspace is opened with target: "current" which REPLACES // the current action and wipes the backstack. Navigate explicitly - // to the plant-view kanban — the 2026-05-23 redesigned Shop Floor - // surface — instead of the deprecated fp_shopfloor_landing OWL - // component. (Bug caught 2026-05-24: Back used to dump the user - // into the old per-step kanban even when they entered via the - // new plant view.) See CLAUDE.md Critical Rule 21 + the - // "Legacy-action redirect" section. + // to the plant kanban — the sole Shop Floor surface as of + // 2026-05-25 (fp_shopfloor_landing was retired the same day). + // See CLAUDE.md Critical Rule 21 + the "Legacy-action redirect" + // section. this.action.doAction({ type: "ir.actions.client", tag: "fp_plant_kanban", diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/plant_kanban.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/plant_kanban.js index f13a4dd7..738c0dae 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/js/plant_kanban.js +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/plant_kanban.js @@ -1,14 +1,19 @@ /** @odoo-module **/ // ===================================================================== -// FpPlantKanban — top-level OWL action for the 2026-05-23 redesigned -// Shop Floor. Mounts via the fp_plant_kanban client action; landing -// resolver dispatches between this and the legacy fp_shopfloor_landing -// based on the x_fc_shopfloor_layout config parameter. +// FpPlantKanban — sole Shop Floor OWL action (2026-05-25 onward). +// Mounts via the fp_plant_kanban client action. fp_shopfloor_landing +// was retired the same day — its only unique feature (inline QR +// scanner) was ported here. // // Architecture: // - Polls /fp/landing/plant_kanban every 10s // - Owns mode + filter + search state (filters persist in localStorage) // - 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 — // all coercion happens here in JS-land. // ===================================================================== @@ -23,6 +28,7 @@ import { FpPlantCard } from "./components/plant_card"; import { FpColumnHeader } from "./components/column_header"; import { FpKpiTile } from "./components/kpi_tile"; import { FpFilterChip } from "./components/filter_chip"; +import { QrScanner } from "./qr_scanner"; const LOCAL_FILTER_KEY = "fp_plant_kanban_filters"; @@ -35,6 +41,7 @@ export class FpPlantKanban extends Component { FpColumnHeader, FpKpiTile, FpFilterChip, + QrScanner, }; setup() { @@ -48,6 +55,10 @@ export class FpPlantKanban extends Component { data: null, loading: true, 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 () => { @@ -140,15 +151,114 @@ export class FpPlantKanban extends Component { this.tabletSessionManager.lockBack("manual"); } - onScanQr() { - this.action.doAction({ - type: "ir.actions.client", - tag: "fp_qr_scanner", - target: "new", - }).catch(() => { - // QR scanner action may not be registered in all installs - this.notification.add("QR scanner not available", { type: "warning" }); - }); + // ---- QR scan (text / wedge / manual paste path) ----------------------- + // Camera path is rendered by the inline component below + // the header — it owns its own modal + decoder + URL routing. This + // drawer is for FP-STATION:/FP-JOB:/FP-STEP: scanner-wedge codes typed + // into the input. + toggleScan() { + this.state.showScan = !this.state.showScan; + 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; + } } } diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_landing.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_landing.js deleted file mode 100644 index 0b2d32f8..00000000 --- a/fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_landing.js +++ /dev/null @@ -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); diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/tablet_lock.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/tablet_lock.js index 7b4852f0..6836602f 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/js/tablet_lock.js +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/tablet_lock.js @@ -125,8 +125,17 @@ export class FpTabletLock extends Component { async _loadTiles() { this.state.loadingTiles = true; try { - const stationId = parseInt(localStorage.getItem("fp_landing_station_id")) || null; - const res = await rpc("/fp/tablet/tiles", { station_id: stationId }); + // 2026-05-25 — the legacy fp_landing_station_id localStorage + // 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) { this.state.company = res.company || null; this.state.kioskUid = res.kiosk_uid || null; diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/plant_kanban.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/plant_kanban.scss index d50797f4..3020e352 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/scss/plant_kanban.scss +++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/plant_kanban.scss @@ -187,3 +187,30 @@ 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 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; + } + } +} diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/shopfloor_landing.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/shopfloor_landing.scss deleted file mode 100644 index dff168d2..00000000 --- a/fusion_plating/fusion_plating_shopfloor/static/src/scss/shopfloor_landing.scss +++ /dev/null @@ -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; -} diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/tablet_lock.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/tablet_lock.scss index 0f73eecd..8b2c074b 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/scss/tablet_lock.scss +++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/tablet_lock.scss @@ -244,7 +244,7 @@ margin: 14px auto 0; padding: 8px 16px; background: transparent; - border: 1px solid $lock-tile-border-rgba; + border: 1px solid $lock-tile-border; color: $lock-muted; border-radius: 6px; font-size: 13px; @@ -252,14 +252,14 @@ cursor: pointer; transition: background 0.1s ease, color 0.1s ease; &:hover { - background: $lock-tile-hover-bg-rgba; + background: $lock-tile-hover-bg; color: $lock-text; } } .o_fp_lock_wizard { - background: $lock-frame-bg-rgba; - border: 1px solid $lock-frame-border-rgba; + background: $lock-frame-bg; + border: 1px solid $lock-frame-border; box-shadow: $lock-frame-shadow; border-radius: 16px; padding: 32px 36px; diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/xml/plant_kanban.xml b/fusion_plating/fusion_plating_shopfloor/static/src/xml/plant_kanban.xml index 321d7e3b..58d25027 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/xml/plant_kanban.xml +++ b/fusion_plating/fusion_plating_shopfloor/static/src/xml/plant_kanban.xml @@ -23,11 +23,30 @@ Manager - 📷 Scan QR + + ⌨️ Scan Code + 🔓 Hand Off + + + + Submit + Cancel + + - - - - - - - - - - - Loading Shop Floor… - - - - - - - - - Shop Floor - - - - @ - - - - - - - - - - - — Pick station — - - - - · - - - - - - - - Station - - - All Plant - - - - - - Code - - - - - - Hand Off - - - - - - - - - - - - - Scan - - - - - - - - Ready - - - - - Running - - - - - Bakes Due - - - - - Holds - - - - - - - - - - - - - - - No jobs at this station right now. Switch to All Plant - to pull one over. - - - Plant is quiet — nothing in progress. - - - - - - - - - - - - - - - - - — - - - - - - - - - - - - - diff --git a/fusion_plating/fusion_plating_shopfloor/views/fp_menu.xml b/fusion_plating/fusion_plating_shopfloor/views/fp_menu.xml index d83c7531..ee882377 100644 --- a/fusion_plating/fusion_plating_shopfloor/views/fp_menu.xml +++ b/fusion_plating/fusion_plating_shopfloor/views/fp_menu.xml @@ -27,10 +27,12 @@ sequence="3" groups="fusion_plating.group_fusion_plating_manager"/> - + - - - - - - + + + + + + Plant Overview @@ -29,12 +29,10 @@ - + - - - - + + Shop Floor