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:
gsinghpal
2026-04-25 04:41:07 -04:00
parent e19d4862ed
commit f8ad224b1a
7 changed files with 1416 additions and 1 deletions

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.2.3.0',
'version': '19.0.2.4.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'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/job_process_tree_action.xml',
'views/job_overview_actions.xml',
'views/job_tablet_action.xml',
'views/fp_job_form_inherit.xml',
'report/report_fp_job_sticker.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_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',
],
},
'installable': True,

View File

@@ -3,3 +3,4 @@ from . import job_scan
from . import process_tree
from . import plant_overview
from . import manager_dashboard
from . import tablet

View 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)}

View 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);

View File

@@ -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;
}
}
}

View 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>

View File

@@ -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>