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:
gsinghpal
2026-05-22 21:52:26 -04:00
parent eae6a471e8
commit a18ef6c405
4 changed files with 765 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@@ -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 &amp; 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 &amp; 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>