feat(fusion_plating_shopfloor): JobWorkspace client action (header/steps/side/rail)
Plan tasks P1.12 through P1.15 batched. Full-screen OWL component
registered as fp_job_workspace. Layout:
STICKY HEADER WO #, customer, part, qty/done, deadline,
WorkflowChip, holds badge
STICKY WORKFLOW BAR 9-stage dots (passed/current/pending) +
Next-action button driving advance_milestone
STEP LIST All steps with state icons; active step
auto-expanded with recipe chips (thickness/
dwell/bake/sign-off) + instructions + Start/
Finish buttons; blocked steps show GateViz;
override-excluded steps faded
SIDE PANEL Customer spec PDF link, attachments list,
chatter notes
STICKY ACTION RAIL Create Hold (HoldComposer modal), Add Note
(chatter via message_post), Issue Cert (when
draft cert exists), Next Milestone
Auto-refresh every 15s. Sign-off steps route Finish through
SignaturePad → /fp/workspace/sign_off.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -80,6 +80,10 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'fusion_plating_shopfloor/static/src/scss/components/_kanban_card.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/components/kanban_card.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/components/kanban_card.js',
|
||||
# ---- Job Workspace (Phase 1 — tablet redesign) ----
|
||||
'fusion_plating_shopfloor/static/src/scss/job_workspace.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/job_workspace.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/job_workspace.js',
|
||||
'fusion_plating_shopfloor/static/src/scss/qr_scanner.scss',
|
||||
'fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss',
|
||||
'fusion_plating_shopfloor/static/src/scss/plant_overview.scss',
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — Job Workspace (full-screen WO surface)
|
||||
// Client action: fp_job_workspace
|
||||
//
|
||||
// Opens from: kanban tap (Landing — Phase 3), smart button (fp.job form),
|
||||
// QR scan (FP-JOB/FP-STEP), manager dashboard card tap (Phase 4).
|
||||
//
|
||||
// Layout (top-to-bottom):
|
||||
// sticky header — WO #, customer, part, qty/done, deadline, holds
|
||||
// sticky workflow bar — 9-stage milestone dots + Next-action button
|
||||
// scrollable main:
|
||||
// left/center — step list with active expansion + GateViz
|
||||
// right side panel — spec PDF link + attachments + chatter
|
||||
// sticky action rail — Hold · Note · Milestone advance · Issue Cert
|
||||
//
|
||||
// Auto-refresh: every 15s.
|
||||
// =============================================================================
|
||||
|
||||
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";
|
||||
import { WorkflowChip } from "./components/workflow_chip";
|
||||
import { GateViz } from "./components/gate_viz";
|
||||
import { FpSignaturePad } from "./components/signature_pad";
|
||||
import { FpHoldComposer } from "./components/hold_composer";
|
||||
|
||||
export class FpJobWorkspace extends Component {
|
||||
static template = "fusion_plating_shopfloor.JobWorkspace";
|
||||
static props = ["*"];
|
||||
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer };
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.action = useService("action");
|
||||
this.dialog = useService("dialog");
|
||||
|
||||
this.state = useState({
|
||||
data: null,
|
||||
jobId: null,
|
||||
focusStepId: null,
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const params = (this.props.action && this.props.action.params) || {};
|
||||
this.state.jobId = params.job_id || null;
|
||||
this.state.focusStepId = params.focus_step_id || null;
|
||||
await this.refresh();
|
||||
this._refreshInterval = setInterval(() => this.refresh(), 15000);
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
if (this._refreshInterval) clearInterval(this._refreshInterval);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Data refresh ------------------------------------------------------
|
||||
async refresh() {
|
||||
if (!this.state.jobId) return;
|
||||
try {
|
||||
const res = await rpc("/fp/workspace/load", { job_id: this.state.jobId });
|
||||
if (res && res.ok) {
|
||||
this.state.data = res;
|
||||
} else if (res && res.error) {
|
||||
this.notification.add(res.error, { type: "danger" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message || String(err), { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Navigation --------------------------------------------------------
|
||||
onBack() {
|
||||
// Close workspace; return to whatever spawned the action
|
||||
this.action.doAction({ type: "ir.actions.act_window_close" });
|
||||
}
|
||||
|
||||
onJumpToBlocker({ model, id }) {
|
||||
// If the predecessor is in this same workspace, just scroll to it
|
||||
const inThisJob = (this.state.data.steps || []).find((s) => s.id === id);
|
||||
if (inThisJob) {
|
||||
const el = document.querySelector(`[data-step-id="${id}"]`);
|
||||
if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
return;
|
||||
}
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: model,
|
||||
res_id: id,
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Step state helpers ------------------------------------------------
|
||||
iconForStepState(state) {
|
||||
const map = {
|
||||
ready: "○", paused: "⏸", in_progress: "▶",
|
||||
done: "✓", skipped: "✕", cancelled: "✕",
|
||||
};
|
||||
return map[state] || "○";
|
||||
}
|
||||
|
||||
isStepActive(step) {
|
||||
return step.state === "in_progress";
|
||||
}
|
||||
|
||||
// ---- Step actions ------------------------------------------------------
|
||||
async onStartStep(stepId) {
|
||||
try {
|
||||
const res = await rpc("/fp/shopfloor/start_wo", { workorder_id: stepId });
|
||||
if (res && res.ok) {
|
||||
this.notification.add("Step started.", { type: "success" });
|
||||
await this.refresh();
|
||||
} else {
|
||||
this.notification.add((res && res.error) || "Start failed", { type: "danger" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message, { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
async onFinishStep(step) {
|
||||
if (step.requires_signoff) {
|
||||
this.dialog.add(FpSignaturePad, {
|
||||
title: `Sign to finish ${step.name}`,
|
||||
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
|
||||
onSubmit: async (dataUri) => {
|
||||
try {
|
||||
const res = await rpc("/fp/workspace/sign_off", {
|
||||
step_id: step.id,
|
||||
signature_data_uri: dataUri,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.notification.add("Step signed off and finished.", { type: "success" });
|
||||
await this.refresh();
|
||||
} else {
|
||||
this.notification.add((res && res.error) || "Sign-off failed", { type: "danger" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message, { type: "danger" });
|
||||
}
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Plain finish — no signature required
|
||||
try {
|
||||
const res = await rpc("/fp/shopfloor/stop_wo", {
|
||||
workorder_id: step.id, finish: true,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.notification.add("Step finished.", { type: "success" });
|
||||
await this.refresh();
|
||||
} else {
|
||||
this.notification.add((res && res.error) || "Finish failed", { type: "danger" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message, { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Action rail handlers ---------------------------------------------
|
||||
onCreateHold() {
|
||||
const job = this.state.data.job;
|
||||
const remaining = Math.max(1, (job.qty || 0) - (job.qty_done || 0));
|
||||
this.dialog.add(FpHoldComposer, {
|
||||
jobId: this.state.jobId,
|
||||
defaultQty: remaining,
|
||||
partRef: job.part_number || "",
|
||||
onCreated: () => this.refresh(),
|
||||
});
|
||||
}
|
||||
|
||||
async onAddNote() {
|
||||
const text = window.prompt("Add a note to this WO:");
|
||||
if (!text) return;
|
||||
try {
|
||||
// ORM call for message_post — keeps chatter behaviour identical
|
||||
// to back-office posts (handles HTML escaping, subscribers, etc.)
|
||||
await rpc("/web/dataset/call_kw", {
|
||||
model: "fp.job",
|
||||
method: "message_post",
|
||||
args: [[this.state.jobId]],
|
||||
kwargs: { body: text, message_type: "comment" },
|
||||
});
|
||||
this.notification.add("Note added.", { type: "success" });
|
||||
await this.refresh();
|
||||
} catch (err) {
|
||||
this.notification.add(err.message, { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
async onAdvanceMilestone() {
|
||||
try {
|
||||
const res = await rpc("/fp/workspace/advance_milestone", {
|
||||
job_id: this.state.jobId,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.notification.add("Milestone advanced.", { type: "success" });
|
||||
await this.refresh();
|
||||
} else {
|
||||
this.notification.add((res && res.error) || "Advance failed", { type: "warning" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message, { type: "danger" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_job_workspace", FpJobWorkspace);
|
||||
@@ -0,0 +1,319 @@
|
||||
// =============================================================================
|
||||
// JobWorkspace — full-screen WO surface
|
||||
// Dark-mode aware via $o-webclient-color-scheme branch.
|
||||
// =============================================================================
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
$_ws-page-hex: #f3f4f6;
|
||||
$_ws-card-hex: #ffffff;
|
||||
$_ws-border-hex: #d8dadd;
|
||||
$_ws-text-hex: #1d1d1f;
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_ws-page-hex: #1a1d21 !global;
|
||||
$_ws-card-hex: #22262d !global;
|
||||
$_ws-border-hex: #424245 !global;
|
||||
$_ws-text-hex: #f5f5f7 !global;
|
||||
}
|
||||
|
||||
.o_fp_ws {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: $_ws-page-hex;
|
||||
color: $_ws-text-hex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.o_fp_ws_loading {
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #666);
|
||||
|
||||
> div { margin-top: 0.6rem; }
|
||||
}
|
||||
|
||||
// ---- HEADER ------------------------------------------------------------
|
||||
.o_fp_ws_head {
|
||||
background: $_ws-card-hex;
|
||||
border-bottom: 1px solid $_ws-border-hex;
|
||||
padding: 0.6rem 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.o_fp_ws_head_l, .o_fp_ws_head_r {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.o_fp_ws_back { padding: 0.25rem 0.5rem; }
|
||||
.o_fp_ws_wo { font-weight: 700; font-size: 1.1rem; }
|
||||
.o_fp_ws_dot { color: var(--text-secondary, #999); }
|
||||
.o_fp_ws_cust, .o_fp_ws_part { color: var(--text-secondary, #555); }
|
||||
|
||||
.o_fp_ws_pill {
|
||||
background: $_ws-page-hex;
|
||||
border: 1px solid $_ws-border-hex;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary, #555);
|
||||
}
|
||||
|
||||
.o_fp_ws_holds_ok {
|
||||
background: rgba(52, 199, 89, 0.12);
|
||||
color: #1d6e2f;
|
||||
border-color: rgba(52, 199, 89, 0.3);
|
||||
}
|
||||
|
||||
.o_fp_ws_holds_red {
|
||||
background: rgba(255, 59, 48, 0.12);
|
||||
color: #b00018;
|
||||
border-color: rgba(255, 59, 48, 0.3);
|
||||
}
|
||||
|
||||
// ---- WORKFLOW BAR ------------------------------------------------------
|
||||
.o_fp_ws_bar {
|
||||
background: $_ws-page-hex;
|
||||
border-bottom: 1px solid $_ws-border-hex;
|
||||
padding: 0.55rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.o_fp_ws_bar_line {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.o_fp_ws_dot_wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 60px;
|
||||
|
||||
.o_fp_ws_bar_dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: $_ws-border-hex;
|
||||
border: 2px solid $_ws-border-hex;
|
||||
}
|
||||
|
||||
.o_fp_ws_bar_label {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-secondary, #888);
|
||||
margin-top: 0.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&.done .o_fp_ws_bar_dot {
|
||||
background: #34c759;
|
||||
border-color: #34c759;
|
||||
}
|
||||
|
||||
&.current .o_fp_ws_bar_dot {
|
||||
background: #0071e3;
|
||||
border-color: #0071e3;
|
||||
box-shadow: 0 0 0 3px rgba(0, 113, 227, 0.25);
|
||||
}
|
||||
|
||||
&.current .o_fp_ws_bar_label {
|
||||
color: #0071e3;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_ws_link {
|
||||
flex: 0 0 8px;
|
||||
height: 2px;
|
||||
background: $_ws-border-hex;
|
||||
margin-top: -16px;
|
||||
|
||||
&.done { background: #34c759; }
|
||||
}
|
||||
|
||||
.o_fp_ws_next { white-space: nowrap; }
|
||||
|
||||
// ---- MAIN (step list + side panel) -------------------------------------
|
||||
.o_fp_ws_main {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1.7fr 1fr;
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: 900px) { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.o_fp_ws_steps {
|
||||
padding: 0.7rem 1rem;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid $_ws-border-hex;
|
||||
}
|
||||
|
||||
.o_fp_ws_side {
|
||||
padding: 0.7rem 1rem;
|
||||
overflow-y: auto;
|
||||
background: $_ws-page-hex;
|
||||
}
|
||||
|
||||
.o_fp_ws_empty {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
color: var(--text-secondary, #999);
|
||||
|
||||
> div { margin-top: 0.5rem; }
|
||||
}
|
||||
|
||||
// ---- STEP ROW ---------------------------------------------------------
|
||||
.o_fp_ws_step {
|
||||
background: $_ws-card-hex;
|
||||
border: 1px solid $_ws-border-hex;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.7rem;
|
||||
margin-bottom: 0.4rem;
|
||||
|
||||
&.done { opacity: 0.7; }
|
||||
&.active {
|
||||
border: 2px solid #0071e3;
|
||||
padding: 0.6rem 0.75rem;
|
||||
box-shadow: 0 0 0 1px #0071e3;
|
||||
}
|
||||
&.blocked {
|
||||
background: rgba(255, 159, 10, 0.06);
|
||||
border-color: #ff9f0a;
|
||||
}
|
||||
&.excluded { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.o_fp_ws_step_l1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.o_fp_ws_step_icon { width: 18px; text-align: center; font-weight: 700; }
|
||||
.o_fp_ws_step_num { color: var(--text-secondary, #999); font-size: 0.78rem; min-width: 50px; }
|
||||
.o_fp_ws_step_name { font-weight: 600; }
|
||||
.o_fp_ws_step_meta { color: var(--text-secondary, #999); font-size: 0.78rem; margin-left: auto; }
|
||||
|
||||
.o_fp_ws_step_badge {
|
||||
background: #0071e3;
|
||||
color: white;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
.o_fp_ws_step_detail {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px dashed $_ws-border-hex;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.o_fp_ws_step_chips { display: flex; gap: 0.3rem; flex-wrap: wrap; }
|
||||
.o_fp_ws_step_instr { font-size: 0.78rem; color: var(--text-secondary, #555); font-style: italic; }
|
||||
.o_fp_ws_step_actions { display: flex; gap: 0.35rem; flex-wrap: wrap; }
|
||||
|
||||
.o_fp_ws_step_excluded {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary, #888);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// ---- Chips (inline mini-chips for recipe targets) ---------------------
|
||||
.o_fp_chip {
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.o_fp_chip_info { background: rgba(0, 113, 227, 0.12); color: #0050a0; }
|
||||
.o_fp_chip_warning { background: rgba(255, 159, 10, 0.15); color: #b06600; }
|
||||
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
.o_fp_chip_info { color: #6cb6ff; }
|
||||
.o_fp_chip_warning { color: #ffb84d; }
|
||||
}
|
||||
|
||||
// ---- SIDE PANEL CARDS -------------------------------------------------
|
||||
.o_fp_ws_side_card {
|
||||
background: $_ws-card-hex;
|
||||
border: 1px solid $_ws-border-hex;
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
margin-bottom: 0.45rem;
|
||||
|
||||
h4 {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-secondary, #777);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_ws_spec_link {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.o_fp_ws_attach {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.2rem 0;
|
||||
font-size: 0.78rem;
|
||||
border-bottom: 1px dashed $_ws-border-hex;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.o_fp_ws_note {
|
||||
font-size: 0.78rem;
|
||||
padding: 0.3rem 0;
|
||||
}
|
||||
|
||||
.o_fp_ws_note_h {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary, #777);
|
||||
}
|
||||
|
||||
.o_fp_ws_note .author { font-weight: 600; }
|
||||
.o_fp_ws_note .body { color: var(--text-secondary, #555); margin-top: 0.15rem; }
|
||||
|
||||
.o_fp_ws_empty_small {
|
||||
color: var(--text-secondary, #999);
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// ---- ACTION RAIL ------------------------------------------------------
|
||||
.o_fp_ws_rail {
|
||||
background: $_ws-card-hex;
|
||||
border-top: 1px solid $_ws-border-hex;
|
||||
padding: 0.55rem 1rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.JobWorkspace">
|
||||
<div class="o_fp_ws">
|
||||
|
||||
<!-- Loading state -->
|
||||
<div t-if="!state.data" class="o_fp_ws_loading">
|
||||
<i class="fa fa-spinner fa-spin fa-2x"/>
|
||||
<div>Loading Job Workspace…</div>
|
||||
</div>
|
||||
|
||||
<t t-if="state.data">
|
||||
|
||||
<!-- =========================================================
|
||||
STICKY HEADER — WO context, qty bumps, workflow chip
|
||||
========================================================= -->
|
||||
<header class="o_fp_ws_head">
|
||||
<div class="o_fp_ws_head_l">
|
||||
<button class="btn btn-link o_fp_ws_back" t-on-click="onBack">
|
||||
<i class="fa fa-arrow-left"/> Back
|
||||
</button>
|
||||
<span class="o_fp_ws_wo"><t t-esc="state.data.job.display_wo_name"/></span>
|
||||
<span class="o_fp_ws_dot"> · </span>
|
||||
<span class="o_fp_ws_cust"><t t-esc="state.data.job.partner_name"/></span>
|
||||
<t t-if="state.data.job.part_number">
|
||||
<span class="o_fp_ws_dot"> · </span>
|
||||
<span class="o_fp_ws_part"><t t-esc="state.data.job.part_number"/></span>
|
||||
</t>
|
||||
</div>
|
||||
<div class="o_fp_ws_head_r">
|
||||
<span class="o_fp_ws_pill">
|
||||
<t t-esc="state.data.job.qty_done"/> / <t t-esc="state.data.job.qty"/> done
|
||||
<t t-if="state.data.job.qty_scrapped">· <t t-esc="state.data.job.qty_scrapped"/> scrap</t>
|
||||
</span>
|
||||
<span class="o_fp_ws_pill" t-if="state.data.job.date_deadline">
|
||||
Due <t t-esc="state.data.job.date_deadline"/>
|
||||
</span>
|
||||
<WorkflowChip t-if="state.data.job.workflow_state"
|
||||
state="state.data.job.workflow_state"/>
|
||||
<span t-att-class="'o_fp_ws_pill ' + (state.data.job.quality_hold_count ? 'o_fp_ws_holds_red' : 'o_fp_ws_holds_ok')">
|
||||
<t t-esc="state.data.job.quality_hold_count"/> holds
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- =========================================================
|
||||
STICKY WORKFLOW BAR — milestone dots + Next button
|
||||
========================================================= -->
|
||||
<div class="o_fp_ws_bar">
|
||||
<div class="o_fp_ws_bar_line">
|
||||
<t t-foreach="state.data.workflow_states" t-as="ws" t-key="ws.id">
|
||||
<div t-att-class="'o_fp_ws_dot_wrap' + (ws.is_current ? ' current' : (ws.passed ? ' done' : ''))">
|
||||
<span class="o_fp_ws_bar_dot"/>
|
||||
<span class="o_fp_ws_bar_label" t-esc="ws.name"/>
|
||||
</div>
|
||||
<span t-if="!ws_last" t-att-class="'o_fp_ws_link' + (ws.passed ? ' done' : '')"/>
|
||||
</t>
|
||||
</div>
|
||||
<button t-if="state.data.job.next_milestone_action"
|
||||
class="btn btn-primary o_fp_ws_next"
|
||||
t-on-click="onAdvanceMilestone">
|
||||
Next: <t t-esc="state.data.job.next_milestone_label"/> <i class="fa fa-arrow-right"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- =========================================================
|
||||
MAIN — step list (left/center) + side panel (right)
|
||||
========================================================= -->
|
||||
<div class="o_fp_ws_main">
|
||||
|
||||
<!-- STEP LIST -->
|
||||
<div class="o_fp_ws_steps">
|
||||
<div t-if="!state.data.steps.length" class="o_fp_ws_empty">
|
||||
<i class="fa fa-exclamation-circle fa-2x"/>
|
||||
<div>Recipe not generated for this WO.</div>
|
||||
</div>
|
||||
|
||||
<t t-foreach="state.data.steps" t-as="step" t-key="step.id">
|
||||
<div t-att-class="'o_fp_ws_step ' + step.state +
|
||||
(isStepActive(step) ? ' active' : '') +
|
||||
(step.override_excluded ? ' excluded' : '') +
|
||||
(step.blocker_kind !== 'none' ? ' blocked' : '')"
|
||||
t-att-data-step-id="step.id">
|
||||
|
||||
<div class="o_fp_ws_step_l1">
|
||||
<span class="o_fp_ws_step_icon" t-esc="iconForStepState(step.state)"/>
|
||||
<span class="o_fp_ws_step_num">Step <t t-esc="step.sequence_display"/></span>
|
||||
<span class="o_fp_ws_step_name" t-esc="step.name"/>
|
||||
<span t-if="isStepActive(step)" class="o_fp_ws_step_badge">ACTIVE</span>
|
||||
<span class="o_fp_ws_step_meta">
|
||||
<t t-if="step.assigned_user_name"><t t-esc="step.assigned_user_name"/></t>
|
||||
<t t-if="step.duration_actual"> · <t t-esc="Math.round(step.duration_actual)"/> min</t>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<t t-if="isStepActive(step) or step.blocker_kind !== 'none' or step.override_excluded">
|
||||
<div class="o_fp_ws_step_detail">
|
||||
<!-- Recipe chips (only on active step) -->
|
||||
<div class="o_fp_ws_step_chips" t-if="isStepActive(step)">
|
||||
<span t-if="step.thickness_target" class="o_fp_chip o_fp_chip_info">
|
||||
🎯 Thickness <t t-esc="step.thickness_target"/> <t t-esc="step.thickness_uom or 'mils'"/>
|
||||
</span>
|
||||
<span t-if="step.dwell_time_minutes" class="o_fp_chip o_fp_chip_info">
|
||||
⏱ Dwell <t t-esc="step.dwell_time_minutes"/> min
|
||||
</span>
|
||||
<span t-if="step.bake_setpoint_temp" class="o_fp_chip o_fp_chip_warning">
|
||||
🔥 Bake <t t-esc="step.bake_setpoint_temp"/>°
|
||||
</span>
|
||||
<span t-if="step.requires_signoff" class="o_fp_chip o_fp_chip_warning">
|
||||
✎ Sign-off required
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Recipe author instructions (only on active step) -->
|
||||
<div t-if="step.instructions and isStepActive(step)"
|
||||
class="o_fp_ws_step_instr">
|
||||
<t t-esc="step.instructions"/>
|
||||
</div>
|
||||
|
||||
<!-- Opt-out notice -->
|
||||
<div t-if="step.override_excluded" class="o_fp_ws_step_excluded">
|
||||
<i class="fa fa-ban"/> Skipped per recipe override for this WO
|
||||
</div>
|
||||
|
||||
<!-- Blocker explainer -->
|
||||
<GateViz t-if="step.blocker_kind !== 'none'"
|
||||
canStart="false"
|
||||
blockerKind="step.blocker_kind"
|
||||
blockerReason="step.blocker_reason"
|
||||
jumpTargetModel="step.blocker_jump_target_model"
|
||||
jumpTargetId="step.blocker_jump_target_id"
|
||||
onJump.bind="onJumpToBlocker"/>
|
||||
|
||||
<!-- Action buttons (only when unblocked) -->
|
||||
<div class="o_fp_ws_step_actions"
|
||||
t-if="isStepActive(step) and step.blocker_kind === 'none'">
|
||||
<button t-if="step.requires_signoff"
|
||||
class="btn btn-success"
|
||||
t-on-click="() => this.onFinishStep(step)">
|
||||
<i class="fa fa-check"/> Finish & Sign Off
|
||||
</button>
|
||||
<button t-else=""
|
||||
class="btn btn-success"
|
||||
t-on-click="() => this.onFinishStep(step)">
|
||||
<i class="fa fa-check"/> Finish
|
||||
</button>
|
||||
</div>
|
||||
<div class="o_fp_ws_step_actions"
|
||||
t-if="step.can_start and !isStepActive(step) and step.blocker_kind === 'none'">
|
||||
<button class="btn btn-primary"
|
||||
t-on-click="() => this.onStartStep(step.id)">
|
||||
<i class="fa fa-play"/> Start
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- SIDE PANEL: spec + attachments + chatter -->
|
||||
<div class="o_fp_ws_side">
|
||||
|
||||
<div class="o_fp_ws_side_card" t-if="state.data.spec">
|
||||
<h4>Customer spec</h4>
|
||||
<div class="o_fp_ws_spec_link">
|
||||
<i class="fa fa-file-pdf-o"/>
|
||||
<a t-att-href="'/web/content/' + state.data.spec.id"
|
||||
target="_blank"><t t-esc="state.data.spec.name"/></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_ws_side_card" t-if="state.data.attachments.length">
|
||||
<h4>Drawings & attachments</h4>
|
||||
<t t-foreach="state.data.attachments" t-as="att" t-key="att.id">
|
||||
<div class="o_fp_ws_attach">
|
||||
<span>📄 <t t-esc="att.name"/></span>
|
||||
<a t-att-href="att.url" target="_blank">Open</a>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<div class="o_fp_ws_side_card">
|
||||
<h4>Notes</h4>
|
||||
<div t-if="!state.data.chatter.length" class="o_fp_ws_empty_small">
|
||||
No notes yet.
|
||||
</div>
|
||||
<t t-foreach="state.data.chatter" t-as="msg" t-key="msg.id">
|
||||
<div class="o_fp_ws_note">
|
||||
<div class="o_fp_ws_note_h">
|
||||
<span class="author"><t t-esc="msg.author"/></span>
|
||||
<span class="time"><t t-esc="msg.date"/></span>
|
||||
</div>
|
||||
<div class="body" t-out="msg.body"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- =========================================================
|
||||
STICKY ACTION RAIL — Hold · Note · Cert · Milestone
|
||||
========================================================= -->
|
||||
<footer class="o_fp_ws_rail">
|
||||
<button class="btn btn-warning" t-on-click="onCreateHold">
|
||||
<i class="fa fa-exclamation-triangle"/> Create Hold
|
||||
</button>
|
||||
<button class="btn btn-light" t-on-click="onAddNote">
|
||||
<i class="fa fa-pencil"/> Note
|
||||
</button>
|
||||
<span style="flex: 1"/>
|
||||
<button t-if="state.data.required_certs.has_draft"
|
||||
class="btn btn-light"
|
||||
t-on-click="onAdvanceMilestone">
|
||||
<i class="fa fa-file-text"/> Issue Cert
|
||||
</button>
|
||||
<button t-if="state.data.job.next_milestone_action"
|
||||
class="btn btn-primary"
|
||||
t-on-click="onAdvanceMilestone">
|
||||
<i class="fa fa-arrow-right"/> <t t-esc="state.data.job.next_milestone_label"/>
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
Reference in New Issue
Block a user