diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index f6140560..1ff12454 100644 --- a/fusion_plating/fusion_plating_jobs/__manifest__.py +++ b/fusion_plating/fusion_plating_jobs/__manifest__.py @@ -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, diff --git a/fusion_plating/fusion_plating_jobs/controllers/__init__.py b/fusion_plating/fusion_plating_jobs/controllers/__init__.py index 469ae9a4..f9bd25b2 100644 --- a/fusion_plating/fusion_plating_jobs/controllers/__init__.py +++ b/fusion_plating/fusion_plating_jobs/controllers/__init__.py @@ -1,3 +1,5 @@ # -*- coding: utf-8 -*- from . import job_scan from . import process_tree +from . import plant_overview +from . import manager_dashboard diff --git a/fusion_plating/fusion_plating_jobs/controllers/manager_dashboard.py b/fusion_plating/fusion_plating_jobs/controllers/manager_dashboard.py new file mode 100644 index 00000000..3ec15473 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/controllers/manager_dashboard.py @@ -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= + # 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} diff --git a/fusion_plating/fusion_plating_jobs/controllers/plant_overview.py b/fusion_plating/fusion_plating_jobs/controllers/plant_overview.py new file mode 100644 index 00000000..985858ff --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/controllers/plant_overview.py @@ -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} diff --git a/fusion_plating/fusion_plating_jobs/static/src/js/job_manager_dashboard.js b/fusion_plating/fusion_plating_jobs/static/src/js/job_manager_dashboard.js new file mode 100644 index 00000000..6771b0ce --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/static/src/js/job_manager_dashboard.js @@ -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); diff --git a/fusion_plating/fusion_plating_jobs/static/src/js/job_plant_overview.js b/fusion_plating/fusion_plating_jobs/static/src/js/job_plant_overview.js new file mode 100644 index 00000000..004d43c0 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/static/src/js/job_plant_overview.js @@ -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); diff --git a/fusion_plating/fusion_plating_jobs/static/src/scss/job_manager_dashboard.scss b/fusion_plating/fusion_plating_jobs/static/src/scss/job_manager_dashboard.scss new file mode 100644 index 00000000..93aece4e --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/static/src/scss/job_manager_dashboard.scss @@ -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; + } +} diff --git a/fusion_plating/fusion_plating_jobs/static/src/scss/job_plant_overview.scss b/fusion_plating/fusion_plating_jobs/static/src/scss/job_plant_overview.scss new file mode 100644 index 00000000..67a7f25f --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/static/src/scss/job_plant_overview.scss @@ -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; + } +} diff --git a/fusion_plating/fusion_plating_jobs/static/src/xml/job_manager_dashboard.xml b/fusion_plating/fusion_plating_jobs/static/src/xml/job_manager_dashboard.xml new file mode 100644 index 00000000..ca88ffd8 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/static/src/xml/job_manager_dashboard.xml @@ -0,0 +1,154 @@ + + + + + +
+ + +
+
+

+ + Manager Dashboard +

+ + Updated + +
+
+ +
+
+ + +
+ + + + + +
+ + +
+ +

Loading jobs...

+
+ + +
+ +

No jobs in this bucket.

+
+ + +
+ +
+ + +
+ + +
+ + +
+
+ + + · + +
+
+ + RUSH + High +
+
+ + +
+ + Qty + + + · + + + · + + + · + + + · + + (overdue) + +
+ + +
+
+
+
+ + + +
+
+ + +
+ +
+
+ +
+ +
+
+ + diff --git a/fusion_plating/fusion_plating_jobs/static/src/xml/job_plant_overview.xml b/fusion_plating/fusion_plating_jobs/static/src/xml/job_plant_overview.xml new file mode 100644 index 00000000..fc8c0c55 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/static/src/xml/job_plant_overview.xml @@ -0,0 +1,163 @@ + + + + + +
+ + +
+
+

+ + Plant Overview +

+ + Updated + +
+
+ + +
+
+ + +
+ +

Loading plant data...

+
+ + +
+ +

+ No active steps in any work centre. +

+
+ + +
+ +
+ + +
+ + + + +
+
+ + · + +
+ + + + +
+
+
+ +
+
+ +
diff --git a/fusion_plating/fusion_plating_jobs/views/job_overview_actions.xml b/fusion_plating/fusion_plating_jobs/views/job_overview_actions.xml new file mode 100644 index 00000000..424418ab --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/views/job_overview_actions.xml @@ -0,0 +1,35 @@ + + + + + Plant Overview (Native) + fp_job_plant_overview + + + + Manager Dashboard (Native) + fp_job_manager_dashboard + + + + + +