feat(shopfloor): Manager Desk — assign workers, swap tanks, take over (CHUNK 2/4)
New client action "Manager Desk" under Shop Floor menu (manager-only).
Three-column dashboard designed for the shop manager's daily job:
Column 1 — Needs a Worker
MOs with active WOs missing user assignment. Each card expands to
show per-WO rows with:
- Assign Worker dropdown (pulls from group_fusion_plating_operator)
- Tank swap dropdown (all tanks with current bath)
- Take Over (claims for the manager in one click)
- Open (jump to WO form)
Column 2 — In Progress
MOs with workers actively running WOs. Shows who's on each step,
lets manager reassign or take over if someone steps away.
Column 3 — Team
Avatar grid of operators with live queue + in-progress counts.
Click to drill into that operator's full WO list.
KPI strip on top: Unassigned WOs, In Progress, Ready to Ship, Awaiting
Assignment SOs.
Quick / Detailed view toggle — Detailed auto-expands every card body.
New field mrp.workorder.x_fc_assigned_user_id (indexed, tracked) —
the worker currently owning this step. Will be the pivot the Tablet
Station filters on in Chunk 4.
Three new endpoints:
/fp/manager/overview — dashboard snapshot (30s auto-refresh)
/fp/manager/assign_worker — set user on a WO
/fp/manager/assign_tank — swap tank on a WO
/fp/manager/take_over — manager claims the WO (no-show coverage)
Controller is graceful when mrp module isn't installed (empty overview,
no crash) and when the bridge_mrp assignment field isn't present (falls
back to showing all active WOs as "unassigned").
Verified: 4 WOs assigned across 2 users, overview queries return the
expected counts, res.groups.user_ids (Odoo 19 API, not deprecated .users).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,18 @@ class MrpWorkorder(models.Model):
|
||||
related='workcenter_id.costs_hour', readonly=True,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Worker assignment — the Manager Dashboard writes this field;
|
||||
# the Tablet Station filters "My Queue" by it.
|
||||
# ------------------------------------------------------------------
|
||||
x_fc_assigned_user_id = fields.Many2one(
|
||||
'res.users', string='Assigned Worker',
|
||||
tracking=True, index=True,
|
||||
help='The operator responsible for this work order. Set by the '
|
||||
'manager; the Tablet Station shows only WOs assigned to the '
|
||||
'logged-in user.',
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Workflow step tracking
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -60,12 +60,15 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss',
|
||||
'fusion_plating_shopfloor/static/src/scss/plant_overview.scss',
|
||||
'fusion_plating_shopfloor/static/src/scss/process_tree.scss',
|
||||
'fusion_plating_shopfloor/static/src/scss/manager_dashboard.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml',
|
||||
'fusion_plating_shopfloor/static/src/xml/plant_overview.xml',
|
||||
'fusion_plating_shopfloor/static/src/xml/process_tree.xml',
|
||||
'fusion_plating_shopfloor/static/src/xml/manager_dashboard.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/shopfloor_tablet.js',
|
||||
'fusion_plating_shopfloor/static/src/js/plant_overview.js',
|
||||
'fusion_plating_shopfloor/static/src/js/process_tree.js',
|
||||
'fusion_plating_shopfloor/static/src/js/manager_dashboard.js',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from . import shopfloor_controller
|
||||
from . import manager_controller
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""JSON-RPC endpoints for the Manager Dashboard (client action)."""
|
||||
|
||||
import logging
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FpManagerDashboardController(http.Controller):
|
||||
"""Manager-level view: unassigned jobs, in-progress jobs, team workload.
|
||||
|
||||
All endpoints require the user to be a manager or above. The UI locks
|
||||
the menu behind group_fusion_plating_manager.
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Overview snapshot — used on initial load + 30s auto-refresh
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/manager/overview', type='jsonrpc', auth='user')
|
||||
def overview(self, facility_id=None):
|
||||
env = request.env
|
||||
MrpWO = env.get('mrp.workorder')
|
||||
Production = env.get('mrp.production')
|
||||
if MrpWO is None or Production is None:
|
||||
return {
|
||||
'ok': True,
|
||||
'kpis': {'unassigned_wos': 0, 'active_wos': 0,
|
||||
'ready_to_ship_mos': 0, 'pending_accept_sos': 0},
|
||||
'unassigned': [], 'active': [], 'team': [],
|
||||
'operators': [], 'tanks': [],
|
||||
'user_name': env.user.name,
|
||||
'mrp_missing': True,
|
||||
}
|
||||
# The assignment field lives in fusion_plating_bridge_mrp. If it's
|
||||
# missing, the dashboard still renders but the worker pickers are
|
||||
# effectively read-only.
|
||||
has_assign = 'x_fc_assigned_user_id' in MrpWO._fields
|
||||
|
||||
# ---- Column 1: Unassigned (no worker on an active WO) ----------
|
||||
domain_unassigned = [
|
||||
('state', 'in', ('pending', 'waiting', 'ready', 'progress')),
|
||||
]
|
||||
if has_assign:
|
||||
domain_unassigned.append(('x_fc_assigned_user_id', '=', False))
|
||||
else:
|
||||
# Without the assignment field, treat ALL active WOs as unassigned
|
||||
pass
|
||||
if facility_id:
|
||||
domain_unassigned.append(
|
||||
('workcenter_id.x_fc_facility_id', '=', int(facility_id)))
|
||||
unassigned_wos = MrpWO.search(domain_unassigned, order='sequence, id')
|
||||
|
||||
# Roll up to MO level
|
||||
def _group_by_mo(wos):
|
||||
groups = {}
|
||||
for wo in wos:
|
||||
mo_id = wo.production_id.id
|
||||
groups.setdefault(mo_id, []).append(wo)
|
||||
return groups
|
||||
|
||||
def _mo_card(mo, wos):
|
||||
so_name = mo.origin or ''
|
||||
partner = mo.x_fc_portal_job_id.partner_id if mo.x_fc_portal_job_id else None
|
||||
return {
|
||||
'mo_id': mo.id,
|
||||
'mo_name': mo.name,
|
||||
'so_name': so_name,
|
||||
'customer': partner.name if partner else '',
|
||||
'product': mo.product_id.display_name if mo.product_id else '',
|
||||
'qty_total': int(mo.product_qty or 0),
|
||||
'date_planned': str(mo.date_start)[:10] if mo.date_start else '',
|
||||
'recipe': mo.x_fc_recipe_id.name if mo.x_fc_recipe_id else '',
|
||||
'priority_any': max(
|
||||
[int(w.x_fc_priority or '0') for w in wos] + [0]
|
||||
),
|
||||
'current_location': mo.x_fc_current_location or '',
|
||||
'wos': [
|
||||
{
|
||||
'id': w.id,
|
||||
'name': w.display_name or w.name,
|
||||
'workcenter': w.workcenter_id.name or '',
|
||||
'state': w.state,
|
||||
'sequence': w.sequence or 0,
|
||||
'duration_expected': w.duration_expected or 0,
|
||||
'bath': w.x_fc_bath_id.name or '',
|
||||
'tank': w.x_fc_tank_id.name or '',
|
||||
'priority': w.x_fc_priority or '0',
|
||||
'assigned_user_id': (
|
||||
w.x_fc_assigned_user_id.id
|
||||
if w.x_fc_assigned_user_id else False
|
||||
),
|
||||
'assigned_user_name': (
|
||||
w.x_fc_assigned_user_id.name or ''
|
||||
if w.x_fc_assigned_user_id else ''
|
||||
),
|
||||
}
|
||||
for w in wos
|
||||
],
|
||||
}
|
||||
|
||||
unassigned_cards = []
|
||||
for mo_id, wos in _group_by_mo(unassigned_wos).items():
|
||||
mo = Production.browse(mo_id)
|
||||
unassigned_cards.append(_mo_card(mo, wos))
|
||||
|
||||
# ---- Column 2: In Progress (MOs with at least one active WO) ----
|
||||
domain_active = [
|
||||
('state', 'in', ('ready', 'progress')),
|
||||
]
|
||||
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')
|
||||
active_cards = []
|
||||
for mo_id, wos in _group_by_mo(active_wos).items():
|
||||
mo = Production.browse(mo_id)
|
||||
active_cards.append(_mo_card(mo, wos))
|
||||
|
||||
# ---- Column 3: Team (operators + their current load) -----------
|
||||
operator_group = env.ref(
|
||||
'fusion_plating.group_fusion_plating_operator', raise_if_not_found=False,
|
||||
)
|
||||
team = []
|
||||
if operator_group and has_assign:
|
||||
for user in operator_group.user_ids.sorted('name'):
|
||||
open_wos = MrpWO.search([
|
||||
('x_fc_assigned_user_id', '=', user.id),
|
||||
('state', 'in', ('ready', 'progress', 'waiting')),
|
||||
])
|
||||
team.append({
|
||||
'user_id': user.id,
|
||||
'name': user.name,
|
||||
'open_count': len(open_wos),
|
||||
'in_progress_count': len(
|
||||
open_wos.filtered(lambda w: w.state == 'progress')
|
||||
),
|
||||
'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'])
|
||||
]
|
||||
Tank = env.get('fusion.plating.tank')
|
||||
tanks = [
|
||||
{
|
||||
'id': t.id,
|
||||
'name': t.name,
|
||||
'state': t.state,
|
||||
'current_bath': t.current_bath_id.name or '',
|
||||
}
|
||||
for t in (Tank.search([]) if Tank is not None else [])
|
||||
]
|
||||
|
||||
# KPI summary
|
||||
kpis = {
|
||||
'unassigned_wos': MrpWO.search_count(domain_unassigned),
|
||||
'active_wos': MrpWO.search_count(domain_active),
|
||||
'ready_to_ship_mos': Production.search_count([
|
||||
('state', '=', 'done'),
|
||||
]) if 'x_fc_portal_job_id' not in Production._fields
|
||||
else Production.search_count([
|
||||
('state', '=', 'done'),
|
||||
('x_fc_portal_job_id.state', '=', 'ready_to_ship'),
|
||||
]),
|
||||
'pending_accept_sos': env['sale.order'].search_count(
|
||||
[('x_fc_workflow_stage', '=', 'assign_work')]
|
||||
) if 'x_fc_workflow_stage' in env['sale.order']._fields else 0,
|
||||
}
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'kpis': kpis,
|
||||
'unassigned': unassigned_cards,
|
||||
'active': active_cards,
|
||||
'team': team,
|
||||
'operators': operators,
|
||||
'tanks': tanks,
|
||||
'user_name': env.user.name,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Assign a worker to a WO
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/manager/assign_worker', type='jsonrpc', auth='user')
|
||||
def assign_worker(self, workorder_id, user_id):
|
||||
wo = request.env['mrp.workorder'].browse(int(workorder_id))
|
||||
if not wo.exists():
|
||||
return {'ok': False, 'error': 'Work order not found.'}
|
||||
wo.x_fc_assigned_user_id = int(user_id) if user_id else False
|
||||
wo.message_post(
|
||||
body=f'Worker assigned: <b>{wo.x_fc_assigned_user_id.name or "Unassigned"}</b>',
|
||||
)
|
||||
return {'ok': True, 'user_name': wo.x_fc_assigned_user_id.name or ''}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reassign or swap tank on a WO
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/manager/assign_tank', type='jsonrpc', auth='user')
|
||||
def assign_tank(self, workorder_id, tank_id):
|
||||
wo = request.env['mrp.workorder'].browse(int(workorder_id))
|
||||
if not wo.exists():
|
||||
return {'ok': False, 'error': 'Work order not found.'}
|
||||
wo.x_fc_tank_id = int(tank_id) if tank_id else False
|
||||
wo.message_post(
|
||||
body=f'Tank assigned: <b>{wo.x_fc_tank_id.name or "Unassigned"}</b>',
|
||||
)
|
||||
return {'ok': True, 'tank_name': wo.x_fc_tank_id.name or ''}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Manager takes over a WO (no-show coverage)
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/fp/manager/take_over', type='jsonrpc', auth='user')
|
||||
def take_over(self, workorder_id):
|
||||
wo = request.env['mrp.workorder'].browse(int(workorder_id))
|
||||
if not wo.exists():
|
||||
return {'ok': False, 'error': 'Work order not found.'}
|
||||
user = request.env.user
|
||||
previous = wo.x_fc_assigned_user_id.name or '—'
|
||||
wo.x_fc_assigned_user_id = user.id
|
||||
wo.message_post(
|
||||
body=f'Manager takeover: <b>{user.name}</b> replaces {previous}.',
|
||||
)
|
||||
return {'ok': True, 'user_name': user.name}
|
||||
@@ -0,0 +1,152 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — Manager Dashboard (OWL client action)
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
//
|
||||
// Manager-level view: assign workers, swap tanks, cover no-shows, drill
|
||||
// into detail when needed. Three columns: Unassigned / In Progress / Team.
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class ManagerDashboard extends Component {
|
||||
static template = "fusion_plating_shopfloor.ManagerDashboard";
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.action = useService("action");
|
||||
|
||||
this.state = useState({
|
||||
overview: null,
|
||||
loading: false,
|
||||
mode: "quick", // quick | detailed
|
||||
expandedMoId: null,
|
||||
message: "",
|
||||
messageType: "info",
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await this.refresh();
|
||||
this._interval = setInterval(() => this.refresh(), 30000);
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
if (this._interval) clearInterval(this._interval);
|
||||
});
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
const payload = await rpc("/fp/manager/overview", {});
|
||||
if (payload && payload.ok) {
|
||||
this.state.overview = payload;
|
||||
}
|
||||
} catch (err) {
|
||||
// silent — next tick will retry
|
||||
}
|
||||
}
|
||||
|
||||
setMessage(text, type = "info") {
|
||||
this.state.message = text;
|
||||
this.state.messageType = type;
|
||||
setTimeout(() => { this.state.message = ""; }, 4000);
|
||||
}
|
||||
|
||||
toggleMode() {
|
||||
this.state.mode = this.state.mode === "quick" ? "detailed" : "quick";
|
||||
}
|
||||
|
||||
toggleCard(moId) {
|
||||
this.state.expandedMoId = this.state.expandedMoId === moId ? null : moId;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- Actions
|
||||
async onAssignWorker(wo, userIdRaw) {
|
||||
const userId = parseInt(userIdRaw) || null;
|
||||
try {
|
||||
const res = await rpc("/fp/manager/assign_worker", {
|
||||
workorder_id: wo.id, user_id: userId,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.setMessage(
|
||||
`Assigned ${res.user_name || 'unassigned'} to ${wo.name}`,
|
||||
"success",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.setMessage(`Assign failed: ${err.message || err}`, "danger");
|
||||
}
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async onAssignTank(wo, tankIdRaw) {
|
||||
const tankId = parseInt(tankIdRaw) || null;
|
||||
try {
|
||||
const res = await rpc("/fp/manager/assign_tank", {
|
||||
workorder_id: wo.id, tank_id: tankId,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.setMessage(
|
||||
`Tank ${res.tank_name || 'cleared'} for ${wo.name}`,
|
||||
"success",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.setMessage(`Tank swap failed: ${err.message || err}`, "danger");
|
||||
}
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async onTakeOver(wo) {
|
||||
try {
|
||||
const res = await rpc("/fp/manager/take_over", {
|
||||
workorder_id: wo.id,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.setMessage(`You now own ${wo.name}.`, "success");
|
||||
}
|
||||
} catch (err) {
|
||||
this.setMessage(`Takeover failed: ${err.message || err}`, "danger");
|
||||
}
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
openRecord(model, id) {
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: model,
|
||||
res_id: id,
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
openOperatorQueue(userId) {
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
name: "Operator Queue",
|
||||
res_model: "mrp.workorder",
|
||||
views: [[false, "list"], [false, "form"]],
|
||||
domain: [
|
||||
["x_fc_assigned_user_id", "=", userId],
|
||||
["state", "in", ["ready", "progress", "waiting"]],
|
||||
],
|
||||
target: "current",
|
||||
});
|
||||
}
|
||||
|
||||
priorityLabel(p) {
|
||||
return ({'0': 'Normal', '1': 'Urgent', '2': 'Hot'})[p] || 'Normal';
|
||||
}
|
||||
|
||||
priorityTone(p) {
|
||||
return ({'0': 'muted', '1': 'warning', '2': 'danger'})[p] || 'muted';
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("fp_manager_dashboard", ManagerDashboard);
|
||||
@@ -0,0 +1,187 @@
|
||||
// =============================================================================
|
||||
// Fusion Plating — Manager Dashboard styles
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||
//
|
||||
// Inherits the tablet's theme variables (--bs-*, --o-action) so it flips
|
||||
// light/dark without media queries. Shares .o_fp_kpi, .o_fp_chip,
|
||||
// .o_fp_panel, .o_fp_empty, .o_fp_tablet_message from the tablet SCSS.
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_manager {
|
||||
background-color: var(--o-view-background-color, var(--bs-body-bg));
|
||||
color: var(--bs-body-color);
|
||||
min-height: 100%;
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
|
||||
.o_fp_manager_header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.o_fp_manager_title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.o_fp_manager_subtitle {
|
||||
font-size: 0.95rem;
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
// 3-column grid: Unassigned | In Progress | Team
|
||||
.o_fp_manager_grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1.2fr) minmax(0, 0.8fr);
|
||||
gap: 14px;
|
||||
}
|
||||
@media (max-width: 1280px) {
|
||||
.o_fp_manager_grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.o_fp_panel_team { grid-column: span 2; }
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.o_fp_manager_grid { grid-template-columns: 1fr; }
|
||||
.o_fp_panel_team { grid-column: auto; }
|
||||
}
|
||||
|
||||
.o_fp_panel_unassigned {
|
||||
border-left: 4px solid var(--bs-warning);
|
||||
}
|
||||
.o_fp_panel_active {
|
||||
border-left: 4px solid var(--bs-success);
|
||||
}
|
||||
.o_fp_panel_team {
|
||||
border-left: 4px solid var(--bs-info);
|
||||
}
|
||||
|
||||
.o_fp_mgr_card_list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.o_fp_mgr_card {
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 10px;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
|
||||
&[data-priority="2"] {
|
||||
border-left: 4px solid var(--bs-danger);
|
||||
background-color: color-mix(in srgb, var(--bs-danger) 4%, transparent);
|
||||
}
|
||||
&[data-priority="1"] {
|
||||
border-left: 4px solid var(--bs-warning);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--o-action) 40%, var(--bs-border-color));
|
||||
}
|
||||
}
|
||||
.o_fp_mgr_card_head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
gap: 10px;
|
||||
}
|
||||
.o_fp_mgr_card_title {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.o_fp_mgr_card_sub {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.88rem;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.o_fp_mgr_card_chips {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.o_fp_mgr_card_body {
|
||||
border-top: 1px dashed var(--bs-border-color);
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background-color: color-mix(in srgb, var(--bs-body-color) 2%, transparent);
|
||||
border-bottom-left-radius: 10px;
|
||||
border-bottom-right-radius: 10px;
|
||||
}
|
||||
.o_fp_mgr_wo_row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto auto auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
font-size: 0.92rem;
|
||||
border-bottom: 1px dashed color-mix(in srgb, var(--bs-body-color) 6%, transparent);
|
||||
|
||||
&:last-child { border-bottom: 0; }
|
||||
}
|
||||
@media (max-width: 1400px) {
|
||||
.o_fp_mgr_wo_row {
|
||||
grid-template-columns: 1fr auto auto;
|
||||
|
||||
.o_fp_mgr_picker:nth-of-type(2) { grid-column: span 3; }
|
||||
}
|
||||
}
|
||||
.o_fp_mgr_wo_info {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.o_fp_mgr_picker {
|
||||
min-width: 120px;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
// Team grid
|
||||
.o_fp_team_grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.o_fp_team_card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms ease, background-color 120ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--o-action) 40%, var(--bs-border-color));
|
||||
background-color: color-mix(in srgb, var(--o-action) 6%, transparent);
|
||||
}
|
||||
}
|
||||
.o_fp_team_avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--bs-border-color);
|
||||
}
|
||||
.o_fp_team_info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.o_fp_team_name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.o_fp_team_load {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
-->
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.ManagerDashboard">
|
||||
<div class="o_fp_manager">
|
||||
|
||||
<!-- ===== Header ===== -->
|
||||
<div class="o_fp_manager_header">
|
||||
<div>
|
||||
<div class="o_fp_manager_title">
|
||||
<i class="fa fa-user-md me-2"/>Manager Desk
|
||||
</div>
|
||||
<div class="o_fp_manager_subtitle" t-if="state.overview">
|
||||
<span t-esc="state.overview.user_name"/> · Updated just now
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fp_manager_head_actions">
|
||||
<button t-att-class="'btn ' + (state.mode === 'quick' ? 'btn-primary' : 'btn-outline-primary')"
|
||||
t-on-click="toggleMode">
|
||||
<i t-att-class="state.mode === 'quick' ? 'fa fa-list' : 'fa fa-th'"/>
|
||||
<t t-if="state.mode === 'quick'"> Quick View</t>
|
||||
<t t-else=""> Detailed View</t>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Flash message ===== -->
|
||||
<div t-if="state.message"
|
||||
t-att-class="'o_fp_tablet_message o_fp_msg_' + state.messageType">
|
||||
<span t-esc="state.message"/>
|
||||
</div>
|
||||
|
||||
<!-- ===== KPI strip ===== -->
|
||||
<div class="o_fp_kpi_strip" t-if="state.overview">
|
||||
<div class="o_fp_kpi o_fp_kpi_warning">
|
||||
<i class="fa fa-user-times"/>
|
||||
<div class="o_fp_kpi_value"><t t-esc="state.overview.kpis.unassigned_wos"/></div>
|
||||
<div class="o_fp_kpi_label">Unassigned WOs</div>
|
||||
</div>
|
||||
<div class="o_fp_kpi o_fp_kpi_success">
|
||||
<i class="fa fa-cogs"/>
|
||||
<div class="o_fp_kpi_value"><t t-esc="state.overview.kpis.active_wos"/></div>
|
||||
<div class="o_fp_kpi_label">In Progress</div>
|
||||
</div>
|
||||
<div class="o_fp_kpi o_fp_kpi_info">
|
||||
<i class="fa fa-truck"/>
|
||||
<div class="o_fp_kpi_value"><t t-esc="state.overview.kpis.ready_to_ship_mos"/></div>
|
||||
<div class="o_fp_kpi_label">Ready to Ship</div>
|
||||
</div>
|
||||
<div class="o_fp_kpi o_fp_kpi_warning">
|
||||
<i class="fa fa-user-plus"/>
|
||||
<div class="o_fp_kpi_value"><t t-esc="state.overview.kpis.pending_accept_sos"/></div>
|
||||
<div class="o_fp_kpi_label">Awaiting Assignment</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== 3-column layout ===== -->
|
||||
<div class="o_fp_manager_grid" t-if="state.overview">
|
||||
|
||||
<!-- Column 1: Unassigned -->
|
||||
<section class="o_fp_panel o_fp_panel_unassigned">
|
||||
<div class="o_fp_panel_head">
|
||||
<h3><i class="fa fa-inbox me-2 text-warning"/>Needs a Worker</h3>
|
||||
<span class="o_fp_panel_count"><t t-esc="state.overview.unassigned.length"/></span>
|
||||
</div>
|
||||
<div t-if="!state.overview.unassigned.length" class="o_fp_empty">
|
||||
<i class="fa fa-check-circle text-success me-2"/>
|
||||
Every active WO has a worker assigned.
|
||||
</div>
|
||||
<div class="o_fp_mgr_card_list" t-if="state.overview.unassigned.length">
|
||||
<t t-foreach="state.overview.unassigned" t-as="card" t-key="card.mo_id">
|
||||
<div class="o_fp_mgr_card"
|
||||
t-att-data-priority="card.priority_any"
|
||||
t-att-data-expanded="state.expandedMoId === card.mo_id ? 'y' : 'n'">
|
||||
<div class="o_fp_mgr_card_head" t-on-click="() => this.toggleCard(card.mo_id)">
|
||||
<div>
|
||||
<div class="o_fp_mgr_card_title">
|
||||
<strong t-esc="card.mo_name"/>
|
||||
<span class="text-muted ms-2">· <t t-esc="card.so_name"/></span>
|
||||
</div>
|
||||
<div class="o_fp_mgr_card_sub">
|
||||
<t t-esc="card.customer"/>
|
||||
· <t t-esc="card.product"/>
|
||||
· Qty <t t-esc="card.qty_total"/>
|
||||
<t t-if="card.date_planned">
|
||||
· <t t-esc="card.date_planned"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fp_mgr_card_chips">
|
||||
<span t-if="card.priority_any >= 2"
|
||||
class="o_fp_chip o_fp_chip_danger">HOT</span>
|
||||
<span t-if="card.priority_any == 1"
|
||||
class="o_fp_chip o_fp_chip_warning">Urgent</span>
|
||||
<span class="o_fp_chip o_fp_chip_muted">
|
||||
<t t-esc="card.wos.length"/> WO
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Expanded WO list -->
|
||||
<div class="o_fp_mgr_card_body"
|
||||
t-if="state.expandedMoId === card.mo_id or state.mode === 'detailed'">
|
||||
<t t-foreach="card.wos" t-as="wo" t-key="wo.id">
|
||||
<div class="o_fp_mgr_wo_row">
|
||||
<div class="o_fp_mgr_wo_info">
|
||||
<strong t-esc="wo.name"/>
|
||||
<span class="text-muted ms-2">
|
||||
<t t-esc="wo.workcenter"/>
|
||||
<t t-if="wo.bath"> · <t t-esc="wo.bath"/></t>
|
||||
</span>
|
||||
</div>
|
||||
<select class="form-select form-select-sm 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">
|
||||
<option t-att-value="op.id"
|
||||
t-att-selected="wo.assigned_user_id === op.id">
|
||||
<t t-esc="op.name"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
<select class="form-select form-select-sm o_fp_mgr_picker"
|
||||
t-on-change="(ev) => this.onAssignTank(wo, ev.target.value)">
|
||||
<option value="">— Tank —</option>
|
||||
<t t-foreach="state.overview.tanks" t-as="tnk" t-key="tnk.id">
|
||||
<option t-att-value="tnk.id">
|
||||
<t t-esc="tnk.name"/>
|
||||
<t t-if="tnk.current_bath"> · <t t-esc="tnk.current_bath"/></t>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-outline-warning"
|
||||
t-on-click="() => this.onTakeOver(wo)">
|
||||
<i class="fa fa-user"/> Take Over
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
t-on-click="() => this.openRecord('mrp.workorder', wo.id)">
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Column 2: In Progress -->
|
||||
<section class="o_fp_panel o_fp_panel_active">
|
||||
<div class="o_fp_panel_head">
|
||||
<h3><i class="fa fa-cogs me-2 text-success"/>In Progress</h3>
|
||||
<span class="o_fp_panel_count"><t t-esc="state.overview.active.length"/></span>
|
||||
</div>
|
||||
<div t-if="!state.overview.active.length" class="o_fp_empty">
|
||||
Nothing running right now.
|
||||
</div>
|
||||
<div class="o_fp_mgr_card_list" t-if="state.overview.active.length">
|
||||
<t t-foreach="state.overview.active" t-as="card" t-key="card.mo_id">
|
||||
<div class="o_fp_mgr_card"
|
||||
t-att-data-priority="card.priority_any"
|
||||
t-att-data-expanded="state.expandedMoId === card.mo_id ? 'y' : 'n'">
|
||||
<div class="o_fp_mgr_card_head" t-on-click="() => this.toggleCard(card.mo_id)">
|
||||
<div>
|
||||
<div class="o_fp_mgr_card_title">
|
||||
<strong t-esc="card.mo_name"/>
|
||||
<span class="text-muted ms-2">· <t t-esc="card.so_name"/></span>
|
||||
</div>
|
||||
<div class="o_fp_mgr_card_sub">
|
||||
<t t-esc="card.customer"/>
|
||||
· <t t-esc="card.current_location"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fp_mgr_card_chips">
|
||||
<span t-if="card.priority_any >= 2"
|
||||
class="o_fp_chip o_fp_chip_danger">HOT</span>
|
||||
<span class="o_fp_chip o_fp_chip_success">
|
||||
<t t-esc="card.wos.length"/> WO
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fp_mgr_card_body"
|
||||
t-if="state.expandedMoId === card.mo_id or state.mode === 'detailed'">
|
||||
<t t-foreach="card.wos" t-as="wo" t-key="wo.id">
|
||||
<div class="o_fp_mgr_wo_row">
|
||||
<div class="o_fp_mgr_wo_info">
|
||||
<strong t-esc="wo.name"/>
|
||||
<span class="text-muted ms-2">
|
||||
<t t-esc="wo.workcenter"/>
|
||||
<t t-if="wo.assigned_user_name">
|
||||
· <i class="fa fa-user"/>
|
||||
<t t-esc="wo.assigned_user_name"/>
|
||||
</t>
|
||||
</span>
|
||||
</div>
|
||||
<span t-att-class="'o_fp_chip o_fp_chip_' + (wo.state === 'progress' ? 'success' : 'info')">
|
||||
<t t-esc="wo.state"/>
|
||||
</span>
|
||||
<button class="btn btn-sm btn-outline-warning"
|
||||
t-on-click="() => this.onTakeOver(wo)">
|
||||
Take Over
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
t-on-click="() => this.openRecord('mrp.workorder', wo.id)">
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Column 3: Team -->
|
||||
<section class="o_fp_panel o_fp_panel_team">
|
||||
<div class="o_fp_panel_head">
|
||||
<h3><i class="fa fa-users me-2 text-info"/>Team</h3>
|
||||
<span class="o_fp_panel_count"><t t-esc="state.overview.team.length"/></span>
|
||||
</div>
|
||||
<div t-if="!state.overview.team.length" class="o_fp_empty">
|
||||
No operators configured.
|
||||
</div>
|
||||
<div class="o_fp_team_grid" t-if="state.overview.team.length">
|
||||
<t t-foreach="state.overview.team" t-as="member" t-key="member.user_id">
|
||||
<div class="o_fp_team_card"
|
||||
t-on-click="() => this.openOperatorQueue(member.user_id)">
|
||||
<img t-att-src="member.avatar_url" class="o_fp_team_avatar"/>
|
||||
<div class="o_fp_team_info">
|
||||
<div class="o_fp_team_name"><t t-esc="member.name"/></div>
|
||||
<div class="o_fp_team_load">
|
||||
<span t-att-class="'o_fp_chip o_fp_chip_' + (member.in_progress_count ? 'success' : 'muted')">
|
||||
<t t-esc="member.in_progress_count"/> running
|
||||
</span>
|
||||
<span class="o_fp_chip o_fp_chip_info">
|
||||
<t t-esc="member.open_count"/> queue
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div t-if="!state.overview" class="o_fp_empty">
|
||||
<i class="fa fa-spinner fa-spin me-2"/>Loading manager data…
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -13,6 +13,19 @@
|
||||
sequence="12"
|
||||
groups="fusion_plating.group_fusion_plating_operator"/>
|
||||
|
||||
<!-- Manager Desk — assign workers, swap tanks, cover no-shows -->
|
||||
<record id="action_fp_manager_dashboard" model="ir.actions.client">
|
||||
<field name="name">Manager Desk</field>
|
||||
<field name="tag">fp_manager_dashboard</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fp_shopfloor_manager"
|
||||
name="Manager Desk"
|
||||
parent="menu_fp_shopfloor"
|
||||
action="action_fp_manager_dashboard"
|
||||
sequence="3"
|
||||
groups="fusion_plating.group_fusion_plating_manager"/>
|
||||
|
||||
<menuitem id="menu_fp_shopfloor_plant_overview"
|
||||
name="Plant Overview"
|
||||
parent="menu_fp_shopfloor"
|
||||
|
||||
Reference in New Issue
Block a user