feat(fusion_plating): Express masking reference images → mask step + workstation viewer
Order-entry shortcut: when masking is toggled ON for an Express order line, an amber "MASK" button appears to attach reference image(s)/PDF(s). The files ride the existing _fp_apply_express_overrides_to_job path onto the job's masking step, so the operator sees exactly what to mask — no recipe edit or custom prompt needed. - configurator: masking_attachment_ids on the wizard line + SO line; action_upload_masking_ref; override branch writes refs onto mask steps; amber multi-file MASK button (express_action_btns) shown when masking is on. - jobs: x_fc_masking_attachment_ids on fp.job.step (per-step) + computed rollup on fp.job; office "Masking Refs" form page (readonly preview). - shopfloor: workspace step payload carries masking_refs (sudo'd attachment read, rule 13m); operator sees thumbnail/PDF tiles on the mask step that open in Odoo's full-screen FileViewer (zoom + swipe). Verified end-to-end on entech: SO-line refs land on the mask step + job rollup (WO-30091); payload mask_refs shape correct (is_image, /web/image). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Configurator',
|
'name': 'Fusion Plating — Configurator',
|
||||||
'version': '19.0.22.11.0',
|
'version': '19.0.22.13.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -350,6 +350,14 @@ class SaleOrderLine(models.Model):
|
|||||||
'steps run, with this text shown on the operator tablet under '
|
'steps run, with this text shown on the operator tablet under '
|
||||||
'fp.job.step.instructions.',
|
'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(
|
x_fc_revision_snapshot = fields.Char(
|
||||||
string='Revision (snapshot)',
|
string='Revision (snapshot)',
|
||||||
copy=False,
|
copy=False,
|
||||||
@@ -840,6 +848,19 @@ class SaleOrderLine(models.Model):
|
|||||||
})
|
})
|
||||||
if nodes:
|
if nodes:
|
||||||
msgs.append(_('Masking + de-masking steps opted out (per SO line)'))
|
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
|
# 2. Bake — empty = opt out; non-empty = keep + write step.instructions
|
||||||
bake_text = (self.x_fc_bake_instructions or '').strip()
|
bake_text = (self.x_fc_bake_instructions or '').strip()
|
||||||
|
|||||||
@@ -91,6 +91,67 @@ export class FpExpressActionBtns extends Component {
|
|||||||
);
|
);
|
||||||
if (action) await this.action.doAction(action);
|
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 = {
|
export const fpExpressActionBtns = {
|
||||||
|
|||||||
@@ -441,6 +441,21 @@
|
|||||||
cursor: not-allowed;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -16,6 +16,13 @@
|
|||||||
title="Open the part record in a modal">
|
title="Open the part record in a modal">
|
||||||
OPEN
|
OPEN
|
||||||
</button>
|
</button>
|
||||||
|
<button t-if="maskingEnabled"
|
||||||
|
class="o_fp_xpr_action_stack_btn o_fp_xpr_mask_btn"
|
||||||
|
t-on-click="onUploadMask"
|
||||||
|
t-att-disabled="!hasPart"
|
||||||
|
title="Attach masking reference image(s)/PDF(s) — shown to the operator on the masking step">
|
||||||
|
MASK<t t-if="maskCount"> (<t t-esc="maskCount"/>)</t>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</t>
|
</t>
|
||||||
|
|
||||||
|
|||||||
@@ -279,6 +279,7 @@
|
|||||||
<field name="customer_line_ref" string="Line Job #" placeholder="ABC" width="80px"/>
|
<field name="customer_line_ref" string="Line Job #" placeholder="ABC" width="80px"/>
|
||||||
<field name="thickness_range" string="Thickness" placeholder=".0005-.0010" width="100px"/>
|
<field name="thickness_range" string="Thickness" placeholder=".0005-.0010" width="100px"/>
|
||||||
<field name="masking_enabled" string="Mask" widget="boolean_toggle" width="55px"/>
|
<field name="masking_enabled" string="Mask" widget="boolean_toggle" width="55px"/>
|
||||||
|
<field name="masking_attachment_ids" column_invisible="1"/>
|
||||||
<!-- Bake pill — click to edit -->
|
<!-- Bake pill — click to edit -->
|
||||||
<field name="bake_instructions"
|
<field name="bake_instructions"
|
||||||
string="Bake"
|
string="Bake"
|
||||||
|
|||||||
@@ -573,6 +573,14 @@ class FpDirectOrderLine(models.Model):
|
|||||||
help='Free-text bake instructions. Empty = bake steps are opted out. '
|
help='Free-text bake instructions. Empty = bake steps are opted out. '
|
||||||
'Non-empty = bake step instructions on the operator tablet.',
|
'Non-empty = bake step instructions on the operator tablet.',
|
||||||
)
|
)
|
||||||
|
masking_attachment_ids = fields.Many2many(
|
||||||
|
'ir.attachment',
|
||||||
|
'fp_direct_order_line_masking_att_rel', 'line_id', 'attachment_id',
|
||||||
|
string='Masking Reference(s)',
|
||||||
|
help='Image(s)/PDF(s) of what to mask. Carried to the SO line and '
|
||||||
|
'shown to the operator on the job\'s masking step. Only relevant '
|
||||||
|
'when Masking is enabled.',
|
||||||
|
)
|
||||||
|
|
||||||
# ---- Computes ----
|
# ---- Computes ----
|
||||||
@api.depends('quantity', 'unit_price')
|
@api.depends('quantity', 'unit_price')
|
||||||
@@ -766,6 +774,29 @@ class FpDirectOrderLine(models.Model):
|
|||||||
'target': 'new',
|
'target': 'new',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def action_upload_masking_ref(self):
|
||||||
|
"""Attach a masking reference (image/PDF) to this line.
|
||||||
|
|
||||||
|
Called by the Express 'MASK REF' button — once per file (multi-select
|
||||||
|
loops in JS), via context keys fp_masking_file + fp_masking_filename.
|
||||||
|
Stored on the line's masking_attachment_ids; carried to the SO line
|
||||||
|
and the job's masking step at order confirm.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
file_data = self.env.context.get('fp_masking_file')
|
||||||
|
filename = self.env.context.get('fp_masking_filename', 'masking-ref')
|
||||||
|
if not file_data:
|
||||||
|
raise UserError(_('No file data received.'))
|
||||||
|
att = self.env['ir.attachment'].sudo().create({
|
||||||
|
'name': filename,
|
||||||
|
'datas': file_data,
|
||||||
|
'res_model': 'fp.direct.order.line',
|
||||||
|
'res_id': self.id,
|
||||||
|
})
|
||||||
|
self.write({'masking_attachment_ids': [(4, att.id)]})
|
||||||
|
return True
|
||||||
|
|
||||||
def action_upload_drawing(self):
|
def action_upload_drawing(self):
|
||||||
"""Attach a file (via context) to the line's part as a drawing.
|
"""Attach a file (via context) to the line's part as a drawing.
|
||||||
|
|
||||||
|
|||||||
@@ -956,6 +956,7 @@ class FpDirectOrderWizard(models.Model):
|
|||||||
'x_fc_customer_line_ref': line.customer_line_ref or False,
|
'x_fc_customer_line_ref': line.customer_line_ref or False,
|
||||||
'x_fc_masking_enabled': line.masking_enabled,
|
'x_fc_masking_enabled': line.masking_enabled,
|
||||||
'x_fc_bake_instructions': line.bake_instructions or False,
|
'x_fc_bake_instructions': line.bake_instructions or False,
|
||||||
|
'x_fc_masking_attachment_ids': [(6, 0, line.masking_attachment_ids.ids)],
|
||||||
# Sub 9 — explicit tax override from the wizard line.
|
# Sub 9 — explicit tax override from the wizard line.
|
||||||
# When blank, Odoo will compute taxes from the product
|
# When blank, Odoo will compute taxes from the product
|
||||||
# defaults at SO-line save time (the standard behaviour).
|
# defaults at SO-line save time (the standard behaviour).
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from . import fp_job_workflow_state # Sub 14 — must load before fp_job (FK t
|
|||||||
from . import fp_job
|
from . import fp_job
|
||||||
from . import fp_job_sticker
|
from . import fp_job_sticker
|
||||||
from . import fp_job_step
|
from . import fp_job_step
|
||||||
|
from . import fp_job_masking
|
||||||
from . import fp_job_node_override
|
from . import fp_job_node_override
|
||||||
from . import fp_portal_job
|
from . import fp_portal_job
|
||||||
from . import account_move
|
from . import account_move
|
||||||
|
|||||||
36
fusion_plating/fusion_plating_jobs/models/fp_job_masking.py
Normal file
36
fusion_plating/fusion_plating_jobs/models/fp_job_masking.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
"""Masking reference attachments — captured at Express order entry, surfaced
|
||||||
|
on the job's masking step (operator workstation) and rolled up to the job
|
||||||
|
form (office). Populated by sale.order.line._fp_apply_express_overrides_to_job.
|
||||||
|
"""
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class FpJobStep(models.Model):
|
||||||
|
_inherit = 'fp.job.step'
|
||||||
|
|
||||||
|
x_fc_masking_attachment_ids = fields.Many2many(
|
||||||
|
'ir.attachment',
|
||||||
|
'fp_job_step_masking_att_rel', 'step_id', 'attachment_id',
|
||||||
|
string='Masking Reference(s)',
|
||||||
|
help='Reference image(s)/PDF(s) of what to mask, attached at order '
|
||||||
|
'entry (Express) and shown to the operator on the masking step.',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FpJob(models.Model):
|
||||||
|
_inherit = 'fp.job'
|
||||||
|
|
||||||
|
x_fc_masking_attachment_ids = fields.Many2many(
|
||||||
|
'ir.attachment',
|
||||||
|
compute='_compute_masking_attachment_ids',
|
||||||
|
string='Masking References',
|
||||||
|
help='All masking reference files across this job\'s masking steps.',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('step_ids.x_fc_masking_attachment_ids')
|
||||||
|
def _compute_masking_attachment_ids(self):
|
||||||
|
for job in self:
|
||||||
|
atts = job.step_ids.mapped('x_fc_masking_attachment_ids')
|
||||||
|
job.x_fc_masking_attachment_ids = [(6, 0, atts.ids)]
|
||||||
@@ -302,6 +302,17 @@
|
|||||||
<xpath expr="//group[@name='x_fc_notes']" position="attributes">
|
<xpath expr="//group[@name='x_fc_notes']" position="attributes">
|
||||||
<attribute name="invisible">1</attribute>
|
<attribute name="invisible">1</attribute>
|
||||||
</xpath>
|
</xpath>
|
||||||
|
<xpath expr="//page[@name='costs']" position="before">
|
||||||
|
<page string="Masking Refs" name="masking_refs"
|
||||||
|
invisible="not x_fc_masking_attachment_ids">
|
||||||
|
<div class="text-muted mb-2">
|
||||||
|
Masking reference image(s)/PDF(s) attached at order entry (Express).
|
||||||
|
The operator sees these on the masking step in the workstation.
|
||||||
|
</div>
|
||||||
|
<field name="x_fc_masking_attachment_ids" widget="many2many_binary"
|
||||||
|
readonly="1" nolabel="1"/>
|
||||||
|
</page>
|
||||||
|
</xpath>
|
||||||
<xpath expr="//page[@name='costs']" position="before">
|
<xpath expr="//page[@name='costs']" position="before">
|
||||||
<page string="Notes" name="notes">
|
<page string="Notes" name="notes">
|
||||||
<group>
|
<group>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Shop Floor',
|
'name': 'Fusion Plating — Shop Floor',
|
||||||
'version': '19.0.37.0.3',
|
'version': '19.0.37.1.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer.',
|
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer.',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -68,6 +68,18 @@ class FpWorkspaceController(http.Controller):
|
|||||||
override = job.override_ids.filtered(
|
override = job.override_ids.filtered(
|
||||||
lambda o, n=step.recipe_node_id: o.node_id.id == n.id
|
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']
|
) 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({
|
steps.append({
|
||||||
'id': step.id,
|
'id': step.id,
|
||||||
'sequence': step.sequence,
|
'sequence': step.sequence,
|
||||||
@@ -109,6 +121,7 @@ class FpWorkspaceController(http.Controller):
|
|||||||
'quick_look_prompt_count': len(
|
'quick_look_prompt_count': len(
|
||||||
getattr(step, 'quick_look_prompt_ids', step.browse())
|
getattr(step, 'quick_look_prompt_ids', step.browse())
|
||||||
),
|
),
|
||||||
|
'masking_refs': mask_refs,
|
||||||
})
|
})
|
||||||
|
|
||||||
# ---- Spec + attachments + chatter -------------------------------
|
# ---- Spec + attachments + chatter -------------------------------
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import { FpRackPartsDialog } from "./rack_parts_dialog";
|
|||||||
import { FpDamageDialog } from "./fp_damage_dialog";
|
import { FpDamageDialog } from "./fp_damage_dialog";
|
||||||
import { FpFinishBlockDialog } from "./fp_finish_block_dialog";
|
import { FpFinishBlockDialog } from "./fp_finish_block_dialog";
|
||||||
import { RackingPanel } from "./components/racking_panel";
|
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 {
|
export class FpJobWorkspace extends Component {
|
||||||
static template = "fusion_plating_shopfloor.JobWorkspace";
|
static template = "fusion_plating_shopfloor.JobWorkspace";
|
||||||
@@ -42,6 +44,8 @@ export class FpJobWorkspace extends Component {
|
|||||||
this.action = useService("action");
|
this.action = useService("action");
|
||||||
this.dialog = useService("dialog");
|
this.dialog = useService("dialog");
|
||||||
this.tabletSessionManager = useService("fp_tablet_session_manager");
|
this.tabletSessionManager = useService("fp_tablet_session_manager");
|
||||||
|
// Full-screen image/PDF viewer (zoom + swipe) for masking refs.
|
||||||
|
this.fileViewer = useFileViewer();
|
||||||
|
|
||||||
this.state = useState({
|
this.state = useState({
|
||||||
data: null,
|
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 ------------------------------------------------
|
// ---- Step state helpers ------------------------------------------------
|
||||||
iconForStepState(state) {
|
iconForStepState(state) {
|
||||||
const map = {
|
const map = {
|
||||||
|
|||||||
@@ -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_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; }
|
.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 {
|
.o_fp_ws_step_excluded {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--text-secondary, #888);
|
color: var(--text-secondary, #888);
|
||||||
|
|||||||
@@ -369,6 +369,32 @@
|
|||||||
<t t-esc="step.instructions"/>
|
<t t-esc="step.instructions"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Masking reference(s) — attached at order entry; tap to enlarge -->
|
||||||
|
<div t-if="step.masking_refs and step.masking_refs.length"
|
||||||
|
class="o_fp_ws_mask_refs">
|
||||||
|
<div class="o_fp_ws_mask_refs_label">
|
||||||
|
<i class="fa fa-paint-brush"/>
|
||||||
|
Masking reference<t t-if="step.masking_refs.length > 1">s</t> — tap to enlarge
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_ws_mask_refs_grid">
|
||||||
|
<t t-foreach="step.masking_refs" t-as="ref" t-key="ref.id">
|
||||||
|
<div class="o_fp_ws_mask_ref"
|
||||||
|
t-on-click="() => this.openMaskRef(step, ref)"
|
||||||
|
t-att-title="ref.name">
|
||||||
|
<img t-if="ref.is_image"
|
||||||
|
class="o_fp_ws_mask_ref_thumb"
|
||||||
|
t-att-src="'/web/image/' + ref.id + '/200x200'"
|
||||||
|
t-att-alt="ref.name"/>
|
||||||
|
<div t-else="" class="o_fp_ws_mask_ref_pdf">
|
||||||
|
<i class="fa fa-file-pdf-o"/>
|
||||||
|
<span class="o_fp_ws_mask_ref_pdfname" t-esc="ref.name"/>
|
||||||
|
</div>
|
||||||
|
<div class="o_fp_ws_mask_ref_zoom"><i class="fa fa-search-plus"/></div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Opt-out notice -->
|
<!-- Opt-out notice -->
|
||||||
<div t-if="step.override_excluded" class="o_fp_ws_step_excluded">
|
<div t-if="step.override_excluded" class="o_fp_ws_step_excluded">
|
||||||
<i class="fa fa-ban"/> Skipped per recipe override for this WO
|
<i class="fa fa-ban"/> Skipped per recipe override for this WO
|
||||||
|
|||||||
Reference in New Issue
Block a user