diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index fda72af4..187b4a60 100644 --- a/fusion_plating/fusion_plating/__manifest__.py +++ b/fusion_plating/fusion_plating/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating', - 'version': '19.0.5.3.0', + 'version': '19.0.5.4.0', 'category': 'Manufacturing/Plating', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'description': """ diff --git a/fusion_plating/fusion_plating/models/fp_bath_log.py b/fusion_plating/fusion_plating/models/fp_bath_log.py index 1e973239..9d073a12 100644 --- a/fusion_plating/fusion_plating/models/fp_bath_log.py +++ b/fusion_plating/fusion_plating/models/fp_bath_log.py @@ -3,7 +3,8 @@ # License OPL-1 (Odoo Proprietary License v1.0) # Part of the Fusion Plating product family. -from odoo import api, fields, models +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError class FpBathLog(models.Model): @@ -115,6 +116,37 @@ class FpBathLog(models.Model): seq = self.env['ir.sequence'].next_by_code('fusion.plating.bath.log') return seq or '/' + @api.constrains('line_ids') + def _check_has_readings(self): + """A bath log without readings is a useless empty record — it + pollutes daily-chemistry reports and the trend graphs assume + every log carries data. Block save until at least one reading. + + Note: @api.constrains only fires when line_ids is in the + write/create vals. The create() override below catches the + "no line_ids in vals at all" case so callers can't sneak past. + """ + for rec in self: + if not rec.line_ids: + raise ValidationError(_( + 'Bath log "%(name)s" needs at least one parameter ' + 'reading before it can be saved.\n\nAdd readings via ' + 'the "Readings" tab (or the Tablet Station\'s Log ' + 'Chemistry button).' + ) % {'name': rec.display_name or rec.name or ''}) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if not vals.get('line_ids'): + raise ValidationError(_( + 'A bath log must include at least one parameter ' + 'reading. Pass `line_ids` with at least one line ' + 'in the create call (or use the Tablet Station\'s ' + 'Log Chemistry button which adds them for you).' + )) + return super().create(vals_list) + @api.depends('name', 'bath_id', 'log_date') def _compute_display_name(self): for rec in self: diff --git a/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py b/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py index 8fea443c..8e882288 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py +++ b/fusion_plating/fusion_plating_bridge_mrp/__manifest__.py @@ -5,7 +5,7 @@ { "name": "Fusion Plating — MRP Bridge", - 'version': '19.0.6.8.0', + 'version': '19.0.6.9.0', 'category': 'Manufacturing/Plating', 'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.', 'description': """ diff --git a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py index 4941848b..232fa988 100644 --- a/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py +++ b/fusion_plating/fusion_plating_bridge_mrp/models/mrp_workorder.py @@ -51,6 +51,24 @@ class MrpWorkorder(models.Model): compute='_compute_wo_kind', store=False, ) + # Manager Desk: stays in "Unassigned" until ALL required-for-kind + # fields are set (operator + bath/tank for wet, oven for bake, etc.). + # Only when this flips True does the WO move to the In Progress column. + x_fc_is_release_ready = fields.Boolean( + string='Release-Ready', + compute='_compute_is_release_ready', + store=False, + help='True when every required field for this WO\'s kind is filled ' + '(operator + per-kind equipment). Used by the Manager Desk to ' + 'keep half-set WOs visible in the Unassigned column.', + ) + x_fc_missing_for_release = fields.Char( + string='Missing to Release', + compute='_compute_is_release_ready', + store=False, + help='Comma-list of fields the manager still needs to set before ' + 'this WO can be released to the operator.', + ) x_fc_bath_id = fields.Many2one( 'fusion.plating.bath', string='Bath', tracking=True, ) @@ -609,6 +627,32 @@ class MrpWorkorder(models.Model): wo.x_fc_requires_bath = kind == 'wet' wo.x_fc_requires_oven = kind == 'bake' + @api.depends('x_fc_assigned_user_id', 'x_fc_bath_id', 'x_fc_tank_id', + 'x_fc_oven_id', 'x_fc_rack_id', 'x_fc_masking_material', + 'x_fc_wo_kind') + def _compute_is_release_ready(self): + """A WO is release-ready when the manager has set EVERY field + button_start would block on. Used by the Manager Desk to keep + half-set WOs in the Unassigned column instead of jumping them + to In Progress as soon as a worker is picked. + """ + for wo in self: + missing = [] + if not wo.x_fc_assigned_user_id: + missing.append('Operator') + kind = wo.x_fc_wo_kind + if kind == 'wet': + if not wo.x_fc_bath_id: missing.append('Bath') + if not wo.x_fc_tank_id: missing.append('Tank') + elif kind == 'bake': + if not wo.x_fc_oven_id: missing.append('Oven') + elif kind == 'rack': + if not wo.x_fc_rack_id: missing.append('Rack') + elif kind == 'mask': + if not wo.x_fc_masking_material: missing.append('Masking material') + wo.x_fc_is_release_ready = not missing + wo.x_fc_missing_for_release = ', '.join(missing) + @api.onchange('workcenter_id', 'x_fc_facility_id', 'x_fc_bath_id') def _onchange_autofill_equipment(self): """If the facility has exactly one option for the equipment this diff --git a/fusion_plating/fusion_plating_certificates/__manifest__.py b/fusion_plating/fusion_plating_certificates/__manifest__.py index 2383d813..c6367141 100644 --- a/fusion_plating/fusion_plating_certificates/__manifest__.py +++ b/fusion_plating/fusion_plating_certificates/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Certificates', - 'version': '19.0.3.1.0', + 'version': '19.0.3.2.0', 'category': 'Manufacturing/Plating', 'summary': 'Certificate registry for CoC, thickness reports, and quality documents.', 'description': """ diff --git a/fusion_plating/fusion_plating_certificates/models/fp_certificate.py b/fusion_plating/fusion_plating_certificates/models/fp_certificate.py index 674777d6..e597b1c1 100644 --- a/fusion_plating/fusion_plating_certificates/models/fp_certificate.py +++ b/fusion_plating/fusion_plating_certificates/models/fp_certificate.py @@ -277,6 +277,25 @@ class FpCertificate(models.Model): '(e.g. "AMS 2404", "MIL-C-26074") so the cert ' 'states which standard the work meets.' ) % {'name': rec.name or rec.display_name}) + # Aerospace / Nadcap customers: actual thickness readings + # must be on file BEFORE the cert is issued. The flag lives + # on the partner so commercial customers aren't blocked. + if (rec.partner_id + and 'x_fc_strict_thickness_required' in rec.partner_id._fields + and rec.partner_id.x_fc_strict_thickness_required + and rec.certificate_type == 'coc'): + if not rec.thickness_reading_ids: + raise UserError(_( + 'Cannot issue CoC "%(name)s" — customer "%(cust)s" ' + 'requires actual thickness readings on every CoC ' + '(Nadcap / aerospace).\n\nLog Fischerscope readings ' + 'against MO %(mo)s via the Tablet Station before ' + 'issuing.' + ) % { + 'name': rec.name or rec.display_name, + 'cust': rec.partner_id.name, + 'mo': rec.production_id.name if rec.production_id else '?', + }) rec.state = 'issued' rec.message_post(body=_('Certificate issued.')) diff --git a/fusion_plating/fusion_plating_certificates/models/res_partner.py b/fusion_plating/fusion_plating_certificates/models/res_partner.py index 81ea6b52..ec11a37f 100644 --- a/fusion_plating/fusion_plating_certificates/models/res_partner.py +++ b/fusion_plating/fusion_plating_certificates/models/res_partner.py @@ -39,3 +39,12 @@ class ResPartner(models.Model): help='Attach the BoL PDF to the shipping confirmation email. ' 'Usually only for customers that invoice freight separately.', ) + x_fc_strict_thickness_required = fields.Boolean( + string='Require Thickness Readings on CoC', + default=False, tracking=True, + help='Aerospace / Nadcap customers expect every CoC to carry ' + 'actual Fischerscope readings (not just "meets spec"). When ' + 'this is on, action_issue() blocks until at least one ' + 'thickness reading has been logged for the MO. Leave off ' + 'for commercial customers.', + ) diff --git a/fusion_plating/fusion_plating_invoicing/__manifest__.py b/fusion_plating/fusion_plating_invoicing/__manifest__.py index 1de47e22..dc4fb64d 100644 --- a/fusion_plating/fusion_plating_invoicing/__manifest__.py +++ b/fusion_plating/fusion_plating_invoicing/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Invoicing', - 'version': '19.0.2.2.0', + 'version': '19.0.2.3.0', 'category': 'Manufacturing/Plating', 'summary': 'Invoice strategy engine with deposit, progress billing, net terms, COD/prepay, and account holds.', 'description': """ diff --git a/fusion_plating/fusion_plating_invoicing/models/sale_order.py b/fusion_plating/fusion_plating_invoicing/models/sale_order.py index 3072df17..6f677af0 100644 --- a/fusion_plating/fusion_plating_invoicing/models/sale_order.py +++ b/fusion_plating/fusion_plating_invoicing/models/sale_order.py @@ -28,8 +28,28 @@ class SaleOrder(models.Model): self.payment_term_id = default.payment_term_id def action_confirm(self): - """Override to check account hold and trigger invoice strategy.""" + """Override to check account hold + customer PO# and trigger + the invoice strategy.""" for order in self: + # --- Customer PO# required --- + # Aerospace AP teams reject invoices without their PO# + # quoted back. Catching this at SO confirm prevents the + # whole downstream chain (CoC, BoL, invoice) from going + # out unreferenced. The PO# is on `client_order_ref` + # (Odoo standard) AND mirrored to `x_fc_po_number` + # (FP-specific) — accept either as filled. + po_set = bool(order.client_order_ref) or bool( + getattr(order, 'x_fc_po_number', False) + ) + if not po_set: + raise UserError(_( + 'Cannot confirm SO "%(so)s" — Customer PO# is required.\n\n' + 'Set the customer\'s purchase order number in the ' + '"Customer Reference" field (or x_fc_po_number) before ' + 'confirming. Aerospace customers\' AP teams reject ' + 'invoices that don\'t quote their PO# back.' + ) % {'so': order.name}) + # --- Account hold check --- if order.partner_id.x_fc_account_hold: is_manager = self.env.user.has_group( diff --git a/fusion_plating/fusion_plating_logistics/__manifest__.py b/fusion_plating/fusion_plating_logistics/__manifest__.py index dda7eaa4..8368ac04 100644 --- a/fusion_plating/fusion_plating_logistics/__manifest__.py +++ b/fusion_plating/fusion_plating_logistics/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Logistics', - 'version': '19.0.1.1.0', + 'version': '19.0.1.2.0', 'category': 'Manufacturing/Plating', 'summary': ( 'Pickup & delivery for plating shops: vehicle master, driver ' diff --git a/fusion_plating/fusion_plating_logistics/models/fp_delivery.py b/fusion_plating/fusion_plating_logistics/models/fp_delivery.py index 02aade20..aedba767 100644 --- a/fusion_plating/fusion_plating_logistics/models/fp_delivery.py +++ b/fusion_plating/fusion_plating_logistics/models/fp_delivery.py @@ -159,7 +159,20 @@ class FpDelivery(models.Model): self.write({'state': 'scheduled'}) def action_start_route(self): + """Block "en route" until at least a driver is assigned. + + Vehicle is encouraged but not strictly required (some shops + let drivers grab whatever vehicle is open at the dock). Driver + is non-negotiable — without it the chain-of-custody hand-off + has no signed party and the POD can't be linked to a person. + """ for rec in self: + if not rec.assigned_driver_id: + raise UserError(_( + 'Cannot mark delivery "%(name)s" en route — no driver ' + 'assigned.\n\nPick a driver on the delivery (or wait for ' + 'the auto-prefill to find one) before tapping Start Route.' + ) % {'name': rec.name or rec.display_name}) rec.write({'state': 'en_route'}) rec._log_custody_event( 'loaded_on_vehicle', diff --git a/fusion_plating/fusion_plating_quality/__manifest__.py b/fusion_plating/fusion_plating_quality/__manifest__.py index 89f5e0c0..13d24594 100644 --- a/fusion_plating/fusion_plating_quality/__manifest__.py +++ b/fusion_plating/fusion_plating_quality/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Quality (QMS)', - 'version': '19.0.1.2.0', + 'version': '19.0.1.3.0', 'category': 'Manufacturing/Plating', 'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, ' 'internal audits, customer specs, document control. CE + EE compatible.', diff --git a/fusion_plating/fusion_plating_quality/models/fp_quality_hold.py b/fusion_plating/fusion_plating_quality/models/fp_quality_hold.py index ed59c1de..4a967751 100644 --- a/fusion_plating/fusion_plating_quality/models/fp_quality_hold.py +++ b/fusion_plating/fusion_plating_quality/models/fp_quality_hold.py @@ -50,10 +50,15 @@ class FpQualityHold(models.Model): ('other', 'Other'), ], string='Hold Reason', - default='other', + required=True, tracking=True, + help='Required so QA can triage holds by category.', + ) + description = fields.Text( + string='Description', + required=True, + help='Required — every hold needs an inspector narrative.', ) - description = fields.Text(string='Description') attachment_ids = fields.Many2many( 'ir.attachment', string='Attachments', diff --git a/fusion_plating/fusion_plating_receiving/__manifest__.py b/fusion_plating/fusion_plating_receiving/__manifest__.py index ce106c93..dbc4a7c9 100644 --- a/fusion_plating/fusion_plating_receiving/__manifest__.py +++ b/fusion_plating/fusion_plating_receiving/__manifest__.py @@ -5,7 +5,7 @@ { 'name': 'Fusion Plating — Receiving & Inspection', - 'version': '19.0.2.0.0', + 'version': '19.0.2.1.0', 'category': 'Manufacturing/Plating', 'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.', 'description': """ diff --git a/fusion_plating/fusion_plating_receiving/models/fp_receiving.py b/fusion_plating/fusion_plating_receiving/models/fp_receiving.py index e3b5ebef..fa99d2ce 100644 --- a/fusion_plating/fusion_plating_receiving/models/fp_receiving.py +++ b/fusion_plating/fusion_plating_receiving/models/fp_receiving.py @@ -108,12 +108,32 @@ class FpReceiving(models.Model): rec.received_date = fields.Datetime.now() def action_accept(self): - """Accept the receiving — parts match and condition is OK.""" + """Accept the receiving — parts match and condition is OK. + + Quantity-mismatch policy: if expected_qty != received_qty, + operators must use action_flag_discrepancy() instead. Managers + can override (the override is logged on chatter for audit). + """ + is_manager = self.env.user.has_group( + 'fusion_plating.group_fusion_plating_manager' + ) for rec in self: if rec.state not in ('inspecting', 'resolved'): raise UserError(_('Can only accept from Inspecting or Resolved state.')) if rec.unresolved_damage_count > 0: raise UserError(_('Cannot accept — there are %d unresolved damage entries.') % rec.unresolved_damage_count) + qty_match = rec.expected_qty > 0 and rec.received_qty == rec.expected_qty + if not qty_match: + if not is_manager: + raise UserError(_( + 'Cannot accept — quantity mismatch (expected %(exp)d, ' + 'received %(rcv)d).\n\nUse "Flag Discrepancy" instead, ' + 'or have a manager override.' + ) % {'exp': rec.expected_qty, 'rcv': rec.received_qty}) + rec.message_post(body=_( + 'Manager override: accepted with quantity mismatch ' + '(expected %(exp)d, received %(rcv)d).' + ) % {'exp': rec.expected_qty, 'rcv': rec.received_qty}) rec.state = 'accepted' rec._update_so_receiving_status() rec.message_post(body=_('Parts accepted — quantity: %d, all checks passed.') % rec.received_qty) diff --git a/fusion_plating/fusion_plating_shopfloor/__manifest__.py b/fusion_plating/fusion_plating_shopfloor/__manifest__.py index 1404e522..18ffb4be 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.14.3.0', + 'version': '19.0.14.4.0', 'category': 'Manufacturing/Plating', 'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, ' 'first-piece inspection gates.', diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py index 2ff6ab4d..3cd789bd 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/manager_controller.py @@ -61,25 +61,26 @@ class FpManagerDashboardController(http.Controller): # effectively read-only. has_assign = 'x_fc_assigned_user_id' in MrpWO._fields - # ---- Column 1: Unassigned (no worker on an active WO) ---------- - # 'not in (done, cancel)' rather than an explicit allow-list so - # we catch every active state Odoo emits — including 'blocked' - # (predecessor not done yet). The previous allow-list missed - # 'blocked' and left the column empty for entire MO routings - # whose first WO was still running. + # ---- Column 1: Unassigned ("Setup Pending") -------------------- + # A WO stays here until the manager has set EVERY field + # button_start would block on (operator + per-kind equipment). + # Without this, picking a worker would auto-jump the row to + # "In Progress" before bath/tank/oven/rack/material are set. + # We compute release-readiness in Python after the SQL search + # because x_fc_is_release_ready is a non-stored compute. ACTIVE_NEG_STATES = ('done', 'cancel') - domain_unassigned = [ - ('state', 'not in', ACTIVE_NEG_STATES), - ] - if has_assign: - domain_unassigned.append(('x_fc_assigned_user_id', '=', False)) - else: - # Without the assignment field, treat ALL active WOs as unassigned - pass + domain_active_states = [('state', 'not in', ACTIVE_NEG_STATES)] if facility_id: - domain_unassigned.append( + domain_active_states.append( ('workcenter_id.x_fc_facility_id', '=', int(facility_id))) - unassigned_wos = MrpWO.search(domain_unassigned, order='sequence, id') + all_active_wos = MrpWO.search(domain_active_states, order='sequence, id') + # Split: not-release-ready → Unassigned/Setup column; rest → In Progress + if 'x_fc_is_release_ready' in MrpWO._fields: + unassigned_wos = all_active_wos.filtered(lambda w: not w.x_fc_is_release_ready) + elif has_assign: + unassigned_wos = all_active_wos.filtered(lambda w: not w.x_fc_assigned_user_id) + else: + unassigned_wos = all_active_wos # Roll up to MO level def _group_by_mo(wos): @@ -135,6 +136,43 @@ class FpManagerDashboardController(http.Controller): w.x_fc_work_role_id.name or '' if w.x_fc_work_role_id else '' ), + # WO kind classification + what's still missing + # before the WO can be released to the operator. + # Manager Desk uses these to render the kind + # badge and the "needs: bath, tank" hint chips. + 'wo_kind': ( + w.x_fc_wo_kind + if 'x_fc_wo_kind' in w._fields else 'other' + ), + 'wo_kind_label': dict( + w._fields['x_fc_wo_kind'].selection + ).get(w.x_fc_wo_kind, '') if 'x_fc_wo_kind' in w._fields else '', + 'is_release_ready': ( + w.x_fc_is_release_ready + if 'x_fc_is_release_ready' in w._fields else False + ), + 'missing_for_release': ( + w.x_fc_missing_for_release or '' + if 'x_fc_missing_for_release' in w._fields else '' + ), + # Surface oven, rack, masking material so the + # manager can see at a glance what's set. + 'oven': ( + w.x_fc_oven_id.name or '' + if 'x_fc_oven_id' in w._fields and w.x_fc_oven_id + else '' + ), + 'rack': ( + w.x_fc_rack_id.name or '' + if 'x_fc_rack_id' in w._fields and w.x_fc_rack_id + else '' + ), + 'masking_material': ( + dict(w._fields['x_fc_masking_material'].selection).get( + w.x_fc_masking_material, '' + ) if 'x_fc_masking_material' in w._fields and w.x_fc_masking_material + else '' + ), } for w in wos ], @@ -145,20 +183,15 @@ class FpManagerDashboardController(http.Controller): mo = Production.browse(mo_id) unassigned_cards.append(_mo_card(mo, wos)) - # ---- Column 2: In Progress (MOs with at least one active WO) ---- - # Same widening as the unassigned domain — capture every active - # state. Without 'blocked' in the set, an MO whose only running - # WO is currently blocked-waiting-on-predecessor disappears from - # the column even though the assigned worker is still on point. - domain_active = [ - ('state', 'not in', ACTIVE_NEG_STATES), - ] - if has_assign: - domain_active.append(('x_fc_assigned_user_id', '!=', False)) - if facility_id: - domain_active.append( - ('workcenter_id.x_fc_facility_id', '=', int(facility_id))) - active_wos = MrpWO.search(domain_active, order='sequence, id') + # ---- Column 2: In Progress ------------------------------------- + # Release-ready WOs (everything the manager needed to set is + # filled in) — operator can tap Start on the iPad. + if 'x_fc_is_release_ready' in MrpWO._fields: + active_wos = all_active_wos.filtered(lambda w: w.x_fc_is_release_ready) + elif has_assign: + active_wos = all_active_wos.filtered(lambda w: w.x_fc_assigned_user_id) + else: + active_wos = MrpWO # empty active_cards = [] for mo_id, wos in _group_by_mo(active_wos).items(): mo = Production.browse(mo_id) diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/scss/manager_dashboard.scss b/fusion_plating/fusion_plating_shopfloor/static/src/scss/manager_dashboard.scss index 2d0db4c5..9815f3cd 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/scss/manager_dashboard.scss +++ b/fusion_plating/fusion_plating_shopfloor/static/src/scss/manager_dashboard.scss @@ -507,6 +507,20 @@ &.o_fp_chip_warning { @include fp-pill(--bs-warning); } &.o_fp_chip_danger { @include fp-pill(--bs-danger); } &.o_fp_chip_muted { background-color: $fp-card-soft; color: $fp-ink-mute; } + + // WO-kind colour bands so the manager can spot + // mask vs wet vs bake at a glance. + &.o_fp_chip_kind { + text-transform: none; + letter-spacing: normal; + font-weight: $fp-weight-bold; + } + &.o_fp_chip_kind_wet { background-color: rgba(13, 110, 253, .15); color: #0d6efd; } + &.o_fp_chip_kind_bake { background-color: rgba(220, 53, 69, .15); color: #dc3545; } + &.o_fp_chip_kind_mask { background-color: rgba(255, 193, 7, .20); color: #997404; } + &.o_fp_chip_kind_rack { background-color: rgba(108, 117, 125, .15); color: #495057; } + &.o_fp_chip_kind_inspect { background-color: rgba(25, 135, 84, .15); color: #198754; } + &.o_fp_chip_kind_other { background-color: $fp-card-soft; color: $fp-ink-mute; } } diff --git a/fusion_plating/fusion_plating_shopfloor/static/src/xml/manager_dashboard.xml b/fusion_plating/fusion_plating_shopfloor/static/src/xml/manager_dashboard.xml index cd42a03c..77f948b7 100644 --- a/fusion_plating/fusion_plating_shopfloor/static/src/xml/manager_dashboard.xml +++ b/fusion_plating/fusion_plating_shopfloor/static/src/xml/manager_dashboard.xml @@ -135,11 +135,23 @@
- - +
+ + +
+
- · - + · + · + · + · + · +
+
+ Needs: +
-