diff --git a/apps/api/src/controllers/analytics.ts b/apps/api/src/controllers/analytics.ts index 7669049..be89b44 100644 --- a/apps/api/src/controllers/analytics.ts +++ b/apps/api/src/controllers/analytics.ts @@ -2,9 +2,10 @@ import type { NextFunction, Request, Response } from 'express'; import { prisma } from '@vector/db'; import * as svc from '../services/analytics.js'; -export async function dashboard(_req: Request, res: Response, next: NextFunction) { +export async function dashboard(req: Request, res: Response, next: NextFunction) { try { - const data = await prisma.$transaction((tx) => svc.dashboard(tx)); + const isAdmin = req.user?.role === 'ADMIN'; + const data = await prisma.$transaction((tx) => svc.dashboard(tx, { isAdmin })); res.json(data); } catch (err) { next(err); diff --git a/apps/api/src/services/analytics.test.ts b/apps/api/src/services/analytics.test.ts index 5fd6889..de84f13 100644 --- a/apps/api/src/services/analytics.test.ts +++ b/apps/api/src/services/analytics.test.ts @@ -2,9 +2,15 @@ import { describe, expect, it } from 'vitest'; import type { Tx } from './types.js'; import { dashboard } from './analytics.js'; -// Minimal in-memory tx double exercising the dashboard() aggregator. -// We only stub the calls dashboard() actually makes; other Prisma methods remain unimplemented. -function makeTx(args: { +type EolPartModel = { + id: string; + mpn: string; + eolDate: Date | null; + manufacturerId: string; + manufacturer: { name: string }; +}; + +type FakeArgs = { partCount: number; stateRows: { state: string; count: number; totalPrice: number }[]; parts: { @@ -15,57 +21,110 @@ function makeTx(args: { partModelId: string; }[]; openFms: number; - eolPartModels: { - id: string; - mpn: string; - eolDate: Date | null; - manufacturerId: string; - manufacturer: { name: string }; - }[]; + pastEolModels: EolPartModel[]; + upcomingEolModels: EolPartModel[]; bins: { id: string; name: string; room: { name: string; site: { name: string } } }[]; -}): Tx { + // Admin-only inputs. Ignored when isAdmin=false path is exercised. + repairs?: { performedAt: Date }[]; + fmsClosed?: { openedAt: Date; closedAt: Date | null }[]; + newFms7d?: number; + openFmGroups?: { hostId: string; count: number }[]; + custodyGroups?: { custodianId: string | null; count: number }[]; + hosts?: { id: string; name: string }[]; + users?: { id: string; username: string }[]; +}; + +function makeTx(args: FakeArgs): Tx { const tx = { part: { count: async () => args.partCount, - groupBy: async () => - args.stateRows.map((s) => ({ + groupBy: async (q: { by: string[]; where?: { custodianId?: unknown } }) => { + if (q.by.includes('custodianId')) { + return (args.custodyGroups ?? []).map((g) => ({ + custodianId: g.custodianId, + _count: { _all: g.count }, + })); + } + return args.stateRows.map((s) => ({ state: s.state, _count: { _all: s.count }, _sum: { price: s.totalPrice }, - })), + })); + }, findMany: async () => args.parts, }, fm: { - count: async () => args.openFms, + count: async (q: { where?: { status?: string; openedAt?: { gte: Date } } }) => { + if (q.where?.openedAt) return args.newFms7d ?? 0; + return args.openFms; + }, + findMany: async () => args.fmsClosed ?? [], + groupBy: async () => + (args.openFmGroups ?? []).map((g) => ({ + hostId: g.hostId, + _count: { _all: g.count }, + })), }, partModel: { - findMany: async () => args.eolPartModels, + findMany: async (q: { where?: { eolDate?: { gt?: Date; lte?: Date; not?: unknown } } }) => { + const gt = q.where?.eolDate?.gt; + if (gt !== undefined) return args.upcomingEolModels; + return args.pastEolModels; + }, }, bin: { findMany: async () => args.bins, }, + repair: { + count: async (q: { where?: { performedAt?: { gte: Date } } }) => { + const gte = q.where?.performedAt?.gte; + if (!gte) return 0; + return (args.repairs ?? []).filter((r) => r.performedAt >= gte).length; + }, + findMany: async (q: { where?: { performedAt?: { gte: Date } } }) => { + const gte = q.where?.performedAt?.gte; + if (!gte) return args.repairs ?? []; + return (args.repairs ?? []).filter((r) => r.performedAt >= gte); + }, + }, + host: { + findMany: async () => args.hosts ?? [], + }, + user: { + findMany: async () => args.users ?? [], + }, }; return tx as unknown as Tx; } const now = new Date('2026-04-16T00:00:00.000Z'); const daysAgo = (n: number) => new Date(now.getTime() - n * 24 * 60 * 60 * 1000); +const daysAhead = (n: number) => new Date(now.getTime() + n * 24 * 60 * 60 * 1000); +const HOUR_MS = 60 * 60 * 1000; -describe('analytics.dashboard', () => { +const EMPTY: FakeArgs = { + partCount: 0, + stateRows: [], + parts: [], + openFms: 0, + pastEolModels: [], + upcomingEolModels: [], + bins: [], +}; + +describe('analytics.dashboard — base fields', () => { it('aggregates totals, state counts and open FMs', async () => { const tx = makeTx({ + ...EMPTY, partCount: 5, stateRows: [ { state: 'SPARE', count: 3, totalPrice: 1500 }, { state: 'DEPLOYED', count: 2, totalPrice: 8000 }, ], - parts: [], openFms: 4, - eolPartModels: [], - bins: [], }); - const r = await dashboard(tx); + const r = await dashboard(tx, { isAdmin: false }); expect(r.totalParts).toBe(5); expect(r.openFms).toBe(4); expect(r.byState).toEqual([ @@ -76,48 +135,42 @@ describe('analytics.dashboard', () => { it('buckets parts by age correctly', async () => { const tx = makeTx({ + ...EMPTY, partCount: 4, - stateRows: [], parts: [ { id: 'p1', state: 'SPARE', binId: null, createdAt: daysAgo(10), partModelId: 'pm' }, { id: 'p2', state: 'SPARE', binId: null, createdAt: daysAgo(60), partModelId: 'pm' }, { id: 'p3', state: 'SPARE', binId: null, createdAt: daysAgo(400), partModelId: 'pm' }, { id: 'p4', state: 'SPARE', binId: null, createdAt: daysAgo(900), partModelId: 'pm' }, ], - openFms: 0, - eolPartModels: [], - bins: [], }); - const r = await dashboard(tx); + const r = await dashboard(tx, { isAdmin: false }); const byLabel = Object.fromEntries(r.ageBuckets.map((b) => [b.label, b.count])); expect(byLabel['0–30d']).toBe(1); expect(byLabel['31–90d']).toBe(1); expect(byLabel['1–2y']).toBe(1); expect(byLabel['2y+']).toBe(1); - // totals should match expect(r.ageBuckets.reduce((s, b) => s + b.count, 0)).toBe(4); }); it('ranks top bins and labels them site/room/bin', async () => { const tx = makeTx({ + ...EMPTY, partCount: 4, - stateRows: [], parts: [ { id: '1', state: 'SPARE', binId: 'b1', createdAt: daysAgo(1), partModelId: 'pm' }, { id: '2', state: 'SPARE', binId: 'b1', createdAt: daysAgo(1), partModelId: 'pm' }, { id: '3', state: 'SPARE', binId: 'b2', createdAt: daysAgo(1), partModelId: 'pm' }, { id: '4', state: 'SPARE', binId: null, createdAt: daysAgo(1), partModelId: 'pm' }, ], - openFms: 0, - eolPartModels: [], bins: [ { id: 'b1', name: 'A1', room: { name: 'Lab', site: { name: 'HQ' } } }, { id: 'b2', name: 'B2', room: { name: 'Lab', site: { name: 'HQ' } } }, ], }); - const r = await dashboard(tx); + const r = await dashboard(tx, { isAdmin: false }); expect(r.topBins).toEqual([ { binId: 'b1', label: 'HQ / Lab / A1', count: 2 }, { binId: 'b2', label: 'HQ / Lab / B2', count: 1 }, @@ -126,15 +179,14 @@ describe('analytics.dashboard', () => { it('flags part models whose EOL has passed and have deployed parts', async () => { const tx = makeTx({ + ...EMPTY, partCount: 3, - stateRows: [], parts: [ { id: '1', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm1' }, { id: '2', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm1' }, { id: '3', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm2' }, ], - openFms: 0, - eolPartModels: [ + pastEolModels: [ { id: 'pm1', mpn: 'ACM-100', @@ -157,10 +209,9 @@ describe('analytics.dashboard', () => { manufacturer: { name: 'Gamma' }, }, ], - bins: [], }); - const r = await dashboard(tx); + const r = await dashboard(tx, { isAdmin: false }); expect(r.deployedPastEol.map((m) => m.mpn)).toEqual(['ACM-100', 'BET-200']); expect(r.deployedPastEol[0]).toMatchObject({ partModelId: 'pm1', @@ -174,3 +225,122 @@ describe('analytics.dashboard', () => { }); }); }); + +describe('analytics.dashboard — upcomingEol', () => { + it('lists models with upcoming EOL sorted by date, filters zero-deployed', async () => { + const tx = makeTx({ + ...EMPTY, + partCount: 3, + parts: [ + { id: '1', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm1' }, + { id: '2', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm2' }, + { id: '3', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm2' }, + ], + upcomingEolModels: [ + { + id: 'pm2', + mpn: 'LATER', + eolDate: daysAhead(150), + manufacturerId: 'm1', + manufacturer: { name: 'Acme' }, + }, + { + id: 'pm1', + mpn: 'SOONER', + eolDate: daysAhead(45), + manufacturerId: 'm1', + manufacturer: { name: 'Acme' }, + }, + { + id: 'pm3', + mpn: 'NODEP', + eolDate: daysAhead(30), + manufacturerId: 'm1', + manufacturer: { name: 'Acme' }, + }, + ], + }); + + const r = await dashboard(tx, { isAdmin: false }); + expect(r.upcomingEol.map((m) => m.mpn)).toEqual(['SOONER', 'LATER']); + expect(r.upcomingEol[0]).toMatchObject({ partModelId: 'pm1', deployedCount: 1 }); + expect(r.upcomingEol[1]).toMatchObject({ partModelId: 'pm2', deployedCount: 2 }); + }); +}); + +describe('analytics.dashboard — isAdmin gating', () => { + it('omits operations when isAdmin is false', async () => { + const tx = makeTx(EMPTY); + const r = await dashboard(tx, { isAdmin: false }); + expect(r.operations).toBeUndefined(); + }); + + it('returns operations with expected shape when isAdmin is true', async () => { + const tx = makeTx({ + ...EMPTY, + repairs: [{ performedAt: daysAgo(1) }], + fmsClosed: [{ openedAt: daysAgo(2), closedAt: daysAgo(1) }], + newFms7d: 3, + openFmGroups: [{ hostId: 'h1', count: 2 }], + custodyGroups: [{ custodianId: 'u1', count: 1 }], + hosts: [{ id: 'h1', name: 'host-1' }], + users: [{ id: 'u1', username: 'alice' }], + }); + + const r = await dashboard(tx, { isAdmin: true }); + expect(r.operations).toBeDefined(); + expect(r.operations).toMatchObject({ + repairs7d: 1, + repairs30d: 1, + newFms7d: 3, + }); + expect(r.operations!.repairsTrend30d).toHaveLength(30); + expect(r.operations!.openFmsByHost).toEqual([{ hostId: 'h1', hostName: 'host-1', count: 2 }]); + expect(r.operations!.custodyBacklog).toEqual([ + { userId: 'u1', username: 'alice', count: 1 }, + ]); + }); +}); + +describe('analytics.dashboard — operations fields', () => { + it('repairsTrend30d has 30 entries and zero-fills empty days', async () => { + const tx = makeTx({ + ...EMPTY, + repairs: [{ performedAt: daysAgo(5) }, { performedAt: daysAgo(28) }], + }); + + const r = await dashboard(tx, { isAdmin: true }); + const trend = r.operations!.repairsTrend30d; + expect(trend).toHaveLength(30); + expect(trend.filter((d) => d.count === 0)).toHaveLength(28); + expect(trend.filter((d) => d.count === 1)).toHaveLength(2); + // Chronological order: earliest first, today last + for (let i = 1; i < trend.length; i++) { + expect(trend[i]!.date >= trend[i - 1]!.date).toBe(true); + } + }); + + it('avgFmCloseHours30d is null when no FMs closed in window', async () => { + const tx = makeTx({ ...EMPTY, fmsClosed: [] }); + const r = await dashboard(tx, { isAdmin: true }); + expect(r.operations!.avgFmCloseHours30d).toBeNull(); + }); + + it('avgFmCloseHours30d averages close durations in hours', async () => { + const tx = makeTx({ + ...EMPTY, + fmsClosed: [ + { + openedAt: new Date(now.getTime() - 4 * HOUR_MS), + closedAt: new Date(now.getTime() - 2 * HOUR_MS), + }, + { + openedAt: new Date(now.getTime() - 10 * HOUR_MS), + closedAt: new Date(now.getTime() - 4 * HOUR_MS), + }, + ], + }); + const r = await dashboard(tx, { isAdmin: true }); + expect(r.operations!.avgFmCloseHours30d).toBe(4); + }); +}); diff --git a/apps/api/src/services/analytics.ts b/apps/api/src/services/analytics.ts index 41fd47e..9c4fc57 100644 --- a/apps/api/src/services/analytics.ts +++ b/apps/api/src/services/analytics.ts @@ -1,7 +1,8 @@ -import type { DashboardAnalytics } from '@vector/shared'; +import type { DashboardAnalytics, OperationsAnalytics } from '@vector/shared'; import type { Tx } from './types.js'; const DAY = 24 * 60 * 60 * 1000; +const HOUR = 60 * 60 * 1000; const AGE_BUCKETS: { label: string; maxDays: number | null }[] = [ { label: '0–30d', maxDays: 30 }, @@ -12,29 +13,56 @@ const AGE_BUCKETS: { label: string; maxDays: number | null }[] = [ { label: '2y+', maxDays: null }, ]; -export async function dashboard(tx: Tx): Promise { - const [totalParts, stateRows, parts, openFms, partModelsWithEol] = await Promise.all([ - tx.part.count(), - tx.part.groupBy({ - by: ['state'], - _count: { _all: true }, - _sum: { price: true }, - }), - tx.part.findMany({ - select: { id: true, state: true, binId: true, createdAt: true, partModelId: true }, - }), - tx.fm.count({ where: { status: 'OPEN' } }), - tx.partModel.findMany({ - where: { eolDate: { not: null, lte: new Date() } }, - select: { - id: true, - mpn: true, - eolDate: true, - manufacturerId: true, - manufacturer: { select: { name: true } }, - }, - }), - ]); +const CUSTODY_STATES = [ + 'PENDING_REPAIR', + 'PENDING_DROP_IN_CUSTODY', + 'PENDING_DESTRUCTION_IN_CUSTODY', +] as const; + +function utcDateKey(d: Date): string { + return d.toISOString().slice(0, 10); +} + +export async function dashboard( + tx: Tx, + opts: { isAdmin: boolean }, +): Promise { + const now = new Date(); + const upcomingEolCutoff = new Date(now.getTime() + 180 * DAY); + + const [totalParts, stateRows, parts, openFms, pastEolModels, upcomingEolModels] = + await Promise.all([ + tx.part.count(), + tx.part.groupBy({ + by: ['state'], + _count: { _all: true }, + _sum: { price: true }, + }), + tx.part.findMany({ + select: { id: true, state: true, binId: true, createdAt: true, partModelId: true }, + }), + tx.fm.count({ where: { status: 'OPEN' } }), + tx.partModel.findMany({ + where: { eolDate: { not: null, lte: now } }, + select: { + id: true, + mpn: true, + eolDate: true, + manufacturerId: true, + manufacturer: { select: { name: true } }, + }, + }), + tx.partModel.findMany({ + where: { eolDate: { gt: now, lte: upcomingEolCutoff } }, + select: { + id: true, + mpn: true, + eolDate: true, + manufacturerId: true, + manufacturer: { select: { name: true } }, + }, + }), + ]); const byState = stateRows.map((row) => ({ state: row.state as DashboardAnalytics['byState'][number]['state'], @@ -42,10 +70,10 @@ export async function dashboard(tx: Tx): Promise { totalPrice: row._sum.price ?? 0, })); - const now = Date.now(); + const nowMs = now.getTime(); const buckets = AGE_BUCKETS.map((b) => ({ label: b.label, count: 0 })); for (const part of parts) { - const ageDays = (now - part.createdAt.getTime()) / DAY; + const ageDays = (nowMs - part.createdAt.getTime()) / DAY; const idx = AGE_BUCKETS.findIndex((b) => b.maxDays === null || ageDays <= b.maxDays); const bucket = idx >= 0 ? buckets[idx] : undefined; if (bucket) bucket.count += 1; @@ -80,7 +108,7 @@ export async function dashboard(tx: Tx): Promise { if (part.state !== 'DEPLOYED') continue; deployedByModel.set(part.partModelId, (deployedByModel.get(part.partModelId) ?? 0) + 1); } - const deployedPastEol = partModelsWithEol + const deployedPastEol = pastEolModels .map((m) => ({ partModelId: m.id, mpn: m.mpn, @@ -92,5 +120,133 @@ export async function dashboard(tx: Tx): Promise { .filter((m) => m.deployedCount > 0) .sort((a, b) => b.deployedCount - a.deployedCount); - return { totalParts, byState, ageBuckets: buckets, topBins, deployedPastEol, openFms }; + const upcomingEol = upcomingEolModels + .map((m) => ({ + partModelId: m.id, + mpn: m.mpn, + manufacturerId: m.manufacturerId, + manufacturerName: m.manufacturer.name, + eolDate: m.eolDate ? m.eolDate.toISOString() : '', + deployedCount: deployedByModel.get(m.id) ?? 0, + })) + .filter((m) => m.deployedCount > 0) + .sort((a, b) => (a.eolDate < b.eolDate ? -1 : 1)); + + const base: DashboardAnalytics = { + totalParts, + byState, + ageBuckets: buckets, + topBins, + deployedPastEol, + upcomingEol, + openFms, + }; + + if (!opts.isAdmin) return base; + + const sevenDaysAgo = new Date(nowMs - 7 * DAY); + const thirtyDaysAgo = new Date(nowMs - 30 * DAY); + + const [ + repairs7d, + repairs30d, + newFms7d, + closedFms, + recentRepairs, + openFmGroups, + custodyGroups, + ] = await Promise.all([ + tx.repair.count({ where: { performedAt: { gte: sevenDaysAgo } } }), + tx.repair.count({ where: { performedAt: { gte: thirtyDaysAgo } } }), + tx.fm.count({ where: { openedAt: { gte: sevenDaysAgo } } }), + tx.fm.findMany({ + where: { closedAt: { gte: thirtyDaysAgo } }, + select: { openedAt: true, closedAt: true }, + }), + tx.repair.findMany({ + where: { performedAt: { gte: thirtyDaysAgo } }, + select: { performedAt: true }, + }), + tx.fm.groupBy({ + by: ['hostId'], + where: { status: 'OPEN' }, + _count: { _all: true }, + }), + tx.part.groupBy({ + by: ['custodianId'], + where: { + custodianId: { not: null }, + state: { in: CUSTODY_STATES as unknown as string[] }, + }, + _count: { _all: true }, + }), + ]); + + const closedWithDates = closedFms.filter( + (f): f is { openedAt: Date; closedAt: Date } => f.closedAt !== null, + ); + const avgFmCloseHours30d = + closedWithDates.length === 0 + ? null + : closedWithDates.reduce( + (sum, f) => sum + (f.closedAt.getTime() - f.openedAt.getTime()) / HOUR, + 0, + ) / closedWithDates.length; + + const trendByDay = new Map(); + for (const r of recentRepairs) { + const key = utcDateKey(r.performedAt); + trendByDay.set(key, (trendByDay.get(key) ?? 0) + 1); + } + const repairsTrend30d: { date: string; count: number }[] = []; + for (let i = 29; i >= 0; i--) { + const d = new Date(nowMs - i * DAY); + const key = utcDateKey(d); + repairsTrend30d.push({ date: key, count: trendByDay.get(key) ?? 0 }); + } + + const topOpenFmHostIds = [...openFmGroups] + .sort((a, b) => b._count._all - a._count._all) + .slice(0, 8); + const openFmHostRows = topOpenFmHostIds.length + ? await tx.host.findMany({ + where: { id: { in: topOpenFmHostIds.map((g) => g.hostId) } }, + select: { id: true, name: true }, + }) + : []; + const openFmHostNames = new Map(openFmHostRows.map((h) => [h.id, h.name])); + const openFmsByHost = topOpenFmHostIds.map((g) => ({ + hostId: g.hostId, + hostName: openFmHostNames.get(g.hostId) ?? 'Unknown', + count: g._count._all, + })); + + const topCustodians = [...custodyGroups] + .filter((g): g is typeof g & { custodianId: string } => g.custodianId !== null) + .sort((a, b) => b._count._all - a._count._all) + .slice(0, 8); + const custodyUsers = topCustodians.length + ? await tx.user.findMany({ + where: { id: { in: topCustodians.map((g) => g.custodianId) } }, + select: { id: true, username: true }, + }) + : []; + const usernames = new Map(custodyUsers.map((u) => [u.id, u.username])); + const custodyBacklog = topCustodians.map((g) => ({ + userId: g.custodianId, + username: usernames.get(g.custodianId) ?? 'Unknown', + count: g._count._all, + })); + + const operations: OperationsAnalytics = { + repairs7d, + repairs30d, + newFms7d, + avgFmCloseHours30d, + repairsTrend30d, + openFmsByHost, + custodyBacklog, + }; + + return { ...base, operations }; } diff --git a/apps/web/src/pages/Dashboard.tsx b/apps/web/src/pages/Dashboard.tsx index da7819c..5589d60 100644 --- a/apps/web/src/pages/Dashboard.tsx +++ b/apps/web/src/pages/Dashboard.tsx @@ -1,11 +1,13 @@ import { useQuery } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; -import { AlertTriangle, Download, Package, Wrench } from 'lucide-react'; +import { AlertTriangle, CalendarClock, Download, Package, Wrench } from 'lucide-react'; import { Bar, BarChart, Cell, Legend, + Line, + LineChart, Pie, PieChart, ResponsiveContainer, @@ -48,10 +50,24 @@ const STATE_COLORS: Record = { PENDING_REPAIR: 'hsl(197 80% 50%)', }; +const LINE_BLUE = 'hsl(217 91% 60%)'; +const FAILURE_COLOR = 'hsl(0 84% 60%)'; + function currency(dollars: number): string { return dollars.toLocaleString(undefined, { style: 'currency', currency: 'USD' }); } +function formatHours(h: number): string { + if (h < 24) return `${h.toFixed(1)} h`; + const days = h / 24; + return `${days.toFixed(1)} d`; +} + +function shortDate(iso: string): string { + const d = new Date(`${iso}T00:00:00Z`); + return `${d.getUTCMonth() + 1}/${d.getUTCDate()}`; +} + export default function Dashboard() { const { user } = useAuth(); const { data, isLoading, isError } = useQuery({ @@ -88,7 +104,7 @@ export default function Dashboard() { {data && ( <> -
+
} label="Total parts" @@ -115,9 +131,61 @@ export default function Dashboard() { .toLocaleString()} tone={data.deployedPastEol.length > 0 ? 'warn' : undefined} /> + } + label="Upcoming EOL (180d)" + value={data.upcomingEol + .reduce((sum, m) => sum + m.deployedCount, 0) + .toLocaleString()} + tone={data.upcomingEol.length > 0 ? 'caution' : undefined} + />
- {data.deployedPastEol.length > 0 && } + {data.operations && ( +
+ + + + +
+ )} + + {data.deployedPastEol.length > 0 && ( + + )} + + {data.upcomingEol.length > 0 && ( + + )}
@@ -233,6 +301,95 @@ export default function Dashboard() {
+ + {data.operations && ( +
+ + + Repairs (last 30 days) + Daily count of logged part swaps. + + + + ({ + label: shortDate(d.date), + count: d.count, + }))} + > + + + + + + + + + + + + Open FMs by host + + Where the active field-maintenance load is concentrated. + + + + {data.operations.openFmsByHost.length === 0 ? ( +
+ No open FMs. +
+ ) : ( + data.operations.openFmsByHost.map((h) => ( + + {h.hostName} + + {h.count} + + + )) + )} +
+
+
+ )} + + {data.operations && data.operations.custodyBacklog.length > 0 && ( + + + Parts sitting in custody + + Users holding parts that haven't been dropped off or returned. + + + + {data.operations.custodyBacklog.map((u) => ( + + {u.username} + + {u.count} pending + + + ))} + + + )} )}
@@ -249,11 +406,17 @@ function KpiCard({ icon?: React.ReactNode; label: string; value: string; - tone?: 'warn'; + tone?: 'warn' | 'caution'; href?: string; }) { + const toneClass = + tone === 'warn' + ? 'border-warning/50' + : tone === 'caution' + ? 'border-warning/30' + : undefined; const body = ( - + {icon && (
@@ -279,9 +442,15 @@ function KpiCard({ return body; } -function PastEolBanner({ +function EolBanner({ + tone, + title, + description, rows, }: { + tone: 'warn' | 'caution'; + title: string; + description: string; rows: { partModelId: string; mpn: string; @@ -291,17 +460,20 @@ function PastEolBanner({ deployedCount: number; }[]; }) { + const classes = + tone === 'warn' + ? 'border-warning/50 bg-warning/5' + : 'border-warning/30 bg-warning/[0.03]'; return ( - + - - Deployed past part-model EOL + + {title} - - These MPNs have passed their end-of-life date — plan replacements for any parts still in - production. - + {description} {rows.map((row) => ( @@ -324,7 +496,7 @@ function PastEolBanner({ {row.deployedCount} deployed
@@ -337,8 +509,8 @@ function PastEolBanner({ function DashboardSkeleton() { return (
-
- {Array.from({ length: 4 }).map((_, i) => ( +
+ {Array.from({ length: 5 }).map((_, i) => ( ))}
diff --git a/packages/shared/src/analytics.ts b/packages/shared/src/analytics.ts index 7b87971..d368d9a 100644 --- a/packages/shared/src/analytics.ts +++ b/packages/shared/src/analytics.ts @@ -27,11 +27,23 @@ export interface PartModelEolSummary { deployedCount: number; } +export interface OperationsAnalytics { + repairs7d: number; + repairs30d: number; + newFms7d: number; + avgFmCloseHours30d: number | null; + repairsTrend30d: { date: string; count: number }[]; + openFmsByHost: { hostId: string; hostName: string; count: number }[]; + custodyBacklog: { userId: string; username: string; count: number }[]; +} + export interface DashboardAnalytics { totalParts: number; byState: StateCount[]; ageBuckets: AgeBucket[]; topBins: BinCount[]; deployedPastEol: PartModelEolSummary[]; + upcomingEol: PartModelEolSummary[]; openFms: number; + operations?: OperationsAnalytics; }