feat(jobs): Phase 6 — Plant Overview kanban + Manager Dashboard

Two operator-facing client actions for the native job model.

Plant Overview: kanban with columns = fp.work.centre, cards = active
fp.job.step rows (ready/in_progress/paused). Drag a card to a different
column to reassign the step's work_centre_id; click to open the step
form. Backend: /fp/jobs/plant_overview returns columns with cards;
/fp/jobs/plant_overview/move_card reassigns work_centre.

Manager Dashboard: list of in-flight fp.job rows with progress bars,
deadline (overdue highlight), current_step / current_location, and a
priority side-bar (rush=red, high=orange, normal=blue, low=grey). Click
a row to open the job form. State-count pills filter by state. Backend:
/fp/jobs/manager_dashboard returns rows + state counts.

Both menu entries land inside the existing 'Plating Jobs (Native)'
submenu under the Plating app (manager-only). The menu items are
defined in this module rather than in fusion_plating core, because
the action xmlids they reference aren't loaded yet at the time the
core menu file is parsed (fusion_plating_jobs depends on core, not
the other way round).

Manifest 19.0.2.2.0 → 19.0.2.3.0. Three new SCSS, three new JS,
three new XML files registered in web.assets_backend.

Verified on entech: module loaded clean, all 41 fusion_plating_jobs
tests pass, asset bundle regenerates without errors, both menus and
both client actions registered in ir_ui_menu / ir_act_client.

Part of: native job model migration (spec 2026-04-25)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-25 04:32:16 -04:00
parent 034a6560ad
commit e19d4862ed
11 changed files with 1596 additions and 1 deletions

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.2.2.0',
'version': '19.0.2.3.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'description': """
@@ -39,6 +39,7 @@ full design rationale and §6.2 of the implementation plan for task list.
'security/ir.model.access.csv',
'views/res_config_settings_views.xml',
'views/job_process_tree_action.xml',
'views/job_overview_actions.xml',
'views/fp_job_form_inherit.xml',
'report/report_fp_job_sticker.xml',
'report/report_fp_job_traveller.xml',
@@ -46,8 +47,14 @@ full design rationale and §6.2 of the implementation plan for task list.
'assets': {
'web.assets_backend': [
'fusion_plating_jobs/static/src/scss/job_process_tree.scss',
'fusion_plating_jobs/static/src/scss/job_plant_overview.scss',
'fusion_plating_jobs/static/src/scss/job_manager_dashboard.scss',
'fusion_plating_jobs/static/src/js/job_process_tree.js',
'fusion_plating_jobs/static/src/js/job_plant_overview.js',
'fusion_plating_jobs/static/src/js/job_manager_dashboard.js',
'fusion_plating_jobs/static/src/xml/job_process_tree.xml',
'fusion_plating_jobs/static/src/xml/job_plant_overview.xml',
'fusion_plating_jobs/static/src/xml/job_manager_dashboard.xml',
],
},
'installable': True,

View File

@@ -1,3 +1,5 @@
# -*- coding: utf-8 -*-
from . import job_scan
from . import process_tree
from . import plant_overview
from . import manager_dashboard

View File

@@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# /fp/jobs/manager_dashboard — JSON endpoint powering the native-job
# Manager Dashboard. Returns a flat list of in-flight fp.job rows
# with progress / current-step / deadline info, plus state-count
# pills for the filter bar at the top of the dashboard.
from odoo import http
from odoo.http import request
class FpJobsManagerDashboardController(http.Controller):
@http.route('/fp/jobs/manager_dashboard', type='jsonrpc', auth='user', website=False)
def fp_jobs_manager_dashboard(self, state=None, **kwargs):
env = request.env
Job = env['fp.job']
# Default view: jobs that need triage. Specifying state=<value>
# narrows to that one bucket; state='all' opens the floodgates.
if state and state != 'all':
domain = [('state', '=', state)]
elif state == 'all':
domain = []
else:
domain = [('state', 'in', ('confirmed', 'in_progress', 'on_hold'))]
jobs = Job.search(
domain,
order='priority desc, date_deadline asc, id desc',
limit=200,
)
rows = []
for job in jobs:
rows.append({
'id': job.id,
'name': job.name,
'partner': job.partner_id.name or '',
'qty': job.qty,
'state': job.state,
'priority': job.priority,
'date_deadline': (
job.date_deadline.isoformat()
if job.date_deadline else None
),
'current_step': (
job.current_step_id.name
if job.current_step_id else None
),
'current_location': job.current_location,
'progress_pct': job.step_progress_pct,
'step_done': job.step_done_count,
'step_total': job.step_count,
'recipe': job.recipe_id.name if job.recipe_id else None,
})
# State-count pills for the filter bar — let the dashboard show
# the manager how big each bucket is at a glance.
counts = {}
for s in ('confirmed', 'in_progress', 'on_hold', 'done'):
counts[s] = Job.search_count([('state', '=', s)])
return {'rows': rows, 'counts': counts}

View File

