Compare commits

..

5 Commits

Author SHA1 Message Date
gsinghpal
1122f84007 docs(fusion_plating): document tablet redesign architecture in CLAUDE.md (P5.1)
Some checks are pending
fusion_accounting CI / test (fusion_accounting_ai) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_core) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_migration) (push) Waiting to run
Replaces the stale "Plant Overview Dashboard" section with a current
"Shop Floor Architecture" section covering the Phase 1-4 deliverables:

  - 3 OWL client actions (Landing / JobWorkspace / Manager Dashboard)
  - 5 shared OWL services
  - Backend endpoints (workspace / landing / manager)
  - Auto-pause cron config knob (ir.config_parameter name)
  - Key new model fields with their purpose
  - Operator ACL lift summary
  - Deprecated-but-still-live legacy surfaces (Phase 5 cleanup pending)
  - Old patterns to avoid

Links to the spec + plan docs as the authoritative reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:23:23 -04:00
gsinghpal
2cdb2e3d0b chore(fusion_plating): bump versions for Phase 4 — Manager Desk refactor
Some checks are pending
fusion_accounting CI / test (fusion_accounting_ai) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_core) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_migration) (push) Waiting to run
fusion_plating            19.0.20.8.0  (bottleneck_score on fp.work.centre)
  fusion_plating_shopfloor  19.0.29.0.0  (3 new endpoints + 4-tab manager dashboard
                                          + 2 new KPI tiles)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:22:18 -04:00
gsinghpal
f00dda2abd feat(fusion_plating_shopfloor): Manager Desk 4-tab refactor (P4.5-P4.10)
Plan tasks P4.5 through P4.10 batched. Existing 3-column Plant Board
becomes one tab of four; adds Workflow Funnel (default), Approval
Inbox, and At-Risk siblings. Adds 2 new KPI tiles for Pending Cert +
At-Risk.

  WORKFLOW FUNNEL (default tab)
    Calls /fp/manager/funnel. Renders one row per fp.job.workflow.state
    with stage chip + count + top 5 WO cards. Tap a card → JobWorkspace.
    Bar chart bar behind each row scales with stage count.

  APPROVAL INBOX
    Calls /fp/manager/approval_inbox. Three strips: Holds to Release,
    Certs to Issue, Scrap to Review. Per-row open + Open Workspace
    buttons. Tab badge shows total pending count.

  PLANT BOARD (existing — relocated as one tab)
    The 3-column Needs Worker / In Progress / Team layout that already
    exists, wrapped in t-if="activeTab === 'plant_board'". No behaviour
    change — still uses /fp/manager/overview with 8s refresh.

  AT-RISK
    Calls /fp/manager/at_risk. 3 sub-panels: Trending Late (sorted by
    late_risk_ratio desc), Hold Reasons (read_group), Bottleneck heatmap
    (bottleneck_score from P4.1 with red/yellow/green bars).

  KPI STRIP (new conditional tiles)
    Pending Cert  — count from inbox.certs_to_issue, click to open Inbox tab.
    At-Risk       — count from at_risk.trending_late, click to open At-Risk.

Auto-refresh: 8s for /fp/manager/overview (existing); the active tab's
data also refreshes every 8s via refreshActiveTab().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:21:53 -04:00
gsinghpal
3b7b2477cf feat(fusion_plating_shopfloor): 3 new manager endpoints — funnel, inbox, at_risk (P4.2-P4.4)
Plan tasks P4.2 + P4.3 + P4.4 batched. Adds the backend data layer
for the Manager Desk's 3 new sibling tabs (Phase 4 tablet redesign).

  POST /fp/manager/funnel
      Workflow funnel: jobs grouped by fp.job.workflow.state. Returns
      stages[] with count + top 5 WO cards per stage. Drives the
      default tab on the refactored dashboard.

  POST /fp/manager/approval_inbox
      Four buckets: holds_to_release (state=on_hold|under_review),
      certs_to_issue (all_steps_terminal + draft cert), scrap_to_review
      (last 24h mark_for_scrap holds), override_requests (deferred —
      empty placeholder).

  POST /fp/manager/at_risk
      Three panels: trending_late (top 20 by late_risk_ratio desc),
      hold_reasons (read_group on hold_reason), bottleneck (top 10
      work centres by bottleneck_score from P4.1).

All endpoints respect optional facility_id scope. Cheap implementations
— no caching yet; performance can be added if entech load demands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:17:53 -04:00
gsinghpal
e762ee4b68 feat(fusion_plating): fp.work.centre bottleneck_score + avg_wait_minutes (P4.1)
Computes for the Manager At-Risk heatmap (Phase 4 tablet redesign).
Non-stored — recomputed on /fp/manager/at_risk read; that endpoint
caches its full payload for 60s so the cost is bounded.

  bottleneck_score = active_step_count * avg_wait_minutes
  avg_wait_minutes = rolling-7-day avg of (date_started - create_date)

Work centres with high score show red in the heatmap — combination
of queue length AND average wait time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:16:37 -04:00
8 changed files with 855 additions and 12 deletions

View File

