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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)}
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
|
||||
@@ -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).
|
||||
-->
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
@@ -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_id — optional; 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";
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user