src/features/reports/report-view.tsx
Linked repository file from this build.
"use client";
import { LoadingPreview } from "@/components/loading-preview";
import { useState, type ReactNode } from "react";
import { ArrowDownUp, Download, Play, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { fieldClass } from "@/components/ui/input";
import { roleLabels, type Actor, type DemoRole } from "@/features/reviews/types";
import { MonthField } from "./month-field";
import {
countyChartSorts,
monthChartSorts,
nextCountyChartSort,
nextMonthChartSort,
sortCountyGroups,
sortMonthGroups,
type CountyChartSort,
type MonthChartSort,
} from "./progress-sort";
import { aggregateReport, countyLabel, examinerLabel, type ReportFilters } from "./report-data";
type Summary = ReturnType<typeof aggregateReport>;
type Group = Summary["byMonth"][number];
export type Run = {
id: string;
at: string;
scope: string;
total: number;
eligible: number;
csv: string;
};
const control = fieldClass;
const placeholderGroups: Group[] = Array.from({ length: 4 }, (_, i) => ({
key: `loading-${i}`,
label: "Loading summary",
total: 10,
completed: 5,
approved: 5,
clean: 3,
error: 2,
eligible: 5,
ineligibleApproved: 0,
}));
const placeholderErrorCodes: Summary["byErrorCode"] = Array.from({ length: 4 }, (_, i) => ({
element: String(i),
nature: "000",
elementLabel: "Loading element",
natureLabel: "Loading nature",
reviews: 0,
}));
type ReportViewProps = {
actor: Actor;
isSignedIn: boolean;
setRole(role: DemoRole): void;
loading: boolean;
loadedAt: string;
error: string;
canView: boolean;
filters: ReportFilters;
update(patch: Partial<ReportFilters>): void;
setFilters(filters: ReportFilters): void;
examiners: string[];
counties: string[];
invalidRange: boolean;
summary: Summary;
runs: Run[];
runReport(): void;
onRefresh(): void;
};
export function ReportView({
actor,
isSignedIn,
setRole,
loading,
loadedAt,
error,
canView,
filters,
update,
setFilters,
examiners,
counties,
invalidRange,
summary,
runs,
runReport,
onRefresh,
}: ReportViewProps) {
const pending = loading && !loadedAt && !error;
return (
<div
className="report-view mx-auto flex w-full max-w-6xl flex-col gap-6 pb-16"
aria-busy={pending}
>
{pending && (
<span role="status" className="sr-only">
Loading reports…
</span>
)}
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Program Error Summary</h1>
</div>
<div className="flex items-end gap-3">
{!isSignedIn && (
<label className="grid gap-1 text-xs">
Demo role
<select
id="reports-role"
className={control}
value={actor.role}
onChange={(event) => setRole(event.target.value as DemoRole)}
>
{Object.entries(roleLabels).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</label>
)}
<Button type="button" variant="outline" disabled={loading} onClick={onRefresh}>
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} aria-hidden />
Refresh
</Button>
</div>
</div>
{error && (
<p
role="alert"
className="rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive"
>
{error}
</p>
)}
{!canView ? (
<p className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
Reports is available to Statistics, Admin and Program manager.
</p>
) : (
<>
<ReportFilters
filters={filters}
update={update}
setFilters={setFilters}
examiners={examiners}
counties={counties}
loading={loading}
loadedAt={loadedAt}
invalidRange={invalidRange}
/>
{!invalidRange && (
<>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Metric
loading={pending}
label="Reviews in scope"
value={summary.total}
detail="Across the selected sample months"
/>
<Metric
loading={pending}
label="Closed reviews"
value={summary.completed}
detail={`${summary.total ? Math.round((summary.completed / summary.total) * 100) : 0}% Released or Dropped`}
/>
<Metric
loading={pending}
label="Released to reporting"
value={summary.eligible}
detail="Released Valid and Invalid reviews"
/>
<Metric
loading={pending}
label="Awaiting release"
value={summary.ineligibleApproved}
detail="Memo approved, response or release pending"
/>
</div>
<ErrorSummarySection summary={summary} pending={pending} />
<ErrorCodeTable summary={summary} pending={pending} />
<div className="grid gap-4 lg:grid-cols-2">
<ProgressChart
loading={pending}
title="Workflow state"
groups={summary.byState}
shareOf={summary.total}
size="stretch"
/>
<ProgressChart
loading={pending}
title="Sample month"
groups={summary.byMonth}
sorts="month"
size="content"
onSelect={(key) => update({ from: key, through: key })}
/>
<ProgressChart
loading={pending}
title="Examiner"
groups={summary.byExaminer}
onSelect={(key) => update({ examiner: key })}
/>
<ProgressChart
loading={pending}
title="County"
groups={summary.byCounty}
sorts="county"
compare="error"
onSelect={(key) => update({ county: key })}
/>
</div>
</>
)}
<ReportRuns
runs={runs}
disabled={loading || !!error || invalidRange || !loadedAt}
runReport={runReport}
/>
</>
)}
</div>
);
}
/** Month, examiner and county filters, with the last load time. */
function ReportFilters({
filters,
update,
setFilters,
examiners,
counties,
loading,
loadedAt,
invalidRange,
}: Pick<
ReportViewProps,
| "filters"
| "update"
| "setFilters"
| "examiners"
| "counties"
| "loading"
| "loadedAt"
| "invalidRange"
>) {
return (
<section className="space-y-3 rounded-lg border bg-card p-4" aria-label="Report filters">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<MonthField
id="report-from-month"
label="From sample month"
value={filters.from || ""}
onChange={(from) => update({ from })}
/>
<MonthField
id="report-through-month"
label="Through sample month"
value={filters.through || ""}
onChange={(through) => update({ through })}
/>
<label className="grid gap-1 text-xs font-medium">
Examiner
<select
className={control}
value={filters.examiner || ""}
onChange={(event) => update({ examiner: event.target.value })}
>
<option value="">All examiners</option>
{examiners.map((id) => (
<option key={id} value={id}>
{examinerLabel(id)}
</option>
))}
</select>
</label>
<label className="grid gap-1 text-xs font-medium">
County
<select
className={control}
value={filters.county || ""}
onChange={(event) => update({ county: event.target.value })}
>
<option value="">All counties</option>
{counties.map((county) => (
<option key={county} value={county}>
{countyLabel(county)}
</option>
))}
</select>
</label>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<span role="status" className="text-xs text-muted-foreground">
{loading
? "Loading reviews…"
: loadedAt
? `Updated ${new Date(loadedAt).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`
: ""}
</span>
<Button type="button" variant="ghost" size="sm" onClick={() => setFilters({})}>
Clear filters
</Button>
</div>
{invalidRange && (
<p role="alert" className="text-sm text-destructive">
From month must be on or before through month.
</p>
)}
</section>
);
}
/** Valid and Invalid counts of released reviews, as a bar. */
function ErrorSummarySection({ summary, pending }: { summary: Summary; pending: boolean }) {
return (
<section className="rounded-lg border bg-card p-5" aria-labelledby="erc-heading">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 id="erc-heading" className="font-semibold">
Program Error Summary
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Released non-drop reviews:{" "}
<LoadingValue loading={pending}>{summary.eligible}</LoadingValue>
</p>
</div>
<p className="text-sm font-medium">
Valid: <LoadingValue loading={pending}>{summary.clean}</LoadingValue> · Invalid:{" "}
<LoadingValue loading={pending}>{summary.error}</LoadingValue>
</p>
</div>
<div
role="img"
aria-label={`${summary.clean} Valid and ${summary.error} Invalid eligible reviews`}
className="mt-4 flex h-4 overflow-hidden rounded-full bg-muted"
>
<div
className="bg-success"
style={{
width: `${summary.eligible ? (summary.clean / summary.eligible) * 100 : 0}%`,
}}
/>
<div
className="bg-warning"
style={{
width: `${summary.eligible ? (summary.error / summary.eligible) * 100 : 0}%`,
}}
/>
</div>
<div className="mt-2 flex gap-4 text-xs text-muted-foreground">
<span>
<span className="mr-1.5 inline-block size-2 rounded-full bg-success" />
Valid
</span>
<span>
<span className="mr-1.5 inline-block size-2 rounded-full bg-warning" />
Invalid
</span>
</div>
<p className="mt-4 text-xs leading-relaxed text-muted-foreground">
Awaiting release:{" "}
<LoadingValue loading={pending}>{summary.ineligibleApproved}</LoadingValue>
</p>
</section>
);
}
/** Released Invalid reviews grouped by element and nature code. */
function ErrorCodeTable({ summary, pending }: { summary: Summary; pending: boolean }) {
return (
<section
className="overflow-hidden rounded-lg border bg-card"
aria-labelledby="erc-errors-heading"
>
<div className="space-y-1 p-5">
<h2 id="erc-errors-heading" className="font-semibold">
Errors by element and nature code
</h2>
<p className="text-sm text-muted-foreground">
Released Invalid reviews in the selected scope. Each review has one element and nature
pair; reviews with multiple pairs appear in multiple rows.
</p>
</div>
<div className="h-48 overflow-auto">
{pending || summary.byErrorCode.length ? (
<LoadingPreview loading={pending} label="Loading error codes…">
<div>
<table
className="w-full text-left text-sm"
aria-label="Error summary errors by element and nature code"
>
<thead className="sticky top-0 bg-muted text-xs text-muted-foreground">
<tr>
<th scope="col" className="px-5 py-3 font-medium">
Element
</th>
<th scope="col" className="px-5 py-3 font-medium">
Nature
</th>
<th scope="col" className="px-5 py-3 text-right font-medium">
Invalid reviews
</th>
<th scope="col" className="px-5 py-3 text-right font-medium">
% of errors
</th>
</tr>
</thead>
<tbody className="divide-y">
{(pending && !summary.byErrorCode.length
? placeholderErrorCodes
: summary.byErrorCode
).map((group) => (
<tr key={JSON.stringify([group.element, group.nature])}>
<td className="px-5 py-3 align-top">
<span className="mr-2 font-mono text-xs">{group.element || "—"}</span>
{group.elementLabel}
</td>
<td className="max-w-xl px-5 py-3 align-top">
<span className="mr-2 font-mono text-xs">{group.nature || "—"}</span>
{group.natureLabel}
</td>
<td className="px-5 py-3 text-right align-top tabular-nums">
{group.reviews}
</td>
<td className="px-5 py-3 text-right align-top tabular-nums">
{summary.error ? Math.round((group.reviews / summary.error) * 100) : 0}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</LoadingPreview>
) : (
<p className="px-5 pb-5 text-sm text-muted-foreground">
No released Invalid reviews in this scope.
</p>
)}
</div>
</section>
);
}
/** CSV snapshots made on this page. They live only while the page is open. */
function ReportRuns({
runs,
disabled,
runReport,
}: {
runs: Run[];
disabled: boolean;
runReport(): void;
}) {
return (
<section className="overflow-hidden rounded-lg border bg-card" aria-labelledby="runs-heading">
<div className="flex flex-wrap items-center justify-between gap-3 p-5">
<div>
<h2 id="runs-heading" className="font-semibold">
Report runs
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Create a CSV snapshot of the displayed totals and progress. Recent runs stay available
while this page is open.
</p>
</div>
<Button type="button" disabled={disabled} onClick={runReport}>
<Play className="size-4" aria-hidden />
Run report
</Button>
</div>
{runs.length ? (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm" aria-label="Report runs">
<thead className="border-y bg-muted/40 text-xs text-muted-foreground">
<tr>
{["Run time", "Scope", "Reviews", "Error summary eligible", "Download"].map(
(label) => (
<th key={label} className="px-4 py-3 font-medium">
{label}
</th>
),
)}
</tr>
</thead>
<tbody>
{runs.map((run) => (
<tr key={run.id} className="border-b last:border-0">
<td className="whitespace-nowrap px-4 py-3 text-xs">
{new Date(run.at).toLocaleString()}
</td>
<td className="max-w-sm px-4 py-3 text-xs">{run.scope}</td>
<td className="px-4 py-3 tabular-nums">{run.total}</td>
<td className="px-4 py-3 tabular-nums">{run.eligible}</td>
<td className="px-4 py-3">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => downloadRun(run)}
>
<Download className="size-3.5" aria-hidden />
CSV
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="border-t px-5 py-4 text-sm text-muted-foreground">No report runs yet.</p>
)}
</section>
);
}
function Metric({
label,
value,
detail,
loading,
}: {
label: string;
value: number;
detail: string;
loading: boolean;
}) {
return (
<section className="rounded-lg border bg-card p-4">
<h2 className="text-xs font-medium text-muted-foreground">{label}</h2>
<p className="mt-2 text-3xl font-semibold tracking-tight tabular-nums">
<LoadingValue loading={loading}>{value.toLocaleString()}</LoadingValue>
</p>
<p className="mt-1 text-xs text-muted-foreground">{detail}</p>
</section>
);
}
function errorRate(group: Group) {
return group.total ? group.error / group.total : 0;
}
function sortedGroups(groups: Group[], sort?: "total" | "open" | "errorRate") {
return [...groups].sort((left, right) => {
if (sort === "total") return right.total - left.total || left.label.localeCompare(right.label);
if (sort === "open")
return (
right.total - right.completed - (left.total - left.completed) ||
left.key.localeCompare(right.key)
);
if (sort === "errorRate")
return (
errorRate(right) - errorRate(left) ||
right.error - left.error ||
right.total - left.total ||
left.label.localeCompare(right.label)
);
return 0;
});
}
function chartMeasure(group: Group, compare: "closed" | "share" | "error", shareOf?: number) {
if (compare === "share")
return { value: group.total, total: shareOf ?? 0, fill: "bg-primary", splitOpen: false };
if (compare === "error")
return { value: group.error, total: group.total, fill: "bg-warning", splitOpen: false };
return {
value: group.completed,
total: group.total,
fill: "bg-primary",
splitOpen: true,
};
}
function ProgressChart({
title,
loading,
groups,
sort,
sorts,
size = "scroll",
shareOf,
compare = shareOf != null ? "share" : "closed",
onSelect,
}: {
title: string;
loading: boolean;
groups: Group[];
sort?: "total" | "open" | "errorRate";
sorts?: "month" | "county";
size?: "content" | "scroll" | "stretch";
shareOf?: number;
compare?: "closed" | "share" | "error";
onSelect?: (key: string) => void;
}) {
const [monthSort, setMonthSort] = useState<MonthChartSort>("newest");
const [countySort, setCountySort] = useState<CountyChartSort>("name-asc");
const rows =
sorts === "month"
? sortMonthGroups(groups, monthSort)
: sorts === "county"
? sortCountyGroups(groups, countySort)
: sortedGroups(groups, sort);
const sortOptions = sorts === "county" ? countyChartSorts : monthChartSorts;
const sortValue = sorts === "county" ? countySort : monthSort;
const sortLabel = sortOptions.find((option) => option.value === sortValue)?.label;
const ratioLabel =
compare === "share"
? "Count / total"
: compare === "error"
? "Error / total"
: "Closed / total";
return (
<section
className="flex h-full min-h-0 flex-col rounded-lg border bg-card p-5"
aria-label={`${title} progress`}
>
<div className="mb-4 flex min-h-8 items-center justify-between gap-3">
<h2 className="font-semibold">{title}</h2>
<div className="flex min-h-8 items-center gap-2">
{sorts && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-xs"
aria-label={`Sort ${title.toLowerCase()}: ${sortLabel}`}
onClick={() =>
sorts === "county"
? setCountySort(nextCountyChartSort)
: setMonthSort(nextMonthChartSort)
}
>
<ArrowDownUp className="size-3.5" aria-hidden />
{sortLabel}
</Button>
)}
<span className="text-xs text-muted-foreground">{ratioLabel}</span>
</div>
</div>
<div
className={
size === "content"
? // 12 bar rows: 12 * 2.375rem + 11 * 0.75rem. Extra months scroll.
"min-h-0 max-h-[36.75rem] flex-1 space-y-3 overflow-y-auto pr-1"
: size === "stretch"
? "min-h-[26rem] flex-1 space-y-3 overflow-y-auto pr-1"
: "h-[26rem] space-y-3 overflow-y-auto pr-1"
}
>
{!loading && !rows.length && (
<p className="text-sm text-muted-foreground">No reviews in this scope.</p>
)}
<LoadingPreview
contentClassName="space-y-3"
loading={loading}
label={`Loading ${title.toLowerCase()}…`}
>
{(loading && !rows.length ? placeholderGroups : rows).map((group) => {
const { value, total, fill, splitOpen } = chartMeasure(group, compare, shareOf);
const filledPct = total ? (value / total) * 100 : 0;
const percentLabel = `${Math.round(filledPct)}%`;
const remainderPct =
splitOpen && group.total ? ((group.total - group.completed) / group.total) * 100 : 0;
const ariaCount =
compare === "error"
? `${value} errors of ${total} reviews (${percentLabel})`
: compare === "share"
? `${value} of ${total} reviews`
: `${group.completed} closed of ${group.total} reviews`;
const body = (
<>
<div className="mb-1.5 flex items-center justify-between gap-3 text-xs">
<span className="truncate">{group.label}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{compare === "error"
? `${value} / ${total} · ${percentLabel}`
: `${value} / ${total}`}
</span>
</div>
<div className="flex h-2 w-full overflow-hidden rounded-full bg-muted">
<div className={`h-full ${fill}`} style={{ width: `${filledPct}%` }} />
{splitOpen && (
<div className="h-full bg-primary/25" style={{ width: `${remainderPct}%` }} />
)}
</div>
</>
);
return onSelect ? (
<button
type="button"
key={group.key}
title={`Filter to ${group.label}`}
aria-label={`Filter to ${group.label}, ${ariaCount}`}
className="block w-full rounded p-1 text-left hover:bg-muted/40 focus-visible:outline-ring"
onClick={() => onSelect(group.key)}
>
{body}
</button>
) : (
<div key={group.key} className="p-1" aria-label={`${group.label}, ${ariaCount}`}>
{body}
</div>
);
})}
</LoadingPreview>
</div>
<div className="mt-4 flex min-h-4 gap-4 text-xs text-muted-foreground">
{compare === "share" ? (
<>
<span>
<span className="mr-1.5 inline-block size-2 rounded-full bg-primary" />
This state
</span>
<span>
<span className="mr-1.5 inline-block size-2 rounded-full bg-muted" />
Other reviews in scope
</span>
</>
) : compare === "error" ? (
<>
<span>
<span className="mr-1.5 inline-block size-2 rounded-full bg-warning" />
Error
</span>
<span>
<span className="mr-1.5 inline-block size-2 rounded-full bg-muted" />
Other reviews
</span>
</>
) : (
<>
<span>
<span className="mr-1.5 inline-block size-2 rounded-full bg-primary" />
Closed
</span>
<span>
<span className="mr-1.5 inline-block size-2 rounded-full bg-primary/25" />
Open
</span>
</>
)}
<span className={`ml-auto ${onSelect ? "" : "invisible"}`}>Select a bar to filter</span>
</div>
</section>
);
}
function downloadRun(run: Run) {
const url = URL.createObjectURL(new Blob([run.csv], { type: "text/csv;charset=utf-8" }));
const link = document.createElement("a");
link.href = url;
link.download = `snap-negative-report-${run.at.replaceAll(":", "-")}.csv`;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function LoadingValue({ loading, children }: { loading: boolean; children: ReactNode }) {
return loading ? (
<span aria-hidden="true" className="inline-block min-w-8 rounded bg-muted">
<span className="invisible">{children}</span>
</span>
) : (
children
);
}