feat(workspace): pre-recipe receiving card with box count + damage log

Adds the receiver workflow to the Job Workspace tablet view (was the
gap behind WO-30057 sitting in Receiving with no way to advance).
Receivers no longer need to go to the backend form.

Workspace card (renders above the step list when fp.receiving in
state draft/counted on the linked SO):
- Draft state: numeric box-count input + per-line received_qty /
  condition picker (good/damaged/mixed) + Damage Log panel + Mark
  Counted button. Autosaves on input blur.
- Counted state: read-only summary (boxes, parts, who/when) +
  Damage Log still editable + Close Receiving button.
- Closed: card disappears, recipe takes over.

New FpDamageDialog OWL modal:
- Severity pill picker (Cosmetic / Functional / Rejected) with
  color-coded active state
- Required description textarea
- Action Required pill picker (None / Notify / Return / As-Is)
- Photo capture: both "Take Photo" (input capture="environment"
  triggers tablet camera) AND "Upload" (file picker fallback).
  Multi-photo with preview grid + per-photo remove.

5 new endpoints on workspace_controller.py:
- receiving_save_lines (autosave box_count_in + per-line qty/cond)
- receiving_mark_counted (wraps action_mark_counted)
- receiving_close (wraps action_close)
- damage_create (creates fp.receiving.damage + attaches base64 photos)
- damage_delete (removes a damage row)

No model changes — wraps existing fp.receiving actions and damage
CRUD. C3 (outbound shipping carrier/label) is a separate spec.

Spec: in-conversation brainstorm (C1+C2) following the 2026-05-24
workspace step actions spec; no standalone doc since scope is small.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-24 19:08:30 -04:00
parent 170398ab6f
commit eed1c4619d
7 changed files with 1004 additions and 2 deletions

View File

@@ -0,0 +1,142 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — FpDamageDialog
//
// Tablet-friendly modal for logging damage during receiving. Captures:
// - Severity (Cosmetic / Functional / Rejected) — pill picker
// - Description (required textarea)
// - Action Required (None / Notify / Return / Proceed) — pill picker
// - Photos — both camera capture (capture="environment") AND file picker
//
// Wired from FpJobWorkspace via onAddDamage. POSTs to
// /fp/workspace/damage_create on Save; caller refreshes after onCreated().
//
// Spec C1+C2 2026-05-24 (receiving tablet UI).
// =============================================================================
import { Component, useState, useRef } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
import { useService } from "@web/core/utils/hooks";
import { rpc } from "@web/core/network/rpc";
const SEVERITIES = [
{ code: "cosmetic", label: "Cosmetic", cssClass: "o_fp_dmg_sev_cosmetic" },
{ code: "functional", label: "Functional", cssClass: "o_fp_dmg_sev_functional" },
{ code: "rejected", label: "Rejected", cssClass: "o_fp_dmg_sev_rejected" },
];
const ACTIONS = [
{ code: "none", label: "None" },
{ code: "notify_customer", label: "Notify Customer" },
{ code: "return_parts", label: "Return Parts" },
{ code: "proceed_as_is", label: "Proceed As-Is" },
];
export class FpDamageDialog extends Component {
static template = "fusion_plating_shopfloor.FpDamageDialog";
static components = { Dialog };
static props = {
close: Function,
receivingId: Number,
onCreated: { type: Function, optional: true },
};
setup() {
this.notification = useService("notification");
this.fileInputRef = useRef("fileInput");
this.cameraInputRef = useRef("cameraInput");
this.state = useState({
severity: "cosmetic",
description: "",
actionRequired: "none",
photos: [], // [{filename, data_base64, preview_url}]
saving: false,
});
this.severities = SEVERITIES;
this.actions = ACTIONS;
}
setSeverity(code) { this.state.severity = code; }
setAction(code) { this.state.actionRequired = code; }
triggerCamera() { this.cameraInputRef.el?.click(); }
triggerFile() { this.fileInputRef.el?.click(); }
async onFilesPicked(ev) {
const files = Array.from(ev.target.files || []);
for (const file of files) {
try {
const base64 = await this._readAsBase64(file);
this.state.photos.push({
filename: file.name || `damage_${Date.now()}.jpg`,
data_base64: base64,
preview_url: URL.createObjectURL(file),
});
} catch (err) {
this.notification.add(
`Couldn't read ${file.name}: ${err.message}`,
{ type: "danger" },
);
}
}
ev.target.value = ""; // reset so picking the same file twice re-fires
}
_readAsBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
// strip the "data:image/jpeg;base64," prefix — backend wants raw base64
const dataUrl = reader.result || "";
const idx = String(dataUrl).indexOf(",");
resolve(idx >= 0 ? dataUrl.slice(idx + 1) : dataUrl);
};
reader.onerror = () => reject(reader.error || new Error("read failed"));
reader.readAsDataURL(file);
});
}
removePhoto(index) {
const p = this.state.photos[index];
if (p && p.preview_url) {
try { URL.revokeObjectURL(p.preview_url); } catch { /* noop */ }
}
this.state.photos.splice(index, 1);
}
async onSave() {
if (!this.state.description.trim()) {
this.notification.add("Description is required.", { type: "warning" });
return;
}
this.state.saving = true;
try {
const res = await rpc("/fp/workspace/damage_create", {
receiving_id: this.props.receivingId,
description: this.state.description.trim(),
severity: this.state.severity,
action_required: this.state.actionRequired,
photos: this.state.photos.map((p) => ({
filename: p.filename,
data_base64: p.data_base64,
})),
});
if (res && res.ok) {
this.notification.add("Damage logged.", { type: "success" });
if (this.props.onCreated) await this.props.onCreated(res);
this.props.close();
} else {
this.notification.add(
(res && res.error) || "Save failed",
{ type: "danger" },
);
this.state.saving = false;
}
} catch (err) {
this.notification.add(err.message || String(err), { type: "danger" });
this.state.saving = false;
}
}
onCancel() { this.props.close(); }
}

