changes
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — Plant Overview Dashboard (OWL backend client action)
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
//
|
||||
// Steelhead-style multi-column kanban showing all active work orders grouped
|
||||
// by work centre / station. Auto-refreshes every 30 s.
|
||||
//
|
||||
// Odoo 19 conventions:
|
||||
// * Backend OWL component: `static template` + `static props = ["*"]`
|
||||
// * RPC via standalone `rpc()` from @web/core/network/rpc
|
||||
// * Registered under registry.category("actions") → "fp_plant_overview"
|
||||
// =============================================================================
|
||||
|
||||
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";
|
||||
|
||||
export class PlantOverview extends Component {
|
||||
static template = "fusion_plating_shopfloor.PlantOverview";
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.action = useService("action");
|
||||
|
||||
this.state = useState({
|
||||
facilityName: "",
|
||||
columns: [],
|
||||
searchTerm: "",
|
||||
loading: false,
|
||||
lastRefresh: null,
|
||||
});
|
||||
|
||||
this._refreshInterval = null;
|
||||
|
||||
onMounted(async () => {
|
||||
await this.loadData();
|
||||
// Auto-refresh every 30 seconds
|
||||
this._refreshInterval = setInterval(() => this.loadData(), 30000);
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
if (this._refreshInterval) {
|
||||
clearInterval(this._refreshInterval);
|
||||
this._refreshInterval = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Data loading ------------------------------------------------------
|
||||
|
||||
async loadData() {
|
||||
this.state.loading = true;
|
||||
try {
|
||||
const result = await rpc("/fp/shopfloor/plant_overview", {
|
||||
search: this.state.searchTerm || null,
|
||||
});
|
||||
if (result) {
|
||||
this.state.facilityName = result.facility_name || "Plant 1";
|
||||
this.state.columns = result.columns || [];
|
||||
this.state.lastRefresh = new Date().toLocaleTimeString();
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(
|
||||
`Failed to load plant overview: ${err.message || err}`,
|
||||
{ type: "danger" },
|
||||
);
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Search ------------------------------------------------------------
|
||||
|
||||
onSearchInput(ev) {
|
||||
this.state.searchTerm = ev.target.value;
|
||||
}
|
||||
|
||||
onSearchKey(ev) {
|
||||
if (ev.key === "Enter") {
|
||||
this.loadData();
|
||||
}
|
||||
}
|
||||
|
||||
onSearchClear() {
|
||||
this.state.searchTerm = "";
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
onRefresh() {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
// ----- Card actions ------------------------------------------------------
|
||||
|
||||
onCardClick(card) {
|
||||
if (!card.id) {
|
||||
return;
|
||||
}
|
||||
// Try opening the work order form if MRP is available, otherwise
|
||||
// fall back to bake window or first-piece gate
|
||||
const model = card.source_model || "mrp.workorder";
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: model,
|
||||
res_id: card.id,
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Helpers -----------------------------------------------------------
|
||||
|
||||
getTagClass(tag) {
|
||||
const lower = (tag || "").toLowerCase();
|
||||
if (lower === "hot") {
|
||||
return "o_fp_tag_hot";
|
||||
}
|
||||
if (lower === "priority" || lower === "high priority") {
|
||||
return "o_fp_tag_priority";
|
||||
}
|
||||
if (lower.includes("attention") || lower.includes("special")) {
|
||||
return "o_fp_tag_attention";
|
||||
}
|
||||
return "o_fp_tag_default";
|
||||
}
|
||||
|
||||
getStateClass(state) {
|
||||
switch (state) {
|
||||
case "progress":
|
||||
return "o_fp_card_progress";
|
||||
case "ready":
|
||||
return "o_fp_card_ready";
|
||||
case "done":
|
||||
return "o_fp_card_done";
|
||||
case "pending":
|
||||
return "o_fp_card_pending";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_plant_overview", PlantOverview);
|
||||
@@ -0,0 +1,165 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — Process Tree View (OWL backend client action)
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
//
|
||||
// Visual routing-step tree for a single manufacturing order showing progress
|
||||
// bars per work order.
|
||||
//
|
||||
// Odoo 19 conventions:
|
||||
// * Backend OWL component: `static template` + `static props = ["*"]`
|
||||
// * RPC via standalone `rpc()` from @web/core/network/rpc
|
||||
// * Registered under registry.category("actions") → "fp_process_tree"
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState, onMounted } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class ProcessTree extends Component {
|
||||
static template = "fusion_plating_shopfloor.ProcessTree";
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.action = useService("action");
|
||||
|
||||
this.state = useState({
|
||||
productionName: "",
|
||||
productName: "",
|
||||
moState: "",
|
||||
nodes: [],
|
||||
loading: false,
|
||||
collapsed: {}, // node id → boolean
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await this.loadTree();
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Data loading ------------------------------------------------------
|
||||
|
||||
get productionId() {
|
||||
// Client action may receive production_id via action context or params
|
||||
const ctx = this.props.action && this.props.action.context;
|
||||
if (ctx && ctx.production_id) {
|
||||
return ctx.production_id;
|
||||
}
|
||||
const params = this.props.action && this.props.action.params;
|
||||
if (params && params.production_id) {
|
||||
return params.production_id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async loadTree() {
|
||||
const prodId = this.productionId;
|
||||
if (!prodId) {
|
||||
this.notification.add(
|
||||
"No manufacturing order specified for the process tree.",
|
||||
{ type: "warning" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.state.loading = true;
|
||||
try {
|
||||
const result = await rpc("/fp/shopfloor/process_tree", {
|
||||
production_id: prodId,
|
||||
});
|
||||
if (result) {
|
||||
this.state.productionName = result.production_name || "";
|
||||
this.state.productName = result.product_name || "";
|
||||
this.state.moState = result.state || "";
|
||||
this.state.nodes = result.nodes || [];
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(
|
||||
`Failed to load process tree: ${err.message || err}`,
|
||||
{ type: "danger" },
|
||||
);
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Collapse / expand -------------------------------------------------
|
||||
|
||||
toggleNode(nodeId) {
|
||||
this.state.collapsed[nodeId] = !this.state.collapsed[nodeId];
|
||||
}
|
||||
|
||||
isCollapsed(nodeId) {
|
||||
return !!this.state.collapsed[nodeId];
|
||||
}
|
||||
|
||||
// ----- Navigation --------------------------------------------------------
|
||||
|
||||
onNodeClick(node) {
|
||||
if (!node.workorder_id) {
|
||||
return;
|
||||
}
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: "mrp.workorder",
|
||||
res_id: node.workorder_id,
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
onBackToOverview() {
|
||||
this.action.doAction("fusion_plating_shopfloor.action_fp_plant_overview");
|
||||
}
|
||||
|
||||
// ----- Helpers -----------------------------------------------------------
|
||||
|
||||
getProgressPct(node) {
|
||||
if (!node.qty_total || node.qty_total === 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round((node.qty_done / node.qty_total) * 100);
|
||||
}
|
||||
|
||||
getProgressClass(node) {
|
||||
const pct = this.getProgressPct(node);
|
||||
if (pct >= 100) {
|
||||
return "o_fp_tree_progress_done";
|
||||
}
|
||||
if (pct > 0) {
|
||||
return "o_fp_tree_progress_active";
|
||||
}
|
||||
return "o_fp_tree_progress_empty";
|
||||
}
|
||||
|
||||
getNodeStateLabel(state) {
|
||||
const map = {
|
||||
pending: "Pending",
|
||||
waiting: "Waiting",
|
||||
ready: "Ready",
|
||||
progress: "In Progress",
|
||||
done: "Done",
|
||||
cancel: "Cancelled",
|
||||
};
|
||||
return map[state] || state || "—";
|
||||
}
|
||||
|
||||
getNodeStateClass(state) {
|
||||
switch (state) {
|
||||
case "done":
|
||||
return "o_fp_tree_state_done";
|
||||
case "progress":
|
||||
return "o_fp_tree_state_progress";
|
||||
case "ready":
|
||||
return "o_fp_tree_state_ready";
|
||||
case "cancel":
|
||||
return "o_fp_tree_state_cancel";
|
||||
default:
|
||||
return "o_fp_tree_state_pending";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_process_tree", ProcessTree);
|
||||
@@ -0,0 +1,178 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — Shop Floor Tablet (OWL backend client action)
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
//
|
||||
// Odoo 19 conventions:
|
||||
// * Backend OWL component using `static template` + `static props = []`
|
||||
// (note: empty array, NOT empty object).
|
||||
// * RPC via standalone `rpc()` from @web/core/network/rpc — NOT useService.
|
||||
// * Registered under registry.category("actions") so the menu / record
|
||||
// action can launch it as a client action ("fp_shopfloor_tablet").
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState, onMounted, useRef } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class ShopfloorTablet extends Component {
|
||||
static template = "fusion_plating_shopfloor.ShopfloorTablet";
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.scanInput = useRef("scanInput");
|
||||
|
||||
this.state = useState({
|
||||
scannedCode: "",
|
||||
station: null,
|
||||
currentTank: null,
|
||||
currentBath: null,
|
||||
currentJob: null,
|
||||
queueRows: [],
|
||||
message: "",
|
||||
messageType: "info", // info | success | warning | danger
|
||||
loading: false,
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await this.refreshQueue();
|
||||
if (this.scanInput.el) {
|
||||
this.scanInput.el.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ----- Helpers --------------------------------------------------------
|
||||
setMessage(text, type = "info") {
|
||||
this.state.message = text;
|
||||
this.state.messageType = type;
|
||||
}
|
||||
|
||||
clearTargets() {
|
||||
this.state.currentTank = null;
|
||||
this.state.currentBath = null;
|
||||
this.state.currentJob = null;
|
||||
}
|
||||
|
||||
// ----- QR scan --------------------------------------------------------
|
||||
async onScan() {
|
||||
const code = (this.state.scannedCode || "").trim();
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
this.state.loading = true;
|
||||
try {
|
||||
const result = await rpc("/fp/shopfloor/scan", { qr_code: code });
|
||||
if (!result || !result.ok) {
|
||||
this.setMessage(
|
||||
(result && result.error) || "Unrecognised QR code",
|
||||
"danger",
|
||||
);
|
||||
this.state.loading = false;
|
||||
return;
|
||||
}
|
||||
this.clearTargets();
|
||||
switch (result.model) {
|
||||
case "fusion.plating.tank":
|
||||
this.state.currentTank = result;
|
||||
this.setMessage(
|
||||
`Tank ${result.name} — ${result.queue_size} in queue`,
|
||||
"info",
|
||||
);
|
||||
break;
|
||||
case "fusion.plating.bath":
|
||||
this.state.currentBath = result;
|
||||
this.setMessage(`Bath ${result.name}`, "info");
|
||||
break;
|
||||
case "fusion.plating.bake.window":
|
||||
this.state.currentJob = result;
|
||||
this.setMessage(
|
||||
`Job ${result.name} — ${result.time_remaining || ""} remaining`,
|
||||
result.state === "missed_window" ? "danger" : "warning",
|
||||
);
|
||||
break;
|
||||
case "fusion.plating.shopfloor.station":
|
||||
this.state.station = result;
|
||||
this.setMessage(
|
||||
`Station paired: ${result.name}`,
|
||||
"success",
|
||||
);
|
||||
break;
|
||||
default:
|
||||
this.setMessage(`Scanned ${result.model}`, "info");
|
||||
}
|
||||
} catch (err) {
|
||||
this.setMessage(`Scan error: ${err.message || err}`, "danger");
|
||||
} finally {
|
||||
this.state.scannedCode = "";
|
||||
this.state.loading = false;
|
||||
if (this.scanInput.el) {
|
||||
this.scanInput.el.focus();
|
||||
}
|
||||
await this.refreshQueue();
|
||||
}
|
||||
}
|
||||
|
||||
onScanKey(ev) {
|
||||
if (ev.key === "Enter") {
|
||||
this.onScan();
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Bake controls --------------------------------------------------
|
||||
async onStartBake() {
|
||||
if (!this.state.currentJob) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await rpc("/fp/shopfloor/start_bake", {
|
||||
bake_window_id: this.state.currentJob.id,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.setMessage("Bake started", "success");
|
||||
this.state.currentJob.state = res.state;
|
||||
}
|
||||
} catch (err) {
|
||||
this.setMessage(`Start bake failed: ${err.message || err}`, "danger");
|
||||
}
|
||||
await this.refreshQueue();
|
||||
}
|
||||
|
||||
async onEndBake() {
|
||||
if (!this.state.currentJob) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await rpc("/fp/shopfloor/end_bake", {
|
||||
bake_window_id: this.state.currentJob.id,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.setMessage(
|
||||
`Bake complete — ${res.bake_duration_hours.toFixed(2)} h`,
|
||||
"success",
|
||||
);
|
||||
this.state.currentJob.state = res.state;
|
||||
}
|
||||
} catch (err) {
|
||||
this.setMessage(`End bake failed: ${err.message || err}`, "danger");
|
||||
}
|
||||
await this.refreshQueue();
|
||||
}
|
||||
|
||||
// ----- Queue ----------------------------------------------------------
|
||||
async refreshQueue() {
|
||||
try {
|
||||
const res = await rpc("/fp/shopfloor/queue", {});
|
||||
if (res && res.ok) {
|
||||
this.state.queueRows = res.rows || [];
|
||||
}
|
||||
} catch (err) {
|
||||
// Non-fatal: queue refresh shouldn't block scanning
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_shopfloor_tablet", ShopfloorTablet);
|
||||
@@ -0,0 +1,280 @@
|
||||
// =============================================================================
|
||||
// Fusion Plating — Shop Floor backend / tablet styles
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
//
|
||||
// THEME AWARENESS
|
||||
// ---------------
|
||||
// All colours come from CSS custom properties (Bootstrap / Odoo tokens) so
|
||||
// the tablet view renders correctly in BOTH light and dark mode without any
|
||||
// duplication or media queries. Status tints use color-mix() against the
|
||||
// theme token so green/yellow/red adapt to the surface.
|
||||
//
|
||||
// background: var(--bs-body-bg)
|
||||
// surface: var(--o-view-background-color)
|
||||
// foreground: var(--bs-body-color)
|
||||
// muted text: var(--bs-secondary-color)
|
||||
// border: var(--bs-border-color)
|
||||
// primary: var(--o-action)
|
||||
// =============================================================================
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Local mixin — semantic tint that respects light/dark mode
|
||||
// -----------------------------------------------------------------------------
|
||||
@mixin fp-shop-tint($color-var, $amount: 14%) {
|
||||
background-color: color-mix(in srgb, var(#{$color-var}) #{$amount}, transparent);
|
||||
color: var(#{$color-var});
|
||||
border: 1px solid color-mix(in srgb, var(#{$color-var}) 35%, transparent);
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Tablet root container — large touch targets, generous whitespace
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_tablet {
|
||||
background-color: var(--o-view-background-color, var(--bs-body-bg));
|
||||
color: var(--bs-body-color);
|
||||
min-height: 100%;
|
||||
padding: 24px;
|
||||
font-size: 1.1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
|
||||
.o_fp_tablet_header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.o_fp_tablet_title {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.o_fp_tablet_station {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.o_fp_tablet_scan_row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.o_fp_tablet_message {
|
||||
padding: 14px 18px;
|
||||
border-radius: 10px;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.4;
|
||||
|
||||
&.o_fp_msg_info { @include fp-shop-tint(--bs-info); }
|
||||
&.o_fp_msg_success { @include fp-shop-tint(--bs-success); }
|
||||
&.o_fp_msg_warning { @include fp-shop-tint(--bs-warning); }
|
||||
&.o_fp_msg_danger { @include fp-shop-tint(--bs-danger); }
|
||||
}
|
||||
|
||||
.o_fp_tablet_grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.o_fp_tablet_queue {
|
||||
background-color: var(--o-view-background-color, var(--bs-body-bg));
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
|
||||
.o_fp_tablet_queue_title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-body-color);
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed var(--bs-border-color);
|
||||
}
|
||||
|
||||
.o_fp_tablet_queue_list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.o_fp_tablet_queue_item {
|
||||
background-color: color-mix(in srgb, var(--bs-body-color) 4%, transparent);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
|
||||
.o_fp_tablet_queue_label {
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.o_fp_tablet_queue_desc {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Large card surface used for tank / bath info on the tablet
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_tablet_card {
|
||||
background-color: var(--o-view-background-color, var(--bs-body-bg));
|
||||
color: var(--bs-body-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
min-height: 140px;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--o-action) 50%, var(--bs-border-color));
|
||||
box-shadow: 0 2px 10px color-mix(in srgb, var(--bs-body-color) 8%, transparent);
|
||||
}
|
||||
|
||||
.o_fp_tablet_card_label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--bs-secondary-color);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.o_fp_tablet_card_value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
color: var(--bs-body-color);
|
||||
margin-bottom: 6px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.o_fp_tablet_card_meta {
|
||||
font-size: 0.95rem;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Bake window card — colour shifts with state
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_bake_window_card {
|
||||
background-color: var(--o-view-background-color, var(--bs-body-bg));
|
||||
color: var(--bs-body-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-left-width: 6px;
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
min-height: 160px;
|
||||
|
||||
.o_fp_tablet_card_label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--bs-secondary-color);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.o_fp_tablet_card_value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.o_fp_tablet_card_meta {
|
||||
font-size: 0.95rem;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
.o_fp_tablet_card_actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
&[data-status="awaiting_bake"] {
|
||||
border-left-color: var(--bs-warning);
|
||||
background-color: color-mix(in srgb, var(--bs-warning) 6%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
}
|
||||
&[data-status="bake_in_progress"] {
|
||||
border-left-color: var(--bs-info, var(--o-action));
|
||||
background-color: color-mix(in srgb, var(--bs-info, var(--o-action)) 6%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
}
|
||||
&[data-status="baked"] {
|
||||
border-left-color: var(--bs-success);
|
||||
background-color: color-mix(in srgb, var(--bs-success) 6%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
}
|
||||
&[data-status="missed_window"],
|
||||
&[data-status="scrapped"] {
|
||||
border-left-color: var(--bs-danger);
|
||||
background-color: color-mix(in srgb, var(--bs-danger) 8%, var(--o-view-background-color, var(--bs-body-bg)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Large QR scan input — friendly to tablet keyboards / wedge scanners
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_scan_input {
|
||||
flex: 1 1 auto;
|
||||
min-height: 56px;
|
||||
padding: 12px 18px;
|
||||
font-size: 1.3rem;
|
||||
border: 2px solid var(--bs-border-color);
|
||||
border-radius: 10px;
|
||||
background-color: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--o-action);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--o-action) 25%, transparent);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Big touch-friendly action button
|
||||
// -----------------------------------------------------------------------------
|
||||
.o_fp_big_button {
|
||||
min-height: 56px;
|
||||
min-width: 120px;
|
||||
padding: 12px 24px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--o-action);
|
||||
background-color: var(--o-action);
|
||||
color: var(--o-we-text-on-action, #fff);
|
||||
cursor: pointer;
|
||||
transition: filter 120ms ease, transform 80ms ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
// =============================================================================
|
||||
// Fusion Plating — Plant Overview Dashboard
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_plant_overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: #f8f9fa;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
// ---- Header -----------------------------------------------------------------
|
||||
|
||||
.o_fp_po_header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
|
||||
.o_fp_po_header_left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.o_fp_po_title {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.o_fp_po_refresh_ts {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.o_fp_po_header_right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Search -----------------------------------------------------------------
|
||||
|
||||
.o_fp_po_search_box {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.o_fp_po_search_icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
color: #6c757d;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.o_fp_po_search_input {
|
||||
padding: 6px 32px 6px 32px;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
width: 260px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&:focus {
|
||||
border-color: #86b7fe;
|
||||
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_po_search_clear {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6c757d;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
|
||||
&:hover {
|
||||
color: #212529;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_po_refresh_btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
// ---- Columns container ------------------------------------------------------
|
||||
|
||||
.o_fp_po_columns {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
overflow-x: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
// ---- Single column (work centre) --------------------------------------------
|
||||
|
||||
.o_fp_po_column {
|
||||
flex: 0 0 280px;
|
||||
min-width: 260px;
|
||||
max-width: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #ffffff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
max-height: calc(100vh - 140px);
|
||||
}
|
||||
|
||||
.o_fp_po_col_header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
background: #f8f9fa;
|
||||
border-radius: 10px 10px 0 0;
|
||||
|
||||
.o_fp_po_col_name {
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
color: #343a40;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.o_fp_po_col_count {
|
||||
background: #6c757d;
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_po_col_body {
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// ---- Card -------------------------------------------------------------------
|
||||
|
||||
.o_fp_po_card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s, transform 0.1s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
// State variants
|
||||
&.o_fp_card_progress {
|
||||
border-left: 4px solid #fd7e14;
|
||||
}
|
||||
&.o_fp_card_ready {
|
||||
border-left: 4px solid #0d6efd;
|
||||
}
|
||||
&.o_fp_card_done {
|
||||
border-left: 4px solid #198754;
|
||||
opacity: 0.75;
|
||||
}
|
||||
&.o_fp_card_pending {
|
||||
border-left: 4px solid #ffc107;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Card top row (image + title + step badge) --------------------------------
|
||||
|
||||
.o_fp_po_card_top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.o_fp_po_card_img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.o_fp_po_card_img_placeholder {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
background: #e9ecef;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: #adb5bd;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.o_fp_po_card_title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.9rem;
|
||||
color: #212529;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.o_fp_po_card_step_badge {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #17a2b8;
|
||||
color: white;
|
||||
font-size: 0.7rem;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// ---- Priority card borders ---------------------------------------------------
|
||||
|
||||
.o_fp_po_card_hot {
|
||||
border-left: 4px solid #dc3545 !important;
|
||||
background: #fff5f5;
|
||||
}
|
||||
|
||||
.o_fp_po_card_urgent {
|
||||
border-left: 4px solid #fd7e14 !important;
|
||||
background: #fff8f0;
|
||||
}
|
||||
|
||||
// ---- Product name and step display -------------------------------------------
|
||||
|
||||
.o_fp_po_card_product {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.o_fp_po_card_step {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.o_fp_po_card_customer {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 2px;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.o_fp_po_card_refs {
|
||||
font-size: 0.8rem;
|
||||
color: #6c757d;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
// ---- Parts progress bar -----------------------------------------------------
|
||||
|
||||
.o_fp_po_card_parts {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.o_fp_po_parts_bar {
|
||||
height: 6px;
|
||||
background: #e9ecef;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.o_fp_po_parts_fill {
|
||||
height: 100%;
|
||||
background: #fd7e14;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.o_fp_po_parts_label {
|
||||
font-size: 0.75rem;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.o_fp_po_card_last {
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
// ---- Tags + date footer -----------------------------------------------------
|
||||
|
||||
.o_fp_po_card_footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.o_fp_po_card_tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.o_fp_po_tag {
|
||||
display: inline-block;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
line-height: 1.4;
|
||||
|
||||
&.o_fp_tag_hot {
|
||||
background: #dc3545;
|
||||
color: #fff;
|
||||
}
|
||||
&.o_fp_tag_priority {
|
||||
background: #198754;
|
||||
color: #fff;
|
||||
}
|
||||
&.o_fp_tag_attention {
|
||||
background: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
&.o_fp_tag_default {
|
||||
background: #e9ecef;
|
||||
color: #495057;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_po_card_date {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
background: #e9ecef;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// ---- Empty / no-cards -------------------------------------------------------
|
||||
|
||||
.o_fp_po_no_cards {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
// ---- Responsive -------------------------------------------------------------
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.o_fp_po_columns {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.o_fp_po_column {
|
||||
flex: 1 1 auto;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.o_fp_po_search_input {
|
||||
width: 180px !important;
|
||||
}
|
||||
|
||||
.o_fp_po_header {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
// =============================================================================
|
||||
// Fusion Plating — Process Tree View
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_process_tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: #f8f9fa;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
// ---- Header -----------------------------------------------------------------
|
||||
|
||||
.o_fp_pt_header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
|
||||
.o_fp_pt_header_left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.o_fp_pt_title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.o_fp_pt_subtitle {
|
||||
font-size: 0.85rem;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Tree container ---------------------------------------------------------
|
||||
|
||||
.o_fp_pt_tree {
|
||||
padding: 24px;
|
||||
padding-left: 48px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
// ---- Node wrapper -----------------------------------------------------------
|
||||
|
||||
.o_fp_pt_node_wrapper {
|
||||
position: relative;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
// ---- Connector line (vertical line between nodes) ---------------------------
|
||||
|
||||
.o_fp_pt_connector {
|
||||
width: 3px;
|
||||
height: 20px;
|
||||
background: #adb5bd;
|
||||
margin-left: 28px;
|
||||
}
|
||||
|
||||
// ---- Node box ---------------------------------------------------------------
|
||||
|
||||
.o_fp_pt_node {
|
||||
background: #2b3035;
|
||||
color: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
padding: 14px 18px;
|
||||
max-width: 440px;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s, transform 0.1s;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.25);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
// State colour accents (left border)
|
||||
&.o_fp_tree_state_done {
|
||||
border-left: 5px solid #198754;
|
||||
}
|
||||
&.o_fp_tree_state_progress {
|
||||
border-left: 5px solid #fd7e14;
|
||||
}
|
||||
&.o_fp_tree_state_ready {
|
||||
border-left: 5px solid #0d6efd;
|
||||
}
|
||||
&.o_fp_tree_state_cancel {
|
||||
border-left: 5px solid #6c757d;
|
||||
opacity: 0.6;
|
||||
}
|
||||
&.o_fp_tree_state_pending {
|
||||
border-left: 5px solid #adb5bd;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_pt_node_header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.o_fp_pt_node_name {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.o_fp_pt_node_seq {
|
||||
color: #adb5bd;
|
||||
font-weight: 400;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.o_fp_pt_toggle_btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #adb5bd;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
font-size: 0.85rem;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_pt_node_wc {
|
||||
font-size: 0.8rem;
|
||||
color: #adb5bd !important;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
// ---- State badges inside tree -----------------------------------------------
|
||||
|
||||
.o_fp_pt_node_state {
|
||||
.badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.o_fp_tree_state_done {
|
||||
background: #198754 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.o_fp_tree_state_progress {
|
||||
background: #fd7e14 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.o_fp_tree_state_ready {
|
||||
background: #0d6efd !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.o_fp_tree_state_cancel {
|
||||
background: #6c757d !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.o_fp_tree_state_pending {
|
||||
background: #495057 !important;
|
||||
color: #dee2e6 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Progress bar -----------------------------------------------------------
|
||||
|
||||
.o_fp_pt_bar {
|
||||
height: 8px;
|
||||
background: #495057;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
&.o_fp_pt_bar_sm {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.o_fp_pt_bar_fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
&.o_fp_tree_progress_active .o_fp_pt_bar_fill {
|
||||
background: #fd7e14;
|
||||
}
|
||||
&.o_fp_tree_progress_done .o_fp_pt_bar_fill {
|
||||
background: #198754;
|
||||
}
|
||||
&.o_fp_tree_progress_empty .o_fp_pt_bar_fill {
|
||||
background: #6c757d;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_pt_bar_label {
|
||||
font-size: 0.75rem;
|
||||
color: #adb5bd;
|
||||
margin-top: 2px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.o_fp_pt_node_duration {
|
||||
font-size: 0.75rem;
|
||||
color: #adb5bd !important;
|
||||
}
|
||||
|
||||
// ---- Children (sub-state nodes) ---------------------------------------------
|
||||
|
||||
.o_fp_pt_children {
|
||||
margin-left: 48px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.o_fp_pt_child_connector {
|
||||
width: 3px;
|
||||
height: 12px;
|
||||
background: #6c757d;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.o_fp_pt_child_node {
|
||||
background: #3a4046;
|
||||
color: #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
max-width: 360px;
|
||||
margin-bottom: 0;
|
||||
|
||||
&.o_fp_tree_state_progress {
|
||||
border-left: 4px solid #fd7e14;
|
||||
}
|
||||
&.o_fp_tree_state_ready {
|
||||
border-left: 4px solid #0d6efd;
|
||||
}
|
||||
&.o_fp_tree_state_done {
|
||||
border-left: 4px solid #198754;
|
||||
}
|
||||
&.o_fp_tree_state_pending {
|
||||
border-left: 4px solid #6c757d;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_pt_child_name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.o_fp_pt_child_progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.o_fp_pt_bar {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Responsive -------------------------------------------------------------
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.o_fp_pt_tree {
|
||||
padding: 16px;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.o_fp_pt_node {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.o_fp_pt_child_node {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.o_fp_pt_children {
|
||||
margin-left: 24px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
-->
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.PlantOverview">
|
||||
<div class="o_fp_plant_overview">
|
||||
|
||||
<!-- ========== HEADER ========== -->
|
||||
<div class="o_fp_po_header">
|
||||
<div class="o_fp_po_header_left">
|
||||
<h2 class="o_fp_po_title">
|
||||
<i class="fa fa-industry me-2"/>
|
||||
<t t-esc="state.facilityName || 'Plant 1'"/> Overview
|
||||
</h2>
|
||||
<span class="o_fp_po_refresh_ts text-muted ms-3"
|
||||
t-if="state.lastRefresh">
|
||||
Updated <t t-esc="state.lastRefresh"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="o_fp_po_header_right">
|
||||
<div class="o_fp_po_search_box">
|
||||
<i class="fa fa-search o_fp_po_search_icon"/>
|
||||
<input type="text"
|
||||
class="o_fp_po_search_input"
|
||||
placeholder="Search customer, SO, WO, part..."
|
||||
t-att-value="state.searchTerm"
|
||||
t-on-input="onSearchInput"
|
||||
t-on-keydown="onSearchKey"/>
|
||||
<button class="o_fp_po_search_clear"
|
||||
t-if="state.searchTerm"
|
||||
t-on-click="onSearchClear"
|
||||
title="Clear search">
|
||||
<i class="fa fa-times"/>
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary o_fp_po_refresh_btn"
|
||||
t-on-click="onRefresh"
|
||||
t-att-disabled="state.loading"
|
||||
title="Refresh">
|
||||
<i t-att-class="state.loading ? 'fa fa-spinner fa-spin' : 'fa fa-refresh'"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== LOADING ========== -->
|
||||
<div class="o_fp_po_loading text-center py-5" t-if="state.loading and !state.columns.length">
|
||||
<i class="fa fa-spinner fa-spin fa-2x"/>
|
||||
<p class="mt-2 text-muted">Loading plant data...</p>
|
||||
</div>
|
||||
|
||||
<!-- ========== EMPTY STATE ========== -->
|
||||
<div class="o_fp_po_empty text-center py-5"
|
||||
t-if="!state.loading and !state.columns.length">
|
||||
<i class="fa fa-inbox fa-3x text-muted"/>
|
||||
<p class="mt-3 text-muted">No work centres with active orders found.</p>
|
||||
</div>
|
||||
|
||||
<!-- ========== COLUMNS (work centres) ========== -->
|
||||
<div class="o_fp_po_columns" t-if="state.columns.length">
|
||||
<t t-foreach="state.columns" t-as="col" t-key="col.work_center_id">
|
||||
<div class="o_fp_po_column">
|
||||
|
||||
<!-- Column header -->
|
||||
<div class="o_fp_po_col_header">
|
||||
<span class="o_fp_po_col_name" t-esc="col.work_center_name"/>
|
||||
<span class="o_fp_po_col_count badge rounded-pill">
|
||||
<t t-esc="col.cards.length"/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Cards -->
|
||||
<div class="o_fp_po_col_body">
|
||||
<t t-if="!col.cards.length">
|
||||
<div class="o_fp_po_no_cards text-muted text-center py-3">
|
||||
<i class="fa fa-check-circle"/> Clear
|
||||
</div>
|
||||
</t>
|
||||
<t t-foreach="col.cards" t-as="card" t-key="card.id">
|
||||
<div t-att-class="'o_fp_po_card ' + getStateClass(card.state) + (card.priority === '2' ? ' o_fp_po_card_hot' : card.priority === '1' ? ' o_fp_po_card_urgent' : '')"
|
||||
t-on-click="() => this.onCardClick(card)">
|
||||
|
||||
<!-- Top row: product image + customer + step badge -->
|
||||
<div class="o_fp_po_card_top">
|
||||
<img t-if="card.customer_logo_url"
|
||||
t-att-src="card.customer_logo_url"
|
||||
class="o_fp_po_card_img"
|
||||
alt="Customer"/>
|
||||
<div class="o_fp_po_card_img_placeholder" t-else="">
|
||||
<i class="fa fa-building"/>
|
||||
</div>
|
||||
<div class="o_fp_po_card_title">
|
||||
<strong t-esc="card.customer_name || 'Walk-In'"/>
|
||||
</div>
|
||||
<span class="o_fp_po_card_step_badge" t-if="card.step_number">
|
||||
<t t-esc="card.step_number"/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- SO / WO refs + product name -->
|
||||
<div class="o_fp_po_card_refs">
|
||||
<span t-if="card.so_name" t-esc="card.so_name"/>
|
||||
<span t-if="card.so_name and card.wo_name"> | </span>
|
||||
<span t-if="card.wo_name" t-esc="card.wo_name"/>
|
||||
</div>
|
||||
<div class="o_fp_po_card_product text-muted small" t-if="card.product_name">
|
||||
<t t-esc="card.product_name"/>
|
||||
</div>
|
||||
|
||||
<!-- Parts progress -->
|
||||
<div class="o_fp_po_card_parts" t-if="card.parts_total">
|
||||
<div class="o_fp_po_parts_bar">
|
||||
<div class="o_fp_po_parts_fill"
|
||||
t-att-style="'width:' + Math.round((card.parts_done / card.parts_total) * 100) + '%'"/>
|
||||
</div>
|
||||
<span class="o_fp_po_parts_label">
|
||||
<t t-esc="card.parts_done"/>/<t t-esc="card.parts_total"/> Parts
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Step display -->
|
||||
<div class="o_fp_po_card_step text-muted small" t-if="card.step_display">
|
||||
<i class="fa fa-map-signs me-1"/>
|
||||
<t t-esc="card.step_display"/>
|
||||
</div>
|
||||
|
||||
<!-- Last activity -->
|
||||
<div class="o_fp_po_card_last text-muted"
|
||||
t-if="card.last_operator">
|
||||
Last: <t t-esc="card.last_operator"/>
|
||||
<t t-if="card.last_activity">
|
||||
<span class="ms-1" t-esc="card.last_activity"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Tags + date badge row -->
|
||||
<div class="o_fp_po_card_footer">
|
||||
<div class="o_fp_po_card_tags">
|
||||
<t t-foreach="card.tags || []" t-as="tag" t-key="tag">
|
||||
<span t-att-class="'o_fp_po_tag ' + getTagClass(tag)"
|
||||
t-esc="tag"/>
|
||||
</t>
|
||||
</div>
|
||||
<div class="o_fp_po_card_date" t-if="card.date_display">
|
||||
<t t-esc="card.date_display"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,154 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
-->
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.ProcessTree">
|
||||
<div class="o_fp_process_tree">
|
||||
|
||||
<!-- ========== HEADER ========== -->
|
||||
<div class="o_fp_pt_header">
|
||||
<div class="o_fp_pt_header_left">
|
||||
<button class="btn btn-outline-secondary btn-sm me-3"
|
||||
t-on-click="onBackToOverview"
|
||||
title="Back to Plant Overview">
|
||||
<i class="fa fa-arrow-left me-1"/> Overview
|
||||
</button>
|
||||
<div class="o_fp_pt_title_block">
|
||||
<h3 class="o_fp_pt_title mb-0">
|
||||
<i class="fa fa-sitemap me-2"/>
|
||||
Process Tree
|
||||
</h3>
|
||||
<span class="o_fp_pt_subtitle text-muted" t-if="state.productionName">
|
||||
<t t-esc="state.productionName"/>
|
||||
<t t-if="state.productName">
|
||||
— <t t-esc="state.productName"/>
|
||||
</t>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fp_pt_header_right" t-if="state.moState">
|
||||
<span class="badge bg-secondary">
|
||||
MO: <t t-esc="state.moState"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== LOADING ========== -->
|
||||
<div class="o_fp_pt_loading text-center py-5" t-if="state.loading">
|
||||
<i class="fa fa-spinner fa-spin fa-2x"/>
|
||||
<p class="mt-2 text-muted">Loading process tree...</p>
|
||||
</div>
|
||||
|
||||
<!-- ========== NO PRODUCTION ID ========== -->
|
||||
<div class="o_fp_pt_empty text-center py-5"
|
||||
t-if="!state.loading and !productionId">
|
||||
<i class="fa fa-exclamation-triangle fa-3x text-warning"/>
|
||||
<p class="mt-3">No manufacturing order selected.
|
||||
Open this view from a production order to see its routing tree.</p>
|
||||
</div>
|
||||
|
||||
<!-- ========== EMPTY TREE ========== -->
|
||||
<div class="o_fp_pt_empty text-center py-5"
|
||||
t-if="!state.loading and productionId and !state.nodes.length">
|
||||
<i class="fa fa-sitemap fa-3x text-muted"/>
|
||||
<p class="mt-3 text-muted">No routing steps found for this order.</p>
|
||||
</div>
|
||||
|
||||
<!-- ========== TREE ========== -->
|
||||
<div class="o_fp_pt_tree" t-if="state.nodes.length">
|
||||
<t t-foreach="state.nodes" t-as="node" t-key="node.id">
|
||||
<div class="o_fp_pt_node_wrapper">
|
||||
|
||||
<!-- Connecting line (not on first node) -->
|
||||
<div class="o_fp_pt_connector" t-if="!node_first"/>
|
||||
|
||||
<!-- Node box -->
|
||||
<div t-att-class="'o_fp_pt_node ' + getNodeStateClass(node.state)"
|
||||
t-on-click="() => this.onNodeClick(node)">
|
||||
|
||||
<div class="o_fp_pt_node_header">
|
||||
<div class="o_fp_pt_node_name">
|
||||
<span class="o_fp_pt_node_seq"
|
||||
t-if="node.sequence">
|
||||
<t t-esc="node.sequence"/>.
|
||||
</span>
|
||||
<strong t-esc="node.name"/>
|
||||
</div>
|
||||
<button class="o_fp_pt_toggle_btn"
|
||||
t-if="node.children and node.children.length"
|
||||
t-on-click.stop="() => this.toggleNode(node.id)"
|
||||
title="Expand / collapse">
|
||||
<i t-att-class="isCollapsed(node.id) ? 'fa fa-chevron-right' : 'fa fa-chevron-down'"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Work centre -->
|
||||
<div class="o_fp_pt_node_wc text-muted"
|
||||
t-if="node.work_center_name">
|
||||
<i class="fa fa-cog me-1"/>
|
||||
<t t-esc="node.work_center_name"/>
|
||||
</div>
|
||||
|
||||
<!-- State badge -->
|
||||
<div class="o_fp_pt_node_state mt-1">
|
||||
<span t-att-class="'badge ' + getNodeStateClass(node.state)">
|
||||
<t t-esc="getNodeStateLabel(node.state)"/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="o_fp_pt_node_progress mt-2"
|
||||
t-if="node.qty_total">
|
||||
<div t-att-class="'o_fp_pt_bar ' + getProgressClass(node)">
|
||||
<div class="o_fp_pt_bar_fill"
|
||||
t-att-style="'width:' + getProgressPct(node) + '%'"/>
|
||||
</div>
|
||||
<span class="o_fp_pt_bar_label">
|
||||
<t t-esc="node.qty_done"/>/<t t-esc="node.qty_total"/>
|
||||
(<t t-esc="getProgressPct(node)"/>%)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Duration -->
|
||||
<div class="o_fp_pt_node_duration text-muted mt-1"
|
||||
t-if="node.duration_display">
|
||||
<i class="fa fa-clock-o me-1"/>
|
||||
<t t-esc="node.duration_display"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Child nodes (sub-states: Ready for X, X-ing) -->
|
||||
<div class="o_fp_pt_children"
|
||||
t-if="node.children and node.children.length and !isCollapsed(node.id)">
|
||||
<t t-foreach="node.children" t-as="child" t-key="child.id">
|
||||
<div class="o_fp_pt_child_connector"/>
|
||||
<div t-att-class="'o_fp_pt_child_node ' + getNodeStateClass(child.state)">
|
||||
<div class="o_fp_pt_child_name">
|
||||
<t t-esc="child.name"/>
|
||||
</div>
|
||||
<div class="o_fp_pt_child_progress"
|
||||
t-if="child.qty_total">
|
||||
<div t-att-class="'o_fp_pt_bar o_fp_pt_bar_sm ' + getProgressClass(child)">
|
||||
<div class="o_fp_pt_bar_fill"
|
||||
t-att-style="'width:' + getProgressPct(child) + '%'"/>
|
||||
</div>
|
||||
<span class="o_fp_pt_bar_label">
|
||||
<t t-esc="child.qty_done"/>/<t t-esc="child.qty_total"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,115 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
-->
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.ShopfloorTablet">
|
||||
<div class="o_fp_tablet">
|
||||
<div class="o_fp_tablet_header">
|
||||
<div class="o_fp_tablet_title">Fusion Plating — Shop Floor</div>
|
||||
<div class="o_fp_tablet_station" t-if="state.station">
|
||||
Station: <strong t-esc="state.station.name"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_tablet_scan_row">
|
||||
<input
|
||||
type="text"
|
||||
class="o_fp_scan_input"
|
||||
placeholder="Scan QR code"
|
||||
t-ref="scanInput"
|
||||
t-model="state.scannedCode"
|
||||
t-on-keydown="onScanKey"
|
||||
/>
|
||||
<button class="o_fp_big_button" t-on-click="onScan" t-att-disabled="state.loading">
|
||||
Scan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div t-if="state.message" t-att-class="'o_fp_tablet_message o_fp_msg_' + state.messageType">
|
||||
<span t-esc="state.message"/>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_tablet_grid">
|
||||
<div class="o_fp_tablet_card" t-if="state.currentTank">
|
||||
<div class="o_fp_tablet_card_label">Tank</div>
|
||||
<div class="o_fp_tablet_card_value">
|
||||
<t t-esc="state.currentTank.name"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta">
|
||||
State: <t t-esc="state.currentTank.state"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta" t-if="state.currentTank.current_bath_name">
|
||||
Bath: <t t-esc="state.currentTank.current_bath_name"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta">
|
||||
Queue: <t t-esc="state.currentTank.queue_size"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_tablet_card" t-if="state.currentBath">
|
||||
<div class="o_fp_tablet_card_label">Bath</div>
|
||||
<div class="o_fp_tablet_card_value">
|
||||
<t t-esc="state.currentBath.name"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta">
|
||||
State: <t t-esc="state.currentBath.state"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta" t-if="state.currentBath.tank_name">
|
||||
Tank: <t t-esc="state.currentBath.tank_name"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_bake_window_card"
|
||||
t-if="state.currentJob"
|
||||
t-att-data-status="state.currentJob.state">
|
||||
<div class="o_fp_tablet_card_label">Bake Job</div>
|
||||
<div class="o_fp_tablet_card_value">
|
||||
<t t-esc="state.currentJob.name"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta">
|
||||
State: <t t-esc="state.currentJob.state"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_meta">
|
||||
Remaining: <t t-esc="state.currentJob.time_remaining"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_card_actions">
|
||||
<button class="o_fp_big_button"
|
||||
t-if="state.currentJob.state === 'awaiting_bake'"
|
||||
t-on-click="onStartBake">
|
||||
Start Bake
|
||||
</button>
|
||||
<button class="o_fp_big_button"
|
||||
t-if="state.currentJob.state === 'bake_in_progress'"
|
||||
t-on-click="onEndBake">
|
||||
End Bake
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_tablet_queue">
|
||||
<div class="o_fp_tablet_queue_title">Next Up</div>
|
||||
<div t-if="!state.queueRows.length" class="text-muted">
|
||||
Queue is empty.
|
||||
</div>
|
||||
<ul class="o_fp_tablet_queue_list" t-if="state.queueRows.length">
|
||||
<t t-foreach="state.queueRows" t-as="row" t-key="row.id">
|
||||
<li class="o_fp_tablet_queue_item">
|
||||
<div class="o_fp_tablet_queue_label">
|
||||
<strong t-esc="row.label"/>
|
||||
</div>
|
||||
<div class="o_fp_tablet_queue_desc text-muted">
|
||||
<t t-esc="row.description"/>
|
||||
</div>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
Reference in New Issue
Block a user