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:
gsinghpal
2026-04-17 20:03:01 -04:00
parent 095d9f487c
commit 1c6a460ca1
8 changed files with 856 additions and 0 deletions

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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>