src/features/reviews/search.tsx
Linked repository file from this build.
"use client";
import { LoadingPreview } from "@/components/loading-preview";
import { Suspense, useEffect, useState } from "react";
import { NavigationLink as Link } from "@/components/navigation-link";
import { usePathname, useSearchParams } from "next/navigation";
import { useReview } from "./provider";
import { workflowLabels } from "./types";
import type { InboxReview } from "@/features/inbox/types";
import type { SearchResponse } from "@/lib/db/search-store";
import { fetchJson } from "@/lib/fetch-json";
import { useRemoteLoad } from "@/lib/use-remote-load";
import { parseSearchFilters, searchHref } from "./filter-builder";
import { FullSearchControls } from "./full-search-controls";
import { getSearchField } from "./search-fields";
import { parseSearchSorts, type SearchSort } from "./search-filters";
import { MailAttachCell } from "./mail-attach";
import { examinerLabel, countyLabel } from "@/features/reports/report-data";
import type { SearchFilters } from "@/lib/db/search-store";
const PAGE_SIZE = 30;
function parseOffset(raw: string | null) {
const value = Number(raw);
return Number.isInteger(value) && value >= 0 ? value : 0;
}
type SearchRow = InboxReview & {
sampleMonth: string;
outcome: string;
county?: string;
values?: Record<string, string>;
examinerName?: string;
};
const placeholderSearchRows: SearchRow[] = Array.from({ length: 6 }, (_, i) => ({
reviewKey: `loading-${i}`,
fiscalYear: 2000,
reviewNumber: "00000",
caseNumber: "000000000",
clientName: "Loading client name",
examiner: "Loading examiner",
state: "new_active",
appointment: null,
sampleMonth: "2000-01",
outcome: "",
county: "Loading county",
}));
type SearchInitial = {
scope: string;
filters: SearchFilters;
data: { reviews: SearchRow[]; total: number };
};
function requestKey(filters: SearchFilters) {
return JSON.stringify(filters);
}
function DeskResult({ row, columns }: { row: SearchRow; columns: string[] }) {
const { actor } = useReview();
return (
<tr className="h-8 border-b last:border-0 hover:bg-muted/40">
<td className="max-w-56 px-3 py-1">
<Link
href={`/reviews/${row.reviewKey}`}
className="block truncate font-medium hover:underline"
title={row.clientName || "Unnamed client"}
>
{row.clientName || "Unnamed client"}
</Link>
</td>
<td className="px-2 py-1 tabular-nums">
<Link href={`/reviews/${row.reviewKey}`} className="hover:underline">
{row.reviewNumber}
</Link>
</td>
<td className="px-2 py-1 tabular-nums">{row.caseNumber}</td>
<td className="px-2 py-1">{row.examinerName || examinerLabel(row.examiner)}</td>
<td className="px-2 py-1">{row.sampleMonth}</td>
<td className="px-2 py-1">
{workflowLabels[row.state]}
{row.outcome
? ` · ${row.outcome === "clean" ? "Valid" : row.outcome === "error" ? "Invalid" : "Drop"}`
: ""}
</td>
<td className="px-2 py-1">{countyLabel(row.county || "")}</td>
{columns.map((field) => (
<td key={field} className="max-w-64 truncate px-2 py-1" title={row.values?.[field] || ""}>
{row.values?.[field] || "—"}
</td>
))}
{actor.role === "admin" && <MailAttachCell row={row} />}
</tr>
);
}
export function SearchLoading({ pathname = "/search" }: { pathname?: string }) {
return (
<LoadingPreview label="Loading search…">
<ReviewSearchPanel previewHref={pathname} />
</LoadingPreview>
);
}
function ReviewSearchPanel({
initial,
previewHref,
}: {
initial?: SearchInitial;
previewHref?: string;
}) {
const preview = previewHref !== undefined;
const { actor, navigate } = useReview();
const pathname = usePathname();
const currentSearchParams = useSearchParams();
const searchParams = preview
? new URLSearchParams(previewHref.split("?")[1]?.split("#")[0] ?? "")
: currentSearchParams;
const urlQuery = searchParams.get("q") ?? "";
const urlFiltersKey = searchParams.get("filters") ?? "";
const urlFilters = parseSearchFilters(urlFiltersKey || null);
const urlOffset = parseOffset(searchParams.get("offset"));
const sort = searchParams.get("sort") || "relevance";
const direction = searchParams.get("direction") === "asc" ? "asc" : "desc";
const sortsKey = searchParams.get("sorts") ?? "";
const sorts = parseSearchSorts(sortsKey, sort, direction);
const columns = [
...new Set([...urlFilters.map((filter) => filter.field), ...sorts.map((entry) => entry.field)]),
].filter(
(field) =>
![
"clientName",
"reviewNumber",
"caseNumber",
"examiner",
"sampleMonth",
"state",
"outcome",
"county",
].includes(field),
);
const scopeKey = `${actor.role}:${actor.staffId}:${actor.program}:${(actor.subordinateStaffIds ?? []).join(",")}`;
const activeFilters: SearchFilters = {
filters: urlFilters,
sorts: sortsKey ? sorts : [],
offset: urlOffset,
q: urlQuery,
quick: "true",
caseSensitive: "false",
exact: "false",
sort,
direction,
limit: PAGE_SIZE,
};
const hasInitial =
initial?.scope === JSON.stringify(actor) &&
requestKey(initial.filters) === requestKey(activeFilters);
const [navigationError, setError] = useState("");
const [reload, setReload] = useState(0);
useEffect(() => {
if (preview) return;
const refresh = () => setReload((value) => value + 1);
window.addEventListener("qc:search-refresh", refresh);
return () => window.removeEventListener("qc:search-refresh", refresh);
}, [preview]);
// Server data answers the first render of its URL. Later URLs and Refresh fetch.
const fromServer = hasInitial && reload === 0;
const [fetched, setFetched] = useState<{ rows: SearchRow[]; total: number }>({
rows: [],
total: 0,
});
const remote = useRemoteLoad(
async (signal) => {
const params = new URLSearchParams({
q: urlQuery,
quick: "true",
limit: String(PAGE_SIZE),
offset: String(urlOffset),
sort,
direction,
});
if (urlFiltersKey) params.set("filters", urlFiltersKey);
if (sortsKey) params.set("sorts", sortsKey);
try {
const result = await fetchJson<SearchResponse>(`/api/search?${params}`, {
signal,
cache: "no-store",
fallback: "Could not load reviews. Please try again.",
});
if (!signal.aborted) setFetched({ rows: result.reviews, total: result.total });
} catch (cause) {
if (!signal.aborted) setFetched({ rows: [], total: 0 });
throw cause;
}
},
[scopeKey, urlQuery, urlFiltersKey, urlOffset, sort, direction, sortsKey, reload],
{ enabled: !preview && !fromServer, initialLoading: !hasInitial },
);
const rows = fromServer ? initial.data.reviews : fetched.rows;
const total = fromServer ? initial.data.total : fetched.total;
const loading = !fromServer && remote.loading;
const error = navigationError || (fromServer ? "" : remote.error);
async function go(href: string) {
if (
pathname === "/search" &&
href ===
searchHref(urlQuery, urlFilters, urlOffset, sort, direction, sortsKey ? sorts : undefined)
) {
setReload((value) => value + 1);
return;
}
if (!(await navigate(href))) setError("Save this review before searching.");
}
function sortedHref(nextSorts: SearchSort[], filters = urlFilters, offset = 0) {
return searchHref(urlQuery, filters, offset, "relevance", "desc", nextSorts);
}
function sortColumn(field: string) {
const index = sorts.findIndex((entry) => entry.field === field);
const next: SearchSort[] =
index < 0
? [...sorts, { field, direction: "asc" }]
: sorts.map((entry, i) =>
i === index
? { ...entry, direction: entry.direction === "asc" ? "desc" : "asc" }
: entry,
);
if (next.length <= 10) void go(sortedHref(next));
}
const from = total === 0 ? 0 : urlOffset + 1;
const to = urlOffset + rows.length;
return (
<div className="mx-auto w-full max-w-screen-2xl space-y-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Search results</h1>
<p className="mt-1 text-sm text-muted-foreground">
Search any review field. Include or exclude ranges, or select a single value.
{actor.role === "admin" ? " Attach incoming mail to a matching review below." : ""}
</p>
</div>
<FullSearchControls
autoFocus={!preview}
key={`${scopeKey}:${urlQuery}:${urlFiltersKey}:${sort}:${direction}:${sortsKey}`}
query={urlQuery}
filters={urlFilters}
sorts={sorts}
onSearch={(filters, nextSorts) => void go(sortedHref(nextSorts, filters))}
/>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<p role="status" aria-live="polite" className="text-sm text-muted-foreground">
{loading
? "Loading reviews…"
: `${total} matching review${total === 1 ? "" : "s"}${
total > 0 ? `. Showing ${from}–${to}.` : "."
}`}
</p>
<div aria-busy={loading}>
{(loading || rows.length > 0) && (
<LoadingPreview loading={loading} label="Loading search results…">
<div className="rounded-md border bg-card">
<div className="overflow-x-auto">
<table
aria-label="Search results"
className="w-full whitespace-nowrap text-left text-xs"
>
<thead className="h-8 border-b bg-muted/40 text-muted-foreground">
<tr>
{[
{ field: "clientName", label: "Client" },
{ field: "reviewNumber", label: "Review" },
{ field: "caseNumber", label: "Case" },
{ field: "examiner", label: "Examiner" },
{ field: "sampleMonth", label: "Month" },
{ field: "state", label: "State" },
{ field: "county", label: "County" },
...columns.map((field) => ({
field,
label: getSearchField(field)?.label ?? field,
})),
].map(({ field, label }) => {
const index = sorts.findIndex((entry) => entry.field === field);
const selected = sorts[index];
return (
<th
key={field}
scope="col"
aria-label={label}
aria-sort={
selected
? selected.direction === "asc"
? "ascending"
: "descending"
: "none"
}
title={label}
className="px-2 font-medium first:px-3"
>
<button
type="button"
className="flex min-h-8 items-center gap-1 rounded px-1 hover:bg-muted hover:text-foreground"
onClick={() => sortColumn(field)}
>
{label.split(" › ").slice(-2).reverse().join(" · ")}
<span aria-hidden>
{selected
? `${selected.direction === "asc" ? "↑" : "↓"}${sorts.length > 1 ? index + 1 : ""}`
: "↕"}
</span>
</button>
</th>
);
})}
{actor.role === "admin" && (
<th scope="col" className="px-3 font-medium">
Mail
</th>
)}
</tr>
</thead>
<tbody>
{(loading ? placeholderSearchRows : rows).map((row) => (
<DeskResult
key={`${actor.staffId}-${row.reviewKey}`}
row={row}
columns={columns}
/>
))}
</tbody>
</table>
</div>
{total > PAGE_SIZE && (
<div className="flex h-9 items-center justify-between border-t px-3 text-xs">
<span>
{from}–{to} of {total}
</span>
<div className="flex gap-2">
<button
type="button"
className="rounded px-2 py-1 hover:bg-muted disabled:opacity-40"
disabled={urlOffset === 0 || loading}
onClick={() =>
void go(
searchHref(
urlQuery,
urlFilters,
Math.max(0, urlOffset - PAGE_SIZE),
sort,
direction,
sortsKey ? sorts : undefined,
),
)
}
>
Prev
</button>
<button
type="button"
className="rounded px-2 py-1 hover:bg-muted disabled:opacity-40"
disabled={urlOffset + rows.length >= total || loading}
onClick={() =>
void go(
searchHref(
urlQuery,
urlFilters,
urlOffset + PAGE_SIZE,
sort,
direction,
sortsKey ? sorts : undefined,
),
)
}
>
Next
</button>
</div>
</div>
)}
</div>
</LoadingPreview>
)}
{!loading && !error && rows.length === 0 && (
<p className="text-sm text-muted-foreground">No reviews match these filters.</p>
)}
</div>
</div>
);
}
export function ReviewSearch({ initial }: { initial?: SearchInitial }) {
const { actor } = useReview();
const scopeKey = `${actor.role}:${actor.staffId}:${actor.program}:${(actor.subordinateStaffIds ?? []).join(",")}`;
return (
<Suspense
fallback={
<p role="status" className="text-sm text-muted-foreground">
Loading search…
</p>
}
>
<ReviewSearchPanel key={scopeKey} initial={initial} />
</Suspense>
);
}