@@ -0,0 +1,98 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# /fp/jobs/plant_overview — JSON endpoints powering the native-job
# Plant Overview kanban (operator triage view, Phase 6 of the native
# job migration). Columns are fp.work.centre rows; cards are
# fp.job.step rows in ready / in_progress / paused state. Drag a
# card across columns to reassign that step's work_centre_id.
from odoo import http
from odoo.http import request
class FpJobsPlantOverviewController(http.Controller):
@http.route('/fp/jobs/plant_overview', type='jsonrpc', auth='user', website=False)
def fp_jobs_plant_overview(self, facility_id=None, **kwargs):
env = request.env
WorkCentre = env['fp.work.centre']
Step = env['fp.job.step']
wc_domain = [('active', '=', True)]
if facility_id:
wc_domain.append(('facility_id', '=', int(facility_id)))
centres = WorkCentre.search(wc_domain, order='sequence, code, name')
# Active steps grouped by work_centre. We pull paused too so a
# manager can see — and re-route — a step that's been paused
# on the wrong line.
step_domain = [('state', 'in', ('ready', 'in_progress', 'paused'))]
if facility_id:
step_domain.append(('facility_id', '=', int(facility_id)))
active_steps = Step.search(step_domain, order='job_id, sequence')
cards_by_wc = {}
for step in active_steps:
wc_id = step.work_centre_id.id or 0
cards_by_wc.setdefault(wc_id, []).append({
'id': step.id,
'name': step.name,
'state': step.state,
'job_id': step.job_id.id,
'job_name': step.job_id.name,
'partner': step.job_id.partner_id.name or '',
'sequence': step.sequence,
'kind': step.kind,
'duration_expected': step.duration_expected,
'duration_actual': step.duration_actual,
'assigned_user': (
step.assigned_user_id.name
if step.assigned_user_id else None
),
'thickness_target': step.thickness_target,
'thickness_uom': step.thickness_uom,
'priority': step.job_id.priority,
})
columns = []
for wc in centres:
columns.append({
'id': wc.id,
'code': wc.code,
'name': wc.name,
'kind': wc.kind,
'facility': wc.facility_id.name if wc.facility_id else None,
'cards': cards_by_wc.get(wc.id, []),
})
# An "Unassigned" pseudo-column for steps without a work centre —
# only rendered when there's something to show, so empty plants
# don't pick up a stray column.
if cards_by_wc.get(0):
columns.append({
'id': 0,
'code': '',
'name': 'Unassigned',
'kind': 'other',
'facility': None,
'cards': cards_by_wc[0],
})
return {'columns': columns}
@http.route('/fp/jobs/plant_overview/move_card', type='jsonrpc', auth='user', website=False)
def fp_jobs_move_card(self, step_id, work_centre_id, **kwargs):
"""Reassign a step to a different work centre.
work_centre_id == 0 (or falsy) clears the work centre — the card
will land in the Unassigned pseudo-column on the next refresh.
"""
env = request.env
Step = env['fp.job.step']
step = Step.browse(int(step_id)).exists()
if not step:
return {'ok': False, 'error': 'Step not found'}
wc_id = int(work_centre_id) if work_centre_id else False
step.work_centre_id = wc_id
return {'ok': True}

View File

@@ -0,0 +1,183 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — Manager Dashboard (native, fp.job edition)
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
//
// Manager triage view for the native job model. Renders all in-flight
// fp.job rows with progress bars, deadline, current-step location, and
// a priority side-bar (rush/high/normal/low). Click a row to open the
// job form. State-count pills filter the grid by state.
//
// Endpoint: POST /fp/jobs/manager_dashboard -> { rows, counts }
// =============================================================================
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 JobManagerDashboard extends Component {
static template = "fusion_plating_jobs.JobManagerDashboard";
static props = ["*"];
setup() {
this.notification = useService("notification");
this.action = useService("action");
this.state = useState({
rows: [],
counts: {},
stateFilter: null, // null = default in-flight; 'all' = no filter
loading: false,
lastRefresh: null,
});
this._refreshInterval = null;
onMounted(async () => {
await this.loadData();
// 30s cadence — same as plant overview, light enough to
// leave the dashboard up on a wall display.
this._refreshInterval = setInterval(() => this.loadData(), 30000);
});
onWillUnmount(() => {
if (this._refreshInterval) {
clearInterval(this._refreshInterval);
this._refreshInterval = null;
}
});
}
// ----- Data --------------------------------------------------------------
async loadData() {
this.state.loading = true;
try {
const payload = {};
if (this.state.stateFilter) {
payload.state = this.state.stateFilter;
}
const result = await rpc("/fp/jobs/manager_dashboard", payload);
if (result) {
this.state.rows = result.rows || [];
this.state.counts = result.counts || {};
this.state.lastRefresh = new Date().toLocaleTimeString();
}
} catch (err) {
this.notification.add(
`Failed to load manager dashboard: ${err.message || err}`,
{ type: "danger" },
);
} finally {
this.state.loading = false;
}
}
onRefresh() {
this.loadData();
}
// ----- Filter pills ------------------------------------------------------
setFilter(state) {
// Clicking the active pill clears the filter back to default.
this.state.stateFilter = (state === this.state.stateFilter) ? null : state;
this.loadData();
}
isActiveFilter(state) {
return this.state.stateFilter === state;
}
// ----- Row click ---------------------------------------------------------
openJob(row) {
if (!row || !row.id) return;
this.action.doAction({
type: "ir.actions.act_window",
res_model: "fp.job",
res_id: row.id,
views: [[false, "form"]],
target: "current",
});
}
// ----- Helpers -----------------------------------------------------------
priorityClass(p) {
switch (p) {
case "rush": return "o_fp_jmd_priority_rush";
case "high": return "o_fp_jmd_priority_high";
case "low": return "o_fp_jmd_priority_low";
default: return "o_fp_jmd_priority_normal";
}
}
priorityLabel(p) {
switch (p) {
case "rush": return "RUSH";
case "high": return "High";
case "low": return "Low";
default: return "Normal";
}
}
stateLabel(s) {
const map = {
draft: "Draft",
confirmed: "Confirmed",
in_progress: "In Progress",
on_hold: "On Hold",
done: "Done",
cancelled: "Cancelled",
};
return map[s] || s || "";
}
stateBadgeClass(s) {
return `o_fp_jmd_state_badge_${s}`;
}
progressLabel(row) {
const pct = (row.progress_pct || 0).toFixed(0);
const done = row.step_done || 0;
const total = row.step_total || 0;
return `${pct}% (${done}/${total})`;
}
progressBarClass(row) {
const pct = row.progress_pct || 0;
if (pct >= 100) return "o_fp_jmd_bar_done";
if (pct >= 50) return "o_fp_jmd_bar_mid";
return "o_fp_jmd_bar_early";
}
deadlineLabel(row) {
if (!row.date_deadline) return "";
// Render as a short, human-friendly date — strip seconds.
try {
const d = new Date(row.date_deadline);
if (isNaN(d.getTime())) return row.date_deadline;
return d.toLocaleDateString(undefined, {
year: "numeric", month: "short", day: "numeric",
});
} catch (e) {
return row.date_deadline;
}
}
isOverdue(row) {
if (!row.date_deadline) return false;
try {
const d = new Date(row.date_deadline);
return !isNaN(d.getTime()) && d.getTime() < Date.now()
&& row.state !== "done";
} catch (e) {
return false;
}
}
}
registry.category("actions").add("fp_job_manager_dashboard", JobManagerDashboard);

