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 @@ + + + + + +
+ + +
+ +
+ + + +
+
+ + +
+ +