diff --git a/fusion_plating/fusion_plating_shopfloor/__manifest__.py b/fusion_plating/fusion_plating_shopfloor/__manifest__.py
index 7e5211c0..7fed7795 100644
--- a/fusion_plating/fusion_plating_shopfloor/__manifest__.py
+++ b/fusion_plating/fusion_plating_shopfloor/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Shop Floor',
- 'version': '19.0.33.1.5',
+ 'version': '19.0.33.1.6',
'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.',
@@ -114,6 +114,13 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'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',
+ # ---- Damage dialog for receiving (Spec C1+C2 2026-05-24) ----
+ # Loaded AFTER job_workspace.js because job_workspace imports
+ # FpDamageDialog; the asset bundler doesn't care about JS module
+ # order (ES modules resolve at runtime) but keep adjacent for
+ # readability.
+ 'fusion_plating_shopfloor/static/src/xml/fp_damage_dialog.xml',
+ 'fusion_plating_shopfloor/static/src/js/fp_damage_dialog.js',
# ---- Shop Floor Landing (Phase 3 — tablet redesign) ----
'fusion_plating_shopfloor/static/src/scss/shopfloor_landing.scss',
'fusion_plating_shopfloor/static/src/xml/shopfloor_landing.xml',
diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py
index 751baca5..f5c84881 100644
--- a/fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py
+++ b/fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py
@@ -21,6 +21,7 @@ import logging
from odoo import fields, http
from odoo.addons.fusion_plating.models.fp_tz import fp_format
+from odoo.exceptions import UserError
from odoo.http import request
_logger = logging.getLogger(__name__)
@@ -133,6 +134,58 @@ class FpWorkspaceController(http.Controller):
else job.step_ids.filtered(lambda s: s.state == 'in_progress')[:1]
)
+ # ---- Receivings (pre-recipe box-count gate) ---------------------
+ # Spec C1+C2 (2026-05-24): tablet UI for box count + per-line
+ # received qty + damage log. Renders a card above the step list
+ # for any receiving in state draft/counted. Closed receivings drop
+ # off (recipe takes over). See fp.receiving model + the receiver
+ # persona note in CLAUDE.md Sub 8.
+ receivings_payload = []
+ so = job.sale_order_id if 'sale_order_id' in job._fields else False
+ if so and 'x_fc_receiving_ids' in so._fields:
+ for rec in so.x_fc_receiving_ids.filtered(
+ lambda r: r.state in ('draft', 'counted')
+ ):
+ receivings_payload.append({
+ 'id': rec.id,
+ 'name': rec.name or '',
+ 'state': rec.state,
+ 'state_label': dict(rec._fields['state'].selection).get(
+ rec.state, rec.state,
+ ),
+ 'box_count_in': int(rec.box_count_in or 0),
+ 'expected_qty': int(rec.expected_qty or 0),
+ 'received_qty': int(rec.received_qty or 0),
+ 'received_date': fp_format(
+ env, rec.received_date, fmt='%Y-%m-%d %H:%M',
+ ) if rec.received_date else '',
+ 'received_by_name': rec.received_by_id.name or '',
+ 'lines': [{
+ 'id': line.id,
+ 'part_number': line.part_number or (
+ line.part_catalog_id.part_number
+ if line.part_catalog_id else ''
+ ),
+ 'description': line.description or '',
+ 'expected_qty': int(line.expected_qty or 0),
+ 'received_qty': int(line.received_qty or 0),
+ 'condition': line.condition or 'good',
+ } for line in rec.line_ids],
+ 'damages': [{
+ 'id': dmg.id,
+ 'severity': dmg.severity or 'cosmetic',
+ 'severity_label': dict(
+ dmg._fields['severity'].selection,
+ ).get(dmg.severity, dmg.severity),
+ 'description': dmg.description or '',
+ 'action_required': dmg.action_required or 'none',
+ 'action_label': dict(
+ dmg._fields['action_required'].selection,
+ ).get(dmg.action_required, dmg.action_required),
+ 'photo_count': len(dmg.photo_ids),
+ } for dmg in rec.damage_ids],
+ })
+
return {
'ok': True,
'job': {
@@ -189,6 +242,7 @@ class FpWorkspaceController(http.Controller):
for m in chatter
],
'required_certs': required_certs,
+ 'receivings': receivings_payload,
}
# ======================================================================
@@ -343,3 +397,147 @@ class FpWorkspaceController(http.Controller):
'next_milestone_action': job.next_milestone_action or '',
'next_milestone_label': job.next_milestone_label or '',
}
+
+ # ======================================================================
+ # Receiving — pre-recipe box-count + damage log (Spec C1+C2 2026-05-24)
+ # ======================================================================
+ # Mirrors the backend fp.receiving form just enough for the receiver
+ # persona to count boxes, log damage with photos, and close the
+ # receiving from the tablet workspace. No new backend models — wraps
+ # action_mark_counted / action_close and fp.receiving.damage CRUD.
+
+ @http.route('/fp/workspace/receiving_save_lines',
+ type='jsonrpc', auth='user')
+ def receiving_save_lines(self, receiving_id, box_count_in=None,
+ lines=None):
+ """Bulk-save box_count_in (on the receiving) + per-line
+ received_qty/condition. Called on input blur for autosave.
+
+ `lines` is a list of {id, received_qty, condition}.
+ """
+ env = request.env
+ rec = env['fp.receiving'].browse(int(receiving_id))
+ if not rec.exists():
+ return {'ok': False, 'error': 'Receiving not found'}
+ if rec.state not in ('draft', 'counted'):
+ return {'ok': False, 'error': (
+ 'Receiving is %s — only Awaiting Parts / Counted are editable.'
+ ) % rec.state}
+ try:
+ if box_count_in is not None:
+ rec.box_count_in = int(box_count_in or 0)
+ for line_dict in (lines or []):
+ line = env['fp.receiving.line'].browse(int(line_dict['id']))
+ if not line.exists() or line.receiving_id.id != rec.id:
+ continue # stale/foreign line id — skip silently
+ line.write({
+ 'received_qty': int(line_dict.get('received_qty') or 0),
+ 'condition': line_dict.get('condition') or 'good',
+ })
+ except Exception as exc:
+ _logger.exception("workspace/receiving_save_lines failed")
+ return {'ok': False, 'error': str(exc)}
+ return {'ok': True}
+
+ @http.route('/fp/workspace/receiving_mark_counted',
+ type='jsonrpc', auth='user')
+ def receiving_mark_counted(self, receiving_id):
+ """Receiver finished counting → advance state draft → counted."""
+ env = request.env
+ rec = env['fp.receiving'].browse(int(receiving_id))
+ if not rec.exists():
+ return {'ok': False, 'error': 'Receiving not found'}
+ try:
+ rec.action_mark_counted()
+ except UserError as e:
+ return {'ok': False, 'error': str(e.args[0])}
+ except Exception as exc:
+ _logger.exception("workspace/receiving_mark_counted failed")
+ return {'ok': False, 'error': str(exc)}
+ return {'ok': True, 'state': rec.state}
+
+ @http.route('/fp/workspace/receiving_close',
+ type='jsonrpc', auth='user')
+ def receiving_close(self, receiving_id):
+ """Close the receiving → advance state counted → closed.
+ Clears the no_parts card_state on the linked job(s).
+ """
+ env = request.env
+ rec = env['fp.receiving'].browse(int(receiving_id))
+ if not rec.exists():
+ return {'ok': False, 'error': 'Receiving not found'}
+ try:
+ rec.action_close()
+ except UserError as e:
+ return {'ok': False, 'error': str(e.args[0])}
+ except Exception as exc:
+ _logger.exception("workspace/receiving_close failed")
+ return {'ok': False, 'error': str(exc)}
+ return {'ok': True, 'state': rec.state}
+
+ @http.route('/fp/workspace/damage_create',
+ type='jsonrpc', auth='user')
+ def damage_create(self, receiving_id, description,
+ severity='cosmetic', action_required='none',
+ photos=None):
+ """Create a fp.receiving.damage row. `photos` is a list of
+ {filename, data_base64}; each gets attached as ir.attachment
+ and added to damage.photo_ids.
+ """
+ env = request.env
+ rec = env['fp.receiving'].browse(int(receiving_id))
+ if not rec.exists():
+ return {'ok': False, 'error': 'Receiving not found'}
+ if not description or not description.strip():
+ return {'ok': False, 'error': 'Description is required'}
+ try:
+ damage = env['fp.receiving.damage'].create({
+ 'receiving_id': rec.id,
+ 'description': description.strip(),
+ 'severity': severity or 'cosmetic',
+ 'action_required': action_required or 'none',
+ })
+ # Attach photos (base64 from camera / file picker). Failure
+ # on a single attach doesn't roll back the damage row —
+ # operator can re-upload via the back office form if needed.
+ photo_atts = env['ir.attachment']
+ for p in (photos or []):
+ try:
+ att = env['ir.attachment'].create({
+ 'name': p.get('filename') or 'damage.jpg',
+ 'datas': p.get('data_base64') or '',
+ 'res_model': 'fp.receiving.damage',
+ 'res_id': damage.id,
+ })
+ photo_atts |= att
+ except Exception:
+ _logger.exception(
+ 'damage_create: photo attach failed for damage %s',
+ damage.id,
+ )
+ if photo_atts:
+ damage.photo_ids = [(6, 0, photo_atts.ids)]
+ except UserError as e:
+ return {'ok': False, 'error': str(e.args[0])}
+ except Exception as exc:
+ _logger.exception("workspace/damage_create failed")
+ return {'ok': False, 'error': str(exc)}
+ return {'ok': True, 'damage_id': damage.id}
+
+ @http.route('/fp/workspace/damage_delete',
+ type='jsonrpc', auth='user')
+ def damage_delete(self, damage_id):
+ """Remove a damage row (operator changed their mind, or logged
+ in error). CASCADE removes the attachment link rows; the
+ ir.attachment records themselves persist (might be referenced
+ elsewhere)."""
+ env = request.env
+ dmg = env['fp.receiving.damage'].browse(int(damage_id))
+ if not dmg.exists():
+ return {'ok': False, 'error': 'Damage row not found'}
+ try:
+ dmg.unlink()
+ except Exception as exc:
+ _logger.exception("workspace/damage_delete failed")
+ return {'ok': False, 'error': str(exc)}
+ return {'ok': True}
diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/fp_damage_dialog.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/fp_damage_dialog.js
new file mode 100644
index 00000000..144f4390
--- /dev/null
+++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/fp_damage_dialog.js
@@ -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(); }
+}
diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js b/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js
index 6f1d570d..76b09510 100644
--- a/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js
+++ b/fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js
@@ -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;
diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss
index a4a198e2..52573b92 100644
--- a/fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss
+++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/job_workspace.scss
@@ -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;
+}
diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/xml/fp_damage_dialog.xml b/fusion_plating/fusion_plating_shopfloor/static/src/xml/fp_damage_dialog.xml
new file mode 100644
index 00000000..943ae97d
--- /dev/null
+++ b/fusion_plating/fusion_plating_shopfloor/static/src/xml/fp_damage_dialog.xml
@@ -0,0 +1,102 @@
+
+