This commit is contained in:
gsinghpal
2026-04-12 09:09:50 -04:00
parent d07159b9b5
commit be611876ad
470 changed files with 41761 additions and 51 deletions

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>