f7028c563a
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>
192 lines
6.8 KiB
TypeScript
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>
|
|
);
|
|
}
|