refactor(shopfloor,jobs): consolidate operator UI into shopfloor

Removes the parallel OWL/controller stack I built in
fusion_plating_jobs (job_process_tree, job_plant_overview,
job_manager_dashboard, job_tablet, job_*.scss, plus parallel
controllers and action XML files). Refactors the existing
fusion_plating_shopfloor components in place to bind to
fp.job / fp.job.step instead of mrp.production / mrp.workorder.

End state:
- ONE operator UI module (shopfloor) instead of two parallel ones
- Existing token system (_fp_shopfloor_tokens.scss) reused as
  designed - no duplicate jobs tokens
- Existing /fp/shopfloor/* RPC URLs preserved (no integration
  breakage); workorder_id kwargs accepted as legacy aliases for
  step_id / job_id so older tablet clients keep working
- Existing visual designs preserved - only the data layer
  underneath changed
- Process Tree button on fp.job form now points at
  fusion_plating_shopfloor's fp_process_tree client action
- Bake Windows / First-Piece Gates / Bake Oven / Operator Queue
  models stay where they were
- legacy_menu_hide.xml trimmed: only the bridge_mrp Production
  Priorities entry remains; the 3 shopfloor menus (Manager Desk,
  Plant Overview, Tablet Station) are now visible (the canonical
  native consoles)

Manifests:
- fusion_plating_jobs 19.0.3.1.0 -> 19.0.4.0.0 (consolidation bump,
  no more bundled JS/SCSS, only job_scan controller retained)
- fusion_plating_shopfloor 19.0.14.4.0 -> 19.0.15.0.0 (asset bundle
  cache-bust + significant controller refactor)

Tests pass on entech: 0 failed, 0 errors of 41 tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-25 06:45:15 -04:00
parent 667654bd4e
commit 5df7d5e6cf
36 changed files with 891 additions and 5128 deletions

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.3.1.0',
'version': '19.0.4.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',
@@ -25,6 +25,13 @@ Activate native jobs via the x_fc_use_native_jobs settings flag (default:
False). When False, SO confirm continues to create mrp.production records
through bridge_mrp. When True, SO confirm creates fp.job records here.
19.0.4.0.0 (2026-04-24): Operator UI consolidation. The parallel
OWL/controller stack (job_process_tree, job_plant_overview,
job_manager_dashboard, job_tablet) was removed. The canonical
operator-facing UIs now live in fusion_plating_shopfloor and bind
directly to fp.job / fp.job.step. This module retains lifecycle hooks,
SO → fp.job creation, reports, and the QR-scan redirect.
See docs/superpowers/specs/2026-04-25-fp-native-job-model-design.md for
full design rationale and §6.2 of the implementation plan for task list.
""",
@@ -40,15 +47,12 @@ full design rationale and §6.2 of the implementation plan for task list.
'fusion_plating_quality', # fusion.plating.customer.spec, fusion.plating.quality.hold
'fusion_plating_receiving', # fp.racking.inspection (Phase 3)
'fusion_plating_reports', # paperformat helpers, customer_line_header (Phase 5)
'fusion_plating_shopfloor', # legacy menus restricted in views/legacy_menu_hide.xml
'fusion_plating_shopfloor', # canonical operator UI consoles
],
'data': [
'security/legacy_groups.xml',
'security/ir.model.access.csv',
'views/res_config_settings_views.xml',
'views/job_process_tree_action.xml',
'views/job_overview_actions.xml',
'views/job_tablet_action.xml',
'views/fp_job_form_inherit.xml',
'views/legacy_menu_hide.xml',
'report/report_fp_job_sticker.xml',
@@ -56,26 +60,8 @@ full design rationale and §6.2 of the implementation plan for task list.
'report/report_fp_job_margin.xml',
],
'assets': {
'web.assets_backend': [
# Tokens MUST be first — Odoo concatenates bundle files in
# order, and SCSS variables defined in earlier files are
# visible to later files in the same bundle. The token
# partial branches on $o-webclient-color-scheme so the dark
# bundle (web.assets_web_dark) gets a distinct palette.
'fusion_plating_jobs/static/src/scss/_fp_jobs_tokens.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/scss/job_tablet.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/js/job_tablet.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',
'fusion_plating_jobs/static/src/xml/job_tablet.xml',
],
# No bundled JS/SCSS — the canonical operator UIs live in
# fusion_plating_shopfloor (consolidated 2026-04-24).
},
'installable': True,
'application': False,

View File

@@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
# Consolidated 2026-04-24: the parallel OWL/controller stack was
# removed. job_scan is the only controller retained — it powers the
# QR-sticker scan redirect for fp.job records.
from . import job_scan
from . import process_tree
from . import plant_overview
from . import manager_dashboard
from . import tablet

View File

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

View File

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

View File

@@ -1,48 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# /fp/jobs/process_tree — JSON endpoint that returns the recipe tree
# for a given fp.job, with each node tagged by the matching
# fp.job.step (if any) and its current state.
from odoo import http
from odoo.http import request
class FpJobProcessTreeController(http.Controller):
@http.route('/fp/jobs/process_tree', type='jsonrpc', auth='user', website=False)
def fp_jobs_process_tree(self, job_id, **kwargs):
Job = request.env['fp.job']
job = Job.browse(int(job_id)).exists()
if not job:
return {'error': 'Job not found'}
# Map recipe_node_id -> step
step_by_node = {s.recipe_node_id.id: s for s in job.step_ids if s.recipe_node_id}
def serialize(node):
step = step_by_node.get(node.id)
return {
'id': node.id,
'name': node.name,
'node_type': node.node_type,
'sequence': node.sequence,
'step_id': step.id if step else None,
'step_state': step.state if step else None,
'step_assigned_user': step.assigned_user_id.name if step and step.assigned_user_id else None,
'duration_expected': step.duration_expected if step else node.estimated_duration,
'duration_actual': step.duration_actual if step else 0.0,
'children': [serialize(c) for c in node.child_ids.sorted('sequence')],
}
return {
'job_name': job.name,
'partner': job.partner_id.name,
'state': job.state,
'qty': job.qty,
'recipe_name': job.recipe_id.name if job.recipe_id else '',
'progress_pct': job.step_progress_pct,
'tree': serialize(job.recipe_id) if job.recipe_id else None,
}

View File

@@ -1,188 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# /fp/jobs/tablet/* — JSON-RPC endpoints powering the native-job
# Tablet Station (Phase 6 of the native job migration). Operator-
# facing touchscreen UI for starting/finishing fp.job.step rows.
#
# Endpoints:
# POST /fp/jobs/tablet/jobs -> active jobs the operator can pick
# POST /fp/jobs/tablet/job_detail -> job header + ordered step list
# POST /fp/jobs/tablet/start_step -> calls fp.job.step.button_start
# POST /fp/jobs/tablet/finish_step -> calls fp.job.step.button_finish
#
# All write paths funnel through the model's button_start / button_finish
# methods so the audit / timelog / duration_actual roll-up logic from
# Phase 1 still applies.
from odoo import http
from odoo.http import request
class FpJobsTabletController(http.Controller):
@http.route('/fp/jobs/tablet/jobs', type='jsonrpc', auth='user', website=False)
def fp_jobs_tablet_jobs(self, facility_id=None, **kwargs):
"""Active jobs the operator can pick from."""
env = request.env
Job = env['fp.job']
domain = [('state', 'in', ('confirmed', 'in_progress'))]
if facility_id:
domain.append(('facility_id', '=', int(facility_id)))
jobs = Job.search(
domain,
order='priority desc, date_deadline asc, id desc',
limit=50,
)
return {
'jobs': [{
'id': j.id,
'name': j.name,
'partner': j.partner_id.name or '',
'qty': j.qty,
'progress_pct': j.step_progress_pct,
'state': j.state,
'priority': j.priority,
'current_step': (
j.current_step_id.name if j.current_step_id else None
),
'deadline': (
j.date_deadline.isoformat() if j.date_deadline else None
),
} for j in jobs],
}
@http.route('/fp/jobs/tablet/job_detail', type='jsonrpc', auth='user', website=False)
def fp_jobs_tablet_job_detail(self, job_id, **kwargs):
"""Job header + ordered step list for the detail panel."""
env = request.env
Job = env['fp.job']
job = Job.browse(int(job_id)).exists()
if not job:
return {'error': 'Job not found'}
steps = []
for step in job.step_ids.sorted('sequence'):
steps.append({
'id': step.id,
'name': step.name,
'sequence': step.sequence,
'state': step.state,
'kind': step.kind,
'work_centre': (
step.work_centre_id.name if step.work_centre_id else None
),
'duration_expected': step.duration_expected,
'duration_actual': step.duration_actual,
'thickness_target': step.thickness_target,
'thickness_uom': step.thickness_uom,
'assigned_user': (
step.assigned_user_id.name
if step.assigned_user_id else None
),
'date_started': (
step.date_started.isoformat() if step.date_started else None
),
'date_finished': (
step.date_finished.isoformat() if step.date_finished else None
),
})
return {
'id': job.id,
'name': job.name,
'partner': job.partner_id.name or '',
'qty': job.qty,
'state': job.state,
'priority': job.priority,
'recipe': job.recipe_id.name if job.recipe_id else None,
'progress_pct': job.step_progress_pct,
'step_done': job.step_done_count,
'step_total': job.step_count,
'steps': steps,
}
@http.route('/fp/jobs/tablet/step_detail', type='jsonrpc', auth='user', website=False)
def fp_jobs_tablet_step_detail(self, step_id, **kwargs):
"""Step detail panel — used to refresh after button_start /
button_finish so the timelog history pulls in the new row.
"""
env = request.env
step = env['fp.job.step'].browse(int(step_id)).exists()
if not step:
return {'error': 'Step not found'}
timelogs = []
for log in step.time_log_ids.sorted('date_started', reverse=True):
timelogs.append({
'id': log.id,
'user': log.user_id.name or '',
'date_started': (
log.date_started.isoformat() if log.date_started else None
),
'date_finished': (
log.date_finished.isoformat() if log.date_finished else None
),
'duration_minutes': log.duration_minutes,
})
return {
'id': step.id,
'name': step.name,
'sequence': step.sequence,
'state': step.state,
'kind': step.kind,
'work_centre': (
step.work_centre_id.name if step.work_centre_id else None
),
'duration_expected': step.duration_expected,
'duration_actual': step.duration_actual,
'thickness_target': step.thickness_target,
'thickness_uom': step.thickness_uom,
'assigned_user': (
step.assigned_user_id.name
if step.assigned_user_id else None
),
'date_started': (
step.date_started.isoformat() if step.date_started else None
),
'date_finished': (
step.date_finished.isoformat() if step.date_finished else None
),
'instructions': step.instructions or '',
'timelogs': timelogs,
}
@http.route('/fp/jobs/tablet/start_step', type='jsonrpc', auth='user', website=False)
def fp_jobs_tablet_start_step(self, step_id, **kwargs):
env = request.env
step = env['fp.job.step'].browse(int(step_id)).exists()
if not step:
return {'ok': False, 'error': 'Step not found'}
try:
step.button_start()
return {
'ok': True,
'state': step.state,
'date_started': (
step.date_started.isoformat() if step.date_started else None
),
}
except Exception as e:
return {'ok': False, 'error': str(e)}
@http.route('/fp/jobs/tablet/finish_step', type='jsonrpc', auth='user', website=False)
def fp_jobs_tablet_finish_step(self, step_id, **kwargs):
env = request.env
step = env['fp.job.step'].browse(int(step_id)).exists()
if not step:
return {'ok': False, 'error': 'Step not found'}
try:
step.button_finish()
return {
'ok': True,
'state': step.state,
'duration_actual': step.duration_actual,
'date_finished': (
step.date_finished.isoformat() if step.date_finished else None
),
}
except Exception as e:
return {'ok': False, 'error': str(e)}

View File

@@ -264,15 +264,19 @@ class FpJob(models.Model):
def action_open_process_tree(self):
"""Open the OWL process-tree visualization for this job.
Launches the fp_job_process_tree client action with job_id in
context. The component fetches /fp/jobs/process_tree and renders
the recipe -> sub_process -> operation hierarchy as cards with
per-step state badges.
Launches the fp_process_tree client action (defined in
fusion_plating_shopfloor) with job_id in context. The component
fetches /fp/shopfloor/process_tree and renders the recipe ->
sub_process -> operation hierarchy as cards with per-step state
badges.
Consolidated 2026-04-24: this points at the canonical shopfloor
client action; the parallel fp_job_process_tree was removed.
"""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'fp_job_process_tree',
'tag': 'fp_process_tree',
'context': {'job_id': self.id},
'name': 'Process Tree — %s' % (self.name or ''),
'target': 'current',

View File

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

View File

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

View File

@@ -1,207 +0,0 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — Job Process Tree (horizontal hierarchical view, fp.job)
// Copyright 2026 Nexa Systems Inc. · License OPL-1
//
// Renders an fp.job's recipe (recipe → sub_process → operation) as a
// horizontal bracket tree, port of fusion_plating_shopfloor's process_tree.js
// rebound to fp.job + fp.job.step (instead of mrp.production + mrp.workorder).
//
// Action context:
// job_id — required; the fp.job whose recipe to render
// back_step_id — optional; if set, the back button returns to that step
// instead of the job form
//
// Endpoint: POST /fp/jobs/process_tree (fusion_plating_jobs/controllers)
// payload : { job_id: <int> }
// response : { job_name, partner, state, qty, recipe_name, progress_pct,
// tree: { id, name, node_type, sequence,
// step_id, step_state, step_assigned_user,
// duration_expected, duration_actual, children: [...] } }
// =============================================================================
import { Component, useState, onMounted } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
export class JobProcessTree extends Component {
static template = "fusion_plating_jobs.JobProcessTree";
static props = ["*"];
setup() {
this.notification = useService("notification");
this.action = useService("action");
this.state = useState({
jobName: "",
partner: "",
jobState: "",
qty: 0,
recipe: "",
progressPct: 0,
root: null,
loading: false,
});
onMounted(async () => {
await this.loadTree();
});
}
// ---- Action context -----------------------------------------------------
get _ctx() {
const a = this.props.action || {};
return { ...(a.context || {}), ...(a.params || {}) };
}
get jobId() { return this._ctx.job_id || null; }
get backStepId() { return this._ctx.back_step_id || null; }
get backLabel() {
return this.backStepId ? "Back to Step" : "Back to Job";
}
// ---- Data ---------------------------------------------------------------
async loadTree() {
const jobId = this.jobId;
if (!jobId) {
this.notification.add(
"No job specified for the process tree.",
{ type: "warning" },
);
return;
}
this.state.loading = true;
try {
const r = await rpc("/fp/jobs/process_tree", {
job_id: jobId,
});
if (r && !r.error) {
this.state.jobName = r.job_name || "";
this.state.partner = r.partner || "";
this.state.jobState = r.state || "";
this.state.qty = r.qty || 0;
this.state.recipe = r.recipe_name || "";
this.state.progressPct = r.progress_pct || 0;
this.state.root = r.tree || null;
} else if (r && r.error) {
this.notification.add(r.error, { type: "warning" });
}
} catch (err) {
this.notification.add(
`Failed to load process tree: ${err.message || err}`,
{ type: "danger" },
);
} finally {
this.state.loading = false;
}
}
// ---- Navigation ---------------------------------------------------------
onNodeClick(node) {
// Only operation cards with a matching fp.job.step are clickable —
// they open the underlying step form.
if (!node || !node.step_id) {
return;
}
this.action.doAction({
type: "ir.actions.act_window",
res_model: "fp.job.step",
res_id: node.step_id,
views: [[false, "form"]],
target: "current",
});
}
onBack() {
const stepId = this.backStepId;
if (stepId) {
this.action.doAction({
type: "ir.actions.act_window",
res_model: "fp.job.step",
res_id: parseInt(stepId, 10),
views: [[false, "form"]],
target: "current",
});
return;
}
// Default back: open the job form.
const jobId = this.jobId;
if (jobId) {
this.action.doAction({
type: "ir.actions.act_window",
res_model: "fp.job",
res_id: parseInt(jobId, 10),
views: [[false, "form"]],
target: "current",
});
return;
}
// Fallback — pop the stack.
this.action.doAction({ type: "ir.actions.act_window_close" });
}
// ---- Helpers ------------------------------------------------------------
/** Return the css class chain for a node card (state + node_type). */
getCardClass(node) {
const parts = ["o_fp_jpt_card"];
parts.push(`o_fp_jpt_type_${node.node_type || "unknown"}`);
if (node.step_state) {
parts.push(`o_fp_jpt_state_${node.step_state}`);
}
if (node.step_id) {
parts.push("o_fp_jpt_clickable");
}
if (this.isHighlight(node)) {
parts.push("o_fp_jpt_highlight");
}
return parts.join(" ");
}
/** Highlight steps that are live (ready / in_progress / paused). */
isHighlight(node) {
return node.step_state === "ready"
|| node.step_state === "in_progress"
|| node.step_state === "paused";
}
/** Friendly label for the step state badge. */
stateLabel(node) {
if (!node.step_state) return null;
const map = {
pending: "Pending",
ready: "Ready",
in_progress: "In Progress",
paused: "Paused",
done: "Done",
skipped: "Skipped",
cancelled: "Cancelled",
};
return map[node.step_state] || node.step_state;
}
/** Concise duration label: "actual / expected min" when available. */
durationLabel(node) {
const exp = node.duration_expected;
const act = node.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 "";
}
nodeIcon(node) {
switch (node.node_type) {
case "recipe": return "fa-cubes";
case "sub_process": return "fa-folder";
case "operation": return "fa-cog";
case "step": return "fa-circle-o";
default: return "fa-square";
}
}
}
registry.category("actions").add("fp_job_process_tree", JobProcessTree);

