From 7f70785b799e4e7d30d0dad2b1efa3890138752e Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Fri, 22 May 2026 22:09:09 -0400 Subject: [PATCH] feat(fusion_plating_shopfloor): ShopfloorLanding client action (P3.2-P3.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan tasks P3.2 + P3.3 + P3.4 batched. Full ShopfloorLanding OWL client action — replaces fp_shopfloor_tablet AND folds in fp_plant_overview. Header strip Title, station chip, station picker dropdown, Station/All-Plant mode toggle, QR scan controls, last-refresh indicator. KPI strip 4 tech-relevant tiles: Ready · Running · Bakes Due (warning) · Holds (red when > 0). Search Live debounced (200ms) across WO# + customer + part. ESC clears. Kanban board Columns = work centres from /fp/landing/kanban. Cards = FpKanbanCard (Phase 1 — P1.7). Drag-and-drop reuses existing /fp/shopfloor/plant_overview/move_card. Card tap doAction → fp_job_workspace with {job_id, focus_step_id}. QR scan FP-STATION pairs, FP-JOB / FP-STEP jump to the Workspace. Mode + station_id persist in localStorage (LS_STATION_ID, LS_MODE). Auto-refresh every 15s; suppressed during a drop and for 5s after. Registers client action `fp_shopfloor_landing`. Menu rewire + endpoint stubs land in P3.5 + P3.6. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../fusion_plating_shopfloor/__manifest__.py | 4 + .../static/src/js/shopfloor_landing.js | 268 ++++++++++++++++++ .../static/src/scss/shopfloor_landing.scss | 246 ++++++++++++++++ .../static/src/xml/shopfloor_landing.xml | 163 +++++++++++ 4 files changed, 681 insertions(+) create mode 100644 fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_landing.js create mode 100644 fusion_plating/fusion_plating_shopfloor/static/src/scss/shopfloor_landing.scss create mode 100644 fusion_plating/fusion_plating_shopfloor/static/src/xml/shopfloor_landing.xml diff --git a/fusion_plating/fusion_plating_shopfloor/__manifest__.py b/fusion_plating/fusion_plating_shopfloor/__manifest__.py index 112a0d19..6149676b 100644 --- a/fusion_plating/fusion_plating_shopfloor/__manifest__.py +++ b/fusion_plating/fusion_plating_shopfloor/__manifest__.py @@ -84,6 +84,10 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved. 'fusion_plating_shopfloor/static/src/scss/job_workspace.scss', 'fusion_plating_shopfloor/static/src/xml/job_workspace.xml', 'fusion_plating_shopfloor/static/src/js/job_workspace.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', 'fusion_plating_shopfloor/static/src/scss/qr_scanner.scss', 'fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss', 'fusion_plating_shopfloor/static/src/scss/plant_overview.scss', 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 new file mode 100644 index 00000000..fbd78702 --- /dev/null +++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/shopfloor_landing.js @@ -0,0 +1,268 @@ +/** @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 { useService } from "@web/core/utils/hooks"; +import { QrScanner } from "./qr_scanner"; +import { FpKanbanCard } from "./components/kanban_card"; + +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 }; + + setup() { + this.notification = useService("notification"); + this.action = useService("action"); + + 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(); + } + + // ---- 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 rpc("/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/scss/shopfloor_landing.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/shopfloor_landing.scss new file mode 100644 index 00000000..4e02e850 --- /dev/null +++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/shopfloor_landing.scss @@ -0,0 +1,246 @@ +// ============================================================================= +// 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 ------------------------------------------------------ +.o_fp_landing_board { + flex: 1; + display: flex; + gap: 0.6rem; + padding: 0.6rem 1rem 1rem; + overflow-x: auto; + align-items: stretch; +} + +.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: 0 0 240px; + background: $_lan-card-hex; + border: 1px solid $_lan-border-hex; + border-radius: 6px; + display: flex; + flex-direction: column; + max-height: 100%; + + &.o_fp_drop_target { + outline: 2px dashed #0071e3; + outline-offset: -2px; + } +} + +.o_fp_landing_col_head { + 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; } + +.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); +} + +.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/xml/shopfloor_landing.xml b/fusion_plating/fusion_plating_shopfloor/static/src/xml/shopfloor_landing.xml new file mode 100644 index 00000000..ce47dc78 --- /dev/null +++ b/fusion_plating/fusion_plating_shopfloor/static/src/xml/shopfloor_landing.xml @@ -0,0 +1,163 @@ + + + + +
+ + +
+ +
Loading Shop Floor…
+
+ + + + +
+
+

+ Shop Floor +

+ + + @ + + + +
+ +
+ + + + +
+ + +
+ + + + + + + + + +
+
+ + +
+ + +
+ + +
+
+ + + 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. +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+ — +
+
+
+
+
+ +
+
+
+ +