feat(dashboard): add upcoming EOL + admin operations widgets
CI / Lint · Typecheck · Test · Build (push) Successful in 47s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m5s

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:
2026-04-17 16:22:10 -04:00
parent ae65d9f2a8
commit 52e092502b
5 changed files with 593 additions and 82 deletions
+188 -16
View File
@@ -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>