View File

@@ -1,322 +0,0 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — Tablet Station (native, fp.job.step edition)
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
//
// Operator-facing touchscreen UI for the native job model. Three modes:
// - 'job_picker': list of active jobs as big touch cards
// - 'job_detail': job header + step list (tap a step to view it)
// - 'step_detail': big Start / Finish buttons + timelog history
//
// Calls fp.job.step.button_start / button_finish through the tablet
// controller endpoints so the audit / timelog / duration_actual logic
// from Phase 1 is preserved.
//
// Endpoints:
// POST /fp/jobs/tablet/jobs -> { jobs: [...] }
// POST /fp/jobs/tablet/job_detail -> { id, name, ..., steps: [...] }
// POST /fp/jobs/tablet/step_detail -> { id, name, ..., timelogs: [...] }
// POST /fp/jobs/tablet/start_step -> { ok, state, ... }
// POST /fp/jobs/tablet/finish_step -> { ok, state, duration_actual, ... }
// =============================================================================
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 JobTablet extends Component {
static template = "fusion_plating_jobs.JobTablet";
static props = ["*"];
setup() {
this.notification = useService("notification");
this.action = useService("action");
this.state = useState({
// 'job_picker' | 'job_detail' | 'step_detail'
mode: "job_picker",
jobs: [],
job: null, // selected job detail payload
step: null, // selected step detail payload
loading: false,
busy: false, // disables Start/Finish during RPC
lastRefresh: null,
});
// Auto-refresh interval — only ticks while we're in job_picker
// mode. job_detail / step_detail are operator-driven so we don't
// surprise them with a UI swap mid-tap.
this._refreshInterval = null;
onMounted(async () => {
await this.loadJobs();
this._refreshInterval = setInterval(() => {
if (this.state.mode === "job_picker") {
this.loadJobs();
}
}, 30000);
});
onWillUnmount(() => {
if (this._refreshInterval) {
clearInterval(this._refreshInterval);
this._refreshInterval = null;
}
});
}
// ----- Data --------------------------------------------------------------
async loadJobs() {
this.state.loading = true;
try {
const result = await rpc("/fp/jobs/tablet/jobs", {});
if (result) {
this.state.jobs = result.jobs || [];
this.state.lastRefresh = new Date().toLocaleTimeString();
}
} catch (err) {
this.notification.add(
`Failed to load jobs: ${err.message || err}`,
{ type: "danger" },
);
} finally {
this.state.loading = false;
}
}
async loadJobDetail(jobId) {
this.state.loading = true;
try {
const result = await rpc("/fp/jobs/tablet/job_detail", {
job_id: jobId,
});
if (result && !result.error) {
this.state.job = result;
} else {
this.notification.add(
(result && result.error) || "Could not load job",
{ type: "danger" },
);
}
} catch (err) {
this.notification.add(
`Failed to load job: ${err.message || err}`,
{ type: "danger" },
);
} finally {
this.state.loading = false;
}
}
async loadStepDetail(stepId) {
this.state.loading = true;
try {
const result = await rpc("/fp/jobs/tablet/step_detail", {
step_id: stepId,
});
if (result && !result.error) {
this.state.step = result;
} else {
this.notification.add(
(result && result.error) || "Could not load step",
{ type: "danger" },
);
}
} catch (err) {
this.notification.add(
`Failed to load step: ${err.message || err}`,
{ type: "danger" },
);
} finally {
this.state.loading = false;
}
}
// ----- Navigation --------------------------------------------------------
async onJobPick(job) {
if (!job || !job.id) return;
await this.loadJobDetail(job.id);
if (this.state.job) {
this.state.mode = "job_detail";
}
}
async onStepPick(step) {
if (!step || !step.id) return;
await this.loadStepDetail(step.id);
if (this.state.step) {
this.state.mode = "step_detail";
}
}
onBackToJobs() {
this.state.mode = "job_picker";
this.state.job = null;
this.state.step = null;
this.loadJobs();
}
onBackToJob() {
this.state.mode = "job_detail";
this.state.step = null;
// Refresh job detail so the step list shows updated states
if (this.state.job && this.state.job.id) {
this.loadJobDetail(this.state.job.id);
}
}
onRefresh() {
if (this.state.mode === "job_picker") {
this.loadJobs();
} else if (this.state.mode === "job_detail" && this.state.job) {
this.loadJobDetail(this.state.job.id);
} else if (this.state.mode === "step_detail" && this.state.step) {
this.loadStepDetail(this.state.step.id);
}
}
// ----- Step actions ------------------------------------------------------
async onStartStep() {
if (!this.state.step || !this.state.step.id || this.state.busy) {
return;
}
this.state.busy = true;
try {
const result = await rpc("/fp/jobs/tablet/start_step", {
step_id: this.state.step.id,
});
if (result && result.ok) {
this.notification.add("Step started — timer running.", {
type: "success",
});
await this.loadStepDetail(this.state.step.id);
} else {
this.notification.add(
(result && result.error) || "Could not start step",
{ type: "warning" },
);
}
} catch (err) {
this.notification.add(
`Start failed: ${err.message || err}`,
{ type: "danger" },
);
} finally {
this.state.busy = false;
}
}
async onFinishStep() {
if (!this.state.step || !this.state.step.id || this.state.busy) {
return;
}
this.state.busy = true;
try {
const result = await rpc("/fp/jobs/tablet/finish_step", {
step_id: this.state.step.id,
});
if (result && result.ok) {
this.notification.add("Step finished.", { type: "success" });
await this.loadStepDetail(this.state.step.id);
} else {
this.notification.add(
(result && result.error) || "Could not finish step",
{ type: "warning" },
);
}
} catch (err) {
this.notification.add(
`Finish failed: ${err.message || err}`,
{ type: "danger" },
);
} finally {
this.state.busy = false;
}
}
// ----- Helpers -----------------------------------------------------------
stateBadgeClass(state) {
// Maps fp.job.step.state -> SCSS class suffix
switch (state) {
case "pending": return "o_fp_jt_badge_pending";
case "ready": return "o_fp_jt_badge_ready";
case "in_progress": return "o_fp_jt_badge_progress";
case "paused": return "o_fp_jt_badge_paused";
case "done": return "o_fp_jt_badge_done";
case "skipped": return "o_fp_jt_badge_skipped";
case "cancelled": return "o_fp_jt_badge_cancelled";
default: return "o_fp_jt_badge_pending";
}
}
stateLabel(state) {
const map = {
pending: "Pending",
ready: "Ready",
in_progress: "In Progress",
paused: "Paused",
done: "Done",
skipped: "Skipped",
cancelled: "Cancelled",
};
return map[state] || state || "";
}
priorityClass(priority) {
switch (priority) {
case "rush": return "o_fp_jt_card_rush";
case "high": return "o_fp_jt_card_high";
default: return "";
}
}
priorityLabel(priority) {
const map = { low: "Low", normal: "", high: "High", rush: "RUSH" };
return map[priority] || "";
}
canStart(state) {
return state === "ready" || state === "paused";
}
canFinish(state) {
return state === "in_progress";
}
durationLabel(step) {
const exp = step && step.duration_expected;
const act = step && step.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 "";
}
formatDateTime(isoStr) {
if (!isoStr) return "";
try {
const d = new Date(isoStr);
return d.toLocaleString();
} catch (e) {
return isoStr;
}
}
formatDeadline(isoStr) {
if (!isoStr) return "";
try {
const d = new Date(isoStr);
return d.toLocaleDateString();
} catch (e) {
return isoStr;
}
}
}
registry.category("actions").add("fp_job_tablet", JobTablet);

View File

