diff --git a/fusion_plating/fusion_plating_configurator/__manifest__.py b/fusion_plating/fusion_plating_configurator/__manifest__.py index faada38e..9c4ccfa7 100644 --- a/fusion_plating/fusion_plating_configurator/__manifest__.py +++ b/fusion_plating/fusion_plating_configurator/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Configurator', - 'version': '19.0.22.11.0', + 'version': '19.0.22.13.0', 'category': 'Manufacturing/Plating', 'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.', 'description': """ diff --git a/fusion_plating/fusion_plating_configurator/models/sale_order_line.py b/fusion_plating/fusion_plating_configurator/models/sale_order_line.py index 092fa9c2..0352c2a9 100644 --- a/fusion_plating/fusion_plating_configurator/models/sale_order_line.py +++ b/fusion_plating/fusion_plating_configurator/models/sale_order_line.py @@ -350,6 +350,14 @@ class SaleOrderLine(models.Model): 'steps run, with this text shown on the operator tablet under ' 'fp.job.step.instructions.', ) + x_fc_masking_attachment_ids = fields.Many2many( + 'ir.attachment', + 'sale_order_line_masking_att_rel', 'line_id', 'attachment_id', + string='Masking Reference(s)', + help='Masking reference image(s)/PDF(s) captured at Express order ' + 'entry; applied to the job\'s masking step at job creation so ' + 'the operator sees what to mask.', + ) x_fc_revision_snapshot = fields.Char( string='Revision (snapshot)', copy=False, @@ -840,6 +848,19 @@ class SaleOrderLine(models.Model): }) if nodes: msgs.append(_('Masking + de-masking steps opted out (per SO line)')) + elif self.x_fc_masking_attachment_ids: + # Masking ON + Express reference file(s) attached → surface them on + # the mask step so the operator sees what to mask. Lands on the + # second call (after steps exist), same as bake below. + mask_steps = job.step_ids.filtered( + lambda s: s.recipe_node_id.default_kind == 'mask' + ) + if mask_steps: + mask_steps.sudo().write({ + 'x_fc_masking_attachment_ids': [(6, 0, self.x_fc_masking_attachment_ids.ids)], + }) + msgs.append(_('Masking reference(s) attached to the mask step: %d file(s)') + % len(self.x_fc_masking_attachment_ids)) # 2. Bake — empty = opt out; non-empty = keep + write step.instructions bake_text = (self.x_fc_bake_instructions or '').strip() diff --git a/fusion_plating/fusion_plating_configurator/static/src/js/express_action_btns.js b/fusion_plating/fusion_plating_configurator/static/src/js/express_action_btns.js index c300fd0d..0178d2a9 100644 --- a/fusion_plating/fusion_plating_configurator/static/src/js/express_action_btns.js +++ b/fusion_plating/fusion_plating_configurator/static/src/js/express_action_btns.js @@ -91,6 +91,67 @@ export class FpExpressActionBtns extends Component { ); if (action) await this.action.doAction(action); } + + // ---- Masking reference upload (2026-06-03) ---- + // Visible only when masking is toggled ON for this line. Accepts MULTIPLE + // image/PDF files; each is attached to the line and (on order confirm) + // copied onto the job's masking step so the operator sees it in the + // workstation. Mirrors onUpload but loops over the file list. + get maskingEnabled() { + return !!this.props.record.data.masking_enabled; + } + + get maskCount() { + const m = this.props.record.data.masking_attachment_ids; + return (m && m.count) || 0; + } + + async onUploadMask(ev) { + ev.stopPropagation(); + ev.preventDefault(); + const input = document.createElement("input"); + input.type = "file"; + input.multiple = true; + input.accept = ".pdf,.png,.jpg,.jpeg,application/pdf,image/*"; + input.onchange = async () => { + const files = Array.from(input.files || []); + if (!files.length) return; + if (!(await this._ensureSaved())) return; + let ok = 0; + for (const file of files) { + try { + const base64 = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result.split(",")[1]); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + await this.orm.call( + this.props.record.resModel, + "action_upload_masking_ref", + [[this.props.record.resId]], + { + context: { + fp_masking_file: base64, + fp_masking_filename: file.name, + }, + }, + ); + ok += 1; + } catch (e) { + this.notification.add( + `Masking upload failed for "${file.name}": ${e.message || e}`, + { type: "danger" }, + ); + } + } + if (ok) { + this.notification.add(`${ok} masking reference(s) added.`, { type: "success" }); + await this.props.record.load(); + } + }; + input.click(); + } } export const fpExpressActionBtns = { diff --git a/fusion_plating/fusion_plating_configurator/static/src/scss/express_order.scss b/fusion_plating/fusion_plating_configurator/static/src/scss/express_order.scss index 8e43cf2d..6261ab11 100644 --- a/fusion_plating/fusion_plating_configurator/static/src/scss/express_order.scss +++ b/fusion_plating/fusion_plating_configurator/static/src/scss/express_order.scss @@ -441,6 +441,21 @@ cursor: not-allowed; } } + + // MASK upload — amber so order-entry notices the "attach reference" + // affordance the moment masking is toggled on. Solid amber works on + // both the light and dark backend bundles (dark text on amber fill). + .o_fp_xpr_mask_btn { + color: #1f2937; + border-color: #d97706; + background: #fbbf24; + + &:hover:not(:disabled) { + color: #1f2937; + border-color: #b45309; + background: #f59e0b; + } + } } // ============================================================ diff --git a/fusion_plating/fusion_plating_configurator/static/src/xml/express_action_btns.xml b/fusion_plating/fusion_plating_configurator/static/src/xml/express_action_btns.xml index bfbd448c..e9c7dda1 100644 --- a/fusion_plating/fusion_plating_configurator/static/src/xml/express_action_btns.xml +++ b/fusion_plating/fusion_plating_configurator/static/src/xml/express_action_btns.xml @@ -16,6 +16,13 @@ title="Open the part record in a modal"> OPEN + + MASK () + diff --git a/fusion_plating/fusion_plating_configurator/views/fp_express_order_views.xml b/fusion_plating/fusion_plating_configurator/views/fp_express_order_views.xml index 5d4e7578..d1a7433d 100644 --- a/fusion_plating/fusion_plating_configurator/views/fp_express_order_views.xml +++ b/fusion_plating/fusion_plating_configurator/views/fp_express_order_views.xml @@ -279,6 +279,7 @@ + 1 + + + + Masking reference image(s)/PDF(s) attached at order entry (Express). + The operator sees these on the masking step in the workstation. + + + + diff --git a/fusion_plating/fusion_plating_shopfloor/__manifest__.py b/fusion_plating/fusion_plating_shopfloor/__manifest__.py index e107543d..16156dc9 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.37.0.3', + 'version': '19.0.37.1.0', 'category': 'Manufacturing/Plating', 'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer.', 'description': """ diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py index c455a7d0..e9e005f0 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/workspace_controller.py @@ -68,6 +68,18 @@ class FpWorkspaceController(http.Controller): override = job.override_ids.filtered( lambda o, n=step.recipe_node_id: o.node_id.id == n.id ) if 'override_ids' in job._fields else env['fp.job.node.override'] + # Masking reference image(s)/PDF(s) attached at Express order entry. + # sudo: low-priv operators can read fp.job.step but not always the + # linked ir.attachment (rule 13m). The files are safe to surface. + mask_atts = (step.sudo().x_fc_masking_attachment_ids + if 'x_fc_masking_attachment_ids' in step._fields + else env['ir.attachment']) + mask_refs = [{ + 'id': a.id, + 'name': a.name or '', + 'mimetype': a.mimetype or '', + 'is_image': (a.mimetype or '').startswith('image/'), + } for a in mask_atts] steps.append({ 'id': step.id, 'sequence': step.sequence, @@ -109,6 +121,7 @@ class FpWorkspaceController(http.Controller): 'quick_look_prompt_count': len( getattr(step, 'quick_look_prompt_ids', step.browse()) ), + 'masking_refs': mask_refs, }) # ---- Spec + attachments + chatter ------------------------------- 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 499e4245..64c3e7bf 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 @@ -31,6 +31,8 @@ import { FpRackPartsDialog } from "./rack_parts_dialog"; import { FpDamageDialog } from "./fp_damage_dialog"; import { FpFinishBlockDialog } from "./fp_finish_block_dialog"; import { RackingPanel } from "./components/racking_panel"; +import { useFileViewer } from "@web/core/file_viewer/file_viewer_hook"; +import { FileModel } from "@web/core/file_viewer/file_model"; export class FpJobWorkspace extends Component { static template = "fusion_plating_shopfloor.JobWorkspace"; @@ -42,6 +44,8 @@ export class FpJobWorkspace extends Component { this.action = useService("action"); this.dialog = useService("dialog"); this.tabletSessionManager = useService("fp_tablet_session_manager"); + // Full-screen image/PDF viewer (zoom + swipe) for masking refs. + this.fileViewer = useFileViewer(); this.state = useState({ data: null, @@ -199,6 +203,24 @@ export class FpJobWorkspace extends Component { }); } + // Open masking reference image(s)/PDF(s) in the full-screen viewer. + // Builds FileModel descriptors so the operator gets zoom + swipe across + // every reference on this step, starting at the tile they tapped. + openMaskRef(step, ref) { + const files = (step.masking_refs || []).map((r) => { + const f = new FileModel(); + f.id = r.id; + f.name = r.name; + f.mimetype = r.mimetype; + f.type = "binary"; + return f; + }); + const clicked = files.find((f) => f.id === ref.id) || files[0]; + if (clicked) { + this.fileViewer.open(clicked, files); + } + } + // ---- Step state helpers ------------------------------------------------ iconForStepState(state) { const map = { 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 4fe22cf5..a661a61b 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,6 +317,89 @@ $_ws-text-hex: #1d1d1f; .o_fp_ws_step_instr { font-size: 0.78rem; color: var(--text-secondary, #555); font-style: italic; } .o_fp_ws_step_actions { display: flex; gap: 0.35rem; flex-wrap: wrap; } +// ---- Masking reference tiles (tap → full-screen FileViewer) ----------- +.o_fp_ws_mask_refs { + margin-top: 0.5rem; + padding: 0.5rem 0.6rem; + border: 1px solid #d97706; + border-left: 4px solid #f59e0b; + border-radius: 8px; + background: rgba(245, 158, 11, 0.08); +} +.o_fp_ws_mask_refs_label { + font-size: 0.78rem; + font-weight: 600; + color: #b06600; + margin-bottom: 0.4rem; + i { margin-right: 0.3rem; } +} +.o_fp_ws_mask_refs_grid { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} +.o_fp_ws_mask_ref { + position: relative; + width: 104px; + height: 104px; + border: 1px solid $_ws-border-hex; + border-radius: 8px; + overflow: hidden; + cursor: pointer; + background: $_ws-card-hex; + transition: transform 0.08s ease, box-shadow 0.08s ease; + + &:hover { + transform: scale(1.04); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18); + border-color: #f59e0b; + } +} +.o_fp_ws_mask_ref_thumb { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.o_fp_ws_mask_ref_pdf { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.25rem; + padding: 0.3rem; + text-align: center; + + i { font-size: 1.8rem; color: #d9534f; } +} +.o_fp_ws_mask_ref_pdfname { + font-size: 0.6rem; + color: $_ws-text-hex; + line-height: 1.1; + word-break: break-word; + max-height: 2.2em; + overflow: hidden; +} +.o_fp_ws_mask_ref_zoom { + position: absolute; + right: 4px; + bottom: 4px; + width: 22px; + height: 22px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.55); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.7rem; +} +@if $o-webclient-color-scheme == dark { + .o_fp_ws_mask_refs_label { color: #f0a93a; } +} + .o_fp_ws_step_excluded { font-size: 0.78rem; color: var(--text-secondary, #888); diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml b/fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml index 64659162..24274d73 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml +++ b/fusion_plating/fusion_plating_shopfloor/static/src/xml/job_workspace.xml @@ -369,6 +369,32 @@ + + + + + Masking references — tap to enlarge + + + + + + + + + + + + + + + Skipped per recipe override for this WO