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:
@@ -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,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import job_scan
|
||||
from . import process_tree
|
||||
from . import plant_overview
|
||||
from . import manager_dashboard
|
||||
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user