View File

@@ -0,0 +1,323 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — Plant Overview (native, fp.job.step edition)
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
//
// Operator triage kanban for the native job model. Columns are
// fp.work.centre rows, cards are active fp.job.step rows. Drag a card
// to a different column to reassign that step's work_centre_id; click
// a card to open the step form.
//
// Port of fusion_plating_shopfloor's plant_overview.js, rebound from
// mrp.workorder + mrp.production to fp.job.step + fp.job. Auto-refresh
// every 30s, debounced search, drag-drop with placeholder preview.
//
// Endpoints (fusion_plating_jobs/controllers/plant_overview.py):
// POST /fp/jobs/plant_overview -> { columns: [...] }
// POST /fp/jobs/plant_overview/move_card -> { ok, error? }
// =============================================================================
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 JobPlantOverview extends Component {
static template = "fusion_plating_jobs.JobPlantOverview";
static props = ["*"];
setup() {
this.notification = useService("notification");
this.action = useService("action");
this.state = useState({
columns: [],
searchTerm: "",
loading: false,
lastRefresh: null,
});
this._refreshInterval = null;
this._draggedCard = null;
onMounted(async () => {
await this.loadData();
// 30s cadence — fast enough for a manager glancing at the
// wall, light enough to not hammer the server.
this._refreshInterval = setInterval(() => this.loadData(), 30000);
});
onWillUnmount(() => {
if (this._refreshInterval) {
clearInterval(this._refreshInterval);
this._refreshInterval = null;
}
});
}
// ----- Data --------------------------------------------------------------
async loadData() {
this.state.loading = true;
try {
const result = await rpc("/fp/jobs/plant_overview", {});
if (result) {
let columns = result.columns || [];
// Client-side search — keeps the round-trip simple.
const term = (this.state.searchTerm || "").trim().toLowerCase();
if (term) {
columns = columns
.map((col) => ({
...col,
cards: (col.cards || []).filter((c) => {
const hay = [
c.name, c.job_name, c.partner,
c.assigned_user || "",
].join(" ").toLowerCase();
return hay.includes(term);
}),
}))
// Hide empty columns when filtering so the wall
// doesn't flood with "Clear" placeholders.
.filter((col) => col.cards.length > 0);
}
this.state.columns = 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;
this._debouncedSearch();
}
_debouncedSearch() {
if (this._searchTimer) clearTimeout(this._searchTimer);
this._searchTimer = setTimeout(() => this.loadData(), 200);
}
onSearchKey(ev) {
if (ev.key === "Enter") {
if (this._searchTimer) clearTimeout(this._searchTimer);
this.loadData();
} else if (ev.key === "Escape") {
this.onSearchClear();
}
}
onSearchClear() {
if (this._searchTimer) clearTimeout(this._searchTimer);
this.state.searchTerm = "";
this.loadData();
}
onRefresh() {
this.loadData();
}
// ----- Drag & drop -------------------------------------------------------
//
// A real insertion placeholder slides between cards as the operator
// drags. Plain DOM nodes (not reactive state) so mouseover updates
// don't trigger OWL re-renders mid-drag.
_getOrCreatePlaceholder() {
let node = document.querySelector(".o_fp_jpo_drop_placeholder");
if (!node) {
node = document.createElement("div");
node.className = "o_fp_jpo_drop_placeholder";
}
return node;
}
_removePlaceholder() {
document.querySelectorAll(".o_fp_jpo_drop_placeholder")
.forEach((el) => el.remove());
}
onCardDragStart(card, col, ev) {
this._draggedCard = {
id: card.id,
source_wc_id: col.id,
el: ev.target,
};
ev.dataTransfer.effectAllowed = "move";
ev.dataTransfer.setData("text/plain", String(card.id));
// Apply the ghost class on the next frame so the drag image
// captures the card opaque.
requestAnimationFrame(() => {
if (ev.target && ev.target.classList) {
ev.target.classList.add("o_fp_dragging");
}
});
}
onCardDragEnd(ev) {
if (ev.target && ev.target.classList) {
ev.target.classList.remove("o_fp_dragging");
}
document.querySelectorAll(".o_fp_drop_target").forEach((el) => {
el.classList.remove("o_fp_drop_target");
});
this._removePlaceholder();
this._draggedCard = null;
}
onColDragOver(col, ev) {
ev.preventDefault();
ev.dataTransfer.dropEffect = "move";
const body = ev.currentTarget;
if (!body) return;
if (!body.classList.contains("o_fp_drop_target")) {
body.classList.add("o_fp_drop_target");
}
// Find which card the cursor is closest to and slide the
// placeholder above or below it. This gives the manager a
// clear "card will land HERE" preview between siblings.
const placeholder = this._getOrCreatePlaceholder();
const cards = [...body.querySelectorAll(
".o_fp_jpo_card:not(.o_fp_dragging):not(.o_fp_jpo_drop_placeholder)",
)];
const y = ev.clientY;
let insertBefore = null;
for (const cardEl of cards) {
const rect = cardEl.getBoundingClientRect();
if (y < rect.top + rect.height / 2) {
insertBefore = cardEl;
break;
}
}
if (insertBefore) {
body.insertBefore(placeholder, insertBefore);
} else {
body.appendChild(placeholder);
}
}
onColDragLeave(col, ev) {
const body = ev.currentTarget;
if (body && !body.contains(ev.relatedTarget)) {
body.classList.remove("o_fp_drop_target");
this._removePlaceholder();
}
}
async onColDrop(col, ev) {
ev.preventDefault();
const body = ev.currentTarget;
if (body) {
body.classList.remove("o_fp_drop_target");
}
this._removePlaceholder();
const dragged = this._draggedCard;
if (!dragged) {
return;
}
// No-op if dropped on the same column
if (dragged.source_wc_id === col.id) {
this._draggedCard = null;
return;
}
try {
const result = await rpc("/fp/jobs/plant_overview/move_card", {
step_id: dragged.id,
work_centre_id: col.id || 0,
});
if (result && result.ok) {
this.notification.add(
`Moved to ${col.name}`,
{ type: "success" },
);
await this.loadData();
} else {
this.notification.add(
(result && result.error) || "Could not move card",
{ type: "warning" },
);
}
} catch (err) {
this.notification.add(
`Move failed: ${err.message || err}`,
{ type: "danger" },
);
}
this._draggedCard = null;
}
// ----- Card actions ------------------------------------------------------
onCardClick(card) {
if (!card || !card.id) {
return;
}
this.action.doAction({
type: "ir.actions.act_window",
res_model: "fp.job.step",
res_id: card.id,
views: [[false, "form"]],
target: "current",
});
}
onJobLink(card, ev) {
// Stop the parent card click from also firing.
if (ev) {
ev.stopPropagation();
}
if (!card || !card.job_id) {
return;
}
this.action.doAction({
type: "ir.actions.act_window",
res_model: "fp.job",
res_id: card.job_id,
views: [[false, "form"]],
target: "current",
});
}
// ----- Helpers -----------------------------------------------------------
getStateClass(state) {
switch (state) {
case "in_progress": return "o_fp_jpo_card_progress";
case "ready": return "o_fp_jpo_card_ready";
case "paused": return "o_fp_jpo_card_paused";
case "done": return "o_fp_jpo_card_done";
default: return "";
}
}
getPriorityClass(p) {
switch (p) {
case "rush": return "o_fp_jpo_card_rush";
case "high": return "o_fp_jpo_card_high";
default: return "";
}
}
durationLabel(card) {
const exp = card.duration_expected;
const act = card.duration_actual;
if (act && exp) return `${act.toFixed(0)}/${exp.toFixed(0)} min`;
if (exp) return `${exp.toFixed(0)} min`;
if (act) return `${act.toFixed(0)} min`;
return "";
}
}
registry.category("actions").add("fp_job_plant_overview", JobPlantOverview);

View File

@@ -0,0 +1,268 @@
// =============================================================================
// Fusion Plating — Manager Dashboard (native, fp.job)
// Copyright 2026 Nexa Systems Inc. · License OPL-1
//
// Class prefix: .o_fp_jmd_* (Job Manager Dashboard)
// Self-contained — no shopfloor token partial dependency.
// =============================================================================
.o_fp_job_manager_dashboard {
height: 100%;
overflow: auto;
-webkit-overflow-scrolling: touch;
padding: 16px 24px;
display: flex;
flex-direction: column;
gap: 12px;
background-color: var(--o-action, #f7f7f8);
color: var(--bs-body-color, #1a1d21);
@media (max-width: 600px) { padding: 12px; gap: 12px; }
// -------------------------------------------------------------------------
// Header strip
// -------------------------------------------------------------------------
.o_fp_jmd_header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: 12px 16px;
background-color: var(--bs-body-bg, #ffffff);
border: 1px solid #d8dadd;
border-radius: 8px;
}
.o_fp_jmd_header_left {
display: flex;
align-items: baseline;
gap: 12px;
}
.o_fp_jmd_title {
font-size: 1.1rem;
font-weight: 700;
margin: 0;
}
// -------------------------------------------------------------------------
// Filter pill bar
// -------------------------------------------------------------------------
.o_fp_jmd_filter_bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px 4px;
}
.o_fp_jmd_pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border: 1px solid #d8dadd;
background-color: var(--bs-body-bg, #ffffff);
color: inherit;
border-radius: 999px;
font-size: 0.8rem;
cursor: pointer;
transition: background-color 0.15s ease,
border-color 0.15s ease,
color 0.15s ease;
&:hover {
background-color: #f1f3f5;
border-color: #c5c8cc;
}
&.o_fp_jmd_pill_active {
background-color: #0d6efd;
border-color: #0d6efd;
color: #ffffff;
font-weight: 600;
}
}
.o_fp_jmd_pill_count {
background-color: rgba(0, 0, 0, 0.08);
border-radius: 999px;
padding: 0 7px;
font-size: 0.7rem;
font-weight: 700;
min-width: 1.5em;
text-align: center;
}
.o_fp_jmd_pill_active .o_fp_jmd_pill_count {
background-color: rgba(255, 255, 255, 0.25);
}
// -------------------------------------------------------------------------
// Empty / loading
// -------------------------------------------------------------------------
.o_fp_jmd_empty,
.o_fp_jmd_loading {
background-color: var(--bs-body-bg, #ffffff);
border: 1px solid #d8dadd;
border-radius: 8px;
}
// -------------------------------------------------------------------------
// Rows
// -------------------------------------------------------------------------
.o_fp_jmd_rows {
display: flex;
flex-direction: column;
gap: 8px;
}
.o_fp_jmd_row {
display: flex;
align-items: stretch;
gap: 0;
background-color: var(--bs-body-bg, #ffffff);
border: 1px solid #d8dadd;
border-radius: 8px;
cursor: pointer;
overflow: hidden;
transition: transform 0.1s ease, box-shadow 0.15s ease,
border-color 0.15s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border-color: #c5c8cc;
}
}
.o_fp_jmd_priority_bar {
flex: 0 0 6px;
background-color: #6c757d; // normal default
}
.o_fp_jmd_priority_rush .o_fp_jmd_priority_bar { background-color: #dc3545; }
.o_fp_jmd_priority_high .o_fp_jmd_priority_bar { background-color: #fd7e14; }
.o_fp_jmd_priority_normal .o_fp_jmd_priority_bar { background-color: #0d6efd; }
.o_fp_jmd_priority_low .o_fp_jmd_priority_bar { background-color: #adb5bd; }
.o_fp_jmd_row_body {
flex: 1 1 auto;
padding: 10px 14px;
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.o_fp_jmd_row_open {
flex: 0 0 auto;
align-self: center;
padding: 0 14px;
opacity: 0.4;
}
.o_fp_jmd_row_top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.o_fp_jmd_row_id {
font-size: 0.95rem;
flex: 1 1 auto;
min-width: 0;
}
.o_fp_jmd_row_chips {
display: inline-flex;
gap: 6px;
flex-wrap: wrap;
}
.o_fp_jmd_row_meta {
font-size: 0.75rem;
opacity: 0.85;
display: flex;
flex-wrap: wrap;
gap: 2px 4px;
}
.o_fp_jmd_overdue {
color: #dc3545;
font-weight: 600;
}
// -------------------------------------------------------------------------
// State badge
// -------------------------------------------------------------------------
.o_fp_jmd_state_badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.65rem;
font-weight: 700;
line-height: 1.4;
text-transform: uppercase;
letter-spacing: 0.02em;
&.o_fp_jmd_state_badge_draft { background-color: #e9ecef; color: #6c757d; }
&.o_fp_jmd_state_badge_confirmed { background-color: rgba(13, 110, 253, 0.18); color: #084298; }
&.o_fp_jmd_state_badge_in_progress { background-color: rgba(13, 110, 253, 0.28); color: #084298; }
&.o_fp_jmd_state_badge_on_hold { background-color: rgba(253, 126, 20, 0.20); color: #97480d; }
&.o_fp_jmd_state_badge_done { background-color: rgba(25, 135, 84, 0.20); color: #0f5132; }
&.o_fp_jmd_state_badge_cancelled { background-color: rgba(220, 53, 69, 0.18); color: #842029; }
}
// -------------------------------------------------------------------------
// Priority chips (top-right of row)
// -------------------------------------------------------------------------
.o_fp_jmd_chip {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.65rem;
font-weight: 700;
line-height: 1.4;
text-transform: uppercase;
letter-spacing: 0.02em;
&.o_fp_jmd_chip_rush { background-color: #dc3545; color: #fff; }
&.o_fp_jmd_chip_high { background-color: #fd7e14; color: #fff; }
}
// -------------------------------------------------------------------------
// Progress bar
// -------------------------------------------------------------------------
.o_fp_jmd_row_progress {
display: flex;
align-items: center;
gap: 10px;
}
.o_fp_jmd_bar_track {
flex: 1 1 auto;
height: 8px;
background-color: #e9ecef;
border-radius: 999px;
overflow: hidden;
}
.o_fp_jmd_bar_fill {
height: 100%;
border-radius: 999px;
transition: width 0.3s ease;
&.o_fp_jmd_bar_early { background-color: #ffc107; }
&.o_fp_jmd_bar_mid { background-color: #0d6efd; }
&.o_fp_jmd_bar_done { background-color: #198754; }
}
.o_fp_jmd_bar_label {
flex: 0 0 auto;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
}
// Suppress hover lift on touch.
@media (hover: none) {
.o_fp_job_manager_dashboard .o_fp_jmd_row:hover {
transform: none !important;
box-shadow: inherit !important;
}
}

View File

@@ -0,0 +1,296 @@
// =============================================================================
// Fusion Plating — Plant Overview (native, fp.job.step)
// Copyright 2026 Nexa Systems Inc. · License OPL-1
//
// Class prefix: .o_fp_jpo_* (Job Plant Overview)
// Self-contained — no shopfloor token partial dependency.
// =============================================================================
.o_fp_job_plant_overview {
height: 100%;
display: flex;
flex-direction: column;
padding: 16px 24px;
gap: 16px;
background-color: var(--o-action, #f7f7f8);
color: var(--bs-body-color, #1a1d21);
overflow: hidden;
@media (max-width: 600px) { padding: 12px; gap: 12px; }
// -------------------------------------------------------------------------
// Header strip
// -------------------------------------------------------------------------
.o_fp_jpo_header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: 12px 16px;
background-color: var(--bs-body-bg, #ffffff);
border: 1px solid #d8dadd;
border-radius: 8px;
}
.o_fp_jpo_header_left {
display: flex;
align-items: baseline;
gap: 12px;
}
.o_fp_jpo_title {
font-size: 1.1rem;
font-weight: 700;
margin: 0;
}
.o_fp_jpo_header_right {
display: flex;
align-items: center;
gap: 8px;
}
.o_fp_jpo_search_box {
display: inline-flex;
align-items: center;
background-color: var(--bs-tertiary-bg, #f1f3f5);
border: 1px solid #d8dadd;
border-radius: 999px;
padding: 4px 10px;
gap: 6px;
min-width: 240px;
}
.o_fp_jpo_search_icon { opacity: 0.6; }
.o_fp_jpo_search_input {
border: none;
background: transparent;
outline: none;
font-size: 0.875rem;
flex: 1;
color: inherit;
}
.o_fp_jpo_search_clear {
border: none;
background: transparent;
opacity: 0.55;
padding: 0 2px;
cursor: pointer;
&:hover { opacity: 0.9; }
}
// -------------------------------------------------------------------------
// Empty / loading
// -------------------------------------------------------------------------
.o_fp_jpo_empty,
.o_fp_jpo_loading {
background-color: var(--bs-body-bg, #ffffff);
border: 1px solid #d8dadd;
border-radius: 8px;
}
// -------------------------------------------------------------------------
// Columns
// -------------------------------------------------------------------------
.o_fp_jpo_columns {
display: flex;
gap: 12px;
overflow-x: auto;
flex: 1 1 auto;
align-items: stretch;
padding-bottom: 4px;
}
.o_fp_jpo_column {
flex: 0 0 280px;
display: flex;
flex-direction: column;
background-color: var(--bs-tertiary-bg, #eef0f2);
border: 1px solid #d8dadd;
border-radius: 8px;
max-height: 100%;
overflow: hidden;
}
.o_fp_jpo_col_header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 10px 12px 4px;
font-weight: 700;
font-size: 0.95rem;
}
.o_fp_jpo_col_subhead {
padding: 0 12px 6px;
opacity: 0.7;
}
.o_fp_jpo_col_count {
background-color: rgba(0, 0, 0, 0.08);
color: inherit;
font-weight: 600;
font-size: 0.7rem;
padding: 2px 8px;
}
.o_fp_jpo_col_body {
flex: 1 1 auto;
overflow-y: auto;
padding: 6px 8px 10px;
display: flex;
flex-direction: column;
gap: 8px;
&.o_fp_drop_target {
background-color: rgba(13, 110, 253, 0.08);
}
}
// -------------------------------------------------------------------------
// Cards
// -------------------------------------------------------------------------
.o_fp_jpo_card {
background-color: var(--bs-body-bg, #ffffff);
border: 1px solid #d8dadd;
border-radius: 6px;
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 4px;
cursor: grab;
transition: transform 0.1s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border-color: #c5c8cc;
}
&:active { cursor: grabbing; }
// ---- State accents (left border) --------------------------------
&.o_fp_jpo_card_progress { border-left: 3px solid #0d6efd; }
&.o_fp_jpo_card_ready { border-left: 3px solid #ffc107; }
&.o_fp_jpo_card_paused { border-left: 3px solid #fd7e14; }
&.o_fp_jpo_card_done { border-left: 3px solid #198754; opacity: 0.75; }
// ---- Priority overlay -------------------------------------------
&.o_fp_jpo_card_rush {
box-shadow: 0 0 0 1px rgba(220, 53, 69, 0.45),
0 2px 8px rgba(220, 53, 69, 0.18);
}
&.o_fp_jpo_card_high {
box-shadow: 0 0 0 1px rgba(253, 126, 20, 0.4),
0 2px 8px rgba(253, 126, 20, 0.16);
}
}
.o_fp_jpo_card_top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 6px;
}
.o_fp_jpo_card_title {
flex: 1 1 auto;
font-size: 0.9rem;
line-height: 1.25;
word-break: break-word;
}
.o_fp_jpo_card_refs {
font-size: 0.8rem;
}
.o_fp_jpo_job_link {
color: var(--bs-link-color, #0d6efd);
cursor: pointer;
text-decoration: none;
&:hover { text-decoration: underline; }
}
.o_fp_jpo_card_meta {
font-size: 0.72rem;
opacity: 0.85;
display: flex;
flex-wrap: wrap;
gap: 2px 4px;
}
.o_fp_jpo_card_footer {
display: flex;
gap: 6px;
margin-top: 2px;
}
// -------------------------------------------------------------------------
// State badges (top-right of card)
// -------------------------------------------------------------------------
.o_fp_jpo_state_badge {
display: inline-flex;
align-items: center;
padding: 1px 7px;
border-radius: 999px;
font-size: 0.65rem;
font-weight: 700;
line-height: 1.4;
white-space: nowrap;
text-transform: uppercase;
letter-spacing: 0.02em;
&.o_fp_jpo_state_badge_pending { background-color: #e9ecef; color: #6c757d; }
&.o_fp_jpo_state_badge_ready { background-color: rgba(255, 193, 7, 0.18); color: #b58105; }
&.o_fp_jpo_state_badge_in_progress { background-color: rgba(13, 110, 253, 0.18); color: #084298; }
&.o_fp_jpo_state_badge_paused { background-color: rgba(253, 126, 20, 0.20); color: #97480d; }
&.o_fp_jpo_state_badge_done { background-color: rgba(25, 135, 84, 0.20); color: #0f5132; }
&.o_fp_jpo_state_badge_skipped { background-color: #e9ecef; color: #6c757d; }
&.o_fp_jpo_state_badge_cancelled { background-color: rgba(220, 53, 69, 0.18); color: #842029; }
}
// -------------------------------------------------------------------------
// Priority chip (footer)
// -------------------------------------------------------------------------
.o_fp_jpo_chip {
display: inline-flex;
align-items: center;
padding: 1px 8px;
border-radius: 999px;
font-size: 0.65rem;
font-weight: 700;
line-height: 1.5;
text-transform: uppercase;
letter-spacing: 0.02em;
&.o_fp_jpo_chip_rush { background-color: #dc3545; color: #fff; }
&.o_fp_jpo_chip_high { background-color: #fd7e14; color: #fff; }
&.o_fp_jpo_chip_low { background-color: #6c757d; color: #fff; }
}
// -------------------------------------------------------------------------
// Drag-drop placeholder + ghost
// -------------------------------------------------------------------------
.o_fp_dragging {
opacity: 0.4;
}
.o_fp_jpo_drop_placeholder {
height: 56px;
border: 2px dashed #0d6efd;
border-radius: 6px;
background-color: rgba(13, 110, 253, 0.08);
margin: 0;
}
// -------------------------------------------------------------------------
// No-cards filler
// -------------------------------------------------------------------------
.o_fp_jpo_no_cards {
opacity: 0.6;
font-size: 0.8rem;
}
}
// Suppress the lift transform on touch so taps don't leave cards in
// hover state.
@media (hover: none) {
.o_fp_job_plant_overview .o_fp_jpo_card:hover {
transform: none !important;
box-shadow: inherit !important;
}
}

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.
Manager Dashboard (Native) — list of in-flight fp.job rows with
progress, deadline, current-step location and priority bar.
-->
<templates xml:space="preserve">
<t t-name="fusion_plating_jobs.JobManagerDashboard">
<div class="o_fp_job_manager_dashboard">
<!-- ========== HEADER ========== -->
<div class="o_fp_jmd_header">
<div class="o_fp_jmd_header_left">
<h2 class="o_fp_jmd_title">
<i class="fa fa-tachometer me-2"/>
Manager Dashboard
</h2>
<span class="o_fp_jmd_refresh_ts text-muted ms-3"
t-if="state.lastRefresh">
Updated <t t-esc="state.lastRefresh"/>
</span>
</div>
<div class="o_fp_jmd_header_right">
<button class="btn btn-outline-secondary"
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>
<!-- ========== FILTER PILLS ========== -->
<div class="o_fp_jmd_filter_bar">
<button t-att-class="'o_fp_jmd_pill ' + (isActiveFilter('confirmed') ? 'o_fp_jmd_pill_active' : '')"
t-on-click="() => this.setFilter('confirmed')">
<span class="o_fp_jmd_pill_label">Confirmed</span>
<span class="o_fp_jmd_pill_count" t-esc="state.counts.confirmed || 0"/>
</button>
<button t-att-class="'o_fp_jmd_pill ' + (isActiveFilter('in_progress') ? 'o_fp_jmd_pill_active' : '')"
t-on-click="() => this.setFilter('in_progress')">
<span class="o_fp_jmd_pill_label">In Progress</span>
<span class="o_fp_jmd_pill_count" t-esc="state.counts.in_progress || 0"/>
</button>
<button t-att-class="'o_fp_jmd_pill ' + (isActiveFilter('on_hold') ? 'o_fp_jmd_pill_active' : '')"
t-on-click="() => this.setFilter('on_hold')">
<span class="o_fp_jmd_pill_label">On Hold</span>
<span class="o_fp_jmd_pill_count" t-esc="state.counts.on_hold || 0"/>
</button>
<button t-att-class="'o_fp_jmd_pill ' + (isActiveFilter('done') ? 'o_fp_jmd_pill_active' : '')"
t-on-click="() => this.setFilter('done')">
<span class="o_fp_jmd_pill_label">Done</span>
<span class="o_fp_jmd_pill_count" t-esc="state.counts.done || 0"/>
</button>
<button t-att-class="'o_fp_jmd_pill ' + (isActiveFilter('all') ? 'o_fp_jmd_pill_active' : '')"
t-on-click="() => this.setFilter('all')">
<span class="o_fp_jmd_pill_label">All</span>
</button>
</div>
<!-- ========== LOADING ========== -->
<div class="o_fp_jmd_loading text-center py-5"
t-if="state.loading and !state.rows.length">
<i class="fa fa-spinner fa-spin fa-2x"/>
<p class="mt-2 text-muted">Loading jobs...</p>
</div>
<!-- ========== EMPTY ========== -->
<div class="o_fp_jmd_empty text-center py-5"
t-if="!state.loading and !state.rows.length">
<i class="fa fa-check-circle fa-3x text-success"/>
<p class="mt-3 text-muted">No jobs in this bucket.</p>
</div>
<!-- ========== ROWS ========== -->
<div class="o_fp_jmd_rows" t-if="state.rows.length">
<t t-foreach="state.rows" t-as="row" t-key="row.id">
<div t-att-class="'o_fp_jmd_row ' + priorityClass(row.priority)"
t-on-click="() => this.openJob(row)">
<!-- Priority bar (left edge) -->
<div class="o_fp_jmd_priority_bar"/>
<!-- Main content -->
<div class="o_fp_jmd_row_body">
<!-- Top: name + state + priority chip -->
<div class="o_fp_jmd_row_top">
<div class="o_fp_jmd_row_id">
<strong t-esc="row.name"/>
<span class="text-muted ms-2 small" t-if="row.partner">
· <t t-esc="row.partner"/>
</span>
</div>
<div class="o_fp_jmd_row_chips">
<span t-att-class="'o_fp_jmd_state_badge ' + stateBadgeClass(row.state)"
t-esc="stateLabel(row.state)"/>
<span t-if="row.priority === 'rush'"
class="o_fp_jmd_chip o_fp_jmd_chip_rush">RUSH</span>
<span t-if="row.priority === 'high'"
class="o_fp_jmd_chip o_fp_jmd_chip_high">High</span>
</div>
</div>
<!-- Meta row: qty / current step / deadline -->
<div class="o_fp_jmd_row_meta text-muted small">
<span t-if="row.qty">
<i class="fa fa-cube me-1"/>Qty <t t-esc="row.qty"/>
</span>
<span t-if="row.recipe">
· <i class="fa fa-flask me-1"/><t t-esc="row.recipe"/>
</span>
<span t-if="row.current_step">
· <i class="fa fa-map-signs me-1"/><t t-esc="row.current_step"/>
</span>
<span t-elif="row.current_location">
· <i class="fa fa-map-signs me-1"/><t t-esc="row.current_location"/>
</span>
<span t-if="row.date_deadline"
t-att-class="isOverdue(row) ? 'o_fp_jmd_overdue' : ''">
· <i class="fa fa-calendar me-1"/>
<t t-esc="deadlineLabel(row)"/>
<t t-if="isOverdue(row)"> (overdue)</t>
</span>
</div>
<!-- Progress bar -->
<div class="o_fp_jmd_row_progress">
<div class="o_fp_jmd_bar_track">
<div t-att-class="'o_fp_jmd_bar_fill ' + progressBarClass(row)"
t-att-style="'width:' + Math.min(100, Math.round(row.progress_pct || 0)) + '%'"/>
</div>
<span class="o_fp_jmd_bar_label small text-muted">
<t t-esc="progressLabel(row)"/>
</span>
</div>
</div>
<!-- Open icon -->
<div class="o_fp_jmd_row_open">
<i class="fa fa-chevron-right"/>
</div>
</div>
</t>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,163 @@
<?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.
Plant Overview (Native) — kanban for fp.job.step. Each column is
one fp.work.centre; cards are active steps (ready / in_progress /
paused). Drag a card across columns to reassign work_centre_id.
-->
<templates xml:space="preserve">
<t t-name="fusion_plating_jobs.JobPlantOverview">
<div class="o_fp_job_plant_overview">
<!-- ========== HEADER ========== -->
<div class="o_fp_jpo_header">
<div class="o_fp_jpo_header_left">
<h2 class="o_fp_jpo_title">
<i class="fa fa-industry me-2"/>
Plant Overview
</h2>
<span class="o_fp_jpo_refresh_ts text-muted ms-3"
t-if="state.lastRefresh">
Updated <t t-esc="state.lastRefresh"/>
</span>
</div>
<div class="o_fp_jpo_header_right">
<div class="o_fp_jpo_search_box">
<i class="fa fa-search o_fp_jpo_search_icon"/>
<input type="text"
class="o_fp_jpo_search_input"
placeholder="Search step, job, customer..."
t-att-value="state.searchTerm"
t-on-input="onSearchInput"
t-on-keydown="onSearchKey"/>
<button class="o_fp_jpo_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_jpo_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_jpo_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 ========== -->
<div class="o_fp_jpo_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 active steps in any work centre.
</p>
</div>
<!-- ========== COLUMNS ========== -->
<div class="o_fp_jpo_columns" t-if="state.columns.length">
<t t-foreach="state.columns" t-as="col" t-key="col.id">
<div class="o_fp_jpo_column">
<!-- Column header -->
<div class="o_fp_jpo_col_header">
<span class="o_fp_jpo_col_name" t-esc="col.name"/>
<span class="o_fp_jpo_col_count badge rounded-pill">
<t t-esc="col.cards.length"/>
</span>
</div>
<div class="o_fp_jpo_col_subhead text-muted small"
t-if="col.code or col.kind">
<span t-if="col.code" t-esc="col.code"/>
<span t-if="col.code and col.kind"> · </span>
<span t-if="col.kind" t-esc="col.kind"/>
</div>
<!-- Cards (drop zone) -->
<div class="o_fp_jpo_col_body"
t-on-dragover="(ev) => this.onColDragOver(col, ev)"
t-on-dragleave="(ev) => this.onColDragLeave(col, ev)"
t-on-drop="(ev) => this.onColDrop(col, ev)">
<t t-if="!col.cards.length">
<div class="o_fp_jpo_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_jpo_card ' + getStateClass(card.state) + ' ' + getPriorityClass(card.priority)"
draggable="true"
t-att-data-card-id="card.id"
t-att-data-source-wc="col.id"
t-on-dragstart="(ev) => this.onCardDragStart(card, col, ev)"
t-on-dragend="(ev) => this.onCardDragEnd(ev)"
t-on-click="() => this.onCardClick(card)">
<!-- Top row: step name + state badge -->
<div class="o_fp_jpo_card_top">
<div class="o_fp_jpo_card_title">
<strong t-esc="card.name"/>
</div>
<span t-attf-class="o_fp_jpo_state_badge o_fp_jpo_state_badge_#{ card.state }"
t-esc="card.state"/>
</div>
<!-- Job link + customer -->
<div class="o_fp_jpo_card_refs">
<a t-on-click="(ev) => this.onJobLink(card, ev)"
class="o_fp_jpo_job_link"
t-esc="card.job_name"/>
<span t-if="card.partner" class="text-muted">
· <t t-esc="card.partner"/>
</span>
</div>
<!-- Meta: assigned user, duration, thickness -->
<div class="o_fp_jpo_card_meta text-muted small">
<span t-if="card.assigned_user">
<i class="fa fa-user me-1"/><t t-esc="card.assigned_user"/>
</span>
<span t-if="card.assigned_user and durationLabel(card)"> · </span>
<span t-if="durationLabel(card)">
<i class="fa fa-clock-o me-1"/><t t-esc="durationLabel(card)"/>
</span>
<span t-if="card.thickness_target">
· <i class="fa fa-arrows-v me-1"/>
<t t-esc="card.thickness_target"/>
<t t-esc="' ' + (card.thickness_uom || '')"/>
</span>
</div>
<!-- Priority chip -->
<div class="o_fp_jpo_card_footer"
t-if="card.priority and card.priority !== 'normal'">
<span t-attf-class="o_fp_jpo_chip o_fp_jpo_chip_#{ card.priority }">
<t t-if="card.priority === 'rush'">RUSH</t>
<t t-elif="card.priority === 'high'">High</t>
<t t-elif="card.priority === 'low'">Low</t>
<t t-else="" t-esc="card.priority"/>
</span>
</div>
</div>
</t>
</div>
</div>
</t>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!--
Phase 6 — operator-facing client actions for the native job model.
Plant Overview kanban: columns = fp.work.centre, cards = fp.job.step
Manager Dashboard: list of in-flight fp.job rows, by state
Menu items live here (not in fusion_plating core's fp_jobs_menu.xml)
because the action records they reference are defined in this
module — and fusion_plating_jobs is loaded AFTER core, so the
XML ids don't exist yet at the time core's menu file is parsed.
-->
<record id="action_job_plant_overview" model="ir.actions.client">
<field name="name">Plant Overview (Native)</field>
<field name="tag">fp_job_plant_overview</field>
</record>
<record id="action_job_manager_dashboard" model="ir.actions.client">
<field name="name">Manager Dashboard (Native)</field>
<field name="tag">fp_job_manager_dashboard</field>
</record>
<menuitem id="menu_fp_jobs_plant_overview"
name="Plant Overview (Native)"
parent="fusion_plating.menu_fp_jobs_native_root"
action="action_job_plant_overview"
sequence="5"/>
<menuitem id="menu_fp_jobs_manager_dashboard"
name="Manager Dashboard (Native)"
parent="fusion_plating.menu_fp_jobs_native_root"
action="action_job_manager_dashboard"
sequence="7"/>
</odoo>