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)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Native Jobs',
|
'name': 'Fusion Plating — Native Jobs',
|
||||||
'version': '19.0.2.2.0',
|
'version': '19.0.2.3.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -39,6 +39,7 @@ full design rationale and §6.2 of the implementation plan for task list.
|
|||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
'views/res_config_settings_views.xml',
|
'views/res_config_settings_views.xml',
|
||||||
'views/job_process_tree_action.xml',
|
'views/job_process_tree_action.xml',
|
||||||
|
'views/job_overview_actions.xml',
|
||||||
'views/fp_job_form_inherit.xml',
|
'views/fp_job_form_inherit.xml',
|
||||||
'report/report_fp_job_sticker.xml',
|
'report/report_fp_job_sticker.xml',
|
||||||
'report/report_fp_job_traveller.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': {
|
'assets': {
|
||||||
'web.assets_backend': [
|
'web.assets_backend': [
|
||||||
'fusion_plating_jobs/static/src/scss/job_process_tree.scss',
|
'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_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_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,
|
'installable': True,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from . import job_scan
|
from . import job_scan
|
||||||
from . import process_tree
|
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