From eed1c4619d005e233c9079527b1b19372052a1f0 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 24 May 2026 19:08:30 -0400 Subject: [PATCH] feat(workspace): pre-recipe receiving card with box count + damage log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../fusion_plating_shopfloor/__manifest__.py | 9 +- .../controllers/workspace_controller.py | 198 +++++++++++ .../static/src/js/fp_damage_dialog.js | 142 ++++++++ .../static/src/js/job_workspace.js | 106 +++++- .../static/src/scss/job_workspace.scss | 315 ++++++++++++++++++ .../static/src/xml/fp_damage_dialog.xml | 102 ++++++ .../static/src/xml/job_workspace.xml | 134 ++++++++ 7 files changed, 1004 insertions(+), 2 deletions(-) create mode 100644 fusion_plating/fusion_plating_shopfloor/static/src/js/fp_damage_dialog.js create mode 100644 fusion_plating/fusion_plating_shopfloor/static/src/xml/fp_damage_dialog.xml 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 @@ + + + + + +
+ + +
+ +
+ + + +
+
+ + +
+ +