View File

@@ -28,11 +28,12 @@ import { FpSignaturePad } from "./components/signature_pad";
import { FpHoldComposer } from "./components/hold_composer";
import { FpTabletLock } from "./tablet_lock";
import { FpRackPartsDialog } from "./rack_parts_dialog";
import { FpDamageDialog } from "./fp_damage_dialog";
export class FpJobWorkspace extends Component {
static template = "fusion_plating_shopfloor.JobWorkspace";
static props = ["*"];
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog };
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog };
setup() {
this.notification = useService("notification");
@@ -360,6 +361,109 @@ export class FpJobWorkspace extends Component {
});
}
// ---- Receiving handlers (Spec C1+C2 2026-05-24) -----------------------
// The receiver card at the top of the workspace lets the dock receiver
// count boxes, set per-line received quantities + condition, log damage
// (with camera photos), and transition draft → counted → closed. Once
// closed, the linked job's no_parts gate clears + the card disappears.
async onReceivingBoxCountBlur(rcv, ev) {
const newVal = parseInt(ev.target.value, 10) || 0;
if (newVal === (rcv.box_count_in || 0)) return;
rcv.box_count_in = newVal;
try {
await rpc("/fp/workspace/receiving_save_lines", {
receiving_id: rcv.id, box_count_in: newVal, lines: [],
});
} catch (err) {
this.notification.add(err.message || "Save failed", { type: "danger" });
}
}
async onReceivingLineQtyBlur(rcv, ln, ev) {
const newVal = parseInt(ev.target.value, 10) || 0;
if (newVal === (ln.received_qty || 0)) return;
ln.received_qty = newVal;
await this._saveReceivingLine(rcv, ln);
}
async onReceivingLineCondChange(rcv, ln, ev) {
ln.condition = ev.target.value || "good";
await this._saveReceivingLine(rcv, ln);
}
async _saveReceivingLine(rcv, ln) {
try {
await rpc("/fp/workspace/receiving_save_lines", {
receiving_id: rcv.id,
lines: [{ id: ln.id, received_qty: ln.received_qty, condition: ln.condition }],
});
} catch (err) {
this.notification.add(err.message || "Save failed", { type: "danger" });
}
}
async onReceivingMarkCounted(rcv) {
try {
const res = await rpc("/fp/workspace/receiving_mark_counted", {
receiving_id: rcv.id,
});
if (res && res.ok) {
this.notification.add(`Receiving ${rcv.name} marked counted.`, { type: "success" });
await this.refresh();
} else {
this.notification.add((res && res.error) || "Mark counted failed", { type: "danger" });
}
} catch (err) {
this.notification.add(err.message, { type: "danger" });
}
}
async onReceivingClose(rcv) {
if (!window.confirm(`Close receiving ${rcv.name}? The recipe steps will take over.`)) {
return;
}
try {
const res = await rpc("/fp/workspace/receiving_close", {
receiving_id: rcv.id,
});
if (res && res.ok) {
this.notification.add(`Receiving ${rcv.name} closed.`, { type: "success" });
await this.refresh();
} else {
this.notification.add((res && res.error) || "Close failed", { type: "danger" });
}
} catch (err) {
this.notification.add(err.message, { type: "danger" });
}
}
onAddDamage(rcv) {
this.dialog.add(FpDamageDialog, {
receivingId: rcv.id,
onCreated: () => this.refresh(),
});
}
async onDeleteDamage(rcv, dmg) {
if (!window.confirm(`Remove ${dmg.severity_label} damage: "${dmg.description}"?`)) {
return;
}
try {
const res = await rpc("/fp/workspace/damage_delete", {
damage_id: dmg.id,
});
if (res && res.ok) {
this.notification.add("Damage entry removed.", { type: "success" });
await this.refresh();
} else {
this.notification.add((res && res.error) || "Delete failed", { type: "danger" });
}
} catch (err) {
this.notification.add(err.message, { type: "danger" });
}
}
// ---- Action rail handlers ---------------------------------------------
onCreateHold() {
const job = this.state.data.job;

View File

@@ -317,3 +317,318 @@ $_ws-text-hex: #1d1d1f;
align-items: center;
flex-wrap: wrap;
}
// =============================================================================
// PRE-RECIPE RECEIVING CARD (Spec C1+C2 2026-05-24)
// =============================================================================
.o_fp_ws_rcv {
background: $_ws-card-hex;
border: 2px solid #f1c40f; // amber — draws receiver's eye
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
display: flex;
flex-direction: column;
gap: 0.8rem;
&.o_fp_ws_rcv_counted {
border-color: #27ae60; // green when counted
}
}
.o_fp_ws_rcv_head {
display: flex;
align-items: center;
gap: 0.6rem;
border-bottom: 1px solid $_ws-border-hex;
padding-bottom: 0.5rem;
}
.o_fp_ws_rcv_icon {
font-size: 1.4rem;
}
.o_fp_ws_rcv_title {
font-size: 1.1rem;
font-weight: 600;
flex: 1;
}
.o_fp_ws_rcv_status {
padding: 0.2rem 0.6rem;
border-radius: 4px;
background: #fef3c7;
color: #78350f;
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
.o_fp_ws_rcv_counted & {
background: #d1fae5;
color: #064e3b;
}
}
.o_fp_ws_rcv_field {
display: flex;
flex-direction: column;
gap: 0.3rem;
label {
font-weight: 600;
color: var(--text-secondary, #555);
font-size: 0.9rem;
}
}
.o_fp_ws_rcv_box_input {
font-size: 1.5rem;
font-weight: 600;
max-width: 12rem;
text-align: center;
}
.o_fp_ws_rcv_lines {
background: $_ws-page-hex;
border-radius: 6px;
padding: 0.6rem;
}
.o_fp_ws_rcv_lines_head {
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
color: var(--text-secondary, #666);
margin-bottom: 0.5rem;
letter-spacing: 0.05em;
}
.o_fp_ws_rcv_line {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
gap: 1rem;
border-bottom: 1px solid $_ws-border-hex;
&:last-child { border-bottom: 0; }
}
.o_fp_ws_rcv_line_part {
flex: 1;
}
.o_fp_ws_rcv_line_qty {
display: flex;
align-items: center;
gap: 0.6rem;
label {
display: flex;
align-items: center;
gap: 0.4rem;
font-weight: 600;
font-size: 0.9rem;
margin: 0;
}
}
.o_fp_ws_rcv_qty_input {
width: 6rem;
text-align: center;
font-weight: 600;
}
.o_fp_ws_rcv_cond_select {
width: 8rem;
}
.o_fp_ws_rcv_summary {
background: $_ws-page-hex;
border-radius: 6px;
padding: 0.8rem;
font-size: 1rem;
}
.o_fp_ws_rcv_summary_parts {
margin-top: 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.o_fp_ws_rcv_damage_section {
border-top: 1px dashed $_ws-border-hex;
padding-top: 0.6rem;
}
.o_fp_ws_rcv_damage_head {
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary, #666);
margin-bottom: 0.5rem;
display: flex;
align-items: center;
}
.o_fp_ws_rcv_damage_empty {
font-style: italic;
font-size: 0.9rem;
padding: 0.4rem 0;
}
.o_fp_ws_rcv_damage {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.8rem;
background: $_ws-page-hex;
border-left: 4px solid #fbbf24;
border-radius: 4px;
margin-bottom: 0.4rem;
font-size: 0.95rem;
&.o_fp_ws_rcv_damage_functional {
border-left-color: #f97316;
}
&.o_fp_ws_rcv_damage_rejected {
border-left-color: #dc2626;
background: rgba(220, 38, 38, 0.05);
}
}
.o_fp_ws_rcv_damage_sev {
font-weight: 700;
text-transform: uppercase;
font-size: 0.8rem;
padding: 0.15rem 0.5rem;
background: rgba(0,0,0,0.06);
border-radius: 3px;
}
.o_fp_ws_rcv_damage_desc {
flex: 1;
}
.o_fp_ws_rcv_damage_photos {
color: var(--text-secondary, #666);
font-size: 0.85rem;
}
.o_fp_ws_rcv_damage_x {
border: 0;
background: transparent;
color: #aaa;
cursor: pointer;
padding: 0.2rem 0.4rem;
&:hover { color: #dc2626; }
}
.o_fp_ws_rcv_actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
padding-top: 0.4rem;
.btn { font-size: 1rem; padding: 0.5rem 1.2rem; }
}
// =============================================================================
// DAMAGE DIALOG (Spec C1+C2 2026-05-24)
// =============================================================================
.o_fp_dmg_dialog .modal-body { padding: 1.5rem; }
.o_fp_dmg_body {
display: flex;
flex-direction: column;
gap: 1.2rem;
}
.o_fp_dmg_field { display: flex; flex-direction: column; gap: 0.4rem; }
.o_fp_dmg_label { font-weight: 600; color: var(--text-secondary, #555); }
.o_fp_dmg_req { color: #dc2626; }
.o_fp_dmg_pills {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.o_fp_dmg_pill {
background: $_ws-page-hex;
border: 2px solid $_ws-border-hex;
color: $_ws-text-hex;
padding: 0.6rem 1.1rem;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: background 80ms, border-color 80ms;
&:hover { background: rgba(0,0,0,0.04); }
&.o_fp_dmg_pill_active {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
&.o_fp_dmg_sev_functional.o_fp_dmg_pill_active {
background: #f97316;
border-color: #f97316;
}
&.o_fp_dmg_sev_rejected.o_fp_dmg_pill_active {
background: #dc2626;
border-color: #dc2626;
}
}
.o_fp_dmg_textarea { font-size: 1rem; }
.o_fp_dmg_photo_buttons {
display: flex;
gap: 0.6rem;
.btn { padding: 0.6rem 1rem; }
}
.o_fp_dmg_photo_grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.6rem;
}
.o_fp_dmg_photo_tile {
position: relative;
width: 110px;
height: 110px;
border: 1px solid $_ws-border-hex;
border-radius: 6px;
overflow: hidden;
background: $_ws-page-hex;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.o_fp_dmg_photo_x {
position: absolute;
top: 4px;
right: 4px;
border: 0;
background: rgba(0,0,0,0.6);
color: white;
border-radius: 50%;
width: 24px;
height: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}

View File

@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.FpDamageDialog">
<Dialog title="'Log Damage'" size="'lg'" contentClass="'o_fp_dmg_dialog'">
<div class="o_fp_dmg_body">
<!-- Severity pill picker -->
<div class="o_fp_dmg_field">
<label class="o_fp_dmg_label">Severity</label>
<div class="o_fp_dmg_pills">
<t t-foreach="severities" t-as="sv" t-key="sv.code">
<button type="button"
t-att-class="'o_fp_dmg_pill ' + sv.cssClass +
(state.severity === sv.code ? ' o_fp_dmg_pill_active' : '')"
t-on-click="() => this.setSeverity(sv.code)">
<t t-esc="sv.label"/>
</button>
</t>
</div>
</div>
<!-- Description textarea -->
<div class="o_fp_dmg_field">
<label class="o_fp_dmg_label">Description <span class="o_fp_dmg_req">*</span></label>
<textarea class="form-control o_fp_dmg_textarea"
rows="3"
placeholder="e.g. dent on top box, two parts crushed"
t-model="state.description"/>
</div>
<!-- Action required pill picker -->
<div class="o_fp_dmg_field">
<label class="o_fp_dmg_label">Action Required</label>
<div class="o_fp_dmg_pills">
<t t-foreach="actions" t-as="ac" t-key="ac.code">
<button type="button"
t-att-class="'o_fp_dmg_pill ' +
(state.actionRequired === ac.code ? 'o_fp_dmg_pill_active' : '')"
t-on-click="() => this.setAction(ac.code)">
<t t-esc="ac.label"/>
</button>
</t>
</div>
</div>
<!-- Photos: camera capture + file picker -->
<div class="o_fp_dmg_field">
<label class="o_fp_dmg_label">Photos</label>
<div class="o_fp_dmg_photo_buttons">
<button type="button" class="btn btn-primary"
t-on-click="() => this.triggerCamera()">
<i class="fa fa-camera me-1"/> Take Photo
</button>
<button type="button" class="btn btn-light"
t-on-click="() => this.triggerFile()">
<i class="fa fa-upload me-1"/> Upload
</button>
<!-- Hidden inputs -->
<input type="file"
accept="image/*"
capture="environment"
t-ref="cameraInput"
style="display: none"
t-on-change="onFilesPicked"/>
<input type="file"
accept="image/*"
multiple="multiple"
t-ref="fileInput"
style="display: none"
t-on-change="onFilesPicked"/>
</div>
<div t-if="state.photos.length" class="o_fp_dmg_photo_grid">
<t t-foreach="state.photos" t-as="p" t-key="p_index">
<div class="o_fp_dmg_photo_tile">
<img t-att-src="p.preview_url" t-att-alt="p.filename"/>
<button type="button" class="o_fp_dmg_photo_x"
t-on-click="() => this.removePhoto(p_index)">
<i class="fa fa-times"/>
</button>
</div>
</t>
</div>
</div>
</div>
<t t-set-slot="footer">
<button class="btn btn-light"
t-on-click="() => this.onCancel()"
t-att-disabled="state.saving">Cancel</button>
<button class="btn btn-success"
t-on-click="() => this.onSave()"
t-att-disabled="state.saving">
<i class="fa fa-check me-1"/>
<t t-if="state.saving">Saving…</t>
<t t-else="">Save Damage</t>
</button>
</t>
</Dialog>
</t>
</templates>

View File

@@ -79,6 +79,140 @@
<!-- STEP LIST -->
<div class="o_fp_ws_steps">
<!-- PRE-RECIPE: RECEIVING CARDS (Spec C1+C2 2026-05-24)
Renders one card per fp.receiving in state
draft/counted on the linked SO. Card disappears
once receiving is closed; recipe takes over. -->
<t t-foreach="state.data.receivings || []"
t-as="rcv" t-key="rcv.id">
<div t-att-class="'o_fp_ws_rcv o_fp_ws_rcv_' + rcv.state">
<div class="o_fp_ws_rcv_head">
<span class="o_fp_ws_rcv_icon">📦</span>
<span class="o_fp_ws_rcv_title">
Receiving <t t-esc="rcv.name"/>
</span>
<span class="o_fp_ws_rcv_status"
t-esc="rcv.state_label"/>
</div>
<!-- DRAFT mode: editable inputs -->
<t t-if="rcv.state === 'draft'">
<div class="o_fp_ws_rcv_field">
<label>Boxes received</label>
<input type="number"
class="form-control o_fp_ws_rcv_box_input"
inputmode="numeric"
t-att-value="rcv.box_count_in || ''"
t-on-blur="(ev) => this.onReceivingBoxCountBlur(rcv, ev)"/>
</div>
<div t-if="rcv.lines.length" class="o_fp_ws_rcv_lines">
<div class="o_fp_ws_rcv_lines_head">PARTS</div>
<t t-foreach="rcv.lines" t-as="ln" t-key="ln.id">
<div class="o_fp_ws_rcv_line">
<div class="o_fp_ws_rcv_line_part">
<strong t-esc="ln.part_number or 'Part'"/>
<span t-if="ln.description" class="text-muted">
<t t-esc="ln.description"/>
</span>
</div>
<div class="o_fp_ws_rcv_line_qty">
<span class="text-muted">Expected: <t t-esc="ln.expected_qty"/></span>
<label>Received
<input type="number"
class="form-control o_fp_ws_rcv_qty_input"
inputmode="numeric"
t-att-value="ln.received_qty || ''"
t-on-blur="(ev) => this.onReceivingLineQtyBlur(rcv, ln, ev)"/>
</label>
<select class="form-select o_fp_ws_rcv_cond_select"
t-on-change="(ev) => this.onReceivingLineCondChange(rcv, ln, ev)">
<option value="good" t-att-selected="ln.condition === 'good'">Good</option>
<option value="damaged" t-att-selected="ln.condition === 'damaged'">Damaged</option>
<option value="mixed" t-att-selected="ln.condition === 'mixed'">Mixed</option>
</select>
</div>
</div>
</t>
</div>
</t>
<!-- COUNTED mode: read-only summary -->
<t t-if="rcv.state === 'counted'">
<div class="o_fp_ws_rcv_summary">
<div>
<strong t-esc="rcv.box_count_in"/> box(es) counted
<t t-if="rcv.received_date">
at <t t-esc="rcv.received_date"/>
</t>
<t t-if="rcv.received_by_name">
by <t t-esc="rcv.received_by_name"/>
</t>
</div>
<div t-if="rcv.lines.length" class="o_fp_ws_rcv_summary_parts">
<t t-foreach="rcv.lines" t-as="ln" t-key="ln.id">
<span class="o_fp_chip o_fp_chip_info">
<t t-esc="ln.part_number"/>: <t t-esc="ln.received_qty"/> / <t t-esc="ln.expected_qty"/>
</span>
</t>
</div>
</div>
</t>
<!-- DAMAGE LOG (visible in both draft + counted) -->
<div class="o_fp_ws_rcv_damage_section">
<div class="o_fp_ws_rcv_damage_head">
DAMAGE LOG
<button type="button"
class="btn btn-sm btn-light ms-2"
t-on-click="() => this.onAddDamage(rcv)">
<i class="fa fa-plus"/> Add Damage
</button>
</div>
<div t-if="!rcv.damages.length"
class="o_fp_ws_rcv_damage_empty text-muted">
No damage logged.
</div>
<t t-foreach="rcv.damages" t-as="dmg" t-key="dmg.id">
<div t-att-class="'o_fp_ws_rcv_damage o_fp_ws_rcv_damage_' + dmg.severity">
<span class="o_fp_ws_rcv_damage_sev"
t-esc="dmg.severity_label"/>
<span class="o_fp_ws_rcv_damage_desc"
t-esc="dmg.description"/>
<span t-if="dmg.action_required and dmg.action_required !== 'none'"
class="o_fp_chip o_fp_chip_warning"
t-esc="dmg.action_label"/>
<span t-if="dmg.photo_count"
class="o_fp_ws_rcv_damage_photos">
<i class="fa fa-camera"/> <t t-esc="dmg.photo_count"/>
</span>
<button type="button"
class="o_fp_ws_rcv_damage_x"
t-on-click="() => this.onDeleteDamage(rcv, dmg)"
title="Remove damage entry">
<i class="fa fa-times"/>
</button>
</div>
</t>
</div>
<!-- Footer: state-transition button -->
<div class="o_fp_ws_rcv_actions">
<button t-if="rcv.state === 'draft'"
class="btn btn-primary"
t-on-click="() => this.onReceivingMarkCounted(rcv)">
<i class="fa fa-check"/> Mark Counted
</button>
<button t-if="rcv.state === 'counted'"
class="btn btn-success"
t-on-click="() => this.onReceivingClose(rcv)">
<i class="fa fa-check-circle"/> Close Receiving
</button>
</div>
</div>
</t>
<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>