@@ -1,234 +0,0 @@
// =============================================================================
// Fusion Plating — Job model design system (v1, 2026-04)
// File: fusion_plating_jobs/static/src/scss/_fp_jobs_tokens.scss
// Copyright 2026 Nexa Systems Inc. · License OPL-1
//
// Parallels fusion_plating_shopfloor/static/src/scss/_fp_shopfloor_tokens.scss
// — same spacing scale, same radius scale, same compile-time dark-mode
// branching pattern. Lives in fusion_plating_jobs so the new client-action
// SCSS files (job_process_tree, job_plant_overview, job_manager_dashboard,
// job_tablet) can reference these tokens without taking a hard SCSS-level
// dependency on the shopfloor module's bundle ordering.
//
// Design philosophy (same as shopfloor):
// * Three-layer contrast: page (grayest) → container (mid) → card
// (brightest) — that's what makes cards pop in BOTH themes.
// * Every value resolves from compile-time SCSS variables that branch
// on $o-webclient-color-scheme, so light and dark themes get distinct
// palettes without runtime CSS custom-property toggling (which Odoo
// 19 does NOT do for surface colours).
// * Semantic state colours (success/warning/danger/info) reserved for
// STATUS — not decoration.
// =============================================================================
// ---------- Spacing scale (8-pt baseline) ------------------------------------
$fp-space-1 : 4px;
$fp-space-2 : 8px;
$fp-space-3 : 12px;
$fp-space-4 : 16px;
$fp-space-5 : 20px;
$fp-space-6 : 24px;
$fp-space-7 : 32px;
$fp-space-8 : 40px;
$fp-space-9 : 48px;
$fp-space-10 : 64px;
// ---------- Radius -----------------------------------------------------------
$fp-radius-sm : 10px;
$fp-radius-md : 14px;
$fp-radius-lg : 20px;
$fp-radius-xl : 28px;
$fp-radius-pill: 999px;
// ---------- Surfaces — COMPILE-TIME branch on Odoo's dark scheme -------------
//
// Odoo 19 compiles TWO asset bundles: web.assets_backend (light) and
// web.assets_web_dark (dark). The two bundles differ only in the value
// of the SCSS variable $o-webclient-color-scheme — `bright` for light,
// `dark` for dark (defined in primary_variables.scss /
// primary_variables.dark.scss in web_enterprise).
//
// Odoo does NOT redefine --bs-body-bg / --bs-card-bg as CSS custom
// properties at runtime. It bakes the chosen palette into the bundle
// at compile time via Bootstrap SCSS variables. So our tokens must do
// the same: branch on $o-webclient-color-scheme at compile time and
// emit the right hex values into each bundle.
$o-webclient-color-scheme: bright !default;
// Default (light / bright) palette
$_fp-page-hex : #f3f4f6;
$_fp-card-hex : #ffffff;
$_fp-card-soft-hex : #f1f3f5;
$_fp-border-hex : #d8dadd;
$_fp-border-strong-hex : #b6babf;
$_fp-ink-hex : #1f2937;
$_fp-ink-soft-hex : #4b5563;
$_fp-ink-mute-hex : #6b7280;
$_fp-ink-faint-hex : #9ca3af;
// Dark palette — engaged when the dark bundle is compiled
@if $o-webclient-color-scheme == dark {
$_fp-page-hex : #1a1d21 !global;
$_fp-card-hex : #22262d !global;
$_fp-card-soft-hex : #1c2027 !global;
$_fp-border-hex : #343942 !global;
$_fp-border-strong-hex : #4a505a !global;
$_fp-ink-hex : #e5e7eb !global;
$_fp-ink-soft-hex : #c8ccd2 !global;
$_fp-ink-mute-hex : #8a909a !global;
$_fp-ink-faint-hex : #5a606b !global;
}
// Public tokens — CSS custom property fallback chain remains so a
// deployment can still override via --fp-* without touching SCSS.
$fp-page : var(--fp-page-bg, $_fp-page-hex);
$fp-card : var(--fp-card-bg, $_fp-card-hex);
$fp-card-soft : var(--fp-card-soft-bg, $_fp-card-soft-hex);
$fp-border : var(--fp-border-color, $_fp-border-hex);
$fp-border-strong : var(--fp-border-strong, $_fp-border-strong-hex);
$fp-ink : var(--fp-ink, $_fp-ink-hex);
$fp-ink-soft : var(--fp-ink-soft, $_fp-ink-soft-hex);
$fp-ink-mute : var(--fp-ink-mute, $_fp-ink-mute-hex);
$fp-ink-faint : var(--fp-ink-faint, $_fp-ink-faint-hex);
// Action colour — Odoo's primary. Same in both bundles (brand purple).
$fp-accent : var(--o-action, #714B67);
// ---------- Elevation — explicit rgba shadows --------------------------------
// Explicit rgba values (not color-mix) so they render identically across
// browsers and themes. In dark mode the shadows still work against the
// darker surfaces because they're translucent.
$fp-elev-1 : 0 1px 2px rgba(0, 0, 0, 0.06),
0 1px 3px rgba(0, 0, 0, 0.08);
$fp-elev-2 : 0 2px 4px rgba(0, 0, 0, 0.06),
0 6px 14px rgba(0, 0, 0, 0.10);
$fp-elev-3 : 0 4px 8px rgba(0, 0, 0, 0.10),
0 12px 28px rgba(0, 0, 0, 0.14);
$fp-elev-hover : 0 6px 12px rgba(0, 0, 0, 0.12),
0 18px 36px rgba(0, 0, 0, 0.16);
// ---------- Semantic colour helpers ------------------------------------------
$fp-ok : var(--bs-success, #28a745);
$fp-warn : var(--bs-warning, #ffc107);
$fp-bad : var(--bs-danger, #dc3545);
$fp-info : var(--bs-info, #17a2b8);
// State-colour hexes (used directly for badges / borders / chips so the
// rendering doesn't depend on Bootstrap variable presence). Different
// hexes per scheme keep contrast crisp on both backgrounds.
$_fp-state-ready-hex : #ffc107;
$_fp-state-ready-text-hex : #b58105;
$_fp-state-progress-hex : #0d6efd;
$_fp-state-progress-text-hex : #084298;
$_fp-state-paused-hex : #fd7e14;
$_fp-state-paused-text-hex : #97480d;
$_fp-state-done-hex : #198754;
$_fp-state-done-text-hex : #0f5132;
$_fp-state-cancel-hex : #dc3545;
$_fp-state-cancel-text-hex : #842029;
$_fp-state-rush-hex : #dc3545;
$_fp-state-high-hex : #fd7e14;
$_fp-state-low-hex : #6c757d;
$_fp-state-pending-bg-hex : #e9ecef;
$_fp-state-pending-text-hex : #6c757d;
@if $o-webclient-color-scheme == dark {
// Slightly brighter / desaturated for legibility against the dark
// card surface ($_fp-card-hex = #22262d).
$_fp-state-ready-hex : #ffd866 !global;
$_fp-state-ready-text-hex : #ffd866 !global;
$_fp-state-progress-hex : #6ea8fe !global;
$_fp-state-progress-text-hex : #6ea8fe !global;
$_fp-state-paused-hex : #ffb86b !global;
$_fp-state-paused-text-hex : #ffb86b !global;
$_fp-state-done-hex : #75d4a4 !global;
$_fp-state-done-text-hex : #75d4a4 !global;
$_fp-state-cancel-hex : #f1aeb5 !global;
$_fp-state-cancel-text-hex : #f1aeb5 !global;
$_fp-state-rush-hex : #e85d6c !global;
$_fp-state-high-hex : #ff9a4d !global;
$_fp-state-low-hex : #8a909a !global;
$_fp-state-pending-bg-hex : #2a2f37 !global;
$_fp-state-pending-text-hex : #c8ccd2 !global;
}
$fp-state-ready : $_fp-state-ready-hex;
$fp-state-ready-text : $_fp-state-ready-text-hex;
$fp-state-progress : $_fp-state-progress-hex;
$fp-state-progress-text : $_fp-state-progress-text-hex;
$fp-state-paused : $_fp-state-paused-hex;
$fp-state-paused-text : $_fp-state-paused-text-hex;
$fp-state-done : $_fp-state-done-hex;
$fp-state-done-text : $_fp-state-done-text-hex;
$fp-state-cancel : $_fp-state-cancel-hex;
$fp-state-cancel-text : $_fp-state-cancel-text-hex;
$fp-state-rush : $_fp-state-rush-hex;
$fp-state-high : $_fp-state-high-hex;
$fp-state-low : $_fp-state-low-hex;
$fp-state-pending-bg : $_fp-state-pending-bg-hex;
$fp-state-pending-text : $_fp-state-pending-text-hex;
// Softened backgrounds for status pills / banners
@function fp-wash($color-var, $strength: 12%) {
@return color-mix(in srgb, var(#{$color-var}) #{$strength}, transparent);
}
// ---------- Type scale ------------------------------------------------------
$fp-text-xs : 0.75rem; // 12px small labels
$fp-text-sm : 0.875rem; // 14px helper text
$fp-text-base : 1rem; // 16px body
$fp-text-md : 1.125rem; // 18px emphasis
$fp-text-lg : 1.25rem; // 20px sub-headings
$fp-text-xl : 1.5rem; // 24px section headings
$fp-text-2xl : 2rem; // 32px page title
$fp-text-3xl : 2.75rem; // 44px KPI number
$fp-text-4xl : clamp(2rem, 5vw, 3rem); // hero
$fp-weight-medium : 500;
$fp-weight-semibold : 600;
$fp-weight-bold : 700;
$fp-font-stack : -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Inter", "Helvetica Neue", Arial, sans-serif;
// ---------- Motion -----------------------------------------------------------
$fp-ease : cubic-bezier(0.22, 1, 0.36, 1);
$fp-ease-out : cubic-bezier(0.33, 1, 0.68, 1);
$fp-dur-fast : 120ms;
$fp-dur : 200ms;
$fp-dur-slow : 360ms;
// ---------- Touch ------------------------------------------------------------
$fp-touch-min : 48px; // larger than Apple's 44px minimum — shop floor
// =============================================================================
// Mixins
// =============================================================================
// Focus ring — used on all interactive inputs/buttons
@mixin fp-focus-ring {
outline: none;
box-shadow: 0 0 0 3px color-mix(in srgb, #{$fp-accent} 35%, transparent);
}
// Card surface — shadow-based, no border
@mixin fp-card($elev: $fp-elev-1) {
background-color: $fp-card;
border-radius: $fp-radius-lg;
box-shadow: $elev;
}
// Status pill (soft tint + colored text)
@mixin fp-pill($color-var) {
background-color: color-mix(in srgb, var(#{$color-var}) 14%, transparent);
color: var(#{$color-var});
}
// Hide hover styles on touch devices (stuck hover = bad UX on phones)
@mixin fp-hover-only {
@media (hover: hover) {
@content;
}
}

View File

@@ -1,291 +0,0 @@
// =============================================================================
// Fusion Plating — Manager Dashboard (native, fp.job)
// Copyright 2026 Nexa Systems Inc. · License OPL-1
//
// Class prefix: .o_fp_jmd_* (Job Manager Dashboard)
//
// Theme-aware: every surface, border and text colour resolves through
// the design tokens defined in _fp_jobs_tokens.scss.
//
// Three-layer contrast:
// page = $fp-page (grayest)
// header / filter bar wrapper = $fp-card-soft (mid)
// rows = $fp-card (brightest)
// =============================================================================
.o_fp_job_manager_dashboard {
height: 100%;
overflow: auto;
-webkit-overflow-scrolling: touch;
padding: $fp-space-4 $fp-space-6;
display: flex;
flex-direction: column;
gap: $fp-space-3;
background-color: $fp-page;
color: $fp-ink;
@media (max-width: 600px) { padding: $fp-space-3; gap: $fp-space-3; }
// -------------------------------------------------------------------------
// Header strip
// -------------------------------------------------------------------------
.o_fp_jmd_header {
display: flex;
align-items: center;
justify-content: space-between;
gap: $fp-space-4;
flex-wrap: wrap;
padding: $fp-space-3 $fp-space-4;
background-color: $fp-card;
border: 1px solid #{$fp-border};
border-radius: $fp-radius-md;
box-shadow: $fp-elev-1;
}
.o_fp_jmd_header_left {
display: flex;
align-items: baseline;
gap: $fp-space-3;
}
.o_fp_jmd_title {
font-size: $fp-text-md;
font-weight: $fp-weight-bold;
margin: 0;
color: $fp-ink;
}
// -------------------------------------------------------------------------
// Filter pill bar — sits on the page; the bar itself is transparent
// -------------------------------------------------------------------------
.o_fp_jmd_filter_bar {
display: flex;
flex-wrap: wrap;
gap: $fp-space-2;
padding: $fp-space-2 4px;
}
.o_fp_jmd_pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border: 1px solid #{$fp-border};
background-color: $fp-card;
color: $fp-ink;
border-radius: $fp-radius-pill;
font-size: 0.8rem;
cursor: pointer;
transition: background-color $fp-dur-fast $fp-ease,
border-color $fp-dur-fast $fp-ease,
color $fp-dur-fast $fp-ease;
@include fp-hover-only {
&:hover {
background-color: $fp-card-soft;
border-color: $fp-border-strong;
}
}
&.o_fp_jmd_pill_active {
background-color: $fp-accent;
border-color: $fp-accent;
color: #ffffff;
font-weight: $fp-weight-semibold;
}
}
.o_fp_jmd_pill_count {
background-color: color-mix(in srgb, #{$fp-ink} 8%, transparent);
color: $fp-ink-soft;
border-radius: $fp-radius-pill;
padding: 0 7px;
font-size: 0.7rem;
font-weight: $fp-weight-bold;
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);
color: #ffffff;
}
// -------------------------------------------------------------------------
// Empty / loading
// -------------------------------------------------------------------------
.o_fp_jmd_empty,
.o_fp_jmd_loading {
background-color: $fp-card;
color: $fp-ink-mute;
border: 1px solid #{$fp-border};
border-radius: $fp-radius-md;
box-shadow: $fp-elev-1;
}
// -------------------------------------------------------------------------
// Rows
// -------------------------------------------------------------------------
.o_fp_jmd_rows {
display: flex;
flex-direction: column;
gap: $fp-space-2;
}
.o_fp_jmd_row {
display: flex;
align-items: stretch;
gap: 0;
background-color: $fp-card;
color: $fp-ink;
border: 1px solid #{$fp-border};
border-radius: $fp-radius-md;
cursor: pointer;
overflow: hidden;
box-shadow: $fp-elev-1;
transition: transform $fp-dur-fast $fp-ease,
box-shadow $fp-dur $fp-ease,
border-color $fp-dur $fp-ease;
@include fp-hover-only {
&:hover {
transform: translateY(-1px);
box-shadow: $fp-elev-2;
border-color: $fp-border-strong;
}
}
}
.o_fp_jmd_priority_bar {
flex: 0 0 6px;
background-color: $fp-state-low; // normal default
}
.o_fp_jmd_priority_rush .o_fp_jmd_priority_bar { background-color: $fp-state-rush; }
.o_fp_jmd_priority_high .o_fp_jmd_priority_bar { background-color: $fp-state-high; }
.o_fp_jmd_priority_normal .o_fp_jmd_priority_bar { background-color: $fp-state-progress; }
.o_fp_jmd_priority_low .o_fp_jmd_priority_bar { background-color: $fp-ink-faint; }
.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;
color: $fp-ink-faint;
}
.o_fp_jmd_row_top {
display: flex;
align-items: center;
justify-content: space-between;
gap: $fp-space-3;
flex-wrap: wrap;
}
.o_fp_jmd_row_id {
font-size: 0.95rem;
flex: 1 1 auto;
min-width: 0;
color: $fp-ink;
}
.o_fp_jmd_row_chips {
display: inline-flex;
gap: 6px;
flex-wrap: wrap;
}
.o_fp_jmd_row_meta {
font-size: 0.75rem;
color: $fp-ink-mute;
display: flex;
flex-wrap: wrap;
gap: 2px 4px;
}
.o_fp_jmd_overdue {
color: $fp-state-cancel;
font-weight: $fp-weight-semibold;
}
// -------------------------------------------------------------------------
// State badge
// -------------------------------------------------------------------------
.o_fp_jmd_state_badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: $fp-radius-pill;
font-size: 0.65rem;
font-weight: $fp-weight-bold;
line-height: 1.4;
text-transform: uppercase;
letter-spacing: 0.02em;
&.o_fp_jmd_state_badge_draft { background-color: $fp-state-pending-bg; color: $fp-state-pending-text; }
&.o_fp_jmd_state_badge_confirmed { background-color: color-mix(in srgb, #{$fp-state-progress} 18%, transparent); color: $fp-state-progress-text; }
&.o_fp_jmd_state_badge_in_progress { background-color: color-mix(in srgb, #{$fp-state-progress} 28%, transparent); color: $fp-state-progress-text; }
&.o_fp_jmd_state_badge_on_hold { background-color: color-mix(in srgb, #{$fp-state-paused} 20%, transparent); color: $fp-state-paused-text; }
&.o_fp_jmd_state_badge_done { background-color: color-mix(in srgb, #{$fp-state-done} 20%, transparent); color: $fp-state-done-text; }
&.o_fp_jmd_state_badge_cancelled { background-color: color-mix(in srgb, #{$fp-state-cancel} 18%, transparent); color: $fp-state-cancel-text; }
}
// -------------------------------------------------------------------------
// Priority chips (top-right of row)
// -------------------------------------------------------------------------
.o_fp_jmd_chip {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: $fp-radius-pill;
font-size: 0.65rem;
font-weight: $fp-weight-bold;
line-height: 1.4;
text-transform: uppercase;
letter-spacing: 0.02em;
color: #ffffff;
&.o_fp_jmd_chip_rush { background-color: $fp-state-rush; }
&.o_fp_jmd_chip_high { background-color: $fp-state-high; }
}
// -------------------------------------------------------------------------
// 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: color-mix(in srgb, #{$fp-ink} 8%, transparent);
border-radius: $fp-radius-pill;
overflow: hidden;
}
.o_fp_jmd_bar_fill {
height: 100%;
border-radius: $fp-radius-pill;
transition: width $fp-dur-slow $fp-ease;
&.o_fp_jmd_bar_early { background-color: $fp-state-ready; }
&.o_fp_jmd_bar_mid { background-color: $fp-state-progress; }
&.o_fp_jmd_bar_done { background-color: $fp-state-done; }
}
.o_fp_jmd_bar_label {
flex: 0 0 auto;
white-space: nowrap;
font-variant-numeric: tabular-nums;
color: $fp-ink-soft;
}
}
// Suppress hover lift on touch.
@media (hover: none) {
.o_fp_job_manager_dashboard .o_fp_jmd_row:hover {
transform: none !important;
box-shadow: inherit !important;
}
}

View File

@@ -1,321 +0,0 @@
// =============================================================================
// Fusion Plating — Plant Overview (native, fp.job.step)
// Copyright 2026 Nexa Systems Inc. · License OPL-1
//
// Class prefix: .o_fp_jpo_* (Job Plant Overview)
//
// Theme-aware: every surface, border and text colour resolves through
// the design tokens defined in _fp_jobs_tokens.scss, which compile-time
// branch on $o-webclient-color-scheme so light and dark bundles get the
// right palette. NO hardcoded hex on theme-sensitive surfaces.
//
// Three-layer contrast:
// page = $fp-page (grayest)
// columns = $fp-card-soft (mid)
// cards = $fp-card (brightest)
// =============================================================================
.o_fp_job_plant_overview {
height: 100%;
display: flex;
flex-direction: column;
padding: $fp-space-4 $fp-space-6;
gap: $fp-space-4;
background-color: $fp-page;
color: $fp-ink;
overflow: hidden;
@media (max-width: 600px) { padding: $fp-space-3; gap: $fp-space-3; }
// -------------------------------------------------------------------------
// Header strip — sits on the page, surfaced as a card layer
// -------------------------------------------------------------------------
.o_fp_jpo_header {
display: flex;
align-items: center;
justify-content: space-between;
gap: $fp-space-4;
flex-wrap: wrap;
padding: $fp-space-3 $fp-space-4;
background-color: $fp-card;
border: 1px solid #{$fp-border};
border-radius: $fp-radius-md;
box-shadow: $fp-elev-1;
}
.o_fp_jpo_header_left {
display: flex;
align-items: baseline;
gap: $fp-space-3;
}
.o_fp_jpo_title {
font-size: $fp-text-md;
font-weight: $fp-weight-bold;
margin: 0;
color: $fp-ink;
}
.o_fp_jpo_header_right {
display: flex;
align-items: center;
gap: $fp-space-2;
}
.o_fp_jpo_search_box {
display: inline-flex;
align-items: center;
background-color: $fp-card-soft;
border: 1px solid #{$fp-border};
border-radius: $fp-radius-pill;
padding: 4px 10px;
gap: $fp-space-2;
min-width: 240px;
}
.o_fp_jpo_search_icon { color: $fp-ink-mute; }
.o_fp_jpo_search_input {
border: none;
background: transparent;
outline: none;
font-size: $fp-text-sm;
flex: 1;
color: $fp-ink;
&::placeholder { color: $fp-ink-faint; }
}
.o_fp_jpo_search_clear {
border: none;
background: transparent;
color: $fp-ink-mute;
padding: 0 2px;
cursor: pointer;
&:hover { color: $fp-ink; }
}
// -------------------------------------------------------------------------
// Empty / loading
// -------------------------------------------------------------------------
.o_fp_jpo_empty,
.o_fp_jpo_loading {
background-color: $fp-card;
color: $fp-ink-mute;
border: 1px solid #{$fp-border};
border-radius: $fp-radius-md;
box-shadow: $fp-elev-1;
}
// -------------------------------------------------------------------------
// Columns
// -------------------------------------------------------------------------
.o_fp_jpo_columns {
display: flex;
gap: $fp-space-3;
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: $fp-card-soft;
border: 1px solid #{$fp-border};
border-radius: $fp-radius-md;
max-height: 100%;
overflow: hidden;
}
.o_fp_jpo_col_header {
display: flex;
align-items: center;
justify-content: space-between;
gap: $fp-space-2;
padding: 10px 12px 4px;
font-weight: $fp-weight-bold;
font-size: 0.95rem;
color: $fp-ink;
}
.o_fp_jpo_col_subhead {
padding: 0 12px 6px;
color: $fp-ink-mute;
}
.o_fp_jpo_col_count {
background-color: color-mix(in srgb, #{$fp-ink} 8%, transparent);
color: $fp-ink-soft;
font-weight: $fp-weight-semibold;
font-size: 0.7rem;
padding: 2px 8px;
border-radius: $fp-radius-pill;
}
.o_fp_jpo_col_body {
flex: 1 1 auto;
overflow-y: auto;
padding: 6px 8px 10px;
display: flex;
flex-direction: column;
gap: $fp-space-2;
&.o_fp_drop_target {
background-color: color-mix(in srgb, #{$fp-accent} 8%, transparent);
}
}
// -------------------------------------------------------------------------
// Cards (brightest layer)
// -------------------------------------------------------------------------
.o_fp_jpo_card {
background-color: $fp-card;
border: 1px solid #{$fp-border};
border-radius: $fp-radius-sm;
padding: $fp-space-2 10px;
display: flex;
flex-direction: column;
gap: 4px;
cursor: grab;
color: $fp-ink;
box-shadow: $fp-elev-1;
transition: transform $fp-dur-fast $fp-ease,
box-shadow $fp-dur $fp-ease,
border-color $fp-dur $fp-ease;
@include fp-hover-only {
&:hover {
transform: translateY(-1px);
box-shadow: $fp-elev-2;
border-color: $fp-border-strong;
}
}
&:active { cursor: grabbing; }
// ---- State accents (left border) --------------------------------
&.o_fp_jpo_card_progress { border-left: 3px solid $fp-state-progress; }
&.o_fp_jpo_card_ready { border-left: 3px solid $fp-state-ready; }
&.o_fp_jpo_card_paused { border-left: 3px solid $fp-state-paused; }
&.o_fp_jpo_card_done { border-left: 3px solid $fp-state-done; 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: $fp-space-2;
}
.o_fp_jpo_card_title {
flex: 1 1 auto;
font-size: 0.9rem;
line-height: 1.25;
word-break: break-word;
color: $fp-ink;
}
.o_fp_jpo_card_refs {
font-size: 0.8rem;
color: $fp-ink-soft;
}
.o_fp_jpo_job_link {
color: $fp-accent;
cursor: pointer;
text-decoration: none;
&:hover { text-decoration: underline; }
}
.o_fp_jpo_card_meta {
font-size: 0.72rem;
color: $fp-ink-mute;
display: flex;
flex-wrap: wrap;
gap: 2px 4px;
}
.o_fp_jpo_card_footer {
display: flex;
gap: $fp-space-2;
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: $fp-radius-pill;
font-size: 0.65rem;
font-weight: $fp-weight-bold;
line-height: 1.4;
white-space: nowrap;
text-transform: uppercase;
letter-spacing: 0.02em;
&.o_fp_jpo_state_badge_pending { background-color: $fp-state-pending-bg; color: $fp-state-pending-text; }
&.o_fp_jpo_state_badge_ready { background-color: color-mix(in srgb, #{$fp-state-ready} 18%, transparent); color: $fp-state-ready-text; }
&.o_fp_jpo_state_badge_in_progress { background-color: color-mix(in srgb, #{$fp-state-progress} 18%, transparent); color: $fp-state-progress-text; }
&.o_fp_jpo_state_badge_paused { background-color: color-mix(in srgb, #{$fp-state-paused} 20%, transparent); color: $fp-state-paused-text; }
&.o_fp_jpo_state_badge_done { background-color: color-mix(in srgb, #{$fp-state-done} 20%, transparent); color: $fp-state-done-text; }
&.o_fp_jpo_state_badge_skipped { background-color: $fp-state-pending-bg; color: $fp-state-pending-text; }
&.o_fp_jpo_state_badge_cancelled { background-color: color-mix(in srgb, #{$fp-state-cancel} 18%, transparent); color: $fp-state-cancel-text; }
}
// -------------------------------------------------------------------------
// Priority chip (footer)
// -------------------------------------------------------------------------
.o_fp_jpo_chip {
display: inline-flex;
align-items: center;
padding: 1px 8px;
border-radius: $fp-radius-pill;
font-size: 0.65rem;
font-weight: $fp-weight-bold;
line-height: 1.5;
text-transform: uppercase;
letter-spacing: 0.02em;
color: #ffffff;
&.o_fp_jpo_chip_rush { background-color: $fp-state-rush; }
&.o_fp_jpo_chip_high { background-color: $fp-state-high; }
&.o_fp_jpo_chip_low { background-color: $fp-state-low; }
}
// -------------------------------------------------------------------------
// Drag-drop placeholder + ghost
// -------------------------------------------------------------------------
.o_fp_dragging {
opacity: 0.4;
}
.o_fp_jpo_drop_placeholder {
height: 56px;
border: 2px dashed $fp-accent;
border-radius: $fp-radius-sm;
background-color: color-mix(in srgb, #{$fp-accent} 8%, transparent);
margin: 0;
}
// -------------------------------------------------------------------------
// No-cards filler
// -------------------------------------------------------------------------
.o_fp_jpo_no_cards {
color: $fp-ink-mute;
font-size: 0.8rem;
}
}
// Suppress the lift transform on touch so taps don't leave cards in
// hover state.
@media (hover: none) {
.o_fp_job_plant_overview .o_fp_jpo_card:hover {
transform: none !important;
box-shadow: inherit !important;
}
}

View File

@@ -1,390 +0,0 @@
// =============================================================================
// Fusion Plating — Job Process Tree (horizontal hierarchical, v1, 2026-04)
// Copyright 2026 Nexa Systems Inc. · License OPL-1
//
// Class prefix: .o_fp_jpt_* (Job Process Tree)
//
// Theme-aware: page, header and connector colours resolve through the
// design tokens defined in _fp_jobs_tokens.scss (compile-time branch
// on $o-webclient-color-scheme). The node CARDS keep an intentional
// Steelhead-style dark-slate fill in BOTH themes — this is a design
// choice, not a theme bug: dark cards on light or dark page give the
// same visual hierarchy as the Steelhead reference UI.
//
// Hierarchical bracket tree layout — see body comments below.
// =============================================================================
// Suppress hover transforms on touch devices so taps don't leave cards
// stuck in the hover state.
@media (hover: none) {
.o_fp_job_process_tree [class*="o_fp_jpt_"]:hover {
transform: none !important;
box-shadow: inherit !important;
}
}
// --- Connector geometry ------------------------------------------------------
// Tweaking these recalculates the whole bracket-tree layout.
$jpt-card-h : 44px; // nominal card height (centre stays at h/2)
$jpt-row-gap : 12px; // vertical gap between sibling children
$jpt-indent : 36px; // horizontal gap from parent → children
$jpt-stub : 28px; // horizontal connector segment length
$jpt-line-width : 2px;
.o_fp_job_process_tree.o_fp_jpt_v1 {
height: 100%;
overflow: auto; // both axes — wide trees scroll horizontally
-webkit-overflow-scrolling: touch;
padding: $fp-space-4 $fp-space-6;
display: flex;
flex-direction: column;
gap: $fp-space-3;
background-color: $fp-page;
color: $fp-ink;
@media (max-width: 600px) { padding: $fp-space-3; gap: $fp-space-3; }
// -------------------------------------------------------------------------
// Header (compact strip — sits on the page as a card)
// -------------------------------------------------------------------------
.o_fp_jpt_header {
display: flex;
align-items: center;
gap: $fp-space-3;
flex-wrap: wrap;
padding: $fp-space-3 $fp-space-4;
background-color: $fp-card;
color: $fp-ink;
border: 1px solid #{$fp-border};
border-radius: $fp-radius-md;
box-shadow: $fp-elev-1;
position: sticky;
top: 0;
z-index: 5;
}
.o_fp_jpt_back {
display: inline-flex;
align-items: center;
padding: 6px 12px;
border-radius: $fp-radius-pill;
background-color: $fp-card-soft;
color: $fp-ink;
font-weight: $fp-weight-medium;
font-size: $fp-text-sm;
border: 1px solid #{$fp-border};
cursor: pointer;
transition: background-color $fp-dur-fast $fp-ease,
border-color $fp-dur-fast $fp-ease,
color $fp-dur-fast $fp-ease;
@include fp-hover-only {
&:hover {
background-color: color-mix(in srgb, #{$fp-ink} 7%, $fp-card-soft);
border-color: $fp-border-strong;
}
}
}
.o_fp_jpt_title_block { flex: 1 1 auto; min-width: 0; }
.o_fp_jpt_title {
font-size: $fp-text-base;
font-weight: $fp-weight-bold;
margin: 0;
color: $fp-ink;
display: inline-flex; align-items: center; gap: 4px;
.o_fp_jpt_job_name {
font-weight: $fp-weight-semibold;
color: $fp-ink-soft;
}
}
.o_fp_jpt_subtitle {
margin-top: 2px;
font-size: $fp-text-xs;
color: $fp-ink-mute;
display: flex; flex-wrap: wrap; align-items: center; gap: 2px;
.fa { margin-right: 2px; color: $fp-ink-faint; }
}
// -------------------------------------------------------------------------
// Empty / loading
// -------------------------------------------------------------------------
.o_fp_jpt_empty {
text-align: center;
padding: $fp-space-8 $fp-space-6;
background-color: $fp-card;
color: $fp-ink-mute;
border: 1px solid #{$fp-border};
border-radius: $fp-radius-md;
box-shadow: $fp-elev-1;
font-size: $fp-text-sm;
max-width: 520px;
> .fa { font-size: 1.75rem; margin-bottom: 8px; color: $fp-ink-faint; }
}
// -------------------------------------------------------------------------
// Tree canvas — horizontally scrollable
// -------------------------------------------------------------------------
.o_fp_jpt_canvas {
padding: $fp-space-3 0;
min-width: max-content; // let cards push the canvas wider for scroll
}
// -------------------------------------------------------------------------
// Recursive node — flex row of [card | children-column]
// -------------------------------------------------------------------------
.o_fp_jpt_node {
display: flex;
align-items: flex-start;
position: relative;
}
// -------------------------------------------------------------------------
// Card (Steelhead-style: dark fill, rounded — intentional in both themes)
//
// The dark slate is a deliberate visual choice (Steelhead parity).
// The contrasting page surface is themed via $fp-page above, so the
// overall composition still feels right in light + dark mode.
// -------------------------------------------------------------------------
.o_fp_jpt_card {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 220px;
max-width: 340px;
min-height: $jpt-card-h;
padding: $fp-space-2 $fp-space-3;
background-color: #2b2f36; // dark slate (Steelhead parity)
color: #f1f3f5;
border-radius: 6px;
box-shadow: $fp-elev-1;
font-size: $fp-text-sm;
line-height: 1.25;
flex: 0 0 auto;
position: relative;
z-index: 1; // sit above connector lines
transition: transform $fp-dur-fast $fp-ease,
box-shadow $fp-dur $fp-ease,
background-color $fp-dur-fast $fp-ease;
&.o_fp_jpt_clickable {
cursor: pointer;
@include fp-hover-only {
&:hover {
transform: translateY(-1px);
box-shadow: $fp-elev-2;
background-color: #353a42;
}
}
}
// ---- Card type tints (subtle) -------------------------------------
&.o_fp_jpt_type_recipe {
background-color: #1f2329;
font-weight: $fp-weight-bold;
}
&.o_fp_jpt_type_sub_process {
background-color: #262a31;
font-weight: $fp-weight-semibold;
}
&.o_fp_jpt_type_step {
background-color: #353a42;
font-size: 0.8rem;
min-height: 36px;
}
// ---- Live state highlight ----------------------------------------
&.o_fp_jpt_state_in_progress {
background-color: #c0392b; // warm red — active step
color: #fff;
box-shadow: 0 0 0 1px rgba(192, 57, 43, .6),
0 4px 14px rgba(192, 57, 43, .35);
}
&.o_fp_jpt_highlight.o_fp_jpt_state_ready {
background-color: #c0392b; // ready also red
color: #fff;
box-shadow: 0 0 0 1px rgba(192, 57, 43, .6),
0 4px 14px rgba(192, 57, 43, .35);
}
&.o_fp_jpt_state_paused {
background-color: #b5651d; // amber — paused
color: #fff;
}
&.o_fp_jpt_state_done {
background-color: #1e8449; // green for completed
color: #fff;
}
&.o_fp_jpt_state_skipped,
&.o_fp_jpt_state_cancelled { opacity: 0.55; }
}
.o_fp_jpt_card_icon {
flex: 0 0 auto;
width: 18px;
text-align: center;
opacity: 0.85;
font-size: 0.95em;
}
.o_fp_jpt_card_body {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.o_fp_jpt_card_title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.o_fp_jpt_card_meta {
font-size: 0.72rem;
opacity: 0.75;
display: flex;
flex-wrap: wrap;
gap: 2px 6px;
.fa { opacity: 0.8; }
}
.o_fp_jpt_card_right {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 6px;
}
.o_fp_jpt_card_open {
opacity: 0.55;
font-size: 0.85em;
}
// -------------------------------------------------------------------------
// State badge (right side of operation cards)
// -------------------------------------------------------------------------
.o_fp_jpt_state_badge {
display: inline-flex;
align-items: center;
padding: 1px 7px;
border-radius: $fp-radius-pill;
font-size: 0.65rem;
font-weight: $fp-weight-bold;
line-height: 1.4;
white-space: nowrap;
text-transform: uppercase;
letter-spacing: 0.02em;
// Cards are dark-slate filled in BOTH themes, so badge palette
// is tuned for that dark surface — light text on translucent
// tints. NOT theme-sensitive (both bundles render the same way).
&.o_fp_jpt_state_badge_pending { background-color: rgba(255,255,255,.12); color: #c8ccd2; }
&.o_fp_jpt_state_badge_ready { background-color: rgba(255, 193, 7, .25); color: #ffd866; }
&.o_fp_jpt_state_badge_in_progress { background-color: rgba(13, 110, 253, .25); color: #6ea8fe; }
&.o_fp_jpt_state_badge_paused { background-color: rgba(255, 145, 0, .28); color: #ffb86b; }
&.o_fp_jpt_state_badge_done { background-color: rgba(25, 135, 84, .28); color: #75d4a4; }
&.o_fp_jpt_state_badge_skipped { background-color: rgba(108, 117, 125, .35); color: #d0d4d9; }
&.o_fp_jpt_state_badge_cancelled { background-color: rgba(220, 53, 69, .25); color: #f1aeb5; }
}
// -------------------------------------------------------------------------
// Children column (recursed nodes laid out vertically to the right)
//
// The ::before pseudo draws the horizontal connector that bridges the
// parent card's right edge → the bus column at left: 0 of this
// container.
// -------------------------------------------------------------------------
.o_fp_jpt_children {
display: flex;
flex-direction: column;
gap: $jpt-row-gap;
margin-left: $jpt-indent;
position: relative;
&::before {
content: "";
position: absolute;
left: -#{$jpt-indent};
top: calc(#{$jpt-card-h} / 2); // parent-card vertical centre
width: $jpt-indent;
height: $jpt-line-width;
background-color: $fp-border-strong;
z-index: 0;
}
}
// -------------------------------------------------------------------------
// Connector lines (bracket style, drawn from CSS only)
//
// Each child .o_fp_jpt_node owns its own connector segments:
// ::before → horizontal stub from the bus column → card centre
// ::after → vertical bus segment for this row
//
// First/last/single children trim the vertical so the bracket stops
// exactly at the card centre.
// -------------------------------------------------------------------------
.o_fp_jpt_children > .o_fp_jpt_node {
position: relative;
padding-left: $jpt-stub; // room for the horizontal stub
// -- horizontal stub from bus column → card --------------------------
&::before {
content: "";
position: absolute;
left: 0;
top: calc(#{$jpt-card-h} / 2); // align with card vertical centre
width: $jpt-stub;
height: $jpt-line-width;
background-color: $fp-border-strong;
z-index: 0;
}
// -- vertical bus segment (default: full row, top → bottom) ----------
&::after {
content: "";
position: absolute;
left: 0;
top: calc(-#{$jpt-row-gap} / 2); // bridge gap to sibling above
bottom: calc(-#{$jpt-row-gap} / 2); // bridge gap to sibling below
width: $jpt-line-width;
background-color: $fp-border-strong;
z-index: 0;
}
// First child — vertical only from card centre → bottom of row
&:first-child::after {
top: calc(#{$jpt-card-h} / 2);
}
// Last child — vertical only from top of row → card centre
&:last-child::after {
bottom: calc(100% - (#{$jpt-card-h} / 2));
}
// Only child — vertical only at the card centre point
&:first-child:last-child::after {
top: calc(#{$jpt-card-h} / 2);
bottom: calc(100% - (#{$jpt-card-h} / 2));
}
}
// -------------------------------------------------------------------------
// Pulse on live (in_progress / ready) cards
// -------------------------------------------------------------------------
@keyframes o_fp_jpt_pulse {
0%, 100% { box-shadow: 0 0 0 1px rgba(192, 57, 43, .55),
0 4px 14px rgba(192, 57, 43, .35); }
50% { box-shadow: 0 0 0 4px rgba(192, 57, 43, .25),
0 4px 18px rgba(192, 57, 43, .45); }
}
.o_fp_jpt_card.o_fp_jpt_state_in_progress,
.o_fp_jpt_card.o_fp_jpt_highlight.o_fp_jpt_state_ready {
animation: o_fp_jpt_pulse 2.4s ease-in-out infinite;
}
}

View File

@@ -1,606 +0,0 @@
// =============================================================================
// Fusion Plating — Tablet Station (native, fp.job.step)
// Copyright 2026 Nexa Systems Inc. · License OPL-1
//
// Class prefix: .o_fp_jt_* (Job Tablet)
//
// Theme-aware: every surface, border and text colour resolves through
// the design tokens defined in _fp_jobs_tokens.scss.
//
// Three-layer contrast:
// page = $fp-page (grayest)
// mode panels (header / body / job-header / step-header) = $fp-card-soft (mid)
// cards / step rows / table rows = $fp-card (brightest)
//
// Touch-first: min 60px tap targets, 16-20pt text, high contrast.
// =============================================================================
.o_fp_job_tablet {
height: 100%;
display: flex;
flex-direction: column;
padding: $fp-space-4 $fp-space-6;
gap: $fp-space-4;
background-color: $fp-page;
color: $fp-ink;
overflow: hidden;
font-size: $fp-text-base;
@media (max-width: 800px) { padding: 10px; gap: 10px; }
// ------------------------------------------------------------------------
// Header strip
// ------------------------------------------------------------------------
.o_fp_jt_header {
display: flex;
align-items: center;
justify-content: space-between;
gap: $fp-space-3;
padding: $fp-space-3 $fp-space-4;
background-color: $fp-card;
color: $fp-ink;
border: 1px solid #{$fp-border};
border-radius: $fp-radius-md;
box-shadow: $fp-elev-1;
}
.o_fp_jt_header_left {
display: flex;
align-items: center;
gap: $fp-space-3;
flex: 1 1 auto;
min-width: 0;
}
.o_fp_jt_title {
font-size: 1.4rem;
font-weight: $fp-weight-bold;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: $fp-ink;
}
.o_fp_jt_back_btn {
min-width: 60px;
min-height: 60px;
border: 1px solid #{$fp-border};
border-radius: $fp-radius-sm;
background-color: $fp-card-soft;
color: $fp-ink;
font-size: 1.4rem;
cursor: pointer;
flex: 0 0 auto;
&:hover { background-color: color-mix(in srgb, #{$fp-ink} 7%, $fp-card-soft); }
&:active { background-color: color-mix(in srgb, #{$fp-ink} 12%, $fp-card-soft); }
}
.o_fp_jt_header_right {
display: flex;
align-items: center;
gap: $fp-space-2;
}
.o_fp_jt_refresh_btn {
min-width: 60px;
min-height: 60px;
border: 1px solid #{$fp-border};
border-radius: $fp-radius-sm;
background-color: $fp-card-soft;
color: $fp-ink;
font-size: 1.3rem;
cursor: pointer;
&:hover { background-color: color-mix(in srgb, #{$fp-ink} 7%, $fp-card-soft); }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
// ------------------------------------------------------------------------
// Body container — holds whichever mode is active
// ------------------------------------------------------------------------
.o_fp_jt_body {
flex: 1 1 auto;
overflow-y: auto;
background-color: $fp-card-soft;
color: $fp-ink;
border: 1px solid #{$fp-border};
border-radius: $fp-radius-md;
padding: 20px;
@media (max-width: 800px) { padding: $fp-space-3; }
}
// ------------------------------------------------------------------------
// Loading / empty
// ------------------------------------------------------------------------
.o_fp_jt_loading,
.o_fp_jt_empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 60px 20px;
color: $fp-ink-mute;
font-size: 1.2rem;
}
// ========================================================================
// JOB PICKER MODE
// ========================================================================
.o_fp_jt_job_grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: $fp-space-4;
}
.o_fp_jt_job_card {
background-color: $fp-card;
color: $fp-ink;
border: 2px solid #{$fp-border};
border-radius: 12px;
padding: $fp-space-4;
display: flex;
flex-direction: column;
gap: 10px;
cursor: pointer;
min-height: 180px;
box-shadow: $fp-elev-1;
transition: transform $fp-dur-fast $fp-ease,
box-shadow $fp-dur $fp-ease,
border-color $fp-dur $fp-ease;
@include fp-hover-only {
&:hover {
transform: translateY(-2px);
box-shadow: $fp-elev-2;
border-color: $fp-accent;
}
}
&:active {
transform: translateY(0);
box-shadow: $fp-elev-1;
}
// Priority emphasis
&.o_fp_jt_card_rush {
border-color: $fp-state-rush;
box-shadow: 0 0 0 1px rgba(220, 53, 69, 0.30);
}
&.o_fp_jt_card_high {
border-color: $fp-state-high;
box-shadow: 0 0 0 1px rgba(253, 126, 20, 0.25);
}
}
.o_fp_jt_job_card_top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: $fp-space-2;
}
.o_fp_jt_job_card_name {
font-size: 1.25rem;
font-weight: $fp-weight-bold;
word-break: break-word;
color: $fp-ink;
}
.o_fp_jt_job_card_partner {
font-size: $fp-text-base;
color: $fp-ink-mute;
font-weight: $fp-weight-medium;
}
.o_fp_jt_job_card_meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
font-size: 0.9rem;
}
.o_fp_jt_meta_item {
color: $fp-ink-mute;
}
.o_fp_jt_job_card_progress {
display: flex;
align-items: center;
gap: $fp-space-2;
margin-top: auto;
}
.o_fp_jt_job_card_current {
font-size: 0.95rem;
padding-top: 6px;
border-top: 1px solid #{$fp-border};
color: $fp-state-progress-text;
}
// ------------------------------------------------------------------------
// Progress bar (shared by job cards + job header)
// ------------------------------------------------------------------------
.o_fp_jt_progress_bar {
flex: 1 1 auto;
height: 12px;
background-color: color-mix(in srgb, #{$fp-ink} 8%, transparent);
border-radius: $fp-radius-pill;
overflow: hidden;
}
.o_fp_jt_progress_fill {
height: 100%;
background-color: $fp-state-done;
transition: width $fp-dur-slow $fp-ease;
}
.o_fp_jt_progress_label {
font-size: 0.85rem;
font-weight: $fp-weight-semibold;
color: $fp-ink-mute;
white-space: nowrap;
}
// ========================================================================
// JOB DETAIL MODE
// ========================================================================
.o_fp_jt_job_header {
// Body wraps this section; this header sits inside the body's
// $fp-card-soft surface and uses $fp-card so it pops as the
// brightest layer in the body region.
background-color: $fp-card;
color: $fp-ink;
border: 1px solid #{$fp-border};
border-radius: $fp-radius-md;
padding: $fp-space-4;
margin-bottom: $fp-space-4;
box-shadow: $fp-elev-1;
}
.o_fp_jt_job_header_row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: $fp-space-3;
margin-bottom: 14px;
}
.o_fp_jt_job_header_label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $fp-ink-mute;
font-weight: $fp-weight-semibold;
}
.o_fp_jt_job_header_value {
font-size: 1.1rem;
font-weight: $fp-weight-semibold;
margin-top: 2px;
color: $fp-ink;
}
.o_fp_jt_job_header_progress {
display: flex;
align-items: center;
gap: $fp-space-3;
}
.o_fp_jt_section_title {
font-size: 1.15rem;
font-weight: $fp-weight-bold;
margin: 0 0 $fp-space-3 0;
color: $fp-ink;
}
.o_fp_jt_step_list {
display: flex;
flex-direction: column;
gap: $fp-space-2;
}
.o_fp_jt_step_row {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
background-color: $fp-card;
color: $fp-ink;
border: 1px solid #{$fp-border};
border-radius: $fp-radius-md;
cursor: pointer;
min-height: 72px;
box-shadow: $fp-elev-1;
transition: background-color $fp-dur-fast $fp-ease,
border-color $fp-dur $fp-ease,
box-shadow $fp-dur $fp-ease,
transform $fp-dur-fast $fp-ease;
@include fp-hover-only {
&:hover {
border-color: $fp-accent;
background-color: color-mix(in srgb, #{$fp-accent} 4%, $fp-card);
box-shadow: $fp-elev-2;
transform: translateX(2px);
}
}
&:active {
background-color: color-mix(in srgb, #{$fp-ink} 6%, $fp-card);
}
}
.o_fp_jt_step_seq {
flex: 0 0 auto;
width: 36px;
height: 36px;
border-radius: 50%;
background-color: $fp-card-soft;
color: $fp-ink-mute;
display: flex;
align-items: center;
justify-content: center;
font-weight: $fp-weight-bold;
font-size: 0.95rem;
}
.o_fp_jt_step_main {
flex: 1 1 auto;
min-width: 0;
}
.o_fp_jt_step_name {
font-size: 1.1rem;
font-weight: $fp-weight-semibold;
word-break: break-word;
color: $fp-ink;
}
.o_fp_jt_step_meta {
margin-top: 4px;
font-size: 0.85rem;
color: $fp-ink-mute;
display: flex;
flex-wrap: wrap;
gap: 4px 6px;
}
.o_fp_jt_step_chevron {
color: $fp-ink-mute;
font-size: 1.1rem;
}
// ========================================================================
// STEP DETAIL MODE
// ========================================================================
.o_fp_jt_step_header {
background-color: $fp-card;
color: $fp-ink;
border: 1px solid #{$fp-border};
border-radius: $fp-radius-md;
padding: 20px;
margin-bottom: 20px;
box-shadow: $fp-elev-1;
}
.o_fp_jt_step_header_top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
margin-bottom: $fp-space-4;
}
.o_fp_jt_step_header_seq {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $fp-ink-mute;
font-weight: $fp-weight-semibold;
}
.o_fp_jt_step_header_name {
font-size: 1.6rem;
font-weight: $fp-weight-bold;
margin: 4px 0 0 0;
word-break: break-word;
color: $fp-ink;
}
.o_fp_jt_step_header_grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 14px;
}
.o_fp_jt_step_header_label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $fp-ink-mute;
font-weight: $fp-weight-semibold;
}
.o_fp_jt_step_header_value {
font-size: 1.1rem;
font-weight: $fp-weight-semibold;
margin-top: 2px;
color: $fp-ink;
}
.o_fp_jt_step_instructions {
margin-top: $fp-space-4;
padding-top: $fp-space-4;
border-top: 1px solid #{$fp-border};
h3 {
font-size: 1rem;
font-weight: $fp-weight-bold;
margin: 0 0 $fp-space-2 0;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $fp-ink-mute;
}
}
// ------------------------------------------------------------------------
// Big action buttons (Start / Finish)
// ------------------------------------------------------------------------
.o_fp_jt_action_buttons {
display: flex;
gap: $fp-space-3;
margin-bottom: 20px;
flex-wrap: wrap;
}
.o_fp_jt_btn_start,
.o_fp_jt_btn_finish {
flex: 1 1 240px;
min-height: 80px;
border: none;
border-radius: 12px;
font-size: 1.5rem;
font-weight: $fp-weight-bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
transition: filter $fp-dur-fast $fp-ease,
transform $fp-dur-fast $fp-ease,
box-shadow $fp-dur $fp-ease;
&:hover { filter: brightness(0.92); }
&:active { transform: translateY(1px); }
&:disabled {
opacity: 0.55;
cursor: not-allowed;
filter: none !important;
}
}
// Start / Finish buttons are CTAs — they keep semantic green / blue
// in both themes for consistent recognition on the shop floor.
.o_fp_jt_btn_start {
background-color: #198754;
box-shadow: 0 4px 12px rgba(25, 135, 84, 0.30);
}
.o_fp_jt_btn_finish {
background-color: #0d6efd;
box-shadow: 0 4px 12px rgba(13, 110, 253, 0.30);
}
.o_fp_jt_no_actions {
flex: 1 1 100%;
padding: 18px;
background-color: color-mix(in srgb, #{$fp-state-ready} 18%, $fp-card);
border: 1px solid color-mix(in srgb, #{$fp-state-ready} 50%, #{$fp-border});
border-radius: $fp-radius-md;
color: $fp-state-ready-text;
font-size: 1rem;
display: flex;
align-items: center;
}
// ------------------------------------------------------------------------
// Timelog table
// ------------------------------------------------------------------------
.o_fp_jt_timelogs {
margin-top: 20px;
}
.o_fp_jt_timelog_table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
background-color: $fp-card;
color: $fp-ink;
border: 1px solid #{$fp-border};
border-radius: $fp-radius-md;
overflow: hidden;
box-shadow: $fp-elev-1;
th {
background-color: $fp-card-soft;
padding: 10px 14px;
text-align: left;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $fp-ink-mute;
font-weight: $fp-weight-semibold;
border-bottom: 1px solid #{$fp-border};
}
td {
padding: 10px 14px;
font-size: 0.95rem;
color: $fp-ink;
border-bottom: 1px solid #{$fp-border};
}
tr:last-child td { border-bottom: none; }
}
.o_fp_jt_running {
color: $fp-state-progress-text;
font-style: italic;
font-weight: $fp-weight-semibold;
}
// ========================================================================
// State badges (small + extra-large)
// ========================================================================
.o_fp_jt_state_badge,
.o_fp_jt_state_badge_xl {
display: inline-flex;
align-items: center;
border-radius: $fp-radius-pill;
font-weight: $fp-weight-bold;
line-height: 1.4;
white-space: nowrap;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.o_fp_jt_state_badge {
padding: 3px 10px;
font-size: 0.75rem;
}
.o_fp_jt_state_badge_xl {
padding: 8px 18px;
font-size: 1rem;
}
// Color variants — match plant_overview palette
.o_fp_jt_badge_pending { background-color: $fp-state-pending-bg; color: $fp-state-pending-text; }
.o_fp_jt_badge_ready { background-color: color-mix(in srgb, #{$fp-state-progress} 18%, transparent); color: $fp-state-progress-text; }
.o_fp_jt_badge_progress {
background-color: color-mix(in srgb, #{$fp-state-paused} 20%, transparent);
color: $fp-state-paused-text;
animation: o_fp_jt_pulse 2s ease-in-out infinite;
}
.o_fp_jt_badge_paused { background-color: color-mix(in srgb, #{$fp-state-ready} 22%, transparent); color: $fp-state-ready-text; }
.o_fp_jt_badge_done { background-color: color-mix(in srgb, #{$fp-state-done} 22%, transparent); color: $fp-state-done-text; }
.o_fp_jt_badge_skipped { background-color: $fp-state-pending-bg; color: $fp-state-pending-text; }
.o_fp_jt_badge_cancelled { background-color: color-mix(in srgb, #{$fp-state-cancel} 18%, transparent); color: $fp-state-cancel-text; }
// ========================================================================
// Priority chip (job picker cards)
// ========================================================================
.o_fp_jt_chip {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: $fp-radius-pill;
font-size: 0.75rem;
font-weight: $fp-weight-bold;
line-height: 1.4;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #ffffff;
&.o_fp_jt_chip_rush { background-color: $fp-state-rush; }
&.o_fp_jt_chip_high { background-color: $fp-state-high; }
&.o_fp_jt_chip_low { background-color: $fp-state-low; }
}
}
// ----------------------------------------------------------------------------
// Pulse animation for in_progress state badges
// ----------------------------------------------------------------------------
@keyframes o_fp_jt_pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.65; }
}
// Suppress hover lift on touch — taps shouldn't leave cards in hover state.
@media (hover: none) {
.o_fp_job_tablet {
.o_fp_jt_job_card:hover,
.o_fp_jt_step_row:hover {
transform: none !important;
box-shadow: inherit !important;
}
}
}

View File

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

View File

@@ -1,163 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Plant Overview (Native) — kanban for fp.job.step. Each column is
one fp.work.centre; cards are active steps (ready / in_progress /
paused). Drag a card across columns to reassign work_centre_id.
-->
<templates xml:space="preserve">
<t t-name="fusion_plating_jobs.JobPlantOverview">
<div class="o_fp_job_plant_overview">
<!-- ========== HEADER ========== -->
<div class="o_fp_jpo_header">
<div class="o_fp_jpo_header_left">
<h2 class="o_fp_jpo_title">
<i class="fa fa-industry me-2"/>
Plant Overview
</h2>
<span class="o_fp_jpo_refresh_ts text-muted ms-3"
t-if="state.lastRefresh">
Updated <t t-esc="state.lastRefresh"/>
</span>
</div>
<div class="o_fp_jpo_header_right">
<div class="o_fp_jpo_search_box">
<i class="fa fa-search o_fp_jpo_search_icon"/>
<input type="text"
class="o_fp_jpo_search_input"
placeholder="Search step, job, customer..."
t-att-value="state.searchTerm"
t-on-input="onSearchInput"
t-on-keydown="onSearchKey"/>
<button class="o_fp_jpo_search_clear"
t-if="state.searchTerm"
t-on-click="onSearchClear"
title="Clear search">
<i class="fa fa-times"/>
</button>
</div>
<button class="btn btn-outline-secondary o_fp_jpo_refresh_btn"
t-on-click="onRefresh"
t-att-disabled="state.loading"
title="Refresh">
<i t-att-class="state.loading ? 'fa fa-spinner fa-spin' : 'fa fa-refresh'"/>
</button>
</div>
</div>
<!-- ========== LOADING ========== -->
<div class="o_fp_jpo_loading text-center py-5"
t-if="state.loading and !state.columns.length">
<i class="fa fa-spinner fa-spin fa-2x"/>
<p class="mt-2 text-muted">Loading plant data...</p>
</div>
<!-- ========== EMPTY ========== -->
<div class="o_fp_jpo_empty text-center py-5"
t-if="!state.loading and !state.columns.length">
<i class="fa fa-inbox fa-3x text-muted"/>
<p class="mt-3 text-muted">
No active steps in any work centre.
</p>
</div>
<!-- ========== COLUMNS ========== -->
<div class="o_fp_jpo_columns" t-if="state.columns.length">
<t t-foreach="state.columns" t-as="col" t-key="col.id">
<div class="o_fp_jpo_column">
<!-- Column header -->
<div class="o_fp_jpo_col_header">
<span class="o_fp_jpo_col_name" t-esc="col.name"/>
<span class="o_fp_jpo_col_count badge rounded-pill">
<t t-esc="col.cards.length"/>
</span>
</div>
<div class="o_fp_jpo_col_subhead text-muted small"
t-if="col.code or col.kind">
<span t-if="col.code" t-esc="col.code"/>
<span t-if="col.code and col.kind"> · </span>
<span t-if="col.kind" t-esc="col.kind"/>
</div>
<!-- Cards (drop zone) -->
<div class="o_fp_jpo_col_body"
t-on-dragover="(ev) => this.onColDragOver(col, ev)"
t-on-dragleave="(ev) => this.onColDragLeave(col, ev)"
t-on-drop="(ev) => this.onColDrop(col, ev)">
<t t-if="!col.cards.length">
<div class="o_fp_jpo_no_cards text-muted text-center py-3">
<i class="fa fa-check-circle"/> Clear
</div>
</t>
<t t-foreach="col.cards" t-as="card" t-key="card.id">
<div t-att-class="'o_fp_jpo_card ' + getStateClass(card.state) + ' ' + getPriorityClass(card.priority)"
draggable="true"
t-att-data-card-id="card.id"
t-att-data-source-wc="col.id"
t-on-dragstart="(ev) => this.onCardDragStart(card, col, ev)"
t-on-dragend="(ev) => this.onCardDragEnd(ev)"
t-on-click="() => this.onCardClick(card)">
<!-- Top row: step name + state badge -->
<div class="o_fp_jpo_card_top">
<div class="o_fp_jpo_card_title">
<strong t-esc="card.name"/>
</div>
<span t-attf-class="o_fp_jpo_state_badge o_fp_jpo_state_badge_#{ card.state }"
t-esc="card.state"/>
</div>
<!-- Job link + customer -->
<div class="o_fp_jpo_card_refs">
<a t-on-click="(ev) => this.onJobLink(card, ev)"
class="o_fp_jpo_job_link"
t-esc="card.job_name"/>
<span t-if="card.partner" class="text-muted">
· <t t-esc="card.partner"/>
</span>
</div>
<!-- Meta: assigned user, duration, thickness -->
<div class="o_fp_jpo_card_meta text-muted small">
<span t-if="card.assigned_user">
<i class="fa fa-user me-1"/><t t-esc="card.assigned_user"/>
</span>
<span t-if="card.assigned_user and durationLabel(card)"> · </span>
<span t-if="durationLabel(card)">
<i class="fa fa-clock-o me-1"/><t t-esc="durationLabel(card)"/>
</span>
<span t-if="card.thickness_target">
· <i class="fa fa-arrows-v me-1"/>
<t t-esc="card.thickness_target"/>
<t t-esc="' ' + (card.thickness_uom || '')"/>
</span>
</div>
<!-- Priority chip -->
<div class="o_fp_jpo_card_footer"
t-if="card.priority and card.priority !== 'normal'">
<span t-attf-class="o_fp_jpo_chip o_fp_jpo_chip_#{ card.priority }">
<t t-if="card.priority === 'rush'">RUSH</t>
<t t-elif="card.priority === 'high'">High</t>
<t t-elif="card.priority === 'low'">Low</t>
<t t-else="" t-esc="card.priority"/>
</span>
</div>
</div>
</t>
</div>
</div>
</t>
</div>
</div>
</t>
</templates>

View File

@@ -1,122 +0,0 @@
<?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.
Job Process Tree — horizontal hierarchical view, fp.job edition.
Recursive template renders the recipe -> sub-process -> operation
hierarchy from the /fp/jobs/process_tree endpoint, with bracket
connectors between cards drawn from CSS.
-->
<templates xml:space="preserve">
<!-- =====================================================================
RECURSIVE NODE TEMPLATE
Expects a `node` set in the t-call context.
===================================================================== -->
<t t-name="fusion_plating_jobs.JobProcessNode">
<div class="o_fp_jpt_node">
<!-- The card itself -->
<div t-att-class="getCardClass(node)"
t-on-click="() => this.onNodeClick(node)">
<i t-attf-class="o_fp_jpt_card_icon fa #{ nodeIcon(node) }"/>
<div class="o_fp_jpt_card_body">
<div class="o_fp_jpt_card_title" t-esc="node.name"/>
<div class="o_fp_jpt_card_meta"
t-if="node.step_assigned_user or durationLabel(node)">
<span t-if="node.step_assigned_user">
<i class="fa fa-user me-1"/><t t-esc="node.step_assigned_user"/>
</span>
<span t-if="durationLabel(node)">
<t t-if="node.step_assigned_user"> · </t>
<i class="fa fa-clock-o me-1"/><t t-esc="durationLabel(node)"/>
</span>
</div>
</div>
<!-- Right-side: state badge / open icon -->
<div class="o_fp_jpt_card_right">
<span t-if="node.step_state"
t-attf-class="o_fp_jpt_state_badge o_fp_jpt_state_badge_#{ node.step_state }"
t-esc="stateLabel(node)"/>
<i class="o_fp_jpt_card_open fa fa-external-link"
t-if="node.step_id"/>
</div>
</div>
<!-- Children — recurse -->
<div class="o_fp_jpt_children" t-if="node.children and node.children.length">
<t t-foreach="node.children" t-as="child" t-key="child.id">
<t t-call="fusion_plating_jobs.JobProcessNode">
<t t-set="node" t-value="child"/>
</t>
</t>
</div>
</div>
</t>
<!-- =====================================================================
ROOT TEMPLATE
===================================================================== -->
<t t-name="fusion_plating_jobs.JobProcessTree">
<div class="o_fp_job_process_tree o_fp_jpt_v1">
<!-- ========== HEADER ========== -->
<div class="o_fp_jpt_header">
<button class="o_fp_jpt_back"
t-on-click="onBack"
t-att-title="backLabel">
<i class="fa fa-arrow-left me-2"/>
<t t-esc="backLabel"/>
</button>
<div class="o_fp_jpt_title_block">
<h2 class="o_fp_jpt_title mb-0">
<i class="fa fa-sitemap me-2"/>Process
<span t-if="state.jobName" class="o_fp_jpt_job_name">
· <t t-esc="state.jobName"/>
</span>
</h2>
<div class="o_fp_jpt_subtitle">
<span t-if="state.partner">
<i class="fa fa-user me-1"/><t t-esc="state.partner"/>
</span>
<span t-if="state.jobState"> · <t t-esc="state.jobState"/></span>
<span t-if="state.qty"> · Qty <t t-esc="state.qty"/></span>
<span t-if="state.recipe"> · <i class="fa fa-flask me-1"/><t t-esc="state.recipe"/></span>
<span t-if="state.progressPct"> · <t t-esc="state.progressPct.toFixed(0)"/>%</span>
</div>
</div>
</div>
<!-- ========== LOADING ========== -->
<div class="o_fp_jpt_loading text-center py-4" t-if="state.loading">
<i class="fa fa-spinner fa-spin fa-2x"/>
<p class="mt-2 text-muted small">Loading process...</p>
</div>
<!-- ========== EMPTY ========== -->
<div class="o_fp_jpt_empty"
t-if="!state.loading and !jobId">
<i class="fa fa-exclamation-triangle"/>
<div>No job selected.</div>
</div>
<div class="o_fp_jpt_empty"
t-if="!state.loading and jobId and !state.root">
<i class="fa fa-sitemap"/>
<div>No recipe assigned to this job.</div>
</div>
<!-- ========== TREE ========== -->
<div class="o_fp_jpt_canvas" t-if="state.root">
<t t-call="fusion_plating_jobs.JobProcessNode">
<t t-set="node" t-value="state.root"/>
</t>
</div>
</div>
</t>
</templates>

View File

@@ -1,325 +0,0 @@
<?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.
Tablet Station (Native) — operator-facing touchscreen UI for the
native fp.job model. Three modes: job_picker, job_detail,
step_detail. Big touch-friendly buttons everywhere.
-->
<templates xml:space="preserve">
<t t-name="fusion_plating_jobs.JobTablet">
<div class="o_fp_job_tablet">
<!-- ============== HEADER ============== -->
<div class="o_fp_jt_header">
<div class="o_fp_jt_header_left">
<button class="o_fp_jt_back_btn"
t-if="state.mode !== 'job_picker'"
t-on-click="() => state.mode === 'step_detail' ? onBackToJob() : onBackToJobs()"
title="Back">
<i class="fa fa-arrow-left"/>
</button>
<h1 class="o_fp_jt_title">
<i class="fa fa-tablet me-2"/>
<t t-if="state.mode === 'job_picker'">Tablet Station</t>
<t t-elif="state.mode === 'job_detail' and state.job" t-esc="state.job.name"/>
<t t-elif="state.mode === 'step_detail' and state.step">Step Detail</t>
</h1>
</div>
<div class="o_fp_jt_header_right">
<span class="o_fp_jt_refresh_ts text-muted me-2"
t-if="state.lastRefresh and state.mode === 'job_picker'">
Updated <t t-esc="state.lastRefresh"/>
</span>
<button class="o_fp_jt_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>
<!-- ============== JOB PICKER MODE ============== -->
<div class="o_fp_jt_body o_fp_jt_picker"
t-if="state.mode === 'job_picker'">
<div class="o_fp_jt_loading"
t-if="state.loading and !state.jobs.length">
<i class="fa fa-spinner fa-spin fa-3x"/>
<p class="mt-3">Loading jobs...</p>
</div>
<div class="o_fp_jt_empty"
t-if="!state.loading and !state.jobs.length">
<i class="fa fa-inbox fa-4x"/>
<p class="mt-3">No active jobs.</p>
<p class="text-muted small">
Confirm a job to see it here.
</p>
</div>
<div class="o_fp_jt_job_grid" t-if="state.jobs.length">
<t t-foreach="state.jobs" t-as="job" t-key="job.id">
<div t-att-class="'o_fp_jt_job_card ' + priorityClass(job.priority)"
t-on-click="() => this.onJobPick(job)">
<div class="o_fp_jt_job_card_top">
<div class="o_fp_jt_job_card_name" t-esc="job.name"/>
<span t-if="priorityLabel(job.priority)"
t-attf-class="o_fp_jt_chip o_fp_jt_chip_#{ job.priority }"
t-esc="priorityLabel(job.priority)"/>
</div>
<div class="o_fp_jt_job_card_partner" t-esc="job.partner"/>
<div class="o_fp_jt_job_card_meta">
<span class="o_fp_jt_meta_item">
<i class="fa fa-cubes me-1"/>
<t t-esc="job.qty"/> qty
</span>
<span class="o_fp_jt_meta_item" t-if="job.deadline">
<i class="fa fa-calendar me-1"/>
<t t-esc="formatDeadline(job.deadline)"/>
</span>
<span t-attf-class="o_fp_jt_state_badge #{ stateBadgeClass(job.state) }"
t-esc="stateLabel(job.state)"/>
</div>
<div class="o_fp_jt_job_card_progress">
<div class="o_fp_jt_progress_bar">
<div class="o_fp_jt_progress_fill"
t-attf-style="width: #{ Math.round(job.progress_pct || 0) }%"/>
</div>
<span class="o_fp_jt_progress_label">
<t t-esc="Math.round(job.progress_pct || 0)"/>%
</span>
</div>
<div class="o_fp_jt_job_card_current" t-if="job.current_step">
<i class="fa fa-arrow-right me-1"/>
<strong>Now:</strong>
<span t-esc="job.current_step"/>
</div>
</div>
</t>
</div>
</div>
<!-- ============== JOB DETAIL MODE ============== -->
<div class="o_fp_jt_body o_fp_jt_job_detail"
t-if="state.mode === 'job_detail' and state.job">
<div class="o_fp_jt_job_header">
<div class="o_fp_jt_job_header_row">
<div>
<div class="o_fp_jt_job_header_label">Customer</div>
<div class="o_fp_jt_job_header_value" t-esc="state.job.partner or '—'"/>
</div>
<div>
<div class="o_fp_jt_job_header_label">Quantity</div>
<div class="o_fp_jt_job_header_value" t-esc="state.job.qty"/>
</div>
<div>
<div class="o_fp_jt_job_header_label">Recipe</div>
<div class="o_fp_jt_job_header_value" t-esc="state.job.recipe or '—'"/>
</div>
<div>
<div class="o_fp_jt_job_header_label">State</div>
<span t-attf-class="o_fp_jt_state_badge #{ stateBadgeClass(state.job.state) }"
t-esc="stateLabel(state.job.state)"/>
</div>
</div>
<div class="o_fp_jt_job_header_progress">
<div class="o_fp_jt_progress_bar">
<div class="o_fp_jt_progress_fill"
t-attf-style="width: #{ Math.round(state.job.progress_pct || 0) }%"/>
</div>
<span class="o_fp_jt_progress_label">
<t t-esc="state.job.step_done"/> / <t t-esc="state.job.step_total"/> steps
(<t t-esc="Math.round(state.job.progress_pct || 0)"/>%)
</span>
</div>
</div>
<h2 class="o_fp_jt_section_title">Steps</h2>
<div class="o_fp_jt_empty" t-if="!state.job.steps.length">
<i class="fa fa-list fa-3x"/>
<p class="mt-3">No steps on this job yet.</p>
</div>
<div class="o_fp_jt_step_list" t-if="state.job.steps.length">
<t t-foreach="state.job.steps" t-as="step" t-key="step.id">
<div class="o_fp_jt_step_row"
t-on-click="() => this.onStepPick(step)">
<div class="o_fp_jt_step_seq">
<t t-esc="step_index + 1"/>
</div>
<div class="o_fp_jt_step_main">
<div class="o_fp_jt_step_name" t-esc="step.name"/>
<div class="o_fp_jt_step_meta">
<span t-if="step.work_centre">
<i class="fa fa-cog me-1"/>
<t t-esc="step.work_centre"/>
</span>
<span t-if="step.work_centre and durationLabel(step)"> · </span>
<span t-if="durationLabel(step)">
<i class="fa fa-clock-o me-1"/>
<t t-esc="durationLabel(step)"/>
</span>
<span t-if="step.assigned_user">
· <i class="fa fa-user me-1"/>
<t t-esc="step.assigned_user"/>
</span>
</div>
</div>
<span t-attf-class="o_fp_jt_state_badge #{ stateBadgeClass(step.state) }"
t-esc="stateLabel(step.state)"/>
<i class="fa fa-chevron-right o_fp_jt_step_chevron"/>
</div>
</t>
</div>
</div>
<!-- ============== STEP DETAIL MODE ============== -->
<div class="o_fp_jt_body o_fp_jt_step_detail"
t-if="state.mode === 'step_detail' and state.step">
<div class="o_fp_jt_step_header">
<div class="o_fp_jt_step_header_top">
<div>
<div class="o_fp_jt_step_header_seq">
Step #<t t-esc="state.step.sequence"/>
</div>
<h2 class="o_fp_jt_step_header_name" t-esc="state.step.name"/>
</div>
<span t-attf-class="o_fp_jt_state_badge_xl #{ stateBadgeClass(state.step.state) }"
t-esc="stateLabel(state.step.state)"/>
</div>
<div class="o_fp_jt_step_header_grid">
<div t-if="state.step.work_centre">
<div class="o_fp_jt_step_header_label">Work Centre</div>
<div class="o_fp_jt_step_header_value" t-esc="state.step.work_centre"/>
</div>
<div t-if="state.step.kind">
<div class="o_fp_jt_step_header_label">Kind</div>
<div class="o_fp_jt_step_header_value" t-esc="state.step.kind"/>
</div>
<div t-if="state.step.duration_expected">
<div class="o_fp_jt_step_header_label">Expected</div>
<div class="o_fp_jt_step_header_value">
<t t-esc="state.step.duration_expected.toFixed(0)"/> min
</div>
</div>
<div t-if="state.step.duration_actual">
<div class="o_fp_jt_step_header_label">Actual</div>
<div class="o_fp_jt_step_header_value">
<t t-esc="state.step.duration_actual.toFixed(1)"/> min
</div>
</div>
<div t-if="state.step.thickness_target">
<div class="o_fp_jt_step_header_label">Target Thickness</div>
<div class="o_fp_jt_step_header_value">
<t t-esc="state.step.thickness_target"/>
<t t-esc="' ' + (state.step.thickness_uom or '')"/>
</div>
</div>
<div t-if="state.step.assigned_user">
<div class="o_fp_jt_step_header_label">Assigned</div>
<div class="o_fp_jt_step_header_value" t-esc="state.step.assigned_user"/>
</div>
</div>
<div class="o_fp_jt_step_instructions"
t-if="state.step.instructions">
<h3>Instructions</h3>
<div t-out="state.step.instructions"/>
</div>
</div>
<!-- BIG ACTION BUTTONS -->
<div class="o_fp_jt_action_buttons">
<button class="o_fp_jt_btn_start"
t-if="canStart(state.step.state)"
t-on-click="onStartStep"
t-att-disabled="state.busy">
<i t-att-class="state.busy ? 'fa fa-spinner fa-spin' : 'fa fa-play'"/>
<span class="ms-2">
<t t-if="state.step.state === 'paused'">Resume</t>
<t t-else="">Start</t>
</span>
</button>
<button class="o_fp_jt_btn_finish"
t-if="canFinish(state.step.state)"
t-on-click="onFinishStep"
t-att-disabled="state.busy">
<i t-att-class="state.busy ? 'fa fa-spinner fa-spin' : 'fa fa-check'"/>
<span class="ms-2">Finish</span>
</button>
<div class="o_fp_jt_no_actions"
t-if="!canStart(state.step.state) and !canFinish(state.step.state)">
<i class="fa fa-info-circle me-2"/>
<span>
<t t-if="state.step.state === 'pending'">
This step is pending. Earlier steps must complete first.
</t>
<t t-elif="state.step.state === 'done'">
This step is complete.
</t>
<t t-elif="state.step.state === 'skipped'">
This step was skipped.
</t>
<t t-elif="state.step.state === 'cancelled'">
This step was cancelled.
</t>
<t t-else="">
No actions available in state <t t-esc="state.step.state"/>.
</t>
</span>
</div>
</div>
<!-- TIMELOG HISTORY -->
<div class="o_fp_jt_timelogs"
t-if="state.step.timelogs and state.step.timelogs.length">
<h3 class="o_fp_jt_section_title">Time Log</h3>
<table class="o_fp_jt_timelog_table">
<thead>
<tr>
<th>Operator</th>
<th>Started</th>
<th>Finished</th>
<th class="text-end">Duration</th>
</tr>
</thead>
<tbody>
<t t-foreach="state.step.timelogs" t-as="log" t-key="log.id">
<tr>
<td t-esc="log.user"/>
<td t-esc="formatDateTime(log.date_started)"/>
<td>
<t t-if="log.date_finished" t-esc="formatDateTime(log.date_finished)"/>
<span t-else="" class="o_fp_jt_running">running</span>
</td>
<td class="text-end">
<t t-if="log.duration_minutes">
<t t-esc="log.duration_minutes.toFixed(1)"/> min
</t>
<t t-else=""></t>
</td>
</tr>
</t>
</tbody>
</table>
</div>
</div>
</div>
</t>
</templates>

View File

@@ -546,16 +546,19 @@ class TestPhase6Controllers(TransactionCase):
})
def test_scan_controller_route_registered(self):
# Verify the route is registered in the controller registry.
# Odoo auto-registers @http.route decorated methods on module load.
# We don't HTTP-call from a TransactionCase; just confirm import works.
from odoo.addons.fusion_plating_jobs.controllers import job_scan, process_tree
# Verify the QR-scan controller is registered. The parallel
# process_tree / plant_overview / manager_dashboard / tablet
# controllers were consolidated into fusion_plating_shopfloor on
# 2026-04-24; the only controller left in this module is
# job_scan (the QR-sticker scan redirect).
from odoo.addons.fusion_plating_jobs.controllers import job_scan
self.assertTrue(hasattr(job_scan, 'FpJobScanController'))
self.assertTrue(hasattr(process_tree, 'FpJobProcessTreeController'))
def test_process_tree_endpoint_logic(self):
# Direct method invocation (not HTTP) to verify serialization logic
# works for a job with steps + recipe.
# The native process_tree endpoint now lives in
# fusion_plating_shopfloor (consolidated 2026-04-24). This test
# verifies the recipe-node → step lookup that the endpoint
# depends on still works for fp.job rows seeded from a recipe.
recipe = self.env['fusion.plating.process.node'].create({
'name': 'R', 'node_type': 'recipe',
})
@@ -568,9 +571,6 @@ class TestPhase6Controllers(TransactionCase):
'job_id': self.job.id, 'name': 'Op1', 'sequence': 10,
'recipe_node_id': op.id,
})
# Direct call to the controller method body via a fake request
# context — in Odoo TransactionCase we can't easily simulate http.request,
# so this test just verifies the underlying step-serialization works.
step_by_node = {s.recipe_node_id.id: s for s in self.job.step_ids if s.recipe_node_id}
self.assertIn(op.id, step_by_node)
self.assertEqual(step_by_node[op.id].name, 'Op1')

View File

@@ -1,8 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!--
Adds a "Process Tree" button to the fp.job form header, launching
the fp_job_process_tree client action with job_id in context.
Adds a "Process Tree" button to the fp.job form header, calling
fp.job.action_open_process_tree (which launches the canonical
fp_process_tree shopfloor client action with job_id in context).
Hidden while the job is in draft (no recipe-derived steps yet).
-->

View File

@@ -1,36 +0,0 @@
<?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</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</field>
<field name="tag">fp_job_manager_dashboard</field>
</record>
<menuitem id="menu_fp_jobs_plant_overview"
name="Plant Overview"
parent="fusion_plating.menu_fp_jobs_native_root"
action="action_job_plant_overview"
sequence="10"/>
<menuitem id="menu_fp_jobs_manager_dashboard"
name="Manager Dashboard"
parent="fusion_plating.menu_fp_jobs_native_root"
action="action_job_manager_dashboard"
sequence="15"
groups="fusion_plating.group_fusion_plating_supervisor"/>
</odoo>

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!--
fp_job_process_tree client action — opened from the "Process Tree"
button on the fp.job form (action_open_process_tree on fp.job)
with {'job_id': self.id} in context.
-->
<record id="action_job_process_tree" model="ir.actions.client">
<field name="name">Job Process Tree</field>
<field name="tag">fp_job_process_tree</field>
</record>
</odoo>

View File

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!--
Tablet Station — touchscreen-friendly OWL client. Sequence 5
puts it at the top of the Jobs submenu (just below All Jobs at 20).
-->
<record id="action_job_tablet" model="ir.actions.client">
<field name="name">Tablet Station</field>
<field name="tag">fp_job_tablet</field>
</record>
<menuitem id="menu_fp_jobs_tablet"
name="Tablet Station"
parent="fusion_plating.menu_fp_jobs_native_root"
action="action_job_tablet"
sequence="5"/>
</odoo>

View File

@@ -1,26 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo noupdate="0">
<!-- Restrict legacy MO/WO-bound menus to the hidden 'Plating Legacy
Menus' group. The native fp.job menus (under Plating Jobs
(Native)) replace these. Original operations menus that don't
have a native equivalent yet are left alone.
<!-- After the shopfloor consolidation (2026-04-24) the shopfloor
operator UIs are the canonical native fp.job / fp.job.step
consoles. Only bridge_mrp's Production Priorities menu (still
bound to mrp.workorder) remains legacy.
List intentionally narrow: only menus that have a CLEAR fp.job
replacement get hidden. Operations like Recipes, Baths, Tanks,
etc. stay visible because there's no native replacement yet. -->
The group_fusion_plating_legacy_menus group is preserved so a
site that needs to bring legacy menus back can simply add a
user to the group. -->
<!-- fusion_plating_shopfloor: legacy Manager Desk, Plant Overview,
Tablet Station — replaced by Manager Dashboard (Native), Plant
Overview (Native), Tablet Station (Native) under
Plating Jobs (Native). -->
<!-- Reset group_ids on the 3 shopfloor menus that used to be
hidden — they are now the canonical UIs and should be visible
to all users (subject to the original groups= attribute on
each menuitem in fusion_plating_shopfloor/views/fp_menu.xml). -->
<record id="fusion_plating_shopfloor.menu_fp_shopfloor_manager" model="ir.ui.menu">
<field name="group_ids" eval="[(6, 0, [ref('fusion_plating_jobs.group_fusion_plating_legacy_menus')])]"/>
<field name="group_ids" eval="[(6, 0, [ref('fusion_plating.group_fusion_plating_manager')])]"/>
</record>
<record id="fusion_plating_shopfloor.menu_fp_shopfloor_plant_overview" model="ir.ui.menu">
<field name="group_ids" eval="[(6, 0, [ref('fusion_plating_jobs.group_fusion_plating_legacy_menus')])]"/>
<field name="group_ids" eval="[(6, 0, [])]"/>
</record>
<record id="fusion_plating_shopfloor.menu_fp_shopfloor_tablet" model="ir.ui.menu">
<field name="group_ids" eval="[(6, 0, [ref('fusion_plating_jobs.group_fusion_plating_legacy_menus')])]"/>
<field name="group_ids" eval="[(6, 0, [])]"/>
</record>
<!-- bridge_mrp: Production Priorities is mrp.workorder ordering UI;

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Shop Floor',
'version': '19.0.14.4.0',
'version': '19.0.15.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.',

View File

@@ -2,7 +2,20 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""JSON-RPC endpoints for the Manager Dashboard (client action)."""
"""JSON-RPC endpoints for the Manager Dashboard (client action).
Native fp.job / fp.job.step edition (consolidated 2026-04-24). All
endpoint URLs are preserved (`/fp/manager/*`); the underlying data
layer is now fp.job + fp.job.step.
Manager Desk ergonomics:
- Column 1 ("Needs a Worker") = jobs that have at least one step
missing the bits a manager has to set before an operator can tap
Start (worker, work centre, kind-specific equipment).
- Column 2 ("In Progress") = jobs whose steps are release-ready or
actively running.
- Column 3 ("Team") = operators with their open / in-progress counts.
"""
import logging
@@ -15,26 +28,45 @@ from odoo.http import request
_logger = logging.getLogger(__name__)
class FpManagerDashboardController(http.Controller):
"""Manager-level view: unassigned jobs, in-progress jobs, team workload.
# --- helpers -----------------------------------------------------------------
All endpoints require the user to be a manager or above. The UI locks
the menu behind group_fusion_plating_manager.
"""
_NEG_JOB_STATES = ('done', 'cancelled')
_ACTIVE_JOB_STATES = ('confirmed', 'in_progress', 'on_hold')
# A step needs an operator and (for wet/bake/mask) the right equipment
# before the operator can tap Start. Mirrors the legacy
# x_fc_is_release_ready compute on mrp.workorder.
def _step_release_readiness(step):
"""Return (is_release_ready, missing_str) for a fp.job.step."""
missing = []
if not step.assigned_user_id:
missing.append('worker')
if not step.work_centre_id:
missing.append('work centre')
if step.kind == 'wet':
if not step.bath_id:
missing.append('bath')
if not step.tank_id:
missing.append('tank')
elif step.kind == 'rack':
if not step.rack_id:
missing.append('rack')
return (not missing, ', '.join(missing))
def _priority_int(priority):
"""fp.job.priority → int 0/1/2 (parallel of legacy x_fc_priority)."""
return {'rush': 2, 'high': 1, 'normal': 0, 'low': 0}.get(priority, 0)
class FpManagerDashboardController(http.Controller):
"""Manager-level view: unassigned jobs, in-progress jobs, team workload."""
# ------------------------------------------------------------------
# Overview snapshot — used on initial load + 30s auto-refresh
# Overview snapshot — used on initial load + 8s auto-refresh
# ------------------------------------------------------------------
@http.route('/fp/manager/overview', type='jsonrpc', auth='user')
def overview(self, facility_id=None, known_hash=None):
"""Build the manager dashboard payload.
`known_hash`: if the client sends back the hash of its last
overview, we compare and return `{'unchanged': True}` when
nothing has moved. Keeps the UI flicker-free between polls
while still catching every shop-floor change within a few
seconds.
"""
try:
return self._overview_payload(facility_id, known_hash)
except Exception as exc: # noqa: BLE001
@@ -43,187 +75,122 @@ class FpManagerDashboardController(http.Controller):
def _overview_payload(self, facility_id, known_hash):
env = request.env
MrpWO = env.get('mrp.workorder')
Production = env.get('mrp.production')
if MrpWO is None or Production is None:
return {
'ok': True,
'kpis': {'unassigned_wos': 0, 'active_wos': 0,
'ready_to_ship_mos': 0, 'pending_accept_sos': 0},
'unassigned': [], 'active': [], 'team': [],
'operators': [], 'tanks': [],
'user_name': env.user.name,
'mrp_missing': True,
'payload_hash': '',
}
# The assignment field lives in fusion_plating_bridge_mrp. If it's
# missing, the dashboard still renders but the worker pickers are
# effectively read-only.
has_assign = 'x_fc_assigned_user_id' in MrpWO._fields
Job = env['fp.job']
Step = env['fp.job.step']
# ---- Column 1: Unassigned ("Setup Pending") --------------------
# A WO stays here until the manager has set EVERY field
# button_start would block on (operator + per-kind equipment).
# Without this, picking a worker would auto-jump the row to
# "In Progress" before bath/tank/oven/rack/material are set.
# We compute release-readiness in Python after the SQL search
# because x_fc_is_release_ready is a non-stored compute.
ACTIVE_NEG_STATES = ('done', 'cancel')
domain_active_states = [('state', 'not in', ACTIVE_NEG_STATES)]
# Pull in-flight jobs (confirmed / in_progress / on_hold)
domain = [('state', 'in', _ACTIVE_JOB_STATES)]
if facility_id:
domain_active_states.append(
('workcenter_id.x_fc_facility_id', '=', int(facility_id)))
all_active_wos = MrpWO.search(domain_active_states, order='sequence, id')
# Split: not-release-ready → Unassigned/Setup column; rest → In Progress
if 'x_fc_is_release_ready' in MrpWO._fields:
unassigned_wos = all_active_wos.filtered(lambda w: not w.x_fc_is_release_ready)
elif has_assign:
unassigned_wos = all_active_wos.filtered(lambda w: not w.x_fc_assigned_user_id)
else:
unassigned_wos = all_active_wos
domain.append(('facility_id', '=', int(facility_id)))
jobs = Job.search(domain, order='priority desc, date_deadline asc, id desc')
# Roll up to MO level
def _group_by_mo(wos):
groups = {}
for wo in wos:
mo_id = wo.production_id.id
groups.setdefault(mo_id, []).append(wo)
return groups
# Compute release-readiness per step in a single pass
all_steps = jobs.mapped('step_ids').filtered(
lambda s: s.state in ('pending', 'ready', 'in_progress', 'paused'),
)
readiness_by_step = {}
for step in all_steps:
ready, missing = _step_release_readiness(step)
readiness_by_step[step.id] = (ready, missing)
# Bucket jobs: "needs a worker" vs "in progress".
# A job lands in unassigned iff at least one of its open steps
# is NOT release-ready. Otherwise it goes to in_progress.
unassigned_jobs = jobs.browse([])
active_jobs = jobs.browse([])
for job in jobs:
open_steps = job.step_ids.filtered(
lambda s: s.state in ('pending', 'ready', 'in_progress', 'paused'),
)
if not open_steps:
continue
not_ready = any(not readiness_by_step.get(s.id, (False, ''))[0]
for s in open_steps)
if not_ready:
unassigned_jobs |= job
else:
active_jobs |= job
def _job_card(job, only_open=True):
partner = job.partner_id
steps_iter = job.step_ids
if only_open:
steps_iter = steps_iter.filtered(
lambda s: s.state in ('pending', 'ready', 'in_progress', 'paused'),
)
steps_iter = steps_iter.sorted('sequence')
wo_rows = []
for s in steps_iter:
ready, missing = readiness_by_step.get(s.id, (False, ''))
wo_rows.append({
'id': s.id,
'name': s.name or '',
'workcenter': s.work_centre_id.name or '',
'state': s.state,
'sequence': s.sequence or 0,
'duration_expected': s.duration_expected or 0,
'bath': s.bath_id.name or '',
'tank': s.tank_id.name or '',
'tank_id': s.tank_id.id if s.tank_id else False,
'priority': str(_priority_int(job.priority)),
'assigned_user_id': s.assigned_user_id.id or False,
'assigned_user_name': s.assigned_user_id.name or '',
'role_id': False,
'role_name': '',
'wo_kind': s.kind or 'other',
'wo_kind_label': dict(s._fields['kind'].selection).get(
s.kind, '',
) if s.kind else '',
'is_release_ready': ready,
'missing_for_release': missing,
'oven': '',
'rack': s.rack_id.name or '',
'masking_material': '',
})
def _mo_card(mo, wos):
so_name = mo.origin or ''
partner = mo.x_fc_portal_job_id.partner_id if mo.x_fc_portal_job_id else None
return {
'mo_id': mo.id,
'mo_name': mo.name,
'so_name': so_name,
'mo_id': job.id,
'mo_name': job.name or '',
'so_name': job.origin or '',
'customer': partner.name if partner else '',
'product': mo.product_id.display_name if mo.product_id else '',
'qty_total': int(mo.product_qty or 0),
'date_planned': fp_format(request.env, mo.date_start, fmt='%Y-%m-%d'),
'recipe': mo.x_fc_recipe_id.name if mo.x_fc_recipe_id else '',
'priority_any': max(
[int(w.x_fc_priority or '0') for w in wos] + [0]
'product': job.product_id.display_name if job.product_id else '',
'qty_total': int(job.qty or 0),
'date_planned': fp_format(
request.env, job.date_planned_start or job.date_deadline,
fmt='%Y-%m-%d',
),
'current_location': mo.x_fc_current_location or '',
'wos': [
{
'id': w.id,
'name': w.display_name or w.name,
'workcenter': w.workcenter_id.name or '',
'state': w.state,
'sequence': w.sequence or 0,
'duration_expected': w.duration_expected or 0,
'bath': w.x_fc_bath_id.name or '',
'tank': w.x_fc_tank_id.name or '',
'tank_id': w.x_fc_tank_id.id if w.x_fc_tank_id else False,
'priority': w.x_fc_priority or '0',
'assigned_user_id': (
w.x_fc_assigned_user_id.id
if w.x_fc_assigned_user_id else False
),
'assigned_user_name': (
w.x_fc_assigned_user_id.name or ''
if w.x_fc_assigned_user_id else ''
),
# Role required by this step. Used by the
# Manager Desk worker dropdown to surface
# qualified operators first.
'role_id': (
w.x_fc_work_role_id.id
if w.x_fc_work_role_id else False
),
'role_name': (
w.x_fc_work_role_id.name or ''
if w.x_fc_work_role_id else ''
),
# WO kind classification + what's still missing
# before the WO can be released to the operator.
# Manager Desk uses these to render the kind
# badge and the "needs: bath, tank" hint chips.
'wo_kind': (
w.x_fc_wo_kind
if 'x_fc_wo_kind' in w._fields else 'other'
),
'wo_kind_label': dict(
w._fields['x_fc_wo_kind'].selection
).get(w.x_fc_wo_kind, '') if 'x_fc_wo_kind' in w._fields else '',
'is_release_ready': (
w.x_fc_is_release_ready
if 'x_fc_is_release_ready' in w._fields else False
),
'missing_for_release': (
w.x_fc_missing_for_release or ''
if 'x_fc_missing_for_release' in w._fields else ''
),
# Surface oven, rack, masking material so the
# manager can see at a glance what's set.
'oven': (
w.x_fc_oven_id.name or ''
if 'x_fc_oven_id' in w._fields and w.x_fc_oven_id
else ''
),
'rack': (
w.x_fc_rack_id.name or ''
if 'x_fc_rack_id' in w._fields and w.x_fc_rack_id
else ''
),
'masking_material': (
dict(w._fields['x_fc_masking_material'].selection).get(
w.x_fc_masking_material, ''
) if 'x_fc_masking_material' in w._fields and w.x_fc_masking_material
else ''
),
}
for w in wos
],
'recipe': job.recipe_id.name if job.recipe_id else '',
'priority_any': _priority_int(job.priority),
'current_location': job.current_location or '',
'wos': wo_rows,
}
unassigned_cards = []
for mo_id, wos in _group_by_mo(unassigned_wos).items():
mo = Production.browse(mo_id)
unassigned_cards.append(_mo_card(mo, wos))
unassigned_cards = [_job_card(j) for j in unassigned_jobs]
active_cards = [_job_card(j) for j in active_jobs]
# ---- Column 2: In Progress -------------------------------------
# Release-ready WOs (everything the manager needed to set is
# filled in) — operator can tap Start on the iPad.
if 'x_fc_is_release_ready' in MrpWO._fields:
active_wos = all_active_wos.filtered(lambda w: w.x_fc_is_release_ready)
elif has_assign:
active_wos = all_active_wos.filtered(lambda w: w.x_fc_assigned_user_id)
else:
active_wos = MrpWO # empty
active_cards = []
for mo_id, wos in _group_by_mo(active_wos).items():
mo = Production.browse(mo_id)
active_cards.append(_mo_card(mo, wos))
# ---- Column 3: Team (operators + their current load) -----------
# ---- Column 3: Team --------------------------------------------
operator_group = env.ref(
'fusion_plating.group_fusion_plating_operator', raise_if_not_found=False,
)
team = []
if operator_group and has_assign:
if operator_group:
for user in operator_group.user_ids.sorted('name'):
open_wos = MrpWO.search([
('x_fc_assigned_user_id', '=', user.id),
('state', 'not in', ACTIVE_NEG_STATES),
open_steps = Step.search([
('assigned_user_id', '=', user.id),
('state', 'in', ('ready', 'in_progress', 'paused')),
])
team.append({
'user_id': user.id,
'name': user.name,
'open_count': len(open_wos),
'open_count': len(open_steps),
'in_progress_count': len(
open_wos.filtered(lambda w: w.state == 'progress')
open_steps.filtered(lambda s: s.state == 'in_progress'),
),
'avatar_url': f'/web/image/res.users/{user.id}/avatar_128',
})
# ---- Pickers: operators (with presence + role data) -----------
# We send richer operator records so the Manager Desk dropdown can
# group qualified-and-present at the top, then lead hands, then
# off-shift workers (greyed). Without this the manager has to
# remember who's clocked in and who can do what.
# ---- Operators picker (with presence + role data) --------------
clocked_in_user_ids = (
env['hr.employee']._fp_clocked_in_user_ids()
if 'hr.employee' in env and hasattr(
@@ -237,7 +204,11 @@ class FpManagerDashboardController(http.Controller):
operators = []
for u in operator_users:
emp = u.employee_id
role_ids = emp.x_fc_work_role_ids.ids if emp else []
role_ids = (
emp.x_fc_work_role_ids.ids
if emp and 'x_fc_work_role_ids' in emp._fields
else []
)
lead_role_ids = (
emp.x_fc_lead_hand_role_ids.ids
if emp and 'x_fc_lead_hand_role_ids' in emp._fields
@@ -250,12 +221,12 @@ class FpManagerDashboardController(http.Controller):
'role_ids': role_ids,
'lead_hand_role_ids': lead_role_ids,
})
# Headline counts so the manager sees at-a-glance who's on shift.
present_count = sum(1 for o in operators if o['is_clocked_in'])
presence = {
'clocked_in': present_count,
'total': len(operators),
}
Tank = env.get('fusion.plating.tank')
tanks = [
{
@@ -267,36 +238,32 @@ class FpManagerDashboardController(http.Controller):
for t in (Tank.search([]) if Tank is not None else [])
]
# KPI summary — every query must use STORED fields only, otherwise
# Odoo raises "Cannot convert … to SQL because it is not stored".
# x_fc_workflow_stage is computed (non-stored); replicate the
# "awaiting assignment" stage directly via its stored antecedents.
# ---- KPI summary ----------------------------------------------
SO = env['sale.order']
so_fields = SO._fields
if ('x_fc_receiving_status' in so_fields
and 'x_fc_assigned_manager_id' in so_fields):
pending_accept_domain = [
pending_accept_sos = SO.search_count([
('state', '=', 'sale'),
('x_fc_receiving_status', '=', 'inspected'),
('x_fc_assigned_manager_id', '=', False),
]
pending_accept_sos = SO.search_count(pending_accept_domain)
])
else:
pending_accept_sos = 0
# KPI counts derived from the in-memory split we already have —
# don't re-query (the release-ready filter is a Python compute,
# not a stored column, so SQL search_count can't see it).
# Ready-to-ship: jobs that are done but the portal job hasn't
# been marked ready_to_ship yet (or no portal mirror at all).
ready_to_ship_jobs = Job.search_count([('state', '=', 'done')])
kpis = {
'unassigned_wos': len(unassigned_wos),
'active_wos': len(active_wos),
'ready_to_ship_mos': Production.search_count([
('state', '=', 'done'),
]) if 'x_fc_portal_job_id' not in Production._fields
else Production.search_count([
('state', '=', 'done'),
('x_fc_portal_job_id.state', '=', 'ready_to_ship'),
]),
'unassigned_wos': len(all_steps.filtered(
lambda s: not readiness_by_step.get(s.id, (False, ''))[0],
)),
'active_wos': len(all_steps.filtered(
lambda s: readiness_by_step.get(s.id, (False, ''))[0]
and s.state in ('ready', 'in_progress'),
)),
'ready_to_ship_mos': ready_to_ship_jobs,
'pending_accept_sos': pending_accept_sos,
}
@@ -325,45 +292,53 @@ class FpManagerDashboardController(http.Controller):
return payload
# ------------------------------------------------------------------
# Assign a worker to a WO
# Assign a worker to a step
# ------------------------------------------------------------------
@http.route('/fp/manager/assign_worker', type='jsonrpc', auth='user')
def assign_worker(self, workorder_id, user_id):
wo = request.env['mrp.workorder'].browse(int(workorder_id))
if not wo.exists():
return {'ok': False, 'error': 'Work order not found.'}
wo.x_fc_assigned_user_id = int(user_id) if user_id else False
wo.message_post(
body=Markup('Worker assigned: <b>%s</b>') % (wo.x_fc_assigned_user_id.name or 'Unassigned'),
"""`workorder_id` is the canonical kwarg name from the legacy
XML; it now resolves to a fp.job.step id."""
step = request.env['fp.job.step'].browse(int(workorder_id))
if not step.exists():
return {'ok': False, 'error': 'Step not found.'}
step.assigned_user_id = int(user_id) if user_id else False
step.message_post(
body=Markup('Worker assigned: <b>%s</b>') % (
step.assigned_user_id.name or 'Unassigned'
),
)
return {'ok': True, 'user_name': wo.x_fc_assigned_user_id.name or ''}
return {'ok': True, 'user_name': step.assigned_user_id.name or ''}
# ------------------------------------------------------------------
# Reassign or swap tank on a WO
# Reassign or swap tank on a step
# ------------------------------------------------------------------
@http.route('/fp/manager/assign_tank', type='jsonrpc', auth='user')
def assign_tank(self, workorder_id, tank_id):
wo = request.env['mrp.workorder'].browse(int(workorder_id))
if not wo.exists():
return {'ok': False, 'error': 'Work order not found.'}
wo.x_fc_tank_id = int(tank_id) if tank_id else False
wo.message_post(
body=Markup('Tank assigned: <b>%s</b>') % (wo.x_fc_tank_id.name or 'Unassigned'),
step = request.env['fp.job.step'].browse(int(workorder_id))
if not step.exists():
return {'ok': False, 'error': 'Step not found.'}
step.tank_id = int(tank_id) if tank_id else False
step.message_post(
body=Markup('Tank assigned: <b>%s</b>') % (
step.tank_id.name or 'Unassigned'
),
)
return {'ok': True, 'tank_name': wo.x_fc_tank_id.name or ''}
return {'ok': True, 'tank_name': step.tank_id.name or ''}
# ------------------------------------------------------------------
# Manager takes over a WO (no-show coverage)
# Manager takes over a step (no-show coverage)
# ------------------------------------------------------------------
@http.route('/fp/manager/take_over', type='jsonrpc', auth='user')
def take_over(self, workorder_id):
wo = request.env['mrp.workorder'].browse(int(workorder_id))
if not wo.exists():
return {'ok': False, 'error': 'Work order not found.'}
step = request.env['fp.job.step'].browse(int(workorder_id))
if not step.exists():
return {'ok': False, 'error': 'Step not found.'}
user = request.env.user
previous = wo.x_fc_assigned_user_id.name or ''
wo.x_fc_assigned_user_id = user.id
wo.message_post(
body=Markup('Manager takeover: <b>%s</b> replaces %s.') % (user.name, previous),
previous = step.assigned_user_id.name or ''
step.assigned_user_id = user.id
step.message_post(
body=Markup('Manager takeover: <b>%s</b> replaces %s.') % (
user.name, previous,
),
)
return {'ok': True, 'user_name': user.name}

View File

@@ -6,6 +6,10 @@
//
// Manager-level view: assign workers, swap tanks, cover no-shows, drill
// into detail when needed. Three columns: Unassigned / In Progress / Team.
//
// Native fp.job / fp.job.step edition (consolidated 2026-04-24). The
// "wo" naming inside payloads is preserved so the existing XML template
// keeps rendering — those keys now carry fp.job.step rows under the hood.
// =============================================================================
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
@@ -244,11 +248,11 @@ export class ManagerDashboard extends Component {
this.action.doAction({
type: "ir.actions.act_window",
name: "Operator Queue",
res_model: "mrp.workorder",
res_model: "fp.job.step",
views: [[false, "list"], [false, "form"]],
domain: [
["x_fc_assigned_user_id", "=", userId],
["state", "in", ["ready", "progress", "waiting"]],
["assigned_user_id", "=", userId],
["state", "in", ["ready", "in_progress", "paused"]],
],
target: "current",
});

View File

@@ -4,8 +4,13 @@
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
//
// Steelhead-style multi-column kanban showing all active work orders grouped
// by work centre / station. Auto-refreshes every 30 s.
// Multi-column kanban showing all active fp.job.step rows grouped by
// fp.work.centre. Auto-refreshes every 30 s. Drag-drop between columns
// reassigns step.work_centre_id.
//
// Native fp.job / fp.job.step edition (consolidated 2026-04-24). The
// data layer underneath now points at fp.job.step (cards) / fp.work.centre
// (columns); the visual design and RPC URL paths are unchanged.
//
// Odoo 19 conventions:
// * Backend OWL component: `static template` + `static props = ["*"]`
@@ -133,7 +138,7 @@ export class PlantOverview extends Component {
onCardDragStart(card, col, ev) {
this._draggedCard = {
id: card.id,
source_model: card.source_model || "mrp.workorder",
source_model: card.source_model || "fp.job.step",
source_wc_id: col.work_center_id,
el: ev.target,
};
@@ -251,9 +256,10 @@ export class PlantOverview extends Component {
if (!card.id) {
return;
}
// Try opening the work order form if MRP is available, otherwise
// fall back to bake window or first-piece gate
const model = card.source_model || "mrp.workorder";
// Cards are fp.job.step rows. The model is overridable per-card
// so we keep working if a future card type joins the kanban
// (e.g. a quality hold drop-zone column).
const model = card.source_model || "fp.job.step";
this.action.doAction({
type: "ir.actions.act_window",
res_model: model,
@@ -281,14 +287,21 @@ export class PlantOverview extends Component {
getStateClass(state) {
switch (state) {
case "progress":
// Native fp.job.step states
case "in_progress":
return "o_fp_card_progress";
case "ready":
return "o_fp_card_ready";
case "paused":
return "o_fp_card_pending";
case "done":
return "o_fp_card_done";
case "pending":
return "o_fp_card_pending";
// Legacy MRP states still recognised so a server still
// serving the old payload renders cleanly.
case "progress":
return "o_fp_card_progress";
default:
return "";
}

View File

@@ -3,15 +3,21 @@
// Fusion Plating — Process Tree (horizontal hierarchical view)
// Copyright 2026 Nexa Systems Inc. · License OPL-1
//
// Renders the MO's recipe (recipe → sub_process → operation → state) as a
// horizontal bracket tree. Cards render dark, identical card style across
// Renders an fp.job's recipe (recipe → sub_process → operation → step) as a
// horizontal bracket tree. Cards render dark, identical card style across
// all depths; connector lines are drawn from CSS so the layout stays in
// pure flexbox.
//
// Native fp.job / fp.job.step edition (consolidated 2026-04-24). The data
// layer underneath now points at fp.job + fp.job.step, but the visual
// design is unchanged.
//
// Action context:
// production_id — required; the MO whose recipe to render
// back_workorder_idoptional; if set, the back button returns to
// that WO instead of Plant Overview
// job_id — required; the fp.job whose recipe to render
// production_id legacy alias for job_id (still accepted)
// back_step_id — optional; if set, the back button returns to
// that step's form instead of Plant Overview
// back_workorder_id — legacy alias for back_step_id
// =============================================================================
import { Component, useState, onMounted } from "@odoo/owl";
@@ -50,19 +56,25 @@ export class ProcessTree extends Component {
const a = this.props.action || {};
return { ...(a.context || {}), ...(a.params || {}) };
}
get productionId() { return this._ctx.production_id || null; }
get backWorkorderId() { return this._ctx.back_workorder_id || null; }
get jobId() {
// job_id is the canonical key; production_id is kept as an alias
// for legacy callers that still encode that name in their URLs.
return this._ctx.job_id || this._ctx.production_id || null;
}
get backStepId() {
return this._ctx.back_step_id || this._ctx.back_workorder_id || null;
}
get backLabel() {
return this.backWorkorderId ? "Back to Work Order" : "Plant Overview";
return this.backStepId ? "Back to Step" : "Plant Overview";
}
// ---- Data ---------------------------------------------------------------
async loadTree() {
const prodId = this.productionId;
if (!prodId) {
const jobId = this.jobId;
if (!jobId) {
this.notification.add(
"No manufacturing order specified for the process tree.",
"No job specified for the process tree.",
{ type: "warning" },
);
return;
@@ -70,7 +82,7 @@ export class ProcessTree extends Component {
this.state.loading = true;
try {
const r = await rpc("/fp/shopfloor/process_tree", {
production_id: prodId,
job_id: jobId,
});
if (r) {
this.state.productionName = r.production_name || "";
@@ -95,25 +107,29 @@ export class ProcessTree extends Component {
// ---- Navigation ---------------------------------------------------------
onNodeClick(node) {
if (!node || !node.workorder_id) {
// Operation cards with a matching fp.job.step are clickable —
// they open the underlying step form. node.workorder_id is the
// legacy template key that now carries the step id.
const stepId = node && (node.step_id || node.workorder_id);
if (!stepId) {
return;
}
this.action.doAction({
type: "ir.actions.act_window",
res_model: "mrp.workorder",
res_id: node.workorder_id,
res_model: "fp.job.step",
res_id: stepId,
views: [[false, "form"]],
target: "current",
});
}
onBack() {
const woId = this.backWorkorderId;
if (woId) {
const stepId = this.backStepId;
if (stepId) {
this.action.doAction({
type: "ir.actions.act_window",
res_model: "mrp.workorder",
res_id: parseInt(woId, 10),
res_model: "fp.job.step",
res_id: parseInt(stepId, 10),
views: [[false, "form"]],
target: "current",
});
@@ -131,7 +147,9 @@ export class ProcessTree extends Component {
if (node.state) {
parts.push(`o_fp_pt_state_${node.state}`);
}
if (node.workorder_id) {
// step_id is the canonical clickable hint; workorder_id is the
// legacy alias. Either one means we have a real step to open.
if (node.step_id || node.workorder_id) {
parts.push("o_fp_pt_clickable");
}
if (this.isHighlight(node)) {
@@ -140,9 +158,13 @@ export class ProcessTree extends Component {
return parts.join(" ");
}
/** A node should pulse-highlight if it is the live position of the MO. */
/** Live-position highlight: ready / in_progress / paused. */
isHighlight(node) {
return node.state === "ready"
|| node.state === "in_progress"
|| node.state === "paused"
// Tolerate the legacy MRP states a node might still
// briefly carry on first render (progress/waiting).
|| node.state === "progress"
|| node.state === "waiting";
}

View File

@@ -4,6 +4,11 @@
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
//
// Native fp.job / fp.job.step edition (consolidated 2026-04-24). Start /
// Finish buttons drive fp.job.step.button_start / button_finish through
// the existing /fp/shopfloor/start_wo / stop_wo URLs (now internally
// step-bound). The visual design is unchanged.
//
// Odoo 19 conventions:
// * Backend OWL component using `static template` + `static props = ["*"]`.
// * RPC via standalone `rpc()` from @web/core/network/rpc.

View File

@@ -191,7 +191,7 @@
<i class="fa fa-user me-1"/>Take Over
</button>
<button class="btn o_fp_mgr_btn"
t-on-click="() => this.openRecord('mrp.workorder', wo.id)">
t-on-click="() => this.openRecord('fp.job.step', wo.id)">
<i class="fa fa-external-link me-1"/>Open WO
</button>
</div>
@@ -250,7 +250,7 @@
</t>
</span>
</div>
<span t-att-class="'o_fp_chip o_fp_chip_' + (wo.state === 'progress' ? 'success' : 'info')">
<span t-att-class="'o_fp_chip o_fp_chip_' + (wo.state === 'in_progress' || wo.state === 'progress' ? 'success' : 'info')">
<t t-esc="wo.state"/>
</span>
<button class="btn"
@@ -258,7 +258,7 @@
Take Over
</button>
<button class="btn"
t-on-click="() => this.openRecord('mrp.workorder', wo.id)">
t-on-click="() => this.openRecord('fp.job.step', wo.id)">
Open
</button>
</div>

View File

@@ -61,7 +61,7 @@
t-if="node.qty_total"
t-esc="qtyLabel(node)"/>
<i class="o_fp_pt_card_open fa fa-external-link"
t-if="node.workorder_id"/>
t-if="node.step_id or node.workorder_id"/>
</div>
</div>

View File

@@ -89,7 +89,7 @@
Active: <strong t-esc="state.overview.active_wo.name"/>
</div>
<div class="o_fp_active_wo_meta">
MO <t t-esc="state.overview.active_wo.mo_name"/>
Job <t t-esc="state.overview.active_wo.mo_name"/>
· <t t-esc="state.overview.active_wo.product_name"/>
· Qty <t t-esc="state.overview.active_wo.qty_done"/>/<t t-esc="state.overview.active_wo.qty_total"/>
<t t-if="state.overview.active_wo.workcenter"> @ <t t-esc="state.overview.active_wo.workcenter"/></t>
@@ -97,8 +97,8 @@
</div>
</div>
<button class="o_fp_big_button"
t-on-click="() => openRecord('mrp.workorder', state.overview.active_wo.id)">
Open WO
t-on-click="() => openRecord('fp.job.step', state.overview.active_wo.id)">
Open Step
</button>
</div>