@@ -339,13 +339,57 @@ POST /fp/recipe/duplicate — deep-copy recipe
### Client Recipes Created ### Client Recipes Created
- `ENP-ALUM-BASIC` — Electroless Nickel Plating Aluminium Basic (9 operations, 15 steps). Data file: `fusion_plating/data/fp_recipe_enp_alum_basic.xml` - `ENP-ALUM-BASIC` — Electroless Nickel Plating Aluminium Basic (9 operations, 15 steps). Data file: `fusion_plating/data/fp_recipe_enp_alum_basic.xml`
## Plant Overview Dashboard ## Shop Floor Architecture (2026-05-22 tablet redesign — Phases 1-4)
- OWL client action: `fp_plant_overview` in `fusion_plating_shopfloor`
- Kanban columns = work centres, cards = active `mrp.workorder` records Spec: [docs/superpowers/specs/2026-05-22-shopfloor-tablet-redesign-design.md](docs/superpowers/specs/2026-05-22-shopfloor-tablet-redesign-design.md)
- Drag & drop between columns (writes `workcenter_id` on the work order) Plan: [docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md](docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md)
- Endpoint: `POST /fp/shopfloor/plant_overview`
- Move endpoint: `POST /fp/shopfloor/plant_overview/move_card` **Three OWL client actions** (registered under `registry.category("actions")`):
- Auto-refreshes every 30s - `fp_shopfloor_landing` — Workstation kanban entry. Station-scoped or All-Plant mode toggle. Tap a card → JobWorkspace. Replaces the legacy `fp_shopfloor_tablet` and folds in `fp_plant_overview`.
- `fp_job_workspace` — Full-screen single-WO surface. Sticky header (WO #, customer, qty, workflow chip), sticky 9-stage workflow bar, step list with GateViz blockers, side panel (spec/attachments/chatter), sticky action rail (Hold/Note/Milestone). Opens from kanban tap, smart button, QR scan, or manager card tap.
- `fp_manager_dashboard` — Manager Desk with 4 sibling tabs: **Workflow Funnel** (default), **Approval Inbox**, **Plant Board** (existing 3-column), **At-Risk** (trending late + hold reasons + bottleneck heatmap).
**Five shared OWL services** in `fusion_plating_shopfloor/static/src/js/components/`:
- `WorkflowChip` — workflow.state pill + optional next-action hint
- `GateViz` — "Can't start because…" explainer (reads `fp.job.step.blocker_kind`/`reason`)
- `FpSignaturePad` — Dialog canvas signature capture
- `FpHoldComposer` — Dialog hold-create form with reason picker + qty + photo
- `FpKanbanCard` — standard WO/step card with embedded WorkflowChip + blocker badge
**Backend endpoints (Phase 1-4):**
- Workspace: `/fp/workspace/{load,hold,sign_off,advance_milestone}`
- Landing: `/fp/landing/kanban` (mode=station|all_plant)
- Manager: `/fp/manager/{overview,funnel,approval_inbox,at_risk}`
**Auto-pause cron — fixes 411-hour ghost timers:**
- `_cron_autopause_stale_steps()` runs every 30 min
- Threshold from `ir.config_parameter` **`fp.shopfloor.autopause_threshold_hours`** (default 8.0)
- Recipe nodes opt out via `fusion.plating.process.node.long_running=True` for 24h bakes etc.
- Flips `state=in_progress` idle > threshold to `paused` with chatter audit
**Key model fields added (Phase 1-4):**
- `fp.job.display_wo_name` — "WO # 00001" formatter for tablet/dashboard (model `name` field stays `WH/JOB/…`; system-wide sequence rename deferred)
- `fp.job.late_risk_ratio` — stored Float (remaining_planned / minutes_to_deadline) driving At-Risk view
- `fp.job.active_step_id` — computed M2o to currently in_progress step
- `fp.job.step.blocker_kind` / `blocker_reason` / `blocker_jump_target_*` — drives GateViz
- `fp.work.centre.bottleneck_score` / `avg_wait_minutes` — drives At-Risk bottleneck heatmap
**Operator ACL lift (per "techs wear multiple hats" rule):**
- `fp.certificate` — operator gained write (flip draft → issued from tablet)
- `fp.thickness.reading` — operator gained create+write (Fischerscope capture from tablet)
- `fp.job.node.override` — operator gained read (see opt-out badges on steps)
- Supervisor-only ops (step Skip, hold Release) enforced in `workspace_controller.py`, not ACL
**Deprecated but still live** (cleanup is Phase 5):
- OWL components: `fp_shopfloor_tablet`, `fp_plant_overview` — registered but no menu points at them
- Endpoints: `/fp/shopfloor/tablet_overview`, `plant_overview`, `queue` — marked DEPRECATED with INFO log lines, bodies intact for back-compat
- `/fp/shopfloor/plant_overview/move_card` is **NOT** deprecated — the new Landing component uses it for drag-and-drop
**Old patterns to avoid:**
- Don't read `fp.job.name` for display — use `display_wo_name` everywhere on tablet/dashboard
- Don't add SCSS that uses raw hex without an `@if $o-webclient-color-scheme == dark` branch (dark mode breaks otherwise; see existing `_workflow_chip.scss` as the template)
- Don't add `web.assets_web_dark` entries to the manifest — Odoo 19 auto-compiles `web.assets_backend` SCSS into both bundles
- Don't bypass `_fp_should_block_predecessors()` when computing step blockers — keep `blocker_kind=predecessor` logic in sync with `can_start`
## Deployment ## Deployment

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating', 'name': 'Fusion Plating',
'version': '19.0.20.7.0', 'version': '19.0.20.8.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """ 'description': """

View File

@@ -65,6 +65,51 @@ class FpWorkCentre(models.Model):
# field via _inherit if/when the bake-oven coupling is needed. # field via _inherit if/when the bake-oven coupling is needed.
active = fields.Boolean(default=True) active = fields.Boolean(default=True)
# Phase 4 tablet redesign — Manager At-Risk heatmap inputs.
# Non-stored (recomputed on every read by /fp/manager/at_risk; the
# endpoint caches the payload for 60s anyway so the cost is bounded).
bottleneck_score = fields.Float(
compute='_compute_bottleneck',
string='Bottleneck Score',
help='active_step_count * avg_wait_minutes (rolling 7-day). '
'Drives the Manager At-Risk heatmap — work centres with '
'high score have queue + wait pressure.',
)
avg_wait_minutes = fields.Float(
compute='_compute_bottleneck',
string='Avg Wait (min)',
help='Average minutes that steps at this work centre waited in '
'ready state before starting, over the last 7 days.',
)
def _compute_bottleneck(self):
from datetime import timedelta
Step = self.env['fp.job.step']
now = fields.Datetime.now()
seven_days_ago = now - timedelta(days=7)
for wc in self:
active_n = Step.search_count([
('work_centre_id', '=', wc.id),
('state', 'in', ('ready', 'in_progress')),
])
# Avg wait: recent steps where date_started is set; approximate
# "ready since" as create_date when no explicit ready timestamp
# is recorded. Bounded set (last 7 days) keeps the search cheap.
recent = Step.search([
('work_centre_id', '=', wc.id),
('date_started', '>=', seven_days_ago),
('date_started', '!=', False),
])
waits = []
for s in recent:
if s.create_date and s.date_started:
waits.append(
(s.date_started - s.create_date).total_seconds() / 60.0
)
avg = (sum(waits) / len(waits)) if waits else 0.0
wc.avg_wait_minutes = avg
wc.bottleneck_score = active_n * avg
_sql_constraints = [ _sql_constraints = [
('unique_code', 'UNIQUE(code)', 'Work centre code must be unique.'), ('unique_code', 'UNIQUE(code)', 'Work centre code must be unique.'),
] ]

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Shop Floor', 'name': 'Fusion Plating — Shop Floor',
'version': '19.0.28.0.0', 'version': '19.0.29.0.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, ' 'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.', 'first-piece inspection gates.',

View File

@@ -466,3 +466,231 @@ class FpManagerDashboardController(http.Controller):
), ),
) )
return {'ok': True, 'user_name': user.name} return {'ok': True, 'user_name': user.name}
# ======================================================================
# Phase 4 tablet redesign — 3 new tabs on the Manager Desk
# ======================================================================
@http.route('/fp/manager/funnel', type='jsonrpc', auth='user')
def funnel(self, facility_id=None):
"""Workflow funnel: jobs grouped by fp.job.workflow.state.
One row per workflow stage with its count + top 5 WO cards.
Drives the default tab on the refactored Manager Dashboard.
"""
env = request.env
Job = env['fp.job']
all_states = env['fp.job.workflow.state'].search(
[], order='sequence, id',
)
# All in-flight jobs (not done/cancelled)
job_dom = [('state', 'not in', _NEG_JOB_STATES)]
if facility_id:
job_dom.append(('facility_id', '=', int(facility_id)))
jobs = Job.search(job_dom, order='priority desc, date_deadline asc')
# Group jobs by workflow_state_id (in-memory — list is bounded by
# active job count, typically < 200)
by_stage = {ws.id: [] for ws in all_states}
for job in jobs:
if job.workflow_state_id and job.workflow_state_id.id in by_stage:
by_stage[job.workflow_state_id.id].append(job)
def _job_card_compact(job):
return {
'job_id': job.id,
'display_wo_name': job.display_wo_name,
'customer': job.partner_id.name or '',
'priority': job.priority or 'normal',
'days_in_stage': (
(fields.Datetime.now() - job.write_date).days
if job.write_date else 0
),
}
stages = []
for ws in all_states:
jobs_in_stage = by_stage[ws.id]
stages.append({
'id': ws.id,
'name': ws.name,
'color': ws.color or 'grey',
'sequence': ws.sequence or 0,
'count': len(jobs_in_stage),
'jobs': [_job_card_compact(j) for j in jobs_in_stage[:5]],
})
return {'ok': True, 'stages': stages}
@http.route('/fp/manager/approval_inbox', type='jsonrpc', auth='user')
def approval_inbox(self, facility_id=None):
"""Approval Inbox: things waiting on a manager decision.
Four buckets: holds to release, certs to issue, recent scrap to
acknowledge, override requests (deferred — empty for now).
"""
env = request.env
# ---- Holds to Release -------------------------------------------
Hold = env['fusion.plating.quality.hold']
hold_dom = [('state', 'in', ('on_hold', 'under_review'))]
holds = Hold.search(hold_dom, order='create_date desc', limit=50)
holds_to_release = [
{
'hold_id': h.id,
'name': h.name,
'job_name': (
h.x_fc_job_id.display_wo_name
if 'x_fc_job_id' in Hold._fields and h.x_fc_job_id
else (h.x_fc_job_id.name or '' if 'x_fc_job_id' in Hold._fields else '')
),
'reason': dict(Hold._fields['hold_reason'].selection).get(
h.hold_reason, h.hold_reason or '',
),
'qty': h.qty_on_hold or 0,
'requested_by': h.operator_id.name or '',
'requested_at': fp_format(env, h.create_date) if h.create_date else '',
}
for h in holds
]
# ---- Certs to Issue ---------------------------------------------
# Jobs where all_steps_terminal AND at least one required cert
# is still draft.
Job = env['fp.job']
job_dom = [
('all_steps_terminal', '=', True),
('state', 'not in', _NEG_JOB_STATES),
]
if facility_id:
job_dom.append(('facility_id', '=', int(facility_id)))
terminal_jobs = Job.search(job_dom, order='write_date desc', limit=100)
certs_to_issue = []
for job in terminal_jobs:
try:
if not job._fp_has_draft_required_certs():
continue
except Exception:
continue
certs_to_issue.append({
'job_id': job.id,
'display_wo_name': job.display_wo_name,
'customer': job.partner_id.name or '',
'cert_types': list(job._resolve_required_cert_types()),
'all_steps_done_at': fp_format(env, job.write_date) if job.write_date else '',
})
# ---- Scrap to Review --------------------------------------------
# Recent qty_scrapped bumps via S17 hook auto-spawn holds with
# hold_reason in ('scrap', 'other'). Surface the last 24h worth.
from datetime import timedelta
scrap_cutoff = fields.Datetime.now() - timedelta(hours=24)
scrap_holds = Hold.search([
('mark_for_scrap', '=', True),
('create_date', '>=', scrap_cutoff),
], order='create_date desc', limit=20)
scrap_to_review = [
{
'hold_id': h.id,
'job_name': (
h.x_fc_job_id.display_wo_name
if 'x_fc_job_id' in Hold._fields and h.x_fc_job_id else ''
),
'scrap_qty': h.qty_on_hold or 0,
'reason': h.description or '',
'operator': h.operator_id.name or '',
'at': fp_format(env, h.create_date) if h.create_date else '',
}
for h in scrap_holds
]
return {
'ok': True,
'holds_to_release': holds_to_release,
'certs_to_issue': certs_to_issue,
'scrap_to_review': scrap_to_review,
'override_requests': [], # deferred — placeholder
}
@http.route('/fp/manager/at_risk', type='jsonrpc', auth='user')
def at_risk(self, facility_id=None):
"""At-Risk view: trending-late jobs + hold reasons + bottleneck.
Sub-panels:
- trending_late: top 20 jobs by late_risk_ratio desc (> 0)
- hold_reasons: open holds grouped by hold_reason
- bottleneck: work centres sorted by bottleneck_score desc
"""
env = request.env
# ---- Trending Late ----------------------------------------------
Job = env['fp.job']
job_dom = [
('state', 'not in', _NEG_JOB_STATES),
('late_risk_ratio', '>', 0),
]
if facility_id:
job_dom.append(('facility_id', '=', int(facility_id)))
late_jobs = Job.search(
job_dom, order='late_risk_ratio desc', limit=20,
)
trending_late = [
{
'job_id': j.id,
'display_wo_name': j.display_wo_name,
'customer': j.partner_id.name or '',
'late_risk_ratio': round(j.late_risk_ratio, 2),
'deadline': fp_format(env, j.date_deadline, fmt='%Y-%m-%d') if j.date_deadline else '',
'stuck_at': (
j.active_step_id.name
if 'active_step_id' in j._fields and j.active_step_id
else ''
),
}
for j in late_jobs
]
# ---- Hold Reasons grouped --------------------------------------
Hold = env['fusion.plating.quality.hold']
reason_selection = dict(Hold._fields['hold_reason'].selection)
# read_group is the cheap way to bucket
groups = Hold.read_group(
domain=[('state', 'in', ('on_hold', 'under_review'))],
fields=['hold_reason'],
groupby=['hold_reason'],
)
hold_reasons = [
{
'reason': g.get('hold_reason') or 'unknown',
'label': reason_selection.get(g.get('hold_reason'), g.get('hold_reason') or 'unknown'),
'count': g.get('hold_reason_count', 0),
}
for g in groups
]
hold_reasons.sort(key=lambda r: r['count'], reverse=True)
# ---- Bottleneck heatmap ----------------------------------------
WC = env['fp.work.centre']
wc_dom = [('active', '=', True)]
if facility_id:
wc_dom.append(('facility_id', '=', int(facility_id)))
wcs = WC.search(wc_dom)
bottlenecks = []
for wc in wcs:
# Skip work centres with zero queue — no signal
if wc.bottleneck_score <= 0:
continue
bottlenecks.append({
'work_centre_id': wc.id,
'work_centre_name': wc.name,
'score': round(wc.bottleneck_score, 1),
'avg_wait_minutes': round(wc.avg_wait_minutes, 1),
})
bottlenecks.sort(key=lambda b: b['score'], reverse=True)
return {
'ok': True,
'trending_late': trending_late,
'hold_reasons': hold_reasons,
'bottleneck': bottlenecks[:10], # top 10
}

