This commit is contained in:
gsinghpal
2026-04-27 08:16:20 -04:00
parent f08f328688
commit 2a4909be25
12 changed files with 788 additions and 20 deletions

View File

@@ -267,6 +267,22 @@ export class ManagerDashboard extends Component {
priorityTone(p) {
return ({'0': 'muted', '1': 'warning', '2': 'danger'})[p] || 'muted';
}
// Open a list view of any model with an optional domain — used by
// the new compliance KPI tiles (Missed Bakes / Open Holds / Stale
// Steps / Locked / Pending QC / Draft Certs) so the manager can
// drill in with one tap. v19.0.24.3.0.
openModelList(model, domain) {
if (!model) return;
this.action.doAction({
type: "ir.actions.act_window",
res_model: model,
view_mode: "list,form",
views: [[false, "list"], [false, "form"]],
domain: domain || [],
target: "current",
});
}
}
registry.category("actions").add("fp_manager_dashboard", ManagerDashboard);

View File

@@ -39,6 +39,10 @@ export class ShopfloorTablet extends Component {
messageType: "info",
loading: false,
showScan: false,
// Live-elapsed timer on active step. Re-rendered every 1s
// by a separate interval so the operator sees seconds tick
// up without waiting for the 30s payload refresh.
activeElapsed: "00:00:00",
});
onMounted(async () => {
@@ -46,13 +50,39 @@ export class ShopfloorTablet extends Component {
if (saved) this.state.stationId = saved;
await this.refresh();
this._interval = setInterval(() => this.refresh(), 30000);
this._tickInterval = setInterval(() => this._tickElapsed(), 1000);
});
onWillUnmount(() => {
if (this._interval) clearInterval(this._interval);
if (this._tickInterval) clearInterval(this._tickInterval);
});
}
// ---------------------------------------------------------- Live timer
// Compute elapsed = (now - date_started_iso) for the active step. Runs
// every second so the operator sees a real clock, not stale seconds.
_tickElapsed() {
const a = this.state.overview && this.state.overview.active_wo;
if (!a || !a.date_started_iso) {
this.state.activeElapsed = "—";
return;
}
// Odoo gives "YYYY-MM-DD HH:MM:SS" in UTC; turn into ISO Z.
const isoUtc = a.date_started_iso.replace(" ", "T") + "Z";
const startMs = Date.parse(isoUtc);
if (isNaN(startMs)) {
this.state.activeElapsed = "—";
return;
}
let s = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
const hh = String(Math.floor(s / 3600)).padStart(2, "0");
s %= 3600;
const mm = String(Math.floor(s / 60)).padStart(2, "0");
const ss = String(s % 60).padStart(2, "0");
this.state.activeElapsed = `${hh}:${mm}:${ss}`;
}
// ---------------------------------------------------------- Helpers
setMessage(text, type = "info") {
this.state.message = text;
@@ -223,6 +253,68 @@ export class ShopfloorTablet extends Component {
await this.refresh();
}
// ---------------------------------------------------------- Qty / scrap
// Bump qty_done from the tablet — operator confirms +1 part finished.
// Also bump scrap with a confirm prompt — auto-spawns a Hold via S17.
async onBumpQtyDone(jobId) {
if (!jobId) return;
try {
const res = await rpc("/fp/shopfloor/bump_qty_done", {
job_id: jobId,
delta: 1,
});
if (res && res.ok) {
this.setMessage(`Qty done: ${res.qty_done} / ${res.qty_total}`, "success");
} else if (res && res.error) {
this.setMessage(res.error, "danger");
}
} catch (err) {
this.setMessage(`Bump failed: ${err.message || err}`, "danger");
}
await this.refresh();
}
async onBumpScrap(jobId) {
if (!jobId) return;
// Block-and-prompt — scrap is a quality event, force the operator
// to acknowledge and ideally type a brief reason.
const reason = window.prompt(
"Reason for scrap (e.g. 'dropped during de-rack', 'flash burn'):",
"",
);
if (reason === null) return; // operator hit Cancel
try {
const res = await rpc("/fp/shopfloor/bump_qty_scrapped", {
job_id: jobId,
delta: 1,
reason: reason || null,
});
if (res && res.ok) {
this.setMessage(
`Scrap recorded: ${res.qty_scrapped}. Hold auto-created.`,
"warning",
);
} else if (res && res.error) {
this.setMessage(res.error, "danger");
}
} catch (err) {
this.setMessage(`Scrap failed: ${err.message || err}`, "danger");
}
await this.refresh();
}
// Open a pending QC directly from the banner — same deep-link the
// FP-QC scan path uses.
onOpenPendingQc(qcId) {
if (!qcId) return;
this.action.doAction({
type: "ir.actions.client",
tag: "fp_qc_checklist",
params: { check_id: qcId },
target: "current",
});
}
// ---------------------------------------------------------- Utility
stateBadge(state) {
const map = {

View File

@@ -699,4 +699,100 @@
color: $fp-ink-faint;
font-size: $fp-text-xs;
}
// -------------------------------------------------------------------------
// S13/S14/S17/S19 — recipe chips, predecessor lock, live clock, scrap bar
// -------------------------------------------------------------------------
.o_fp_active_wo_left {
display: flex; gap: $fp-space-3; align-items: flex-start;
flex: 1; min-width: 0;
}
.o_fp_active_wo_body { flex: 1; min-width: 0; }
.o_fp_active_wo_right {
display: flex; flex-direction: column; gap: $fp-space-2; align-items: flex-end;
}
.o_fp_active_wo_clock {
display: inline-flex; gap: $fp-space-2; align-items: baseline;
background: $fp-card; padding: $fp-space-2 $fp-space-4;
border-radius: $fp-radius-md;
font-family: ui-monospace, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: $fp-weight-semibold;
font-size: $fp-text-lg;
small { color: $fp-ink-faint; font-size: $fp-text-xs; }
i { color: $fp-ink-faint; }
&.o_fp_active_wo_clock_overrun {
background: rgba(220, 53, 69, 0.12);
color: var(--bs-danger, #c52131);
}
}
.o_fp_active_wo_actions {
display: flex; gap: $fp-space-2; flex-wrap: wrap;
.btn {
min-height: $fp-touch-min;
padding: 0 $fp-space-3;
font-size: $fp-text-sm;
font-weight: $fp-weight-semibold;
}
}
.o_fp_active_wo_chips,
.o_fp_queue_chips {
display: flex; flex-wrap: wrap; gap: $fp-space-2;
margin-top: $fp-space-2;
}
.o_fp_active_wo_instructions,
.o_fp_queue_instructions {
margin-top: $fp-space-2;
padding: $fp-space-2 $fp-space-3;
background: rgba(13, 110, 253, 0.08);
border-radius: $fp-radius-sm;
color: $fp-ink-soft;
font-size: $fp-text-sm;
line-height: 1.4;
}
.o_fp_chip {
display: inline-flex; align-items: center; gap: $fp-space-1;
padding: 2px $fp-space-2;
font-size: $fp-text-xs;
font-weight: $fp-weight-semibold;
border-radius: $fp-radius-pill;
background: $fp-card-soft;
color: $fp-ink-soft;
i { font-size: 10px; }
&.o_fp_chip_info {
background: rgba(13, 110, 253, 0.15);
color: var(--bs-primary, #0d6efd);
}
&.o_fp_chip_warning {
background: rgba(255, 193, 7, 0.20);
color: #856404;
}
&.o_fp_chip_danger {
background: rgba(220, 53, 69, 0.15);
color: var(--bs-danger, #c52131);
}
&.o_fp_chip_success {
background: rgba(25, 135, 84, 0.15);
color: var(--bs-success, #198754);
}
&.o_fp_chip_muted { background: $fp-card-soft; color: $fp-ink-faint; }
}
.o_fp_queue_step_pos {
margin-left: $fp-space-2;
color: $fp-ink-faint;
font-size: $fp-text-xs;
font-weight: 400;
}
.o_fp_queue_blocked_msg {
margin-top: $fp-space-2;
padding: $fp-space-2 $fp-space-3;
background: rgba(255, 193, 7, 0.18);
border-radius: $fp-radius-sm;
color: #856404;
font-size: $fp-text-sm;
i { margin-right: $fp-space-1; }
}
.o_fp_queue_row_blocked {
opacity: 0.65;
.o_fp_queue_pri { color: var(--bs-warning, #f59e0b); }
}
}

View File

@@ -90,6 +90,69 @@
<div class="o_fp_kpi_value"><t t-esc="state.overview.kpis.pending_accept_sos"/></div>
<div class="o_fp_kpi_label">Awaiting Assignment</div>
</div>
<!-- v19.0.24.3.0 — compliance + floor-health KPIs. -->
<!-- Hidden when 0 to keep the strip clean; light up -->
<!-- when something needs attention. Click to drill. -->
<div class="o_fp_kpi o_fp_kpi_danger"
t-if="state.overview.kpis.missed_bakes"
t-on-click="() => this.openModelList('fusion.plating.bake.window', [['state','=','missed_window']])">
<i class="fa fa-fire"/>
<div class="o_fp_kpi_value"><t t-esc="state.overview.kpis.missed_bakes"/></div>
<div class="o_fp_kpi_label">Missed Bakes</div>
</div>
<div class="o_fp_kpi o_fp_kpi_danger"
t-if="state.overview.kpis.open_holds"
t-on-click="() => this.openModelList('fusion.plating.quality.hold', [['state','in',['on_hold','under_review']]])">
<i class="fa fa-pause-circle"/>
<div class="o_fp_kpi_value"><t t-esc="state.overview.kpis.open_holds"/></div>
<div class="o_fp_kpi_label">Open Holds</div>
</div>
<div class="o_fp_kpi o_fp_kpi_warning"
t-if="state.overview.kpis.stale_paused_steps or state.overview.kpis.stale_inprogress_steps"
t-on-click="() => this.openModelList('fp.job.step', [['state','in',['paused','in_progress']]])">
<i class="fa fa-hourglass-end"/>
<div class="o_fp_kpi_value">
<t t-esc="state.overview.kpis.stale_paused_steps + state.overview.kpis.stale_inprogress_steps"/>
</div>
<div class="o_fp_kpi_label">Stale Steps</div>
</div>
<div class="o_fp_kpi o_fp_kpi_warning"
t-if="state.overview.kpis.predecessor_locked_steps"
t-on-click="() => this.openModelList('fp.job.step', [['requires_predecessor_done','=',true],['state','in',['ready','paused']]])">
<i class="fa fa-lock"/>
<div class="o_fp_kpi_value"><t t-esc="state.overview.kpis.predecessor_locked_steps"/></div>
<div class="o_fp_kpi_label">Locked Steps</div>
</div>
<div class="o_fp_kpi o_fp_kpi_info"
t-if="state.overview.kpis.pending_qcs"
t-on-click="() => this.openModelList('fusion.plating.quality.check', [['state','in',['draft','in_progress']]])">
<i class="fa fa-clipboard-list"/>
<div class="o_fp_kpi_value"><t t-esc="state.overview.kpis.pending_qcs"/></div>
<div class="o_fp_kpi_label">
Pending QC
<t t-if="state.overview.kpis.qcs_missing_fischerscope">
<span class="text-danger">
(<t t-esc="state.overview.kpis.qcs_missing_fischerscope"/> need PDF)
</span>
</t>
</div>
</div>
<div class="o_fp_kpi o_fp_kpi_info"
t-if="state.overview.kpis.draft_certs or state.overview.kpis.issued_certs_today"
t-on-click="() => this.openModelList('fp.certificate', [['state','in',['draft','issued']]])">
<i class="fa fa-certificate"/>
<div class="o_fp_kpi_value">
<t t-esc="state.overview.kpis.draft_certs"/>
</div>
<div class="o_fp_kpi_label">
Draft Certs
<t t-if="state.overview.kpis.issued_certs_today">
<span class="text-success">
(<t t-esc="state.overview.kpis.issued_certs_today"/> today)
</span>
</t>
</div>
</div>
</div>
<!-- ============ Workload grid ============ -->

View File

@@ -85,7 +85,7 @@
t-if="state.overview and state.overview.active_wo">
<div class="o_fp_active_wo_left">
<span class="o_fp_active_wo_pulse"/>
<div>
<div class="o_fp_active_wo_body">
<div class="o_fp_active_wo_title">
Active: <strong t-esc="state.overview.active_wo.name"/>
</div>
@@ -93,14 +93,78 @@
Job <t t-esc="state.overview.active_wo.mo_name"/>
· <t t-esc="state.overview.active_wo.product_name"/>
· Qty <t t-esc="state.overview.active_wo.qty_done"/>/<t t-esc="state.overview.active_wo.qty_total"/>
<t t-if="state.overview.active_wo.qty_scrapped">
<span class="text-danger ms-1">
(scrap <t t-esc="state.overview.active_wo.qty_scrapped"/>)
</span>
</t>
<t t-if="state.overview.active_wo.workcenter"> @ <t t-esc="state.overview.active_wo.workcenter"/></t>
</div>
<!-- Recipe-author chips so operator sees the
targets without leaving the active card. -->
<div class="o_fp_active_wo_chips">
<span class="o_fp_chip o_fp_chip_info"
t-if="state.overview.active_wo.thickness_target">
<i class="fa fa-bullseye"/>
Thickness <t t-esc="state.overview.active_wo.thickness_target"/>
<t t-esc="state.overview.active_wo.thickness_uom or 'mils'"/>
</span>
<span class="o_fp_chip o_fp_chip_info"
t-if="state.overview.active_wo.dwell_time_minutes">
<i class="fa fa-clock-o"/>
Dwell <t t-esc="state.overview.active_wo.dwell_time_minutes"/> min
</span>
<span class="o_fp_chip o_fp_chip_warning"
t-if="state.overview.active_wo.bake_setpoint_temp">
<i class="fa fa-fire"/>
Bake <t t-esc="state.overview.active_wo.bake_setpoint_temp"/>°
</span>
<span class="o_fp_chip o_fp_chip_info"
t-if="state.overview.active_wo.requires_signoff">
<i class="fa fa-pencil-square-o"/>
Sign-off required
</span>
</div>
<!-- Recipe-author instructions (from S13). Inline
collapsible so operator never has to scan to
remind themselves of the procedure. -->
<div class="o_fp_active_wo_instructions"
t-if="state.overview.active_wo.instructions">
<i class="fa fa-info-circle me-1"/>
<t t-esc="state.overview.active_wo.instructions"/>
</div>
</div>
</div>
<div class="o_fp_active_wo_right">
<!-- Live ticking elapsed time. _tickElapsed() updates
this every 1s; far better than a stale duration. -->
<div class="o_fp_active_wo_clock"
t-att-class="{ 'o_fp_active_wo_clock_overrun': state.overview.active_wo.duration_expected
and state.overview.active_wo.duration > state.overview.active_wo.duration_expected * 1.5 }">
<i class="fa fa-stopwatch"/>
<span class="o_fp_active_wo_clock_v"
t-esc="state.activeElapsed"/>
<small t-if="state.overview.active_wo.duration_expected">
of <t t-esc="Math.round(state.overview.active_wo.duration_expected/60)"/>m planned
</small>
</div>
<div class="o_fp_active_wo_actions">
<button class="btn btn-success"
t-on-click="() => this.onBumpQtyDone(this.state.overview.active_wo.mo_id)"
title="Increment qty_done by 1">
<i class="fa fa-plus"/> +1 Done
</button>
<button class="btn btn-outline-danger"
t-on-click="() => this.onBumpScrap(this.state.overview.active_wo.mo_id)"
title="Record one scrap part — auto-creates a Hold">
<i class="fa fa-trash"/> Scrap
</button>
<button class="o_fp_big_button"
t-on-click="() => openRecord('fp.job.step', state.overview.active_wo.id)">
Open Step
</button>
</div>
</div>
<button class="o_fp_big_button"
t-on-click="() => openRecord('fp.job.step', state.overview.active_wo.id)">
Open Step
</button>
</div>
<!-- ============ Dashboard ============ -->
@@ -118,16 +182,61 @@
</div>
<ul class="o_fp_queue_list" t-if="state.overview.my_queue.length">
<t t-foreach="state.overview.my_queue" t-as="row" t-key="row.id">
<li class="o_fp_queue_row">
<li class="o_fp_queue_row"
t-att-class="{ 'o_fp_queue_row_blocked': row.predecessor_blocked }">
<div class="o_fp_queue_pri" t-att-data-priority="row.priority >= 90 ? 'high' : (row.priority >= 70 ? 'med' : 'low')">
<t t-if="row.priority >= 90">HI</t>
<t t-if="row.predecessor_blocked">
<i class="fa fa-lock"/>
</t>
<t t-elif="row.priority >= 90">HI</t>
<t t-elif="row.priority >= 70">M</t>
<t t-else="">·</t>
</div>
<div class="o_fp_queue_body"
t-on-click="() => this.onQueueItemClick(row)">
<div class="o_fp_queue_label"><t t-esc="row.label"/></div>
<div class="o_fp_queue_label">
<t t-esc="row.label"/>
<t t-if="row.step_sequence and row.job_step_count">
<span class="o_fp_queue_step_pos">
· step <t t-esc="row.step_sequence/10"/>/<t t-esc="row.job_step_count"/>
</span>
</t>
</div>
<div class="o_fp_queue_desc"><t t-esc="row.description"/></div>
<!-- S14 — predecessor block notice. Replaces -->
<!-- the green Start with a clear "wait for X". -->
<div class="o_fp_queue_blocked_msg"
t-if="row.predecessor_blocked">
<i class="fa fa-lock"/>
Awaiting <strong t-esc="row.blocked_by_name"/>
— finish that step first
</div>
<!-- S13 — recipe-author chips inline -->
<div class="o_fp_queue_chips"
t-if="row.thickness_target or row.dwell_time_minutes or row.bake_setpoint_temp or row.requires_signoff">
<span class="o_fp_chip o_fp_chip_info"
t-if="row.thickness_target">
Target <t t-esc="row.thickness_target"/>
<t t-esc="row.thickness_uom or 'mils'"/>
</span>
<span class="o_fp_chip o_fp_chip_info"
t-if="row.dwell_time_minutes">
<t t-esc="row.dwell_time_minutes"/>m dwell
</span>
<span class="o_fp_chip o_fp_chip_warning"
t-if="row.bake_setpoint_temp">
Bake <t t-esc="row.bake_setpoint_temp"/>°
</span>
<span class="o_fp_chip o_fp_chip_info"
t-if="row.requires_signoff">
Sign-off
</span>
</div>
<!-- S13 — instructions snippet (first 120 chars) -->
<div class="o_fp_queue_instructions"
t-if="row.instructions">
<t t-esc="row.instructions.length > 120 ? row.instructions.slice(0,120) + '…' : row.instructions"/>
</div>
</div>
<div class="o_fp_queue_actions">
<button t-if="row.can_start"
@@ -135,6 +244,12 @@
t-on-click="() => this.onStartWo(row.source_id)">
<i class="fa fa-play"/> Start
</button>
<button t-if="row.predecessor_blocked"
class="btn btn-light"
disabled="disabled"
title="Finish the earlier step first.">
<i class="fa fa-lock"/> Locked
</button>
<button t-if="row.can_finish"
class="btn btn-primary"
t-on-click="() => this.onFinishWo(row.source_id)">
@@ -275,6 +390,52 @@
</ul>
</section>
<!-- ===== Pending QC banner (S19 follow-up) ===== -->
<!-- Shows whenever Carlos's job has an open QC. Tap -->
<!-- the QC name to deep-link straight into Lisa's -->
<!-- mobile checklist. Without this Carlos doesn't -->
<!-- know to call inspection — QC sits in draft. -->
<section class="o_fp_panel"
t-if="state.overview.pending_qcs and state.overview.pending_qcs.length">
<div class="o_fp_panel_head">
<h3><i class="fa fa-clipboard-check text-warning"/>Pending QC</h3>
<span class="o_fp_panel_count">
<t t-esc="state.overview.pending_qcs.length"/>
</span>
</div>
<ul class="o_fp_bake_list">
<t t-foreach="state.overview.pending_qcs" t-as="qc" t-key="qc.id">
<li class="o_fp_bake_row" t-att-data-state="qc.state">
<div class="o_fp_bake_main">
<div class="o_fp_bake_name">
<t t-esc="qc.name"/>
<span class="text-muted ms-1">
<t t-esc="qc.template_name"/>
</span>
</div>
<div class="o_fp_bake_meta">
Job <t t-esc="qc.job_name"/>
· <t t-esc="qc.partner_name"/>
· <t t-esc="qc.lines_pending"/>/<t t-esc="qc.line_count"/> pending
<t t-if="qc.require_thickness_report_pdf">
<span t-att-class="qc.has_thickness_pdf ? 'text-success ms-1' : 'text-danger ms-1'">
<i t-att-class="qc.has_thickness_pdf ? 'fa fa-check-circle' : 'fa fa-exclamation-circle'"/>
Fischerscope PDF
</span>
</t>
</div>
</div>
<div class="o_fp_bake_actions">
<button class="btn btn-warning"
t-on-click="() => this.onOpenPendingQc(qc.id)">
<i class="fa fa-clipboard-list"/> Open QC
</button>
</div>
</li>
</t>
</ul>
</section>
<section class="o_fp_panel" t-if="state.overview.holds.length">
<div class="o_fp_panel_head">
<h3><i class="fa fa-pause-circle text-danger"/>Quality Holds</h3>