feat(fusion_plating_shopfloor): ShopfloorLanding client action (P3.2-P3.4)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.ShopfloorLanding">
|
||||
<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'"/>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
</templates>
|
||||
Reference in New Issue
Block a user