Files
TicketingSystem/client/src/pages/Dashboard.tsx
T
josh f7028c563a
Build & Push / Test (client) (push) Successful in 26s
Build & Push / Test (server) (push) Successful in 29s
Build & Push / Build Client (push) Successful in 51s
Build & Push / Build Server (push) Successful in 1m38s
Let Dashboard and wide Layout spread on 2xl screens
Dashboard now opts into `wide`, and the wide container scales from 1400
to 1800px at the 2xl breakpoint so content uses the extra room on big
monitors. Queue-load grid gains xl/2xl column counts for the new width.
Below 1536px nothing changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 21:28:44 -04:00

192 lines
6.8 KiB
TypeScript

import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { AlertTriangle, Clock, TrendingUp, Users } from 'lucide-react';
import Layout from '../components/Layout';
import { useAnalytics, useUsers } from '../api/queries';
const SEV_NAMES: Record<number, string> = {
1: 'SEV 1 — Critical',
2: 'SEV 2 — High',
3: 'SEV 3 — Medium',
4: 'SEV 4 — Low',
5: 'SEV 5 — Minimal',
};
const SEV_COLORS: Record<number, string> = {
1: 'bg-red-500',
2: 'bg-orange-400',
3: 'bg-yellow-400',
4: 'bg-blue-400',
5: 'bg-gray-500',
};
function fmtHours(hours: number | null) {
if (hours == null) return '—';
if (hours < 1) return `${Math.round(hours * 60)} min`;
if (hours < 48) return `${hours.toFixed(1)} h`;
return `${(hours / 24).toFixed(1)} d`;
}
export default function Dashboard() {
const { data: a } = useAnalytics(30);
const { data: users = [] } = useUsers();
const userById = useMemo(
() => new Map(users.map((u) => [u.id, u.displayName])),
[users],
);
const totalOpen =
a?.openBySeverity.reduce((sum, row) => sum + row.count, 0) ?? 0;
const maxBucket = Math.max(
...Object.values(a?.ageBuckets ?? { d1: 0, d7: 0, d14: 0, older: 0 }),
1,
);
const maxSeverity = Math.max(...(a?.openBySeverity.map((r) => r.count) ?? [1]));
return (
<Layout wide title="Dashboard" subheader={<p className="text-xs text-muted-foreground">Last 30 days</p>}>
{!a ? (
<p className="py-16 text-center text-sm text-muted-foreground">Loading analytics</p>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card
icon={<AlertTriangle size={14} />}
label="Open tickets"
value={totalOpen.toString()}
/>
<Card
icon={<Clock size={14} />}
label="Aging >7d"
value={((a.ageBuckets.d14 ?? 0) + (a.ageBuckets.older ?? 0)).toString()}
/>
<Card
icon={<TrendingUp size={14} />}
label="Median resolution"
value={fmtHours(a.medianResolutionHours)}
/>
<Card
icon={<Users size={14} />}
label="Assignees loaded"
value={a.queueByAssignee.filter((q) => q.assigneeId).length.toString()}
/>
{/* Open by severity */}
<section className="md:col-span-2 rounded-md border border-border p-4">
<h2 className="text-sm font-semibold mb-3">Open by severity</h2>
<div className="space-y-2">
{[1, 2, 3, 4, 5].map((sev) => {
const row = a.openBySeverity.find((r) => r.severity === sev);
const count = row?.count ?? 0;
const pct = maxSeverity > 0 ? (count / maxSeverity) * 100 : 0;
return (
<div key={sev} className="flex items-center gap-3 text-sm">
<span className="w-32 text-xs text-muted-foreground">
{SEV_NAMES[sev]}
</span>
<div className="flex-1 h-5 rounded-sm bg-muted overflow-hidden">
<div
className={`h-full ${SEV_COLORS[sev]}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="w-10 text-right text-xs font-mono tabular-nums">
{count}
</span>
</div>
);
})}
</div>
<div className="mt-3">
<Link
to="/tickets?status=OPEN"
className="text-xs text-primary hover:underline"
>
Go to open tickets
</Link>
</div>
</section>
{/* Age buckets */}
<section className="md:col-span-2 rounded-md border border-border p-4">
<h2 className="text-sm font-semibold mb-3">Age of open tickets</h2>
<div className="space-y-2">
{[
{ key: 'd1', label: '≤ 1 day' },
{ key: 'd7', label: '≤ 7 days' },
{ key: 'd14', label: '≤ 14 days' },
{ key: 'older', label: '> 14 days' },
].map(({ key, label }) => {
const count = a.ageBuckets[key as keyof typeof a.ageBuckets] ?? 0;
const pct = (count / maxBucket) * 100;
return (
<div key={key} className="flex items-center gap-3 text-sm">
<span className="w-24 text-xs text-muted-foreground">{label}</span>
<div className="flex-1 h-5 rounded-sm bg-muted overflow-hidden">
<div
className={`h-full ${key === 'older' ? 'bg-red-500' : 'bg-primary'}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="w-10 text-right text-xs font-mono tabular-nums">
{count}
</span>
</div>
);
})}
</div>
</section>
{/* Queue by assignee */}
<section className="md:col-span-2 lg:col-span-4 rounded-md border border-border p-4">
<h2 className="text-sm font-semibold mb-3">Queue load by assignee</h2>
{a.queueByAssignee.length === 0 ? (
<p className="text-xs text-muted-foreground">No open tickets right now.</p>
) : (
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-6">
{a.queueByAssignee
.slice()
.sort((x, y) => y.count - x.count)
.map((row) => {
const name = row.assigneeId
? userById.get(row.assigneeId) ?? 'Unknown'
: 'Unassigned';
return (
<div
key={row.assigneeId ?? 'none'}
className="flex items-center justify-between px-3 py-2 rounded-md bg-muted/40"
>
<span className="text-sm truncate">{name}</span>
<span className="text-xs font-mono tabular-nums">{row.count}</span>
</div>
);
})}
</div>
)}
</section>
</div>
)}
</Layout>
);
}
function Card({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="rounded-md border border-border p-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
{icon}
<span>{label}</span>
</div>
<p className="text-2xl font-semibold tabular-nums">{value}</p>
</div>
);
}