From f8ad224b1af4bd804faedfcc79727ab7a541d671 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 25 Apr 2026 04:41:07 -0400 Subject: [PATCH] =?UTF-8?q?feat(jobs):=20Phase=206=20=E2=80=94=20Tablet=20?= =?UTF-8?q?Station=20for=20fp.job?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../fusion_plating_jobs/__manifest__.py | 6 +- .../controllers/__init__.py | 1 + .../fusion_plating_jobs/controllers/tablet.py | 188 ++++++ .../static/src/js/job_tablet.js | 322 ++++++++++ .../static/src/scss/job_tablet.scss | 556 ++++++++++++++++++ .../static/src/xml/job_tablet.xml | 325 ++++++++++ .../views/job_tablet_action.xml | 19 + 7 files changed, 1416 insertions(+), 1 deletion(-) create mode 100644 fusion_plating/fusion_plating_jobs/controllers/tablet.py create mode 100644 fusion_plating/fusion_plating_jobs/static/src/js/job_tablet.js create mode 100644 fusion_plating/fusion_plating_jobs/static/src/scss/job_tablet.scss create mode 100644 fusion_plating/fusion_plating_jobs/static/src/xml/job_tablet.xml create mode 100644 fusion_plating/fusion_plating_jobs/views/job_tablet_action.xml diff --git a/fusion_plating/fusion_plating_jobs/__manifest__.py b/fusion_plating/fusion_plating_jobs/__manifest__.py index 1ff12454..a3a42796 100644 --- a/fusion_plating/fusion_plating_jobs/__manifest__.py +++ b/fusion_plating/fusion_plating_jobs/__manifest__.py @@ -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, diff --git a/fusion_plating/fusion_plating_jobs/controllers/__init__.py b/fusion_plating/fusion_plating_jobs/controllers/__init__.py index f9bd25b2..44e28286 100644 --- a/fusion_plating/fusion_plating_jobs/controllers/__init__.py +++ b/fusion_plating/fusion_plating_jobs/controllers/__init__.py @@ -3,3 +3,4 @@ from . import job_scan from . import process_tree from . import plant_overview from . import manager_dashboard +from . import tablet diff --git a/fusion_plating/fusion_plating_jobs/controllers/tablet.py b/fusion_plating/fusion_plating_jobs/controllers/tablet.py new file mode 100644 index 00000000..f41c2671 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/controllers/tablet.py @@ -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)} diff --git a/fusion_plating/fusion_plating_jobs/static/src/js/job_tablet.js b/fusion_plating/fusion_plating_jobs/static/src/js/job_tablet.js new file mode 100644 index 00000000..7c330204 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/static/src/js/job_tablet.js @@ -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); diff --git a/fusion_plating/fusion_plating_jobs/static/src/scss/job_tablet.scss b/fusion_plating/fusion_plating_jobs/static/src/scss/job_tablet.scss new file mode 100644 index 00000000..10ed6c86 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/static/src/scss/job_tablet.scss @@ -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; + } + } +} diff --git a/fusion_plating/fusion_plating_jobs/static/src/xml/job_tablet.xml b/fusion_plating/fusion_plating_jobs/static/src/xml/job_tablet.xml new file mode 100644 index 00000000..9d5fdaf1 --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/static/src/xml/job_tablet.xml @@ -0,0 +1,325 @@ + + + + + +
+ + +
+
+ +

+ + Tablet Station + + Step Detail +

+
+
+ + Updated + + +
+
+ + +
+ +
+ +

Loading jobs...

+
+ +
+ +

No active jobs.

+

+ Confirm a job to see it here. +

+
+ +
+ +
+ +
+
+ +
+ +
+ +
+ + + qty + + + + + + +
+ +
+
+
+
+ + % + +
+ +
+ + Now: + +
+
+ +
+
+ + +
+ +
+
+
+
Customer
+
+
+
+
Quantity
+
+
+
+
Recipe
+
+
+
+
State
+ +
+
+
+
+
+
+ + / steps + (%) + +
+
+ +

Steps

+ +
+ +

No steps on this job yet.

+
+ +
+ +
+
+ +
+
+
+
+ + + + + · + + + + + + · + + +
+
+ + +
+ +
+
+ + +
+ +
+
+
+
+ Step # +
+

+

+ +
+ +
+
+
Work Centre
+
+
+
+
Kind
+
+
+
+
Expected
+
+ min +
+
+
+
Actual
+
+ min +
+
+
+
Target Thickness
+
+ + +
+
+
+
Assigned
+
+
+
+ +
+

Instructions

+
+
+
+ + +
+ + +
+ + + + This step is pending. Earlier steps must complete first. + + + This step is complete. + + + This step was skipped. + + + This step was cancelled. + + + No actions available in state . + + +
+
+ + +
+

Time Log

+ + + + + + + + + + + + + + + + + +
OperatorStartedFinishedDuration
+ + + + running + + + min + + +
+
+
+ +
+ + + diff --git a/fusion_plating/fusion_plating_jobs/views/job_tablet_action.xml b/fusion_plating/fusion_plating_jobs/views/job_tablet_action.xml new file mode 100644 index 00000000..f818998a --- /dev/null +++ b/fusion_plating/fusion_plating_jobs/views/job_tablet_action.xml @@ -0,0 +1,19 @@ + + + + + Tablet Station (Native) + fp_job_tablet + + + +