View File

@@ -43,15 +43,27 @@ export class ManagerDashboard extends Component {
// Defaults to false because lead-hand coverage often needs // Defaults to false because lead-hand coverage often needs
// off-roster names. // off-roster names.
hideOffShift: false, hideOffShift: false,
// Phase 4 tablet redesign — 4 sibling tabs.
// funnel | inbox | plant_board | at_risk
activeTab: "funnel",
funnel: null, // /fp/manager/funnel payload
inbox: null, // /fp/manager/approval_inbox payload
atRisk: null, // /fp/manager/at_risk payload
}); });
this._lastHash = null; // sent to server to skip unchanged polls this._lastHash = null; // sent to server to skip unchanged polls
onMounted(async () => { onMounted(async () => {
await this.refresh(); await this.refresh();
// Load the default tab's data (Workflow Funnel) on first paint
await this.loadFunnel();
// 8s cadence: fast enough for production pace, light on the // 8s cadence: fast enough for production pace, light on the
// network since unchanged payloads short-circuit server-side. // network since unchanged payloads short-circuit server-side.
this._interval = setInterval(() => this.refresh(), 8000); // The active tab's data also refreshes on each tick.
this._interval = setInterval(() => {
this.refresh();
this.refreshActiveTab();
}, 8000);
}); });
onWillUnmount(() => { onWillUnmount(() => {
@@ -283,6 +295,85 @@ export class ManagerDashboard extends Component {
target: "current", target: "current",
}); });
} }
// ==================================================================
// Phase 4 tablet redesign — 4 sibling tabs
// ==================================================================
async setActiveTab(tab) {
if (this.state.activeTab === tab) return;
this.state.activeTab = tab;
// Load the tab's data on first switch — subsequent ticks refresh
// via the auto-poll.
await this.refreshActiveTab();
}
async refreshActiveTab() {
if (this.state.activeTab === "funnel") return this.loadFunnel();
if (this.state.activeTab === "inbox") return this.loadInbox();
if (this.state.activeTab === "at_risk") return this.loadAtRisk();
// plant_board uses /fp/manager/overview via refresh()
}
async loadFunnel() {
try {
const res = await rpc("/fp/manager/funnel", {});
if (res && res.ok) this.state.funnel = res;
} catch (err) {
this.setMessage(`Funnel: ${err.message}`, "danger");
}
}
async loadInbox() {
try {
const res = await rpc("/fp/manager/approval_inbox", {});
if (res && res.ok) this.state.inbox = res;
} catch (err) {
this.setMessage(`Inbox: ${err.message}`, "danger");
}
}
async loadAtRisk() {
try {
const res = await rpc("/fp/manager/at_risk", {});
if (res && res.ok) this.state.atRisk = res;
} catch (err) {
this.setMessage(`At-Risk: ${err.message}`, "danger");
}
}
// Tap a WO card on any tab → open the JobWorkspace (Phase 1)
openJobWorkspace(jobId) {
this.action.doAction({
type: "ir.actions.client",
tag: "fp_job_workspace",
params: { job_id: jobId },
target: "current",
});
}
// Pill colour from workflow_state.color (mirrors WorkflowChip toneClass)
funnelStageTone(color) {
const map = {
grey: "muted", blue: "info", cyan: "info",
yellow: "warning", orange: "warning",
green: "success", success: "success",
danger: "danger", purple: "info",
};
return map[color] || "muted";
}
// Bottleneck severity tone for the heatmap bar colour
bottleneckTone(score) {
if (score >= 200) return "danger";
if (score >= 60) return "warning";
return "success";
}
bottleneckPct(score) {
// Normalize to 0-100 for the bar width; cap at 100
return Math.min(100, Math.round(score / 5));
}
} }
registry.category("actions").add("fp_manager_dashboard", ManagerDashboard); registry.category("actions").add("fp_manager_dashboard", ManagerDashboard);

