feat(shopfloor): Phase 4 — plant-view kanban frontend (OWL + SCSS + XML)
PV-Phase4 of the plant-view redesign. 19 new files implementing the
6-component OWL tree plus design tokens.
Components (each = JS + XML + SCSS triple):
- FpMiniTimeline — 9-step bar consuming mini_timeline_json
- FpPlantCard — Variant C card; 13 state-* CSS classes; tap
opens fp_job_workspace
- FpColumnHeader — column label + count badge + 'You're here'
badge when paired
- FpKpiTile — clickable KPI button with urgent/warn/good
variants and active state
- FpFilterChip — toggleable chip
- FpPlantKanban — top-level orchestrator: 10s polling, mode
toggle, search + 6 filter chips, board with
9 fixed columns, localStorage filter persistence
SCSS:
- _plant_tokens.scss (loads first, exposes $plant-* vars to every
later file — required because Odoo 19 forbids @import in custom
SCSS, manifest order IS the concat order)
- Dark mode via $o-webclient-color-scheme compile-time branch
Manifest registers all assets in dependency order: tokens → component
SCSS → component XML → leaf JS → top-level JS. Mirrors the existing
project pattern.
Critical patterns honored:
- Project rule 20 (no String/Number/parseInt in OWL templates):
all coercion in JS, string literals in foreach arrays.
- No t-out without markup() (none in this batch — all card text is
pre-formatted by the controller).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -108,6 +108,33 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'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) ---------------
|
||||
# Tokens MUST load first (project rule 8: SCSS @import is
|
||||
# forbidden in Odoo 19 custom code; manifest order is the
|
||||
# concatenation order, and tokens carry the $plant-* vars
|
||||
# used by every component partial below).
|
||||
'fusion_plating_shopfloor/static/src/scss/_plant_tokens.scss',
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_plant_card.scss',
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_mini_timeline.scss',
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_column_header.scss',
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_kpi_tile.scss',
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_filter_chip.scss',
|
||||
'fusion_plating_shopfloor/static/src/scss/plant_kanban.scss',
|
||||
# XML templates (must precede their JS consumers)
|
||||
'fusion_plating_shopfloor/static/src/xml/components/mini_timeline.xml',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/plant_card.xml',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/column_header.xml',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/kpi_tile.xml',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/filter_chip.xml',
|
||||
'fusion_plating_shopfloor/static/src/xml/plant_kanban.xml',
|
||||
# JS — leaf components first, then card (imports timeline),
|
||||
# then top-level orchestrator (imports all).
|
||||
'fusion_plating_shopfloor/static/src/js/components/mini_timeline.js',
|
||||
'fusion_plating_shopfloor/static/src/js/components/plant_card.js',
|
||||
'fusion_plating_shopfloor/static/src/js/components/column_header.js',
|
||||
'fusion_plating_shopfloor/static/src/js/components/kpi_tile.js',
|
||||
'fusion_plating_shopfloor/static/src/js/components/filter_chip.js',
|
||||
'fusion_plating_shopfloor/static/src/js/plant_kanban.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,13 @@
|
||||
/** @odoo-module **/
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class FpColumnHeader extends Component {
|
||||
static template = "fusion_plating_shopfloor.ColumnHeader";
|
||||
static props = {
|
||||
column: { type: Object }, // {area_kind, label, is_mine, card_ids}
|
||||
};
|
||||
|
||||
get cardCount() {
|
||||
return this.props.column.card_ids.length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/** @odoo-module **/
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class FpFilterChip extends Component {
|
||||
static template = "fusion_plating_shopfloor.FilterChip";
|
||||
static props = {
|
||||
label: { type: String },
|
||||
active: { type: Boolean },
|
||||
onToggle: { type: Function },
|
||||
};
|
||||
|
||||
onClick() {
|
||||
this.props.onToggle();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** @odoo-module **/
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class FpKpiTile extends Component {
|
||||
static template = "fusion_plating_shopfloor.KpiTile";
|
||||
static props = {
|
||||
value: { type: [Number, String] },
|
||||
label: { type: String },
|
||||
kind: { type: String, optional: true }, // urgent | warn | good | ''
|
||||
active: { type: Boolean, optional: true },
|
||||
onClick: { type: Function, optional: true },
|
||||
};
|
||||
|
||||
get tileClass() {
|
||||
const classes = ["o_fp_kpi_tile"];
|
||||
if (this.props.kind) classes.push(this.props.kind);
|
||||
if (this.props.active) classes.push("active");
|
||||
return classes.join(" ");
|
||||
}
|
||||
|
||||
onClick() {
|
||||
if (this.props.onClick) this.props.onClick();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/** @odoo-module **/
|
||||
// =====================================================================
|
||||
// FpMiniTimeline — 9-step horizontal bar showing recipe journey.
|
||||
// Consumes mini_timeline JSON from /fp/landing/plant_kanban.
|
||||
// Per project rule 20: no String()/Number() in templates; classFor()
|
||||
// and labelFor() do all the formatting in JS.
|
||||
// =====================================================================
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
const AREA_LABELS = {
|
||||
receiving: "Rec",
|
||||
masking: "Mask",
|
||||
blasting: "Blast",
|
||||
racking: "Rack",
|
||||
plating: "Plat",
|
||||
baking: "Bake",
|
||||
de_racking: "D-R",
|
||||
inspection: "Insp",
|
||||
shipping: "Ship",
|
||||
};
|
||||
|
||||
// Map card_state variant → CSS modifier class on the current step
|
||||
const VARIANT_TO_CLASS = {
|
||||
on_hold: "hold",
|
||||
predecessor_locked: "locked",
|
||||
bake_due: "bake",
|
||||
awaiting_signoff: "signoff",
|
||||
idle_warning: "idle",
|
||||
awaiting_qc: "qc",
|
||||
no_parts: "noparts",
|
||||
done: "done",
|
||||
contract_review: "paperwork",
|
||||
// ready / running / *_mine → default yellow (no extra class)
|
||||
};
|
||||
|
||||
export class FpMiniTimeline extends Component {
|
||||
static template = "fusion_plating_shopfloor.MiniTimeline";
|
||||
static props = {
|
||||
timeline: { type: Array },
|
||||
};
|
||||
|
||||
labelFor(area) {
|
||||
return AREA_LABELS[area] || area;
|
||||
}
|
||||
|
||||
classFor(entry) {
|
||||
if (entry.state === "done") return "tl-step done";
|
||||
if (entry.state === "current") {
|
||||
const variant = (entry.variant || "").replace("_mine", "");
|
||||
const cls = VARIANT_TO_CLASS[variant] || "";
|
||||
return cls ? `tl-step current ${cls}` : "tl-step current";
|
||||
}
|
||||
return "tl-step";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/** @odoo-module **/
|
||||
// =====================================================================
|
||||
// FpPlantCard — Variant C card for the plant-view kanban.
|
||||
// Renders the full job summary + 9-step mini-timeline. Tap opens the
|
||||
// Job Workspace.
|
||||
//
|
||||
// All formatting / class composition happens in JS — per project rule
|
||||
// 20, OWL templates can't call String(), Number(), etc. as functions.
|
||||
// =====================================================================
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { FpMiniTimeline } from "./mini_timeline";
|
||||
|
||||
const TAG_LABELS = {
|
||||
rush: "RUSH",
|
||||
fair: "FAIR",
|
||||
vip: "VIP",
|
||||
as9100: "AS9100",
|
||||
};
|
||||
|
||||
export class FpPlantCard extends Component {
|
||||
static template = "fusion_plating_shopfloor.PlantCard";
|
||||
static components = { FpMiniTimeline };
|
||||
static props = {
|
||||
card: { type: Object },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.action = useService("action");
|
||||
}
|
||||
|
||||
get cardClass() {
|
||||
const c = this.props.card;
|
||||
const classes = ["o_fp_plant_card", "state-" + (c.card_state || "ready")];
|
||||
if (c.is_mine) classes.push("mine");
|
||||
if (c.is_overdue) classes.push("overdue");
|
||||
return classes.join(" ");
|
||||
}
|
||||
|
||||
get progressStyle() {
|
||||
const c = this.props.card;
|
||||
if (!c.step_total) return "width: 0%";
|
||||
const pct = Math.round((c.step_seq / c.step_total) * 100);
|
||||
return "width: " + pct + "%";
|
||||
}
|
||||
|
||||
tagChipClass(tag) {
|
||||
return "chip tag-" + tag;
|
||||
}
|
||||
|
||||
tagLabel(tag) {
|
||||
return TAG_LABELS[tag] || tag.toUpperCase();
|
||||
}
|
||||
|
||||
stateChipClass(kind) {
|
||||
return "chip kind-" + (kind || "ready");
|
||||
}
|
||||
|
||||
onCardClick() {
|
||||
const c = this.props.card;
|
||||
if (!c.job_id) return;
|
||||
this.action.doAction({
|
||||
type: "ir.actions.client",
|
||||
tag: "fp_job_workspace",
|
||||
target: "current",
|
||||
params: { job_id: c.job_id },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/** @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.
|
||||
//
|
||||
// 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
|
||||
// - Per project rule 20, no String()/Number()/etc. in templates —
|
||||
// all coercion happens here in JS-land.
|
||||
// =====================================================================
|
||||
|
||||
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 { FpTabletLock } from "./tablet_lock";
|
||||
import { FpPlantCard } from "./components/plant_card";
|
||||
import { FpColumnHeader } from "./components/column_header";
|
||||
import { FpKpiTile } from "./components/kpi_tile";
|
||||
import { FpFilterChip } from "./components/filter_chip";
|
||||
|
||||
const LOCAL_FILTER_KEY = "fp_plant_kanban_filters";
|
||||
|
||||
export class FpPlantKanban extends Component {
|
||||
static template = "fusion_plating_shopfloor.PlantKanban";
|
||||
static props = ["*"];
|
||||
static components = {
|
||||
FpTabletLock,
|
||||
FpPlantCard,
|
||||
FpColumnHeader,
|
||||
FpKpiTile,
|
||||
FpFilterChip,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.action = useService("action");
|
||||
// techStore may not be registered until first PIN unlock; guard with try.
|
||||
try {
|
||||
this.techStore = useService("fp_shopfloor_tech_store");
|
||||
} catch {
|
||||
this.techStore = null;
|
||||
}
|
||||
|
||||
this.state = useState({
|
||||
mode: "station",
|
||||
filters: this._loadFilters(),
|
||||
data: null,
|
||||
loading: true,
|
||||
search: "",
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await this.refresh();
|
||||
this._poll = setInterval(() => this.refresh(), 10000);
|
||||
});
|
||||
onWillUnmount(() => {
|
||||
if (this._poll) clearInterval(this._poll);
|
||||
});
|
||||
}
|
||||
|
||||
_loadFilters() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCAL_FILTER_KEY);
|
||||
return raw ? JSON.parse(raw) : { all: true };
|
||||
} catch {
|
||||
return { all: true };
|
||||
}
|
||||
}
|
||||
_saveFilters() {
|
||||
try {
|
||||
localStorage.setItem(LOCAL_FILTER_KEY, JSON.stringify(this.state.filters));
|
||||
} catch { /* localStorage may be disabled */ }
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
const res = await rpc("/fp/landing/plant_kanban", {
|
||||
mode: this.state.mode,
|
||||
filters: this.state.filters,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.state.data = res;
|
||||
} else if (res && res.error) {
|
||||
this.notification.add(res.error, { type: "danger" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message || String(err), { type: "danger" });
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
toggleFilter(name) {
|
||||
if (name === "all") {
|
||||
this.state.filters = { all: true };
|
||||
} else {
|
||||
delete this.state.filters.all;
|
||||
this.state.filters[name] = !this.state.filters[name];
|
||||
const anyActive = Object.keys(this.state.filters)
|
||||
.some(k => this.state.filters[k]);
|
||||
if (!anyActive) {
|
||||
this.state.filters = { all: true };
|
||||
}
|
||||
}
|
||||
this._saveFilters();
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
setMode(mode) {
|
||||
this.state.mode = mode;
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
modeClass(mode) {
|
||||
return this.state.mode === mode ? "mode-btn active" : "mode-btn";
|
||||
}
|
||||
|
||||
onSearchInput(ev) {
|
||||
this.state.search = (ev.target.value || "").toLowerCase();
|
||||
}
|
||||
|
||||
filteredCardIds(column) {
|
||||
// Client-side search filter on top of the server-side filtered set.
|
||||
if (!this.state.search) return column.card_ids;
|
||||
const term = this.state.search;
|
||||
return column.card_ids.filter(id => {
|
||||
const c = this.state.data.cards[id];
|
||||
if (!c) return false;
|
||||
return (
|
||||
(c.wo_name || "").toLowerCase().includes(term)
|
||||
|| (c.customer || "").toLowerCase().includes(term)
|
||||
|| (c.part_number || "").toLowerCase().includes(term)
|
||||
|| (c.po_number || "").toLowerCase().includes(term)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
onHandOff() {
|
||||
if (this.techStore && this.techStore.lock) {
|
||||
this.techStore.lock();
|
||||
}
|
||||
}
|
||||
|
||||
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" });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_plant_kanban", FpPlantKanban);
|
||||
@@ -0,0 +1,80 @@
|
||||
// =====================================================================
|
||||
// Plant-view kanban — design tokens
|
||||
// MUST load BEFORE the component SCSS files. SCSS @import is forbidden
|
||||
// in custom Odoo 19 SCSS (project rule 8); the manifest concatenates
|
||||
// files in registration order, so this file's $vars are visible to
|
||||
// every later file.
|
||||
// =====================================================================
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
// === Light-mode defaults ===
|
||||
$_plant-bg-hex: #f8f9fa;
|
||||
$_plant-card-bg-hex: #ffffff;
|
||||
$_plant-card-border-hex: #d8dadd;
|
||||
$_plant-text-hex: #1d1f1e;
|
||||
$_plant-muted-hex: #777;
|
||||
|
||||
$_plant-mine-bg-hex: #fffaeb;
|
||||
$_plant-mine-border-hex: #f0a500;
|
||||
$_plant-hold-bg-hex: #fff5f5;
|
||||
$_plant-hold-border-hex: #dc3545;
|
||||
$_plant-bake-bg-hex: #fff8e1;
|
||||
$_plant-bake-border-hex: #ff9800;
|
||||
$_plant-signoff-bg-hex: #f5f0ff;
|
||||
$_plant-signoff-border-hex: #6f42c1;
|
||||
$_plant-idle-bg-hex: #fef9e7;
|
||||
$_plant-idle-border-hex: #e6a800;
|
||||
$_plant-qc-bg-hex: #e7f5fc;
|
||||
$_plant-qc-border-hex: #17a2b8;
|
||||
$_plant-locked-bg-hex: #f8f9fa;
|
||||
$_plant-locked-border-hex: #6c757d;
|
||||
$_plant-noparts-bg-hex: #f5f5f5;
|
||||
$_plant-noparts-border-hex: #6c757d;
|
||||
$_plant-done-bg-hex: #f0f9f4;
|
||||
$_plant-done-border-hex: #28a745;
|
||||
|
||||
// === Dark-mode overrides (compile-time branch per project rule) ===
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_plant-bg-hex: #1a1d21 !global;
|
||||
$_plant-card-bg-hex: #22262d !global;
|
||||
$_plant-card-border-hex: #424245 !global;
|
||||
$_plant-text-hex: #f5f5f7 !global;
|
||||
$_plant-muted-hex: #adb5bd !global;
|
||||
|
||||
$_plant-mine-bg-hex: #3a2f10 !global;
|
||||
$_plant-hold-bg-hex: #3a1e1e !global;
|
||||
$_plant-bake-bg-hex: #3a2f10 !global;
|
||||
$_plant-signoff-bg-hex: #1f1730 !global;
|
||||
$_plant-idle-bg-hex: #2d2818 !global;
|
||||
$_plant-qc-bg-hex: #14252e !global;
|
||||
$_plant-locked-bg-hex: #2d3138 !global;
|
||||
$_plant-noparts-bg-hex: #2d3138 !global;
|
||||
$_plant-done-bg-hex: #14281a !global;
|
||||
}
|
||||
|
||||
// === CSS-custom-property wrappers so future themes can override ===
|
||||
$plant-bg: var(--fp-plant-bg, $_plant-bg-hex);
|
||||
$plant-card-bg: var(--fp-plant-card-bg, $_plant-card-bg-hex);
|
||||
$plant-card-border: var(--fp-plant-card-border, $_plant-card-border-hex);
|
||||
$plant-text: var(--fp-plant-text, $_plant-text-hex);
|
||||
$plant-muted: var(--fp-plant-muted, $_plant-muted-hex);
|
||||
|
||||
$plant-mine-bg: var(--fp-plant-mine-bg, $_plant-mine-bg-hex);
|
||||
$plant-mine-border: var(--fp-plant-mine-border, $_plant-mine-border-hex);
|
||||
$plant-hold-bg: var(--fp-plant-hold-bg, $_plant-hold-bg-hex);
|
||||
$plant-hold-border: var(--fp-plant-hold-border, $_plant-hold-border-hex);
|
||||
$plant-bake-bg: var(--fp-plant-bake-bg, $_plant-bake-bg-hex);
|
||||
$plant-bake-border: var(--fp-plant-bake-border, $_plant-bake-border-hex);
|
||||
$plant-signoff-bg: var(--fp-plant-signoff-bg, $_plant-signoff-bg-hex);
|
||||
$plant-signoff-border: var(--fp-plant-signoff-border, $_plant-signoff-border-hex);
|
||||
$plant-idle-bg: var(--fp-plant-idle-bg, $_plant-idle-bg-hex);
|
||||
$plant-idle-border: var(--fp-plant-idle-border, $_plant-idle-border-hex);
|
||||
$plant-qc-bg: var(--fp-plant-qc-bg, $_plant-qc-bg-hex);
|
||||
$plant-qc-border: var(--fp-plant-qc-border, $_plant-qc-border-hex);
|
||||
$plant-locked-bg: var(--fp-plant-locked-bg, $_plant-locked-bg-hex);
|
||||
$plant-locked-border: var(--fp-plant-locked-border, $_plant-locked-border-hex);
|
||||
$plant-noparts-bg: var(--fp-plant-noparts-bg, $_plant-noparts-bg-hex);
|
||||
$plant-noparts-border: var(--fp-plant-noparts-border, $_plant-noparts-border-hex);
|
||||
$plant-done-bg: var(--fp-plant-done-bg, $_plant-done-bg-hex);
|
||||
$plant-done-border: var(--fp-plant-done-border, $_plant-done-border-hex);
|
||||
@@ -0,0 +1,45 @@
|
||||
// _column_header.scss — depends on _plant_tokens.scss
|
||||
|
||||
.o_fp_col_header {
|
||||
padding: 6px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
background: $plant-card-bg;
|
||||
border: 1px solid $plant-card-border;
|
||||
border-radius: 6px 6px 0 0;
|
||||
border-bottom: 0;
|
||||
|
||||
&.mine {
|
||||
background: linear-gradient(180deg, $plant-mine-bg 0%, $plant-card-bg 100%);
|
||||
border-color: $plant-mine-border;
|
||||
}
|
||||
|
||||
.col-meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||
.mine-badge {
|
||||
font-size: 9px; font-weight: 700;
|
||||
color: $plant-mine-border;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.col-name {
|
||||
font-size: 12px; font-weight: 700;
|
||||
color: $plant-text;
|
||||
text-transform: uppercase; letter-spacing: 0.02em;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.col-count {
|
||||
font-size: 13px; font-weight: 700;
|
||||
color: $plant-muted;
|
||||
background: $plant-card-bg;
|
||||
padding: 0 6px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid $plant-card-border;
|
||||
min-width: 22px; text-align: center;
|
||||
}
|
||||
&.mine .col-count {
|
||||
border-color: $plant-mine-border;
|
||||
color: $plant-mine-border;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// _filter_chip.scss — depends on _plant_tokens.scss
|
||||
|
||||
.o_fp_filter_chip {
|
||||
padding: 4px 12px;
|
||||
font-size: 11px;
|
||||
background: $plant-card-bg;
|
||||
border: 1px solid $plant-card-border;
|
||||
border-radius: 14px;
|
||||
color: $plant-muted;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
|
||||
&.active {
|
||||
background: #1d4ed8;
|
||||
border-color: #1d4ed8;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
&:hover:not(.active) { background: $plant-bg; }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// _kpi_tile.scss — depends on _plant_tokens.scss
|
||||
|
||||
.o_fp_kpi_tile {
|
||||
padding: 6px 10px;
|
||||
background: $plant-card-bg;
|
||||
border-radius: 6px;
|
||||
border: 1px solid $plant-card-border;
|
||||
display: flex; flex-direction: column; gap: 1px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
text-align: left;
|
||||
color: $plant-text;
|
||||
font-family: inherit;
|
||||
|
||||
&:hover { background: $plant-bg; }
|
||||
&.active {
|
||||
border-color: $plant-mine-border;
|
||||
background: $plant-mine-bg;
|
||||
}
|
||||
&.urgent .kpi-val { color: $plant-hold-border; }
|
||||
&.warn .kpi-val { color: $plant-idle-border; }
|
||||
&.good .kpi-val { color: $plant-done-border; }
|
||||
|
||||
.kpi-val {
|
||||
font-size: 20px; font-weight: 700;
|
||||
color: $plant-text; line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.kpi-lbl {
|
||||
font-size: 9px; font-weight: 600;
|
||||
color: $plant-muted;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// _mini_timeline.scss — depends on _plant_tokens.scss
|
||||
|
||||
.o_fp_mini_timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.tl-row {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 2px 0;
|
||||
|
||||
.tl-step {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 1.5px;
|
||||
cursor: help;
|
||||
|
||||
&.done { background: #28a745; }
|
||||
&.current {
|
||||
background: #f0a500;
|
||||
height: 11px;
|
||||
margin-top: -1.5px;
|
||||
box-shadow: 0 0 0 1px rgba(240, 165, 0, 0.25);
|
||||
&.hold { background: $plant-hold-border; }
|
||||
&.locked { background: $plant-locked-border; }
|
||||
&.bake { background: $plant-bake-border; }
|
||||
&.signoff { background: $plant-signoff-border; }
|
||||
&.idle { background: $plant-idle-border; }
|
||||
&.qc { background: $plant-qc-border; }
|
||||
&.noparts { background: $plant-noparts-border; }
|
||||
&.done { background: $plant-done-border; }
|
||||
&.paperwork { background: $plant-signoff-border; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tl-labels {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
font-size: 8px;
|
||||
color: $plant-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
span {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
&.current { color: $plant-mine-border; font-weight: 700; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
.o_fp_mini_timeline .tl-row .tl-step { background: #2d3138; }
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// _plant_card.scss — depends on _plant_tokens.scss
|
||||
|
||||
.o_fp_plant_card {
|
||||
background: $plant-card-bg;
|
||||
border: 1px solid $plant-card-border;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s, box-shadow 0.1s;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
color: $plant-text;
|
||||
|
||||
&:hover { transform: translateY(-1px); box-shadow: 0 3px 8px rgba(0,0,0,0.12); }
|
||||
|
||||
// === Card state chrome ===
|
||||
&.mine,
|
||||
&.state-ready_mine,
|
||||
&.state-running_mine {
|
||||
background: $plant-mine-bg;
|
||||
border-left: 4px solid $plant-mine-border;
|
||||
padding-left: 7px;
|
||||
}
|
||||
&.state-on_hold {
|
||||
background: $plant-hold-bg;
|
||||
border-left: 4px solid $plant-hold-border;
|
||||
padding-left: 7px;
|
||||
}
|
||||
&.state-bake_due {
|
||||
background: $plant-bake-bg;
|
||||
border-left: 4px solid $plant-bake-border;
|
||||
padding-left: 7px;
|
||||
}
|
||||
&.state-awaiting_signoff {
|
||||
background: $plant-signoff-bg;
|
||||
border-left: 4px solid $plant-signoff-border;
|
||||
padding-left: 7px;
|
||||
}
|
||||
&.state-idle_warning {
|
||||
background: $plant-idle-bg;
|
||||
border-left: 4px solid $plant-idle-border;
|
||||
padding-left: 7px;
|
||||
}
|
||||
&.state-awaiting_qc {
|
||||
background: $plant-qc-bg;
|
||||
border-left: 4px solid $plant-qc-border;
|
||||
padding-left: 7px;
|
||||
}
|
||||
&.state-predecessor_locked {
|
||||
background: $plant-locked-bg;
|
||||
}
|
||||
&.state-no_parts {
|
||||
background: $plant-noparts-bg;
|
||||
border: 1px dashed #999;
|
||||
border-left: 4px solid $plant-noparts-border;
|
||||
padding-left: 7px;
|
||||
}
|
||||
&.state-done {
|
||||
background: $plant-done-bg;
|
||||
border-left: 4px solid $plant-done-border;
|
||||
padding-left: 7px;
|
||||
}
|
||||
&.overdue:not(.mine):not(.state-on_hold):not(.state-bake_due) {
|
||||
border-left: 4px solid $plant-hold-border;
|
||||
padding-left: 7px;
|
||||
}
|
||||
|
||||
// === Sub-elements ===
|
||||
.card-top { display: flex; align-items: baseline; justify-content: space-between; gap: 6px; }
|
||||
.card-wo { font-size: 13px; font-weight: 700; color: $plant-text; }
|
||||
.card-due { font-size: 10px; color: $plant-muted; white-space: nowrap; }
|
||||
.card-due.overdue { color: $plant-hold-border; font-weight: 700; }
|
||||
.card-sub { font-size: 10px; color: $plant-muted; line-height: 1.3; }
|
||||
.card-sub-em { color: $plant-text; font-weight: 600; }
|
||||
.card-meta { font-size: 10px; color: $plant-muted; }
|
||||
.card-step { font-size: 12px; font-weight: 600; color: $plant-text; margin-top: 2px; }
|
||||
.card-chips { display: flex; flex-wrap: wrap; gap: 3px; }
|
||||
|
||||
.chip {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
background: #f1f3f5;
|
||||
color: #4e4e4e;
|
||||
border: 1px solid #e5e7eb;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
&.tank { background: #e7f1ff; color: #0d4a8c; border-color: #cfe2ff; }
|
||||
&.kind-ready { background: #d1ecf1; color: #0c5460; border-color: #bee5eb; font-weight: 600; }
|
||||
&.kind-running{ background: #fff3cd; color: #856404; border-color: #ffeeba; font-weight: 600; }
|
||||
&.kind-hold { background: #f8d7da; color: #721c24; border-color: #f5c6cb; font-weight: 700; }
|
||||
&.kind-locked { background: #e2e3e5; color: #383d41; border-color: #d6d8db; font-weight: 600; }
|
||||
&.kind-due { background: #ffe9c6; color: #8a4a00; border-color: #ffd28a; font-weight: 700; }
|
||||
&.kind-signoff{ background: #e8d9ff; color: #4a2db0; border-color: #d4c5ff; font-weight: 700; }
|
||||
&.kind-idle { background: #fff3cd; color: #856404; border-color: #ffeeba; font-weight: 700; }
|
||||
&.kind-qc { background: #c4e9f3; color: #0c5460; border-color: #a8dde9; font-weight: 700; }
|
||||
&.kind-no_parts { background: #e2e3e5; color: #383d41; border-color: #d6d8db; font-weight: 700; }
|
||||
&.kind-done { background: #d4edda; color: #155724; border-color: #c3e6cb; font-weight: 700; }
|
||||
&.kind-paperwork { background: #e8e0ff; color: #4a2db0; border-color: #d4c5ff; font-weight: 600; }
|
||||
&.tag-rush { background: #ffe5e5; color: #b00; border-color: #ffcfcf; font-weight: 700; font-size: 9px; }
|
||||
&.tag-fair { background: #fff0d9; color: #8a4a00; border-color: #ffe0b3; font-weight: 700; font-size: 9px; }
|
||||
&.tag-vip { background: #e8e0ff; color: #4a2db0; border-color: #d4c5ff; font-weight: 700; font-size: 9px; }
|
||||
&.tag-as9100 { background: #d4edda; color: #155724; border-color: #c3e6cb; font-weight: 700; font-size: 9px; }
|
||||
}
|
||||
|
||||
.card-bottom {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 6px; padding-top: 4px; margin-top: 2px;
|
||||
border-top: 1px solid #f1f3f5;
|
||||
font-size: 9px; color: $plant-muted;
|
||||
}
|
||||
.progress { display: flex; align-items: center; gap: 4px; flex: 1; }
|
||||
.progress-bar { flex: 1; max-width: 60px; height: 3px; background: #e5e7eb; border-radius: 1.5px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: $plant-mine-border; border-radius: 1.5px; }
|
||||
.operator-pill {
|
||||
display: inline-flex; align-items: center;
|
||||
background: #f1f3f5; border-radius: 8px;
|
||||
padding: 0 4px 0 1px; font-size: 9px; border: 1px solid #e5e7eb;
|
||||
}
|
||||
.operator-avatar {
|
||||
width: 12px; height: 12px; border-radius: 50%;
|
||||
background: #4caf50; color: #fff;
|
||||
font-size: 7px; font-weight: 700;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.icon-row { display: flex; gap: 3px; font-size: 10px; }
|
||||
}
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
.o_fp_plant_card .card-bottom { border-top-color: #424245; }
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// plant_kanban.scss — depends on _plant_tokens.scss and the component partials
|
||||
|
||||
.o_fp_plant_kanban {
|
||||
padding: 8px;
|
||||
background: $plant-bg;
|
||||
min-height: 100vh;
|
||||
color: $plant-text;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
|
||||
.floor-header {
|
||||
background: $plant-card-bg;
|
||||
border: 1px solid $plant-card-border;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.floor-header-top {
|
||||
display: flex; justify-content: space-between; gap: 12px;
|
||||
align-items: center; flex-wrap: wrap;
|
||||
}
|
||||
.floor-title { font-size: 16px; font-weight: 700; }
|
||||
.floor-controls { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||
|
||||
.station-picker {
|
||||
padding: 5px 10px;
|
||||
background: $plant-mine-bg;
|
||||
border: 1px solid $plant-mine-border;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #856404;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.mode-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid $plant-card-border;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
.mode-btn {
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: $plant-card-bg;
|
||||
color: $plant-muted;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
border-right: 1px solid $plant-card-border;
|
||||
font-family: inherit;
|
||||
&:last-child { border-right: 0; }
|
||||
&.active { background: #1d4ed8; color: #fff; }
|
||||
&:hover:not(.active) { background: $plant-bg; }
|
||||
}
|
||||
}
|
||||
.toolbar-btn {
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
background: $plant-card-bg;
|
||||
border: 1px solid $plant-card-border;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: $plant-text;
|
||||
font-family: inherit;
|
||||
&:hover { background: $plant-bg; }
|
||||
&.handoff {
|
||||
background: #ffc107;
|
||||
border-color: #d39e00;
|
||||
color: #856404;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.kpi-strip { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; }
|
||||
|
||||
.search-row { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
|
||||
.search-input {
|
||||
flex: 1; min-width: 200px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid $plant-card-border;
|
||||
border-radius: 6px;
|
||||
background: $plant-card-bg;
|
||||
color: $plant-text;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(9, 1fr);
|
||||
gap: 4px;
|
||||
min-height: 520px;
|
||||
}
|
||||
.col {
|
||||
background: $plant-bg;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 110px;
|
||||
&.mine {
|
||||
background: linear-gradient(180deg, $plant-mine-bg 0%, $plant-card-bg 100%);
|
||||
border: 1px solid $plant-mine-border;
|
||||
}
|
||||
}
|
||||
.col-scroll {
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 2px;
|
||||
max-height: calc(100vh - 280px);
|
||||
min-height: 100px;
|
||||
}
|
||||
.col-empty {
|
||||
font-size: 10px;
|
||||
color: $plant-muted;
|
||||
font-style: italic;
|
||||
padding: 14px 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: $plant-muted;
|
||||
}
|
||||
}
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
.o_fp_plant_kanban .toolbar-btn.handoff {
|
||||
color: #856404; // keep gold legible on dark
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.ColumnHeader">
|
||||
<div t-att-class="props.column.is_mine ? 'o_fp_col_header mine' : 'o_fp_col_header'">
|
||||
<div class="col-meta">
|
||||
<div t-if="props.column.is_mine" class="mine-badge">📍 You're here</div>
|
||||
<div class="col-name" t-esc="props.column.label"/>
|
||||
</div>
|
||||
<span class="col-count" t-esc="cardCount"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.FilterChip">
|
||||
<button t-att-class="props.active ? 'o_fp_filter_chip active' : 'o_fp_filter_chip'"
|
||||
t-on-click="onClick"
|
||||
t-esc="props.label"/>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.KpiTile">
|
||||
<button t-att-class="tileClass" t-on-click="onClick">
|
||||
<div class="kpi-val" t-esc="props.value"/>
|
||||
<div class="kpi-lbl" t-esc="props.label"/>
|
||||
</button>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.MiniTimeline">
|
||||
<div class="o_fp_mini_timeline">
|
||||
<div class="tl-row">
|
||||
<t t-foreach="props.timeline" t-as="entry" t-key="entry_index">
|
||||
<span t-att-class="classFor(entry)" t-att-title="entry.area"/>
|
||||
</t>
|
||||
</div>
|
||||
<div class="tl-labels">
|
||||
<t t-foreach="props.timeline" t-as="entry" t-key="entry_index">
|
||||
<span t-att-class="entry.state === 'current' ? 'current' : ''"
|
||||
t-esc="labelFor(entry.area)"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,80 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.PlantCard">
|
||||
<div t-att-class="cardClass" t-on-click="onCardClick">
|
||||
<!-- Header: WO + due -->
|
||||
<div class="card-top">
|
||||
<div class="card-wo">
|
||||
<t t-esc="props.card.wo_name"/>
|
||||
<span t-if="props.card.is_mine"> ⭐</span>
|
||||
</div>
|
||||
<div t-att-class="props.card.is_overdue ? 'card-due overdue' : 'card-due'">
|
||||
<span t-if="props.card.is_overdue">⚠ </span>
|
||||
<t t-esc="props.card.due_label"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customer -->
|
||||
<div class="card-sub" t-esc="props.card.customer"/>
|
||||
|
||||
<!-- PN / Qty / PO -->
|
||||
<div class="card-sub">
|
||||
<t t-if="props.card.part_number">
|
||||
PN <span class="card-sub-em">
|
||||
<t t-esc="props.card.part_number"/>
|
||||
<t t-if="props.card.part_revision"> Rev <t t-esc="props.card.part_revision"/></t>
|
||||
</span> ·
|
||||
</t>
|
||||
Qty <span class="card-sub-em" t-esc="props.card.qty"/>
|
||||
<t t-if="props.card.po_number"> · PO <t t-esc="props.card.po_number"/></t>
|
||||
</div>
|
||||
|
||||
<!-- Recipe + spec (compact line) -->
|
||||
<div t-if="props.card.recipe_name or props.card.spec_code" class="card-meta">
|
||||
<t t-if="props.card.recipe_name" t-esc="props.card.recipe_name"/>
|
||||
<t t-if="props.card.recipe_name and props.card.spec_code"> · </t>
|
||||
<t t-if="props.card.spec_code" t-esc="props.card.spec_code"/>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div t-if="props.card.tags.length" class="card-chips">
|
||||
<t t-foreach="props.card.tags" t-as="tag" t-key="tag">
|
||||
<span t-att-class="tagChipClass(tag)" t-esc="tagLabel(tag)"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Step name -->
|
||||
<div class="card-step" t-esc="props.card.step_name"/>
|
||||
|
||||
<!-- Tank + state chip -->
|
||||
<div class="card-chips">
|
||||
<span t-if="props.card.tank_label" class="chip tank" t-esc="props.card.tank_label"/>
|
||||
<span t-att-class="stateChipClass(props.card.state_chip.kind)"
|
||||
t-esc="props.card.state_chip.label"/>
|
||||
</div>
|
||||
|
||||
<!-- Mini-timeline -->
|
||||
<FpMiniTimeline timeline="props.card.mini_timeline"/>
|
||||
|
||||
<!-- Footer: progress + operator + icons -->
|
||||
<div class="card-bottom">
|
||||
<div class="progress">
|
||||
<span><t t-esc="props.card.step_seq"/>/<t t-esc="props.card.step_total"/></span>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" t-att-style="progressStyle"/>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="props.card.operator and props.card.operator.initials" class="operator-pill">
|
||||
<span class="operator-avatar" t-esc="props.card.operator.initials"/>
|
||||
</div>
|
||||
<div t-if="props.card.icons.length" class="icon-row">
|
||||
<t t-foreach="props.card.icons" t-as="icon" t-key="icon_index">
|
||||
<span t-esc="icon"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,107 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.PlantKanban">
|
||||
<FpTabletLock>
|
||||
<t t-set-slot="default">
|
||||
<div class="o_fp_plant_kanban">
|
||||
|
||||
<!-- ============== STICKY HEADER ============== -->
|
||||
<div class="floor-header">
|
||||
<div class="floor-header-top">
|
||||
<div class="floor-title">🏭 Shop Floor</div>
|
||||
<div class="floor-controls">
|
||||
<button t-if="state.data and state.data.paired_station"
|
||||
class="station-picker">
|
||||
📍 <t t-esc="state.data.paired_station.name"/>
|
||||
</button>
|
||||
<div class="mode-toggle">
|
||||
<button t-att-class="modeClass('station')"
|
||||
t-on-click="() => this.setMode('station')">Station</button>
|
||||
<button t-att-class="modeClass('all_plant')"
|
||||
t-on-click="() => this.setMode('all_plant')">All Plant</button>
|
||||
<button t-att-class="modeClass('manager')"
|
||||
t-on-click="() => this.setMode('manager')">Manager</button>
|
||||
</div>
|
||||
<button class="toolbar-btn" t-on-click="onScanQr">📷 Scan QR</button>
|
||||
<button class="toolbar-btn handoff" t-on-click="onHandOff">🔓 Hand Off</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI strip -->
|
||||
<div t-if="state.data" class="kpi-strip">
|
||||
<FpKpiTile value="state.data.kpis.active_jobs"
|
||||
label="'Active Jobs'"
|
||||
kind="'good'"
|
||||
active="!!state.filters.all"
|
||||
onClick="() => this.toggleFilter('all')"/>
|
||||
<FpKpiTile value="state.data.kpis.at_my_station"
|
||||
label="'At My Station'"
|
||||
active="!!state.filters.mine"
|
||||
onClick="() => this.toggleFilter('mine')"/>
|
||||
<FpKpiTile value="state.data.kpis.bakes_due_soon"
|
||||
label="'Bakes Due ≤2h'"
|
||||
kind="'warn'"/>
|
||||
<FpKpiTile value="state.data.kpis.on_hold"
|
||||
label="'On Hold'"
|
||||
kind="'urgent'"
|
||||
active="!!state.filters.on_hold"
|
||||
onClick="() => this.toggleFilter('on_hold')"/>
|
||||
<FpKpiTile value="state.data.kpis.overdue"
|
||||
label="'Overdue'"
|
||||
kind="'urgent'"
|
||||
active="!!state.filters.overdue"
|
||||
onClick="() => this.toggleFilter('overdue')"/>
|
||||
</div>
|
||||
|
||||
<!-- Search + filter chips -->
|
||||
<div class="search-row">
|
||||
<input class="search-input"
|
||||
placeholder="🔎 Search WO #, customer, part #, PO..."
|
||||
t-on-input="onSearchInput"
|
||||
t-att-value="state.search"/>
|
||||
<FpFilterChip label="'All'"
|
||||
active="!!state.filters.all"
|
||||
onToggle="() => this.toggleFilter('all')"/>
|
||||
<FpFilterChip label="'My Station'"
|
||||
active="!!state.filters.mine"
|
||||
onToggle="() => this.toggleFilter('mine')"/>
|
||||
<FpFilterChip label="'Running'"
|
||||
active="!!state.filters.running"
|
||||
onToggle="() => this.toggleFilter('running')"/>
|
||||
<FpFilterChip label="'Blocked'"
|
||||
active="!!state.filters.blocked"
|
||||
onToggle="() => this.toggleFilter('blocked')"/>
|
||||
<FpFilterChip label="'Overdue'"
|
||||
active="!!state.filters.overdue"
|
||||
onToggle="() => this.toggleFilter('overdue')"/>
|
||||
<FpFilterChip label="'FAIR'"
|
||||
active="!!state.filters.fair"
|
||||
onToggle="() => this.toggleFilter('fair')"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============== KANBAN BOARD ============== -->
|
||||
<div t-if="state.data" class="board">
|
||||
<t t-foreach="state.data.columns" t-as="col" t-key="col.area_kind">
|
||||
<div t-att-class="col.is_mine ? 'col mine' : 'col'">
|
||||
<FpColumnHeader column="col"/>
|
||||
<div class="col-scroll">
|
||||
<t t-foreach="filteredCardIds(col)" t-as="card_id" t-key="card_id">
|
||||
<FpPlantCard card="state.data.cards[card_id]"/>
|
||||
</t>
|
||||
<div t-if="filteredCardIds(col).length === 0" class="col-empty">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<div t-if="state.loading and !state.data" class="loading">
|
||||
<i class="fa fa-spinner fa-spin"/> Loading…
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</FpTabletLock>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
Reference in New Issue
Block a user