135 lines
3.8 KiB
TypeScript
135 lines
3.8 KiB
TypeScript
|
|
/* QueueHeatmap — open tickets by queue × priority.
|
|||
|
|
*
|
|||
|
|
* Geometric grid; each cell is a square tinted with the brand blue.
|
|||
|
|
* Intensity scales linearly to the most-loaded cell (so the heaviest
|
|||
|
|
* cell renders at full saturation). Per-row totals on the right; the
|
|||
|
|
* column headers carry priority labels.
|
|||
|
|
*
|
|||
|
|
* Empty cells render as a thin dashed outline rather than nothing —
|
|||
|
|
* preserves the grid alignment and makes the absence visible. */
|
|||
|
|
|
|||
|
|
'use client';
|
|||
|
|
|
|||
|
|
import { priorityBadge } from '@/lib/status-registry';
|
|||
|
|
import { cn } from '@/lib/utils';
|
|||
|
|
|
|||
|
|
interface HeatmapRow {
|
|||
|
|
queueId: number;
|
|||
|
|
queueLabel: string;
|
|||
|
|
total: number;
|
|||
|
|
byPriority: Record<number, number>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface QueueHeatmapProps {
|
|||
|
|
data: HeatmapRow[];
|
|||
|
|
/** Priority IDs in column order. Defaults to Critical→Very Low. */
|
|||
|
|
priorities?: number[];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const DEFAULT_PRIORITIES = [2, 3, 4, 6, 7];
|
|||
|
|
|
|||
|
|
export function QueueHeatmap({ data, priorities = DEFAULT_PRIORITIES }: QueueHeatmapProps) {
|
|||
|
|
const max = data.reduce(
|
|||
|
|
(m, row) => Math.max(m, ...priorities.map((p) => row.byPriority[p] ?? 0)),
|
|||
|
|
1,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
if (data.length === 0) {
|
|||
|
|
return (
|
|||
|
|
<p className="text-sm text-muted-foreground py-4">
|
|||
|
|
No open tickets.
|
|||
|
|
</p>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="overflow-x-auto">
|
|||
|
|
<table className="w-full text-sm border-separate border-spacing-x-1 border-spacing-y-0">
|
|||
|
|
<thead>
|
|||
|
|
<tr>
|
|||
|
|
<th className="text-left metric-label py-1 pr-3">Queue</th>
|
|||
|
|
{priorities.map((p) => {
|
|||
|
|
const badge = priorityBadge(p);
|
|||
|
|
return (
|
|||
|
|
<th key={p} className="metric-label py-1 px-1 text-center w-12">
|
|||
|
|
{badge.label.slice(0, 3)}
|
|||
|
|
</th>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
<th className="metric-label py-1 pl-3 text-right">Total</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
|
|
{data.map((row) => (
|
|||
|
|
<tr key={row.queueId} className="hover:bg-muted/30">
|
|||
|
|
<td
|
|||
|
|
className="py-1 pr-3 truncate max-w-[260px]"
|
|||
|
|
title={row.queueLabel}
|
|||
|
|
>
|
|||
|
|
{row.queueLabel}
|
|||
|
|
</td>
|
|||
|
|
{priorities.map((p) => (
|
|||
|
|
<Cell
|
|||
|
|
key={p}
|
|||
|
|
value={row.byPriority[p] ?? 0}
|
|||
|
|
max={max}
|
|||
|
|
priorityLabel={priorityBadge(p).label}
|
|||
|
|
queueLabel={row.queueLabel}
|
|||
|
|
/>
|
|||
|
|
))}
|
|||
|
|
<td className="py-1 pl-3 num text-right text-muted-foreground">
|
|||
|
|
{row.total}
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
))}
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function Cell({
|
|||
|
|
value,
|
|||
|
|
max,
|
|||
|
|
priorityLabel,
|
|||
|
|
queueLabel,
|
|||
|
|
}: {
|
|||
|
|
value: number;
|
|||
|
|
max: number;
|
|||
|
|
priorityLabel: string;
|
|||
|
|
queueLabel: string;
|
|||
|
|
}) {
|
|||
|
|
if (value === 0) {
|
|||
|
|
return (
|
|||
|
|
<td className="py-1 px-1">
|
|||
|
|
<div
|
|||
|
|
className="h-7 w-full rounded-sm border border-dashed border-border/50"
|
|||
|
|
aria-label="0"
|
|||
|
|
/>
|
|||
|
|
</td>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
// Normalize 0..1 then floor into one of 6 opacity steps; smallest cell still
|
|||
|
|
// reads, largest is at 0.85.
|
|||
|
|
const ratio = Math.min(value / max, 1);
|
|||
|
|
const opacity = Math.max(0.18, ratio * 0.85);
|
|||
|
|
return (
|
|||
|
|
<td className="py-1 px-1">
|
|||
|
|
<div
|
|||
|
|
className={cn(
|
|||
|
|
'h-7 w-full rounded-sm flex items-center justify-center num text-xs',
|
|||
|
|
'transition-opacity hover:opacity-100',
|
|||
|
|
)}
|
|||
|
|
style={{
|
|||
|
|
backgroundColor: `color-mix(in oklch, var(--primary) ${Math.round(opacity * 100)}%, transparent)`,
|
|||
|
|
color: ratio > 0.55 ? 'var(--primary-foreground)' : 'var(--foreground)',
|
|||
|
|
}}
|
|||
|
|
title={`${queueLabel} · ${priorityLabel}: ${value}`}
|
|||
|
|
aria-label={`${queueLabel}, ${priorityLabel}: ${value}`}
|
|||
|
|
>
|
|||
|
|
{value}
|
|||
|
|
</div>
|
|||
|
|
</td>
|
|||
|
|
);
|
|||
|
|
}
|