feat(plating): in-Odoo notifications, timer audit, presence-aware Manager Desk, auto-promotion
End-to-end workflow tightening + the team / skills system. Three
phases bundled because they share the same touchpoints (button_start /
button_finish / Manager Desk dropdown).
PHASE 1 — In-Odoo notifications + timer audit
=============================================
Workers now get a bell-icon notification (Odoo Discuss inbox) the
moment a manager assigns them a WO. No email — operators check Discuss
between jobs, and the customer-facing notification dispatcher stays
out of the worker loop.
- mrp.workorder.write() override fires message_notify(message_type=
'user_notification') only when x_fc_assigned_user_id transitions to
a non-empty value (clearing or no-op writes don't ping)
- 4 new fields on the WO header surface what was previously buried in
time_ids: x_fc_started_by_user_id, x_fc_started_at,
x_fc_finished_by_user_id, x_fc_finished_at
- button_start stamps started_* once (subsequent pause/resume cycles
preserve the original); button_finish stamps finished_* every time
the WO closes
- New "Timer Audit" group on the WO form (Time & Cost tab)
PHASE 2 — Presence-aware Manager Desk
=====================================
Manager Desk now knows who's clocked in. Works with vanilla
hr_attendance and fusion_clock — both expose hr.attendance with an
open record while the operator is on shift.
- bridge_mrp depends on hr_attendance
- hr.employee.x_fc_is_clocked_in computed field (batched query — one
DB hit for the whole employee set, not N+1)
- hr.employee._fp_clocked_in_user_ids() classmethod for the dashboard
- manager_controller sends operators with is_clocked_in / role_ids /
lead_hand_role_ids per worker, plus presence dict {clocked_in: N,
total: M}; each WO carries role_id/role_name so the dropdown can
match qualified operators
Manager Desk OWL:
- Header gets a "Present 7 / 12" pill chip; tap to toggle hideOffShift
(off-shift hidden when active, accent colour when filter is on)
- New operatorsForWO(wo) helper sorts dropdown options into 4 buckets:
qualified+clocked-in → lead-hand+clocked-in → clocked-in untrained
(training mode) → off-shift (greyed; only shown when hideOffShift
is false). Each option carries a ●/○ dot prefix and a soft suffix.
PHASE 3 — Skills, lead-hand-per-role, auto-promotion
====================================================
The team grows organically: managers assign training tasks, operators
finish them, the system auto-promotes after N successful runs.
- fp.work.role.mastery_required (integer, default reads from the
company-level Default Mastery Threshold). Each role can override —
masking might need 1 success, electroless nickel 5.
- res.company.x_fc_default_mastery_threshold + res.config.settings
exposure under "Workforce Settings" in the Fusion Plating settings
block (default 3)
- hr.employee.x_fc_lead_hand_role_ids m2m, separate from
x_fc_work_role_ids — Sarah can be a lead hand for masking + racking
even if those aren't her primary roles. Manager-only group access.
- New fp.operator.proficiency model (one row per employee+role) with
completed_count, first/last_completed_at, promoted, promoted_at,
progress_label compute. SQL-unique on (employee, role).
- mrp.workorder.button_finish increments the (employee, role)
counter, then if count >= role.mastery_required AND not promoted,
adds the role to x_fc_work_role_ids and posts a "🎉 Promoted"
chatter line on the employee record. Wrapped in try/except so a
tracker glitch never blocks production.
- Promotion uses the WO's assigned_user_id, NOT env.user — credit
goes to the operator who was supposed to do it, even if a manager
finished on their behalf.
Employee form gets a "Shop Roles" tab (supervisor+):
- "Tasks This Operator Can Do" m2m
- "Lead Hand For" m2m (manager-only)
- Read-only Task Proficiency list with progress / promotion badges
Verified on odoo-entech: all fields land, default threshold = 3,
asset bundle regenerated as 9f38f05.
Module bumps: fusion_plating 19.0.4.0.0,
fusion_plating_bridge_mrp 19.0.4.0.0,
fusion_plating_shopfloor 19.0.11.0.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating',
|
||||
'version': '19.0.3.0.0',
|
||||
'version': '19.0.4.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||
'description': """
|
||||
|
||||
@@ -29,6 +29,20 @@ class ResCompany(models.Model):
|
||||
'Settings > Fusion Plating.',
|
||||
)
|
||||
|
||||
# ----- Worker auto-promotion default -----------------------------------
|
||||
# Default number of successful WO completions a worker needs on a role
|
||||
# before it's auto-added to their Shop Roles. Each role can override
|
||||
# via fp.work.role.mastery_required.
|
||||
x_fc_default_mastery_threshold = fields.Integer(
|
||||
string='Default Mastery Threshold',
|
||||
default=3,
|
||||
help='How many successful WO completions an operator needs on a '
|
||||
"task before it's added to their Shop Roles automatically. "
|
||||
'New roles inherit this number; managers can override per '
|
||||
'role on the role form. 1 = promote on first success; 3 = '
|
||||
'solid baseline; 5+ for tasks that need real practice.',
|
||||
)
|
||||
|
||||
# ----- Facility footprint for this legal entity ----------------------
|
||||
x_fc_facility_ids = fields.One2many(
|
||||
'fusion.plating.facility',
|
||||
|
||||
@@ -20,3 +20,8 @@ class ResConfigSettings(models.TransientModel):
|
||||
readonly=False,
|
||||
string='Fusion Plating Timezone',
|
||||
)
|
||||
x_fc_default_mastery_threshold = fields.Integer(
|
||||
related='company_id.x_fc_default_mastery_threshold',
|
||||
readonly=False,
|
||||
string='Default Mastery Threshold',
|
||||
)
|
||||
|
||||
@@ -27,6 +27,16 @@
|
||||
<field name="x_fc_default_tz"/>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<block title="Workforce Settings"
|
||||
name="fp_workforce_settings"
|
||||
help="Defaults that govern how the shop tracks worker skills and promotions across recipes.">
|
||||
<setting id="fp_default_mastery"
|
||||
string="Default Mastery Threshold"
|
||||
help="How many successful WO completions an operator needs on a new task before it's added to their Shop Roles automatically. Each role can override this on its own form (e.g. masking 1, electroless nickel 5).">
|
||||
<field name="x_fc_default_mastery_threshold"/>
|
||||
</setting>
|
||||
</block>
|
||||
</app>
|
||||
</xpath>
|
||||
</field>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — MRP Bridge',
|
||||
'version': '19.0.3.0.0',
|
||||
'version': '19.0.4.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
|
||||
'description': """
|
||||
@@ -42,6 +42,13 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'fusion_plating_shopfloor',
|
||||
'fusion_plating_configurator',
|
||||
'hr',
|
||||
# hr_attendance gives us the standard hr.attendance model
|
||||
# (check_in / check_out). fusion_clock builds on the same model
|
||||
# so this works whether the shop runs vanilla attendance or the
|
||||
# full Fusion Clock T&A. Bringing the dep into the bridge keeps
|
||||
# the Manager Desk's "show only clocked-in workers" filter
|
||||
# working out of the box.
|
||||
'hr_attendance',
|
||||
'mrp',
|
||||
'mrp_workorder',
|
||||
'mrp_account',
|
||||
|
||||
@@ -17,4 +17,5 @@ from . import account_move
|
||||
from . import sale_order
|
||||
from . import fp_work_role
|
||||
from . import hr_employee
|
||||
from . import fp_proficiency
|
||||
from . import fp_process_node
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""Operator proficiency tracker — counts successful WO completions per
|
||||
(employee, role) pair and auto-promotes the employee once the role's
|
||||
mastery threshold is crossed.
|
||||
|
||||
The promotion mechanic lets managers casually train workers on the job:
|
||||
they assign someone a task they've never done, the worker finishes it
|
||||
successfully, and after N successes the role is added to the employee's
|
||||
Shop Roles automatically. The operator never has to fill in a form;
|
||||
their growing skill set just unlocks itself.
|
||||
"""
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
|
||||
class FpOperatorProficiency(models.Model):
|
||||
_name = 'fp.operator.proficiency'
|
||||
_description = 'Fusion Plating — Operator Task Proficiency'
|
||||
_rec_name = 'display_name'
|
||||
_order = 'employee_id, role_id'
|
||||
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee', string='Operator',
|
||||
required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
role_id = fields.Many2one(
|
||||
'fp.work.role', string='Role',
|
||||
required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
completed_count = fields.Integer(
|
||||
string='Completions',
|
||||
default=0,
|
||||
help='Number of times this operator has successfully finished a '
|
||||
'WO that required this role.',
|
||||
)
|
||||
first_completed_at = fields.Datetime(
|
||||
string='First Success',
|
||||
help='When the operator finished their first WO for this role.',
|
||||
)
|
||||
last_completed_at = fields.Datetime(
|
||||
string='Last Success',
|
||||
help='Most recent WO completion against this role.',
|
||||
)
|
||||
promoted = fields.Boolean(
|
||||
string='Promoted',
|
||||
default=False,
|
||||
index=True,
|
||||
help='True once the role has been added to the operator\'s Shop '
|
||||
'Roles automatically. Stays True even if a manager removes '
|
||||
'the role afterwards — the count and promotion history are '
|
||||
'preserved as a training record.',
|
||||
)
|
||||
promoted_at = fields.Datetime(
|
||||
string='Promoted On',
|
||||
help='When the auto-promotion fired (count crossed the role\'s '
|
||||
'mastery threshold).',
|
||||
)
|
||||
|
||||
display_name = fields.Char(
|
||||
compute='_compute_display_name', store=True,
|
||||
)
|
||||
progress_label = fields.Char(
|
||||
compute='_compute_progress_label',
|
||||
help='"3 / 5" style indicator of how close this operator is to '
|
||||
'mastery.',
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
('fp_proficiency_uniq',
|
||||
'unique(employee_id, role_id)',
|
||||
'There is already a proficiency record for this operator and role.'),
|
||||
]
|
||||
|
||||
@api.depends('employee_id.name', 'role_id.name')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
rec.display_name = (
|
||||
f'{rec.employee_id.name or "?"} — {rec.role_id.name or "?"}'
|
||||
)
|
||||
|
||||
@api.depends('completed_count', 'role_id.mastery_required')
|
||||
def _compute_progress_label(self):
|
||||
for rec in self:
|
||||
target = rec.role_id.mastery_required or 0
|
||||
rec.progress_label = (
|
||||
f'{rec.completed_count} / {target}' if target
|
||||
else str(rec.completed_count)
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# API used by mrp.workorder.button_finish (via _fp_record_proficiency).
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def _record_completion(self, employee, role):
|
||||
"""Increment the (employee, role) tally and promote if at threshold.
|
||||
|
||||
Idempotent for the (employee, role) pair — if no record exists,
|
||||
we create one. Always uses sudo() because the worker may not
|
||||
have write access to their own profile.
|
||||
"""
|
||||
if not employee or not role:
|
||||
return self.browse()
|
||||
|
||||
rec = self.sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('role_id', '=', role.id),
|
||||
], limit=1)
|
||||
now = fields.Datetime.now()
|
||||
if rec:
|
||||
new_count = rec.completed_count + 1
|
||||
rec.write({
|
||||
'completed_count': new_count,
|
||||
'last_completed_at': now,
|
||||
})
|
||||
else:
|
||||
rec = self.sudo().create({
|
||||
'employee_id': employee.id,
|
||||
'role_id': role.id,
|
||||
'completed_count': 1,
|
||||
'first_completed_at': now,
|
||||
'last_completed_at': now,
|
||||
})
|
||||
rec._maybe_promote()
|
||||
return rec
|
||||
|
||||
def _maybe_promote(self):
|
||||
"""Promote the employee if they've crossed the role's threshold.
|
||||
|
||||
- Already promoted: no-op (history is preserved but no duplicate
|
||||
chatter spam).
|
||||
- Already in Shop Roles (e.g. manager added it manually): mark
|
||||
promoted but don't post chatter.
|
||||
- Below threshold: nothing to do.
|
||||
- At/above threshold AND not on Shop Roles yet: add the role and
|
||||
post a celebratory chatter line on the employee.
|
||||
"""
|
||||
for rec in self:
|
||||
if rec.promoted:
|
||||
continue
|
||||
target = rec.role_id.mastery_required or 0
|
||||
if target <= 0:
|
||||
continue # Auto-promotion disabled for this role
|
||||
if rec.completed_count < target:
|
||||
continue
|
||||
employee = rec.employee_id
|
||||
role = rec.role_id
|
||||
already_assigned = role in employee.x_fc_work_role_ids
|
||||
rec.sudo().write({
|
||||
'promoted': True,
|
||||
'promoted_at': fields.Datetime.now(),
|
||||
})
|
||||
if already_assigned:
|
||||
# Manager pre-added the role; don't double-announce.
|
||||
continue
|
||||
# Add to Shop Roles + announce on the employee chatter.
|
||||
employee.sudo().write({
|
||||
'x_fc_work_role_ids': [(4, role.id)],
|
||||
})
|
||||
employee.message_post(
|
||||
body=_(
|
||||
'🎉 <b>%(name)s promoted</b> — qualified for '
|
||||
'<b>%(role)s</b> after %(count)s successful '
|
||||
'completions.',
|
||||
name=employee.name,
|
||||
role=role.name,
|
||||
count=rec.completed_count,
|
||||
),
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FpWorkRole(models.Model):
|
||||
@@ -43,7 +43,25 @@ class FpWorkRole(models.Model):
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('fp_work_role_code_uniq', 'unique(code)',
|
||||
'Role code must be unique.'),
|
||||
]
|
||||
# ------------------------------------------------------------------
|
||||
# Mastery threshold — how many successful WO completions a worker
|
||||
# needs on this role before they're auto-promoted (added to their
|
||||
# x_fc_work_role_ids). Default reads from the company-level Fusion
|
||||
# Plating settings so a new role inherits the shop default; the
|
||||
# manager can override per role for tasks that need more practice
|
||||
# (e.g. masking = 1, electroless nickel plating = 5).
|
||||
# ------------------------------------------------------------------
|
||||
mastery_required = fields.Integer(
|
||||
string='Mastery Threshold',
|
||||
default=lambda self: self._default_mastery_required(),
|
||||
help='Number of successful WO completions a worker needs on this '
|
||||
"role before they're added to its qualified-operators list "
|
||||
'automatically. 1 = promote on first success; 3 = solid '
|
||||
"default for everyday roles; 5+ for tasks that need real "
|
||||
'practice. Defaults from Settings > Fusion Plating > '
|
||||
'Default Mastery Threshold.',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _default_mastery_required(self):
|
||||
return self.env.company.x_fc_default_mastery_threshold or 3
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class HrEmployee(models.Model):
|
||||
@@ -13,6 +13,12 @@ class HrEmployee(models.Model):
|
||||
are generated; an employee with multiple roles receives WOs for all
|
||||
of them. A small shop where the owner wears every hat just tags
|
||||
themselves with every role.
|
||||
|
||||
Lead hands are a separate per-role list — they don't have to be
|
||||
primary owners of those roles, but they're authorised to step in
|
||||
when the regular owner is absent or behind. The Manager Desk
|
||||
promotes lead hands above other workers in its dropdown for any
|
||||
role they cover.
|
||||
"""
|
||||
_inherit = 'hr.employee'
|
||||
|
||||
@@ -20,5 +26,94 @@ class HrEmployee(models.Model):
|
||||
'fp.work.role', 'fp_employee_work_role_rel',
|
||||
'employee_id', 'role_id', string='Shop Roles',
|
||||
help='Which shop roles this employee performs. Used by the '
|
||||
'Manager Desk and auto-assignment on WO generation.',
|
||||
'Manager Desk and auto-assignment on WO generation. '
|
||||
'Roles are added automatically when an employee completes '
|
||||
'a task that meets the role mastery threshold.',
|
||||
)
|
||||
# Per-role lead-hand list. Sarah might be a lead hand for masking +
|
||||
# racking but not for plating; Mike might cover everything during
|
||||
# a graveyard shift. Stored on a separate relation table so the
|
||||
# primary "Shop Roles" list stays distinct from the cover-anything
|
||||
# authority.
|
||||
x_fc_lead_hand_role_ids = fields.Many2many(
|
||||
'fp.work.role', 'fp_employee_lead_hand_role_rel',
|
||||
'employee_id', 'role_id', string='Lead Hand For',
|
||||
help='Roles where this employee is authorised to lead or cover '
|
||||
'for an absent operator. Lead hands are surfaced first in '
|
||||
'the Manager Desk worker picker for these roles.',
|
||||
)
|
||||
|
||||
x_fc_proficiency_ids = fields.One2many(
|
||||
'fp.operator.proficiency', 'employee_id',
|
||||
string='Task Proficiency',
|
||||
help='Per-role completion tally. Workers earn one count per WO '
|
||||
'they finish on a given role. Once the count crosses the '
|
||||
"role's mastery threshold the role is added to their "
|
||||
'Shop Roles list automatically.',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Attendance helpers — used by the Manager Desk to show who is
|
||||
# currently clocked in. Works with vanilla hr_attendance or the
|
||||
# full fusion_clock module — both store an open record (no
|
||||
# check_out) for as long as the employee is on shift.
|
||||
# ------------------------------------------------------------------
|
||||
x_fc_is_clocked_in = fields.Boolean(
|
||||
string='Clocked In',
|
||||
compute='_compute_x_fc_is_clocked_in',
|
||||
search='_search_x_fc_is_clocked_in',
|
||||
help='True if this employee currently has an open hr.attendance '
|
||||
'record (clocked in but not clocked out).',
|
||||
)
|
||||
|
||||
def _compute_x_fc_is_clocked_in(self):
|
||||
"""Compute attendance status from hr.attendance.
|
||||
|
||||
Batched so the manager dashboard doesn't issue one query per
|
||||
employee — important when the shop has dozens of operators.
|
||||
"""
|
||||
if not self:
|
||||
return
|
||||
Att = self.env.get('hr.attendance')
|
||||
if Att is None:
|
||||
for emp in self:
|
||||
emp.x_fc_is_clocked_in = False
|
||||
return
|
||||
# One read for the whole recordset.
|
||||
open_emp_ids = set(Att.sudo().search([
|
||||
('employee_id', 'in', self.ids),
|
||||
('check_out', '=', False),
|
||||
]).mapped('employee_id').ids)
|
||||
for emp in self:
|
||||
emp.x_fc_is_clocked_in = emp.id in open_emp_ids
|
||||
|
||||
def _search_x_fc_is_clocked_in(self, operator, value):
|
||||
"""Lets `[('x_fc_is_clocked_in', '=', True)]` work as a domain."""
|
||||
Att = self.env.get('hr.attendance')
|
||||
if Att is None:
|
||||
return [('id', '=', False)]
|
||||
open_ids = Att.sudo().search([
|
||||
('check_out', '=', False),
|
||||
]).mapped('employee_id').ids
|
||||
if operator in ('=', '!='):
|
||||
wanted = bool(value)
|
||||
if operator == '!=':
|
||||
wanted = not wanted
|
||||
return [('id', 'in' if wanted else 'not in', open_ids)]
|
||||
return []
|
||||
|
||||
@api.model
|
||||
def _fp_clocked_in_user_ids(self):
|
||||
"""Return the set of res.users.ids whose linked employee is on shift.
|
||||
|
||||
Used by the Manager Desk controller to short-circuit the worker
|
||||
dropdown to "present today" without an N+1 attendance query
|
||||
per worker.
|
||||
"""
|
||||
Att = self.env.get('hr.attendance')
|
||||
if Att is None:
|
||||
return set()
|
||||
emps = Att.sudo().search([
|
||||
('check_out', '=', False),
|
||||
]).mapped('employee_id')
|
||||
return set(emps.user_id.ids)
|
||||
|
||||
@@ -70,6 +70,34 @@ class MrpWorkorder(models.Model):
|
||||
'recipe operation on WO generation).',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Timer audit — surface the who / when of the timer on the WO header.
|
||||
# Odoo records every start/stop in mrp.workcenter.productivity but
|
||||
# the operator + manager need to see "started by Sarah at 09:14,
|
||||
# finished by Sarah at 11:42" without drilling into time_ids.
|
||||
# Populated by the button_start / button_finish overrides below.
|
||||
# ------------------------------------------------------------------
|
||||
x_fc_started_by_user_id = fields.Many2one(
|
||||
'res.users', string='Started By',
|
||||
readonly=True, copy=False,
|
||||
help='The operator who first hit Start on this work order.',
|
||||
)
|
||||
x_fc_started_at = fields.Datetime(
|
||||
string='Started At',
|
||||
readonly=True, copy=False,
|
||||
help='Wall-clock time the timer first started running.',
|
||||
)
|
||||
x_fc_finished_by_user_id = fields.Many2one(
|
||||
'res.users', string='Finished By',
|
||||
readonly=True, copy=False,
|
||||
help='The operator who hit Finish to close the WO.',
|
||||
)
|
||||
x_fc_finished_at = fields.Datetime(
|
||||
string='Finished At',
|
||||
readonly=True, copy=False,
|
||||
help='Wall-clock time the timer was closed for the last time.',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Workflow step tracking
|
||||
# ------------------------------------------------------------------
|
||||
@@ -420,6 +448,68 @@ class MrpWorkorder(models.Model):
|
||||
})
|
||||
return {'holds': holds, 'ncrs': ncrs}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# write() — fire an in-Odoo notification when a worker is assigned.
|
||||
# Email is intentionally NOT sent here; the operator gets a bell-icon
|
||||
# ping in Odoo Discuss the moment the manager picks them. The
|
||||
# fp.notification.template hooks still send emails for customer-facing
|
||||
# events, but worker assignment is internal.
|
||||
# ------------------------------------------------------------------
|
||||
def write(self, vals):
|
||||
# Snapshot the previous assignee so we know if it actually changed.
|
||||
# We only notify on a real change to a non-empty value (clearing
|
||||
# the field doesn't deserve a ping).
|
||||
previous = {wo.id: wo.x_fc_assigned_user_id.id for wo in self}
|
||||
res = super().write(vals)
|
||||
if 'x_fc_assigned_user_id' in vals:
|
||||
for wo in self:
|
||||
new_id = wo.x_fc_assigned_user_id.id
|
||||
if new_id and new_id != previous.get(wo.id):
|
||||
wo._fp_notify_assignee()
|
||||
return res
|
||||
|
||||
def _fp_notify_assignee(self):
|
||||
"""Send a bell-icon notification to the newly-assigned operator.
|
||||
|
||||
Uses message_type='user_notification' which routes to the user's
|
||||
Inbox in Discuss without creating a chatter entry on the record
|
||||
(Odoo treats it as a transient ping). The body is intentionally
|
||||
terse — operators read these on a tablet between jobs.
|
||||
"""
|
||||
for wo in self:
|
||||
user = wo.x_fc_assigned_user_id
|
||||
if not user or not user.partner_id:
|
||||
continue
|
||||
mo = wo.production_id
|
||||
customer = wo.x_fc_customer_id.name if wo.x_fc_customer_id else ''
|
||||
product = (
|
||||
mo.product_id.display_name if mo and mo.product_id else ''
|
||||
)
|
||||
qty = int(mo.product_qty or 0) if mo else 0
|
||||
wc = wo.workcenter_id.name or ''
|
||||
role = wo.x_fc_work_role_id.name or ''
|
||||
|
||||
# Build a short, scannable body
|
||||
lines = [
|
||||
_('You have been assigned <b>%s</b>.', wo.display_name or wo.name),
|
||||
_('MO: %s · %s · Qty %s', mo.name if mo else '—', product, qty),
|
||||
]
|
||||
if wc:
|
||||
lines.append(_('Work centre: %s', wc))
|
||||
if role:
|
||||
lines.append(_('Role: %s', role))
|
||||
if customer:
|
||||
lines.append(_('Customer: %s', customer))
|
||||
body = '<br/>'.join(lines)
|
||||
|
||||
wo.message_notify(
|
||||
partner_ids=user.partner_id.ids,
|
||||
subject=_('Work order assigned — %s', wo.display_name or wo.name),
|
||||
body=body,
|
||||
# Inbox-only ping; no chatter post, no email.
|
||||
email_layout_xmlid=False,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# T2.2 — Certification gate on WO start
|
||||
# ------------------------------------------------------------------
|
||||
@@ -427,7 +517,20 @@ class MrpWorkorder(models.Model):
|
||||
"""Block start unless the current user's linked employee holds
|
||||
an active certification for this WO's process type."""
|
||||
self._fp_check_operator_certification()
|
||||
return super().button_start()
|
||||
res = super().button_start()
|
||||
# Capture audit AFTER the super call so we don't stamp WOs that
|
||||
# the cert gate (or any other downstream check) rejected.
|
||||
now = fields.Datetime.now()
|
||||
uid = self.env.user.id
|
||||
for wo in self:
|
||||
# Only stamp the first time — subsequent pause/resume cycles
|
||||
# shouldn't overwrite the original start.
|
||||
if not wo.x_fc_started_at:
|
||||
wo.sudo().write({
|
||||
'x_fc_started_at': now,
|
||||
'x_fc_started_by_user_id': uid,
|
||||
})
|
||||
return res
|
||||
|
||||
def _fp_check_operator_certification(self):
|
||||
"""Raise UserError if the user isn't certified for this process."""
|
||||
@@ -461,14 +564,57 @@ class MrpWorkorder(models.Model):
|
||||
# T1.3 — Rack MTO increment when a rack was used
|
||||
# ------------------------------------------------------------------
|
||||
def button_finish(self):
|
||||
"""Finish the WO, bump rack MTO, spawn bake window if required."""
|
||||
"""Finish the WO, bump rack MTO, spawn bake window if required.
|
||||
|
||||
Also stamps the finished_by/finished_at audit fields and runs
|
||||
the proficiency tracker so workers earn credit toward auto-
|
||||
promotion (see fp.operator.proficiency).
|
||||
"""
|
||||
res = super().button_finish()
|
||||
now = fields.Datetime.now()
|
||||
uid = self.env.user.id
|
||||
for wo in self:
|
||||
if wo.x_fc_rack_id:
|
||||
wo.x_fc_rack_id._increment_mto(1.0)
|
||||
# Audit stamp — overwrite each time the WO is closed so the
|
||||
# most recent finish is what's shown.
|
||||
wo.sudo().write({
|
||||
'x_fc_finished_at': now,
|
||||
'x_fc_finished_by_user_id': uid,
|
||||
})
|
||||
# Proficiency tracking + auto-promotion. Wrapped in try so a
|
||||
# tracker glitch never blocks production.
|
||||
try:
|
||||
wo._fp_record_proficiency()
|
||||
except Exception:
|
||||
import logging
|
||||
logging.getLogger(__name__).exception(
|
||||
'Proficiency tracker failed for WO %s', wo.id,
|
||||
)
|
||||
self._fp_spawn_bake_window_if_needed()
|
||||
return res
|
||||
|
||||
def _fp_record_proficiency(self):
|
||||
"""Increment the (employee, role) completion counter and promote
|
||||
the employee if they've crossed the role's mastery threshold.
|
||||
|
||||
Runs on the assigned worker, NOT the user who clicked Finish —
|
||||
sometimes a manager finishes a job on behalf of an absent
|
||||
operator. The CREDIT belongs to the assigned worker.
|
||||
"""
|
||||
Prof = self.env.get('fp.operator.proficiency')
|
||||
if Prof is None:
|
||||
return # tracker model not installed yet — nothing to do
|
||||
for wo in self:
|
||||
user = wo.x_fc_assigned_user_id
|
||||
role = wo.x_fc_work_role_id
|
||||
if not user or not role:
|
||||
continue
|
||||
employee = user.employee_id
|
||||
if not employee:
|
||||
continue
|
||||
Prof.sudo()._record_completion(employee, role)
|
||||
|
||||
def _fp_spawn_bake_window_if_needed(self):
|
||||
"""Create a fusion.plating.bake.window record if the MO's coating
|
||||
config requires it and this WO was the plating step.
|
||||
|
||||
@@ -17,3 +17,6 @@ access_fp_job_consumption_supervisor,fp.job.consumption.supervisor,model_fp_job_
|
||||
access_fp_job_consumption_manager,fp.job.consumption.manager,model_fp_job_consumption,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_work_role_operator,fp.work.role.operator,model_fp_work_role,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_work_role_manager,fp.work.role.manager,model_fp_work_role,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_proficiency_operator,fp.operator.proficiency.operator,model_fp_operator_proficiency,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_proficiency_supervisor,fp.operator.proficiency.supervisor,model_fp_operator_proficiency,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_proficiency_manager,fp.operator.proficiency.manager,model_fp_operator_proficiency,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
|
||||
|
@@ -39,12 +39,21 @@
|
||||
</group>
|
||||
<group>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
<field name="mastery_required"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="description"
|
||||
placeholder="Short operator-facing description of what this role covers."/>
|
||||
</group>
|
||||
<div class="alert alert-info" role="alert">
|
||||
<i class="fa fa-info-circle me-1"/>
|
||||
<strong>Mastery Threshold</strong> controls auto-promotion: when an
|
||||
operator has finished this many WOs against this role, the role is
|
||||
added to their Shop Roles automatically and a chatter line is
|
||||
posted to their employee record. Defaults from
|
||||
<em>Settings > Fusion Plating > Default Mastery Threshold</em>.
|
||||
</div>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
@@ -73,24 +82,62 @@
|
||||
sequence="55"
|
||||
groups="fusion_plating.group_fusion_plating_manager"/>
|
||||
|
||||
<!-- Employee form — add roles section -->
|
||||
<!-- Employee form — Shop Roles + Lead Hand For + Proficiency tracker -->
|
||||
<record id="view_hr_employee_form_fp_roles" model="ir.ui.view">
|
||||
<field name="name">hr.employee.form.fp.roles</field>
|
||||
<field name="model">hr.employee</field>
|
||||
<field name="inherit_id" ref="hr.view_employee_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Shop Roles" name="fp_shop_roles">
|
||||
<page string="Shop Roles" name="fp_shop_roles"
|
||||
groups="fusion_plating.group_fusion_plating_supervisor">
|
||||
<group>
|
||||
<field name="x_fc_work_role_ids" widget="many2many_tags"
|
||||
options="{'no_create_edit': True}"
|
||||
placeholder="Tag the shop roles this employee performs..."/>
|
||||
<div class="text-muted" colspan="2">
|
||||
Work orders tagged with these roles will auto-assign to
|
||||
this employee (or to another employee with the same role,
|
||||
whichever is least loaded).
|
||||
</div>
|
||||
<group string="Tasks This Operator Can Do">
|
||||
<field name="x_fc_work_role_ids"
|
||||
widget="many2many_tags"
|
||||
options="{'no_create_edit': True, 'color_field': 'color'}"
|
||||
placeholder="Tag the shop roles this employee performs..."/>
|
||||
<div class="text-muted small" colspan="2">
|
||||
Work orders tagged with these roles auto-assign to
|
||||
this employee (or to whoever has the same role and
|
||||
the lighter open queue).
|
||||
</div>
|
||||
</group>
|
||||
<group string="Lead Hand For"
|
||||
groups="fusion_plating.group_fusion_plating_manager">
|
||||
<field name="x_fc_lead_hand_role_ids"
|
||||
widget="many2many_tags"
|
||||
options="{'no_create_edit': True}"
|
||||
placeholder="Roles where this employee can cover for absent operators..."/>
|
||||
<div class="text-muted small" colspan="2">
|
||||
Lead hands appear at the top of the Manager Desk
|
||||
worker dropdown for these roles, even when they
|
||||
aren't the primary owner. Use for cross-trained
|
||||
workers who can step in during absences.
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<separator string="Task Proficiency"/>
|
||||
<p class="text-muted small">
|
||||
Auto-tracked: every successfully completed WO bumps the
|
||||
count for its role. When the count crosses the role's
|
||||
mastery threshold the role is added to <em>Tasks This
|
||||
Operator Can Do</em> automatically.
|
||||
</p>
|
||||
<field name="x_fc_proficiency_ids" nolabel="1"
|
||||
readonly="1">
|
||||
<list>
|
||||
<field name="role_id"/>
|
||||
<field name="completed_count"/>
|
||||
<field name="progress_label" string="Progress"/>
|
||||
<field name="promoted" widget="boolean_toggle"
|
||||
readonly="1"/>
|
||||
<field name="first_completed_at"/>
|
||||
<field name="last_completed_at"/>
|
||||
<field name="promoted_at"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
@@ -109,17 +156,10 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Work Order form — show role + assigned worker -->
|
||||
<record id="view_mrp_workorder_form_fp_roles" model="ir.ui.view">
|
||||
<field name="name">mrp.workorder.form.fp.roles</field>
|
||||
<field name="model">mrp.workorder</field>
|
||||
<field name="inherit_id" ref="fusion_plating_bridge_mrp.view_mrp_workorder_form_fp_bridge"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//sheet//field[@name='x_fc_customer_id']" position="after">
|
||||
<field name="x_fc_work_role_id" readonly="1"/>
|
||||
<field name="x_fc_assigned_user_id"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
<!--
|
||||
NOTE: the WO form already shows x_fc_work_role_id + x_fc_assigned_user_id
|
||||
via mrp_workorder_views.xml (after production_id). The earlier inherit
|
||||
here would cause the fields to render twice.
|
||||
-->
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -91,6 +91,10 @@
|
||||
<xpath expr="//sheet//field[@name='production_id']" position="after">
|
||||
<field name="x_fc_step_display" widget="badge" readonly="1"/>
|
||||
<field name="x_fc_priority" widget="priority"/>
|
||||
<field name="x_fc_assigned_user_id"
|
||||
string="Assigned To"
|
||||
options="{'no_create': True}"/>
|
||||
<field name="x_fc_work_role_id" readonly="1"/>
|
||||
</xpath>
|
||||
|
||||
<!-- ============================================================
|
||||
@@ -136,6 +140,24 @@
|
||||
string="Expected Duration" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!--
|
||||
Audit trail surfaced from the timer overrides.
|
||||
Mirrors what's already in time_ids (one row per
|
||||
pause/resume) but distilled to the two events
|
||||
that matter to the manager: who first picked the
|
||||
job up, and who closed it out.
|
||||
-->
|
||||
<group string="Timer Audit" name="timer_audit">
|
||||
<group>
|
||||
<field name="x_fc_started_by_user_id" readonly="1"/>
|
||||
<field name="x_fc_started_at" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="x_fc_finished_by_user_id" readonly="1"/>
|
||||
<field name="x_fc_finished_at" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
</xpath>
|
||||
|
||||
<!-- 5b. Plating Details tab (insert AFTER Time & Cost) -->
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Shop Floor',
|
||||
'version': '19.0.10.0.0',
|
||||
'version': '19.0.11.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
||||
'first-piece inspection gates.',
|
||||
|
||||
@@ -115,6 +115,17 @@ class FpManagerDashboardController(http.Controller):
|
||||
w.x_fc_assigned_user_id.name or ''
|
||||
if w.x_fc_assigned_user_id else ''
|
||||
),
|
||||
# Role required by this step. Used by the
|
||||
# Manager Desk worker dropdown to surface
|
||||
# qualified operators first.
|
||||
'role_id': (
|
||||
w.x_fc_work_role_id.id
|
||||
if w.x_fc_work_role_id else False
|
||||
),
|
||||
'role_name': (
|
||||
w.x_fc_work_role_id.name or ''
|
||||
if w.x_fc_work_role_id else ''
|
||||
),
|
||||
}
|
||||
for w in wos
|
||||
],
|
||||
@@ -161,11 +172,43 @@ class FpManagerDashboardController(http.Controller):
|
||||
'avatar_url': f'/web/image/res.users/{user.id}/avatar_128',
|
||||
})
|
||||
|
||||
# ---- Pickers: operators, tanks, work centres ------------------
|
||||
operators = [
|
||||
{'id': u.id, 'name': u.name}
|
||||
for u in (operator_group.user_ids if operator_group else env['res.users'])
|
||||
]
|
||||
# ---- Pickers: operators (with presence + role data) -----------
|
||||
# We send richer operator records so the Manager Desk dropdown can
|
||||
# group qualified-and-present at the top, then lead hands, then
|
||||
# off-shift workers (greyed). Without this the manager has to
|
||||
# remember who's clocked in and who can do what.
|
||||
clocked_in_user_ids = (
|
||||
env['hr.employee']._fp_clocked_in_user_ids()
|
||||
if 'hr.employee' in env and hasattr(
|
||||
env['hr.employee'], '_fp_clocked_in_user_ids',
|
||||
)
|
||||
else set()
|
||||
)
|
||||
operator_users = (
|
||||
operator_group.user_ids if operator_group else env['res.users']
|
||||
)
|
||||
operators = []
|
||||
for u in operator_users:
|
||||
emp = u.employee_id
|
||||
role_ids = emp.x_fc_work_role_ids.ids if emp else []
|
||||
lead_role_ids = (
|
||||
emp.x_fc_lead_hand_role_ids.ids
|
||||
if emp and 'x_fc_lead_hand_role_ids' in emp._fields
|
||||
else []
|
||||
)
|
||||
operators.append({
|
||||
'id': u.id,
|
||||
'name': u.name,
|
||||
'is_clocked_in': u.id in clocked_in_user_ids,
|
||||
'role_ids': role_ids,
|
||||
'lead_hand_role_ids': lead_role_ids,
|
||||
})
|
||||
# Headline counts so the manager sees at-a-glance who's on shift.
|
||||
present_count = sum(1 for o in operators if o['is_clocked_in'])
|
||||
presence = {
|
||||
'clocked_in': present_count,
|
||||
'total': len(operators),
|
||||
}
|
||||
Tank = env.get('fusion.plating.tank')
|
||||
tanks = [
|
||||
{
|
||||
@@ -214,6 +257,7 @@ class FpManagerDashboardController(http.Controller):
|
||||
'active': active_cards,
|
||||
'team': team,
|
||||
'operators': operators,
|
||||
'presence': presence,
|
||||
'tanks': tanks,
|
||||
'user_name': env.user.name,
|
||||
}
|
||||
|
||||
@@ -30,6 +30,13 @@ export class ManagerDashboard extends Component {
|
||||
messageType: "info",
|
||||
isFetching: false, // pulses the "updating" dot in the header
|
||||
lastUpdated: null, // epoch ms of last successful payload
|
||||
// Worker dropdown filter: when true, off-shift operators
|
||||
// are HIDDEN. When false, they appear at the bottom of
|
||||
// every dropdown (greyed) so the manager can still pick
|
||||
// them in a pinch (training, walk-in coverage).
|
||||
// Defaults to false because lead-hand coverage often needs
|
||||
// off-roster names.
|
||||
hideOffShift: false,
|
||||
});
|
||||
|
||||
this._lastHash = null; // sent to server to skip unchanged polls
|
||||
@@ -99,6 +106,8 @@ export class ManagerDashboard extends Component {
|
||||
for (const k of ["unassigned", "active", "team", "operators", "tanks"]) {
|
||||
if (Array.isArray(source[k])) target[k] = source[k];
|
||||
}
|
||||
// Presence dict: copy over so the badge updates on every poll.
|
||||
if (source.presence) target.presence = source.presence;
|
||||
}
|
||||
|
||||
/** Human-readable "updated Xs ago" label. */
|
||||
@@ -125,6 +134,51 @@ export class ManagerDashboard extends Component {
|
||||
this.state.expandedMoId = this.state.expandedMoId === moId ? null : moId;
|
||||
}
|
||||
|
||||
toggleOffShift() {
|
||||
this.state.hideOffShift = !this.state.hideOffShift;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort + filter the operator list for a specific WO's dropdown.
|
||||
*
|
||||
* Buckets, top-down, each kept in original (alphabetical) order:
|
||||
* 1. Qualified for this role AND clocked in — primary picks
|
||||
* 2. Lead hands for this role AND clocked in — coverage picks
|
||||
* 3. Clocked in but NOT qualified — training mode
|
||||
* 4. Off-shift — greyed; only
|
||||
* shown when hideOffShift is false
|
||||
*
|
||||
* Each option carries a `bucket` so the template can render a tiny
|
||||
* green/grey dot and (for buckets 3-4) a soft helper label.
|
||||
*/
|
||||
operatorsForWO(wo) {
|
||||
const all = (this.state.overview && this.state.overview.operators) || [];
|
||||
const roleId = wo && wo.role_id;
|
||||
const out = [];
|
||||
for (const op of all) {
|
||||
const qualified = roleId && op.role_ids && op.role_ids.includes(roleId);
|
||||
const isLead = roleId && op.lead_hand_role_ids && op.lead_hand_role_ids.includes(roleId);
|
||||
let bucket;
|
||||
if (op.is_clocked_in && qualified) bucket = 1;
|
||||
else if (op.is_clocked_in && isLead) bucket = 2;
|
||||
else if (op.is_clocked_in) bucket = 3;
|
||||
else bucket = 4;
|
||||
if (this.state.hideOffShift && bucket === 4) continue;
|
||||
out.push({ ...op, bucket, qualified, isLead });
|
||||
}
|
||||
// Stable sort by bucket; alphabetical name as the secondary
|
||||
out.sort((a, b) => (a.bucket - b.bucket) || a.name.localeCompare(b.name));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Label that goes next to each option (after the name). */
|
||||
operatorBadge(op) {
|
||||
if (op.bucket === 1) return ""; // primary — no extra noise
|
||||
if (op.bucket === 2) return " · lead hand";
|
||||
if (op.bucket === 3) return " · training";
|
||||
return " · off-shift";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- Actions
|
||||
async onAssignWorker(wo, userIdRaw) {
|
||||
const userId = parseInt(userIdRaw) || null;
|
||||
|
||||
@@ -79,6 +79,59 @@
|
||||
50% { box-shadow: 0 0 0 8px color-mix(in srgb, #{$fp-ok} 0%, transparent); }
|
||||
}
|
||||
|
||||
// ---- Presence chip (Present 7 / 12) -------------------------------------
|
||||
// Small toggle in the header. Green dot = clocked-in workers visible
|
||||
// in the dropdown; grey dot when filter is active (off-shift hidden).
|
||||
// The chip itself is a button so the manager can hide off-shift names
|
||||
// with one tap when the dropdown gets crowded during a busy shift.
|
||||
.o_fp_presence_chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $fp-space-2;
|
||||
padding: 6px 14px;
|
||||
border: 1px solid #{$fp-border};
|
||||
border-radius: $fp-radius-pill;
|
||||
background-color: $fp-card;
|
||||
color: $fp-ink;
|
||||
font-size: $fp-text-sm;
|
||||
font-weight: $fp-weight-medium;
|
||||
cursor: pointer;
|
||||
transition: border-color $fp-dur $fp-ease,
|
||||
background-color $fp-dur $fp-ease;
|
||||
|
||||
strong {
|
||||
color: $fp-ok;
|
||||
font-weight: $fp-weight-bold;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@include fp-hover-only {
|
||||
&:hover { border-color: color-mix(in srgb, #{$fp-accent} 45%, #{$fp-border}); }
|
||||
}
|
||||
|
||||
// Filter active = off-shift hidden. Make the chip pop a bit so
|
||||
// the manager remembers the filter is on.
|
||||
&[data-active="y"] {
|
||||
background-color: color-mix(in srgb, #{$fp-accent} 10%, transparent);
|
||||
border-color: color-mix(in srgb, #{$fp-accent} 50%, #{$fp-border});
|
||||
color: $fp-accent;
|
||||
strong { color: $fp-accent; }
|
||||
.o_fp_presence_dot { background-color: $fp-accent; }
|
||||
}
|
||||
}
|
||||
.o_fp_presence_dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: $fp-ok;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// ---- Worker dropdown bucket cues ----------------------------------------
|
||||
// Browsers don't let us style each <option> very richly, but we can
|
||||
// colour the text of off-shift / training options to give the manager
|
||||
// a glanceable hint about who the "good" picks are.
|
||||
.o_fp_mgr_picker option[data-bucket="3"] { color: $fp-ink-mute; }
|
||||
.o_fp_mgr_picker option[data-bucket="4"] { color: $fp-ink-faint; font-style: italic; }
|
||||
|
||||
.o_fp_manager_head_actions {
|
||||
display: flex; gap: $fp-space-2;
|
||||
|
||||
|
||||
@@ -24,6 +24,21 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fp_manager_head_actions">
|
||||
<!-- Presence chip — clocked-in workers vs roster.
|
||||
Tap to toggle whether off-shift names show in
|
||||
the worker dropdowns. -->
|
||||
<button class="btn o_fp_presence_chip"
|
||||
t-att-data-active="state.hideOffShift ? 'y' : 'n'"
|
||||
t-on-click="toggleOffShift"
|
||||
t-att-title="state.hideOffShift ? 'Showing only clocked-in workers — click to include off-shift' : 'Showing all workers — click to hide off-shift'"
|
||||
t-if="state.overview and state.overview.presence">
|
||||
<span class="o_fp_presence_dot"/>
|
||||
Present
|
||||
<strong>
|
||||
<t t-esc="state.overview.presence.clocked_in"/>
|
||||
</strong>
|
||||
/ <t t-esc="state.overview.presence.total"/>
|
||||
</button>
|
||||
<button class="btn"
|
||||
t-on-click="refresh"
|
||||
t-att-disabled="state.isFetching">
|
||||
@@ -129,10 +144,13 @@
|
||||
<select class="o_fp_mgr_picker"
|
||||
t-on-change="(ev) => this.onAssignWorker(wo, ev.target.value)">
|
||||
<option value="">— Assign worker —</option>
|
||||
<t t-foreach="state.overview.operators" t-as="op" t-key="op.id">
|
||||
<t t-foreach="operatorsForWO(wo)" t-as="op" t-key="op.id">
|
||||
<option t-att-value="op.id"
|
||||
t-att-selected="wo.assigned_user_id === op.id">
|
||||
<t t-esc="op.name"/>
|
||||
t-att-selected="wo.assigned_user_id === op.id"
|
||||
t-att-data-bucket="op.bucket">
|
||||
<t t-if="op.is_clocked_in">●</t>
|
||||
<t t-else="">○</t>
|
||||
<t t-esc="' ' + op.name + operatorBadge(op)"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
|
||||
Reference in New Issue
Block a user