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