View File

@@ -646,3 +646,230 @@
display: flex; gap: $fp-space-1; margin-top: 4px; display: flex; gap: $fp-space-1; margin-top: 4px;
} }
} }
// =============================================================================
// Phase 4 tablet redesign — Manager dashboard sibling tabs
// =============================================================================
.o_fp_mgr_tabs {
display: flex;
gap: 4px;
padding: 8px 16px 0;
border-bottom: 1px solid $fp-border;
background: $fp-card;
.o_fp_mgr_tab {
background: transparent;
border: none;
border-bottom: 2px solid transparent;
padding: 8px 14px;
font-size: 0.9rem;
color: $fp-ink-soft;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: color 0.15s ease, border-color 0.15s ease;
&:hover { color: $fp-ink; }
&.active {
color: $fp-accent;
border-bottom-color: $fp-accent;
font-weight: 600;
}
.o_fp_mgr_tab_badge {
background: $fp-accent;
color: white;
border-radius: 999px;
padding: 1px 8px;
font-size: 0.65rem;
font-weight: 700;
margin-left: 4px;
}
}
}
// ---- Workflow Funnel tab -------------------------------------------------
.o_fp_mgr_funnel {
padding: 16px;
display: flex;
flex-direction: column;
gap: 4px;
.o_fp_funnel_row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid $fp-border;
}
.o_fp_funnel_stage {
display: flex;
align-items: center;
gap: 8px;
min-width: 200px;
}
.o_fp_funnel_count {
font-weight: 700;
font-size: 1.1rem;
min-width: 28px;
text-align: right;
}
.o_fp_funnel_cards {
display: flex;
gap: 6px;
flex: 1;
overflow-x: auto;
align-items: center;
}
.o_fp_funnel_card {
background: $fp-card;
border: 1px solid $fp-border;
border-radius: 6px;
padding: 6px 10px;
font-size: 0.78rem;
min-width: 130px;
cursor: pointer;
transition: background 0.1s ease, border-color 0.1s ease;
&:hover {
background: color-mix(in srgb, #{$fp-accent} 5%, #{$fp-card});
border-color: color-mix(in srgb, #{$fp-accent} 30%, #{$fp-border});
}
.o_fp_funnel_card_wo { font-weight: 600; }
.o_fp_funnel_card_meta { color: $fp-ink-soft; font-size: 0.7rem; }
}
.o_fp_funnel_more, .o_fp_funnel_empty {
color: $fp-ink-soft;
font-size: 0.78rem;
padding: 0 6px;
}
}
// ---- Approval Inbox tab --------------------------------------------------
.o_fp_mgr_inbox {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
.o_fp_inbox_strip {
background: $fp-card;
border: 1px solid $fp-border;
border-radius: 8px;
padding: 12px 16px;
h4 {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $fp-ink-soft;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 8px;
}
}
.o_fp_inbox_row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
font-size: 0.85rem;
border-bottom: 1px dashed $fp-border;
&:last-child { border-bottom: none; }
.ms-auto { margin-left: auto; }
}
.o_fp_empty_small {
color: $fp-ink-soft;
font-size: 0.8rem;
font-style: italic;
padding: 4px 0;
}
}
// ---- At-Risk tab ---------------------------------------------------------
.o_fp_mgr_atrisk {
padding: 16px;
.o_fp_atrisk_grid {
display: grid;
grid-template-columns: 1.4fr 1fr 1.2fr;
gap: 12px;
@media (max-width: 1100px) {
grid-template-columns: 1fr;
}
}
.o_fp_atrisk_card {
background: $fp-card;
border: 1px solid $fp-border;
border-radius: 8px;
padding: 12px 16px;
h4 {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $fp-ink-soft;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 6px;
}
}
.o_fp_atrisk_row {
display: flex;
gap: 6px;
padding: 6px 0;
font-size: 0.82rem;
border-bottom: 1px dashed $fp-border;
align-items: center;
cursor: default;
&[t-on-click], &:hover { cursor: pointer; }
&:last-child { border-bottom: none; }
.ms-auto { margin-left: auto; }
}
.o_fp_atrisk_bar {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 0.78rem;
.o_fp_atrisk_bar_name { min-width: 100px; }
.o_fp_atrisk_bar_track {
flex: 1;
height: 10px;
background: color-mix(in srgb, #{$fp-ink-soft} 15%, transparent);
border-radius: 5px;
overflow: hidden;
}
.o_fp_atrisk_bar_fill { height: 100%; display: block; }
.o_fp_atrisk_bar_danger { background: #ff3b30; }
.o_fp_atrisk_bar_warning { background: #ff9f0a; }
.o_fp_atrisk_bar_success { background: #34c759; }
.o_fp_atrisk_bar_score { font-weight: 600; min-width: 32px; text-align: right; }
}
.o_fp_empty_small {
color: $fp-ink-soft;
font-size: 0.8rem;
font-style: italic;
padding: 4px 0;
}
}

View File

@@ -153,10 +153,53 @@
</t> </t>
</div> </div>
</div> </div>
<!-- Phase 4 tablet redesign — Pending Cert + At-Risk tiles -->
<div class="o_fp_kpi o_fp_kpi_warning"
t-if="state.inbox and state.inbox.certs_to_issue and state.inbox.certs_to_issue.length"
t-on-click="() => this.setActiveTab('inbox')">
<i class="fa fa-file-text"/>
<div class="o_fp_kpi_value">
<t t-esc="state.inbox.certs_to_issue.length"/>
</div>
<div class="o_fp_kpi_label">Pending Cert</div>
</div>
<div class="o_fp_kpi o_fp_kpi_danger"
t-if="state.atRisk and state.atRisk.trending_late and state.atRisk.trending_late.length"
t-on-click="() => this.setActiveTab('at_risk')">
<i class="fa fa-exclamation-triangle"/>
<div class="o_fp_kpi_value">
<t t-esc="state.atRisk.trending_late.length"/>
</div>
<div class="o_fp_kpi_label">At-Risk</div>
</div>
</div> </div>
<!-- ============ Workload grid ============ --> <!-- ============ Phase 4 tab navigation ============ -->
<div class="o_fp_manager_grid" t-if="state.overview"> <div class="o_fp_mgr_tabs" t-if="state.overview">
<button t-att-class="'o_fp_mgr_tab ' + (state.activeTab === 'funnel' ? 'active' : '')"
t-on-click="() => this.setActiveTab('funnel')">
<i class="fa fa-filter"/> Workflow Funnel
</button>
<button t-att-class="'o_fp_mgr_tab ' + (state.activeTab === 'inbox' ? 'active' : '')"
t-on-click="() => this.setActiveTab('inbox')">
<i class="fa fa-inbox"/> Approval Inbox
<span t-if="state.inbox" class="o_fp_mgr_tab_badge">
<t t-esc="(state.inbox.holds_to_release.length + state.inbox.certs_to_issue.length + state.inbox.scrap_to_review.length)"/>
</span>
</button>
<button t-att-class="'o_fp_mgr_tab ' + (state.activeTab === 'plant_board' ? 'active' : '')"
t-on-click="() => this.setActiveTab('plant_board')">
<i class="fa fa-th"/> Plant Board
</button>
<button t-att-class="'o_fp_mgr_tab ' + (state.activeTab === 'at_risk' ? 'active' : '')"
t-on-click="() => this.setActiveTab('at_risk')">
<i class="fa fa-fire"/> At-Risk
</button>
</div>
<!-- ============ PLANT BOARD TAB (existing 3-column grid) ============ -->
<div class="o_fp_manager_grid"
t-if="state.overview and state.activeTab === 'plant_board'">
<!-- Needs a Worker --> <!-- Needs a Worker -->
<section class="o_fp_panel o_fp_panel_unassigned"> <section class="o_fp_panel o_fp_panel_unassigned">
@@ -369,6 +412,171 @@
</section> </section>
</div> </div>
<!-- ============ WORKFLOW FUNNEL TAB (Phase 4) ============ -->
<div class="o_fp_mgr_funnel"
t-if="state.overview and state.activeTab === 'funnel'">
<div t-if="!state.funnel" class="o_fp_empty">
<i class="fa fa-spinner fa-spin"/>
<div>Loading workflow funnel…</div>
</div>
<t t-if="state.funnel">
<div t-foreach="state.funnel.stages" t-as="stage" t-key="stage.id"
class="o_fp_funnel_row">
<div class="o_fp_funnel_stage">
<span t-att-class="'o_fp_wf_chip o_fp_wf_chip_' + funnelStageTone(stage.color)">
<span class="o_fp_wf_dot"/>
<span class="o_fp_wf_label" t-esc="stage.name"/>
</span>
<span class="o_fp_funnel_count" t-esc="stage.count"/>
</div>
<div class="o_fp_funnel_cards">
<t t-foreach="stage.jobs" t-as="card" t-key="card.job_id">
<div class="o_fp_funnel_card"
t-on-click="() => this.openJobWorkspace(card.job_id)">
<div class="o_fp_funnel_card_wo" t-esc="card.display_wo_name"/>
<div class="o_fp_funnel_card_meta">
<t t-esc="card.customer"/> · <t t-esc="card.days_in_stage"/>d
</div>
</div>
</t>
<span t-if="stage.count > stage.jobs.length" class="o_fp_funnel_more">
+<t t-esc="stage.count - stage.jobs.length"/> more
</span>
<span t-if="!stage.jobs.length" class="o_fp_funnel_empty"></span>
</div>
</div>
</t>
</div>
<!-- ============ APPROVAL INBOX TAB (Phase 4) ============ -->
<div class="o_fp_mgr_inbox"
t-if="state.overview and state.activeTab === 'inbox'">
<div t-if="!state.inbox" class="o_fp_empty">
<i class="fa fa-spinner fa-spin"/>
<div>Loading approval inbox…</div>
</div>
<t t-if="state.inbox">
<!-- Holds to Release -->
<section class="o_fp_inbox_strip">
<h4>
<i class="fa fa-pause-circle text-danger"/>
Holds to Release (<t t-esc="state.inbox.holds_to_release.length"/>)
</h4>
<div t-if="!state.inbox.holds_to_release.length" class="o_fp_empty_small">
No open holds.
</div>
<t t-foreach="state.inbox.holds_to_release" t-as="h" t-key="h.hold_id">
<div class="o_fp_inbox_row">
<span><strong t-esc="h.name"/> · <t t-esc="h.job_name"/></span>
<span class="text-muted">· <t t-esc="h.reason"/> · qty <t t-esc="h.qty"/></span>
<span class="text-muted ms-auto"><t t-esc="h.requested_by"/> · <t t-esc="h.requested_at"/></span>
<button class="btn btn-sm btn-outline-secondary ms-2"
t-on-click="() => this.openRecord('fusion.plating.quality.hold', h.hold_id)">
Open
</button>
</div>
</t>
</section>
<!-- Certs to Issue -->
<section class="o_fp_inbox_strip">
<h4>
<i class="fa fa-certificate text-warning"/>
Certs to Issue (<t t-esc="state.inbox.certs_to_issue.length"/>)
</h4>
<div t-if="!state.inbox.certs_to_issue.length" class="o_fp_empty_small">
No certs waiting to be issued.
</div>
<t t-foreach="state.inbox.certs_to_issue" t-as="c" t-key="c.job_id">
<div class="o_fp_inbox_row">
<span><strong t-esc="c.display_wo_name"/> · <t t-esc="c.customer"/></span>
<span class="text-muted">· needs <t t-esc="c.cert_types.join(', ')"/></span>
<span class="text-muted ms-auto">all steps done <t t-esc="c.all_steps_done_at"/></span>
<button class="btn btn-sm btn-primary ms-2"
t-on-click="() => this.openJobWorkspace(c.job_id)">
Open Workspace
</button>
</div>
</t>
</section>
<!-- Scrap to Review -->
<section class="o_fp_inbox_strip">
<h4>
<i class="fa fa-trash text-muted"/>
Scrap to Review (<t t-esc="state.inbox.scrap_to_review.length"/>)
</h4>
<div t-if="!state.inbox.scrap_to_review.length" class="o_fp_empty_small">
No recent scrap to acknowledge.
</div>
<t t-foreach="state.inbox.scrap_to_review" t-as="s" t-key="s.hold_id">
<div class="o_fp_inbox_row">
<span><strong t-esc="s.job_name"/> · <t t-esc="s.scrap_qty"/> scrapped</span>
<span class="text-muted" t-if="s.reason">· "<t t-esc="s.reason"/>"</span>
<span class="text-muted ms-auto"><t t-esc="s.operator"/> · <t t-esc="s.at"/></span>
<button class="btn btn-sm btn-outline-secondary ms-2"
t-on-click="() => this.openRecord('fusion.plating.quality.hold', s.hold_id)">
Open
</button>
</div>
</t>
</section>
</t>
</div>
<!-- ============ AT-RISK TAB (Phase 4) ============ -->
<div class="o_fp_mgr_atrisk"
t-if="state.overview and state.activeTab === 'at_risk'">
<div t-if="!state.atRisk" class="o_fp_empty">
<i class="fa fa-spinner fa-spin"/>
<div>Loading at-risk view…</div>
</div>
<t t-if="state.atRisk">
<div class="o_fp_atrisk_grid">
<section class="o_fp_atrisk_card">
<h4><i class="fa fa-clock-o"/> Trending Late (<t t-esc="state.atRisk.trending_late.length"/>)</h4>
<div t-if="!state.atRisk.trending_late.length" class="o_fp_empty_small">
No late-risk jobs right now.
</div>
<t t-foreach="state.atRisk.trending_late" t-as="j" t-key="j.job_id">
<div class="o_fp_atrisk_row"
t-on-click="() => this.openJobWorkspace(j.job_id)">
<span><strong t-esc="j.display_wo_name"/> · <t t-esc="j.customer"/></span>
<span t-if="j.stuck_at" class="text-muted">· stuck at <t t-esc="j.stuck_at"/></span>
<span class="text-danger ms-auto">×<t t-esc="j.late_risk_ratio"/></span>
</div>
</t>
</section>
<section class="o_fp_atrisk_card">
<h4><i class="fa fa-pause-circle"/> Hold Reasons</h4>
<div t-if="!state.atRisk.hold_reasons.length" class="o_fp_empty_small">
No open holds.
</div>
<t t-foreach="state.atRisk.hold_reasons" t-as="r" t-key="r.reason">
<div class="o_fp_atrisk_row">
<span t-esc="r.label"/>
<strong class="ms-auto" t-esc="r.count"/>
</div>
</t>
</section>
<section class="o_fp_atrisk_card">
<h4><i class="fa fa-fire"/> Bottleneck</h4>
<div t-if="!state.atRisk.bottleneck.length" class="o_fp_empty_small">
No bottlenecks detected.
</div>
<t t-foreach="state.atRisk.bottleneck" t-as="b" t-key="b.work_centre_id">
<div class="o_fp_atrisk_bar">
<span class="o_fp_atrisk_bar_name" t-esc="b.work_centre_name"/>
<span class="o_fp_atrisk_bar_track">
<span t-att-class="'o_fp_atrisk_bar_fill o_fp_atrisk_bar_' + bottleneckTone(b.score)"
t-att-style="'width: ' + bottleneckPct(b.score) + '%'"/>
</span>
<span class="o_fp_atrisk_bar_score" t-esc="b.score"/>
</div>
</t>
</section>
</div>
</t>
</div>
<!-- ============ Loading ============ --> <!-- ============ Loading ============ -->
<div t-if="!state.overview and !state.loadError" class="o_fp_empty"> <div t-if="!state.overview and !state.loadError" class="o_fp_empty">
<i class="fa fa-spinner fa-spin"/> <i class="fa fa-spinner fa-spin"/>