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)
|
||||
{
|
||||
'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,
|
||||
|
||||
@@ -3,3 +3,4 @@ from . import job_scan
|
||||
from . import process_tree
|
||||
from . import plant_overview
|
||||
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