feat(dashboard): add upcoming EOL + admin operations widgets
Surface operational signal alongside inventory: upcoming-EOL banner and KPI for everyone; admin-only repairs tempo, FM close time, open FMs by host, and custody backlog. Service shapes payload by role. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<DashboardAnalytics> {
|
||||
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<DashboardAnalytics> {
|
||||
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<DashboardAnalytics> {
|
||||
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<DashboardAnalytics> {
|
||||
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<DashboardAnalytics> {
|
||||
.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<string, number>();
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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<PartState, string> = {
|
||||
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 && (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<KpiCard
|
||||
icon={<Package className="h-4 w-4" />}
|
||||
label="Total parts"
|
||||
@@ -115,9 +131,61 @@ export default function Dashboard() {
|
||||
.toLocaleString()}
|
||||
tone={data.deployedPastEol.length > 0 ? 'warn' : undefined}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<CalendarClock className="h-4 w-4" />}
|
||||
label="Upcoming EOL (180d)"
|
||||
value={data.upcomingEol
|
||||
.reduce((sum, m) => sum + m.deployedCount, 0)
|
||||
.toLocaleString()}
|
||||
tone={data.upcomingEol.length > 0 ? 'caution' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{data.deployedPastEol.length > 0 && <PastEolBanner rows={data.deployedPastEol} />}
|
||||
{data.operations && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<KpiCard
|
||||
label="Repairs (7d)"
|
||||
value={data.operations.repairs7d.toLocaleString()}
|
||||
href="/repairs"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Repairs (30d)"
|
||||
value={data.operations.repairs30d.toLocaleString()}
|
||||
href="/repairs"
|
||||
/>
|
||||
<KpiCard
|
||||
label="FMs opened (7d)"
|
||||
value={data.operations.newFms7d.toLocaleString()}
|
||||
href="/fms"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Avg FM close (30d)"
|
||||
value={
|
||||
data.operations.avgFmCloseHours30d == null
|
||||
? '—'
|
||||
: formatHours(data.operations.avgFmCloseHours30d)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.deployedPastEol.length > 0 && (
|
||||
<EolBanner
|
||||
tone="warn"
|
||||
title="Deployed past part-model EOL"
|
||||
description="These MPNs have passed their end-of-life date — plan replacements for any parts still in production."
|
||||
rows={data.deployedPastEol}
|
||||
/>
|
||||
)}
|
||||
|
||||
{data.upcomingEol.length > 0 && (
|
||||
<EolBanner
|
||||
tone="caution"
|
||||
title="EOL within 180 days"
|
||||
description="MPNs with a near-term EOL and deployed parts. Get procurement ahead of the wave."
|
||||
rows={data.upcomingEol}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
@@ -233,6 +301,95 @@ export default function Dashboard() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{data.operations && (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Repairs (last 30 days)</CardTitle>
|
||||
<CardDescription>Daily count of logged part swaps.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-72">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={data.operations.repairsTrend30d.map((d) => ({
|
||||
label: shortDate(d.date),
|
||||
count: d.count,
|
||||
}))}
|
||||
>
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11 }} interval={3} />
|
||||
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
|
||||
<Tooltip cursor={{ stroke: 'hsl(var(--accent) / 0.4)' }} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke={LINE_BLUE}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Open FMs by host</CardTitle>
|
||||
<CardDescription>
|
||||
Where the active field-maintenance load is concentrated.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 pb-5">
|
||||
{data.operations.openFmsByHost.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
No open FMs.
|
||||
</div>
|
||||
) : (
|
||||
data.operations.openFmsByHost.map((h) => (
|
||||
<Link
|
||||
key={h.hostId}
|
||||
to={`/hosts/${h.hostId}`}
|
||||
className="flex items-center justify-between rounded-md border border-border bg-background px-3 py-2 text-sm transition-colors hover:bg-accent/40"
|
||||
>
|
||||
<span className="truncate font-medium">{h.hostName}</span>
|
||||
<span
|
||||
className="tabular-nums font-semibold"
|
||||
style={{ color: FAILURE_COLOR }}
|
||||
>
|
||||
{h.count}
|
||||
</span>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.operations && data.operations.custodyBacklog.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Parts sitting in custody</CardTitle>
|
||||
<CardDescription>
|
||||
Users holding parts that haven't been dropped off or returned.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 pb-5">
|
||||
{data.operations.custodyBacklog.map((u) => (
|
||||
<Link
|
||||
key={u.userId}
|
||||
to={`/parts?custodianId=${u.userId}`}
|
||||
className="flex items-center justify-between rounded-md border border-border bg-background px-3 py-2 text-sm transition-colors hover:bg-accent/40"
|
||||
>
|
||||
<span className="truncate font-medium">{u.username}</span>
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{u.count} pending
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -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 = (
|
||||
<Card className={tone === 'warn' ? 'border-warning/50' : undefined}>
|
||||
<Card className={toneClass}>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
{icon && (
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-accent text-accent-foreground">
|
||||
@@ -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 (
|
||||
<Card className="border-warning/50 bg-warning/5">
|
||||
<Card className={classes}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<AlertTriangle className="h-4 w-4 text-warning" />
|
||||
Deployed past part-model EOL
|
||||
<AlertTriangle
|
||||
className={`h-4 w-4 ${tone === 'warn' ? 'text-warning' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
{title}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
These MPNs have passed their end-of-life date — plan replacements for any parts still in
|
||||
production.
|
||||
</CardDescription>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 pb-5">
|
||||
{rows.map((row) => (
|
||||
@@ -324,7 +496,7 @@ function PastEolBanner({
|
||||
{row.deployedCount} deployed
|
||||
</span>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={`/parts?partModelId=${row.partModelId}&state=DEPLOYED`}>View</Link>
|
||||
<Link to={`/part-models/${row.partModelId}`}>View</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -337,8 +509,8 @@ function PastEolBanner({
|
||||
function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user