feat(jobs): Phase 6 — Tablet Station for fp.job
Operator-facing touchscreen UI. Three modes: - job_picker: list of active jobs as big touch cards - job_detail: job header + steps list, click a step to view detail - step_detail: big Start/Finish buttons depending on state Backend: 4 JSON-RPC endpoints under /fp/jobs/tablet/* for jobs list, job detail, start step, finish step. Calls through to fp.job.step.button_start / button_finish so all the audit preservation, timelog creation, duration_actual roll-up logic from Phase 1 still applies. Menu entry 'Tablet Station (Native)' at sequence 3 (top) of the Plating Jobs (Native) submenu inside the existing Plating app. Manifest 19.0.2.3.0 → 19.0.2.4.0. Part of: native job model migration (spec 2026-04-25) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Native Jobs',
|
'name': 'Fusion Plating — Native Jobs',
|
||||||
'version': '19.0.2.3.0',
|
'version': '19.0.2.4.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -40,6 +40,7 @@ full design rationale and §6.2 of the implementation plan for task list.
|
|||||||
'views/res_config_settings_views.xml',
|
'views/res_config_settings_views.xml',
|
||||||
'views/job_process_tree_action.xml',
|
'views/job_process_tree_action.xml',
|
||||||
'views/job_overview_actions.xml',
|
'views/job_overview_actions.xml',
|
||||||
|
'views/job_tablet_action.xml',
|
||||||
'views/fp_job_form_inherit.xml',
|
'views/fp_job_form_inherit.xml',
|
||||||
'report/report_fp_job_sticker.xml',
|
'report/report_fp_job_sticker.xml',
|
||||||
'report/report_fp_job_traveller.xml',
|
'report/report_fp_job_traveller.xml',
|
||||||
@@ -49,12 +50,15 @@ full design rationale and §6.2 of the implementation plan for task list.
|
|||||||
'fusion_plating_jobs/static/src/scss/job_process_tree.scss',
|
'fusion_plating_jobs/static/src/scss/job_process_tree.scss',
|
||||||
'fusion_plating_jobs/static/src/scss/job_plant_overview.scss',
|
'fusion_plating_jobs/static/src/scss/job_plant_overview.scss',
|
||||||
'fusion_plating_jobs/static/src/scss/job_manager_dashboard.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_process_tree.js',
|
||||||
'fusion_plating_jobs/static/src/js/job_plant_overview.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_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_process_tree.xml',
|
||||||
'fusion_plating_jobs/static/src/xml/job_plant_overview.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_manager_dashboard.xml',
|
||||||
|
'fusion_plating_jobs/static/src/xml/job_tablet.xml',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'installable': True,
|
'installable': True,
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ from . import job_scan
|
|||||||
from . import process_tree
|
from . import process_tree
|
||||||
from . import plant_overview
|
from . import plant_overview
|
||||||
from . import manager_dashboard
|
from . import manager_dashboard
|
||||||
|
from . import tablet
|
||||||
|
|||||||
188
fusion_plating/fusion_plating_jobs/controllers/tablet.py
Normal file
188
fusion_plating/fusion_plating_jobs/controllers/tablet.py
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
# -*- 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)}
|
||||||
322
fusion_plating/fusion_plating_jobs/static/src/js/job_tablet.js
Normal file
322
fusion_plating/fusion_plating_jobs/static/src/js/job_tablet.js
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
/** @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);
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// Fusion Plating — Tablet Station (native, fp.job.step)
|
||||||
|
// Copyright 2026 Nexa Systems Inc. · License OPL-1
|
||||||
|
//
|
||||||
|
// Class prefix: .o_fp_jt_* (Job Tablet)
|
||||||
|
// Self-contained — no shopfloor token partial dependency.
|
||||||
|
// Touch-first: min 60px tap targets, 16-20pt text, high contrast.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
.o_fp_job_tablet {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 16px 24px;
|
||||||
|
gap: 16px;
|
||||||
|
background-color: var(--o-action, #f7f7f8);
|
||||||
|
color: var(--bs-body-color, #1a1d21);
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 1rem;
|
||||||
|
|
||||||
|
@media (max-width: 800px) { padding: 10px; gap: 10px; }
|
||||||
|
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
// Header strip
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
.o_fp_jt_header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background-color: var(--bs-body-bg, #ffffff);
|
||||||
|
border: 1px solid #d8dadd;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.o_fp_jt_header_left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.o_fp_jt_title {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.o_fp_jt_back_btn {
|
||||||
|
min-width: 60px;
|
||||||
|
min-height: 60px;
|
||||||
|
border: 1px solid #d8dadd;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: var(--bs-tertiary-bg, #f1f3f5);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
|
||||||
|
&:hover { background-color: #e2e6ea; }
|
||||||
|
&:active { background-color: #d8dadd; }
|
||||||
|
}
|
||||||
|
.o_fp_jt_header_right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.o_fp_jt_refresh_btn {
|
||||||
|
min-width: 60px;
|
||||||
|
min-height: 60px;
|
||||||
|
border: 1px solid #d8dadd;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: var(--bs-tertiary-bg, #f1f3f5);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover { background-color: #e2e6ea; }
|
||||||
|
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
// Body container
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
.o_fp_jt_body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: var(--bs-body-bg, #ffffff);
|
||||||
|
border: 1px solid #d8dadd;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
|
||||||
|
@media (max-width: 800px) { padding: 12px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
// 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: var(--bs-secondary-color, #6c757d);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// JOB PICKER MODE
|
||||||
|
// ========================================================================
|
||||||
|
.o_fp_jt_job_grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_jt_job_card {
|
||||||
|
background-color: var(--bs-body-bg, #ffffff);
|
||||||
|
border: 2px solid #d8dadd;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 180px;
|
||||||
|
transition: transform 0.1s ease, box-shadow 0.15s ease, border-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.10);
|
||||||
|
border-color: #0d6efd;
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority emphasis
|
||||||
|
&.o_fp_jt_card_rush {
|
||||||
|
border-color: #dc3545;
|
||||||
|
box-shadow: 0 0 0 1px rgba(220, 53, 69, 0.30);
|
||||||
|
}
|
||||||
|
&.o_fp_jt_card_high {
|
||||||
|
border-color: #fd7e14;
|
||||||
|
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: 8px;
|
||||||
|
}
|
||||||
|
.o_fp_jt_job_card_name {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.o_fp_jt_job_card_partner {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--bs-secondary-color, #6c757d);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.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: var(--bs-secondary-color, #6c757d);
|
||||||
|
}
|
||||||
|
.o_fp_jt_job_card_progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
.o_fp_jt_job_card_current {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding-top: 6px;
|
||||||
|
border-top: 1px solid #f1f3f5;
|
||||||
|
color: #084298;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
// Progress bar (shared by job cards + job header)
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
.o_fp_jt_progress_bar {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
height: 12px;
|
||||||
|
background-color: #e9ecef;
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.o_fp_jt_progress_fill {
|
||||||
|
height: 100%;
|
||||||
|
background-color: #198754;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
.o_fp_jt_progress_label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--bs-secondary-color, #6c757d);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// JOB DETAIL MODE
|
||||||
|
// ========================================================================
|
||||||
|
.o_fp_jt_job_header {
|
||||||
|
background-color: var(--bs-tertiary-bg, #f1f3f5);
|
||||||
|
border: 1px solid #d8dadd;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.o_fp_jt_job_header_row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.o_fp_jt_job_header_label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--bs-secondary-color, #6c757d);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.o_fp_jt_job_header_value {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.o_fp_jt_job_header_progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_jt_section_title {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_jt_step_list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fp_jt_step_row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background-color: var(--bs-body-bg, #ffffff);
|
||||||
|
border: 1px solid #d8dadd;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 72px;
|
||||||
|
transition: background-color 0.1s ease, border-color 0.15s ease, transform 0.1s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #0d6efd;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.o_fp_jt_step_seq {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--bs-tertiary-bg, #f1f3f5);
|
||||||
|
color: var(--bs-secondary-color, #6c757d);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
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: 600;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.o_fp_jt_step_meta {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--bs-secondary-color, #6c757d);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 6px;
|
||||||
|
}
|
||||||
|
.o_fp_jt_step_chevron {
|
||||||
|
color: var(--bs-secondary-color, #6c757d);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// STEP DETAIL MODE
|
||||||
|
// ========================================================================
|
||||||
|
.o_fp_jt_step_header {
|
||||||
|
background-color: var(--bs-tertiary-bg, #f1f3f5);
|
||||||
|
border: 1px solid #d8dadd;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.o_fp_jt_step_header_top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.o_fp_jt_step_header_seq {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--bs-secondary-color, #6c757d);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.o_fp_jt_step_header_name {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.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: var(--bs-secondary-color, #6c757d);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.o_fp_jt_step_header_value {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.o_fp_jt_step_instructions {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #d8dadd;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--bs-secondary-color, #6c757d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
// Big action buttons (Start / Finish)
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
.o_fp_jt_action_buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
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: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #ffffff;
|
||||||
|
transition: filter 0.1s ease, transform 0.1s ease, box-shadow 0.15s ease;
|
||||||
|
|
||||||
|
&:hover { filter: brightness(0.92); }
|
||||||
|
&:active { transform: translateY(1px); }
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
filter: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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: #fff3cd;
|
||||||
|
border: 1px solid #ffe69c;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #664d03;
|
||||||
|
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: var(--bs-body-bg, #ffffff);
|
||||||
|
border: 1px solid #d8dadd;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: var(--bs-tertiary-bg, #f1f3f5);
|
||||||
|
padding: 10px 14px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--bs-secondary-color, #6c757d);
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid #d8dadd;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
border-bottom: 1px solid #f1f3f5;
|
||||||
|
}
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
}
|
||||||
|
.o_fp_jt_running {
|
||||||
|
color: #0d6efd;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// State badges (small + extra-large)
|
||||||
|
// ========================================================================
|
||||||
|
.o_fp_jt_state_badge,
|
||||||
|
.o_fp_jt_state_badge_xl {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 700;
|
||||||
|
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: #e9ecef; color: #6c757d; }
|
||||||
|
.o_fp_jt_badge_ready { background-color: rgba(13, 110, 253, 0.18); color: #084298; }
|
||||||
|
.o_fp_jt_badge_progress {
|
||||||
|
background-color: rgba(253, 126, 20, 0.20); color: #97480d;
|
||||||
|
animation: o_fp_jt_pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.o_fp_jt_badge_paused { background-color: rgba(255, 193, 7, 0.22); color: #b58105; }
|
||||||
|
.o_fp_jt_badge_done { background-color: rgba(25, 135, 84, 0.22); color: #0f5132; }
|
||||||
|
.o_fp_jt_badge_skipped { background-color: #e9ecef; color: #6c757d; }
|
||||||
|
.o_fp_jt_badge_cancelled { background-color: rgba(220, 53, 69, 0.18); color: #842029; }
|
||||||
|
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Priority chip (job picker cards)
|
||||||
|
// ========================================================================
|
||||||
|
.o_fp_jt_chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
|
||||||
|
&.o_fp_jt_chip_rush { background-color: #dc3545; color: #fff; }
|
||||||
|
&.o_fp_jt_chip_high { background-color: #fd7e14; color: #fff; }
|
||||||
|
&.o_fp_jt_chip_low { background-color: #6c757d; color: #fff; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
325
fusion_plating/fusion_plating_jobs/static/src/xml/job_tablet.xml
Normal file
325
fusion_plating/fusion_plating_jobs/static/src/xml/job_tablet.xml
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!--
|
||||||
|
Phase 6 — Tablet Station (Native) client action + menu entry.
|
||||||
|
The OWL component is registered as fp_job_tablet in
|
||||||
|
static/src/js/job_tablet.js. Sequence 3 puts it at the top of
|
||||||
|
the Plating Jobs (Native) submenu (above Jobs at 10).
|
||||||
|
-->
|
||||||
|
<record id="action_job_tablet" model="ir.actions.client">
|
||||||
|
<field name="name">Tablet Station (Native)</field>
|
||||||
|
<field name="tag">fp_job_tablet</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<menuitem id="menu_fp_jobs_tablet"
|
||||||
|
name="Tablet Station (Native)"
|
||||||
|
parent="fusion_plating.menu_fp_jobs_native_root"
|
||||||
|
action="action_job_tablet"
|
||||||
|
sequence="3"/>
|
||||||
|
</odoo>
|
||||||
Reference in New Issue
Block a user