src/features/reviews/intake.tsx
Linked repository file from this build.
"use client";
import { businessKey, fiscalYear, reviewIdentity } from "./review-key";
import { useState } from "react";
import { LoadingPreview } from "@/components/loading-preview";
import { AppShell } from "@/components/app-shell";
import { Button } from "@/components/ui/button";
import { fieldClass } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { parseSnapNegativeBatch, SNAP_NEGATIVE_FIXTURE_FILES } from "@/lib/import/snap-negative";
import { useReview } from "./provider";
import { roleLabels, type DemoRole } from "./types";
import type { IntakeFiles } from "./intake-command";
const labels = {
closedSuspended: "Closed / suspended sample",
rejected: "Rejected sample",
};
const empty = { closedSuspended: "", rejected: "" };
export function SnapNegativeIntake() {
return (
<AppShell title="SNAP Negative intake">
<IntakeContent />
</AppShell>
);
}
export function ImportsLoading() {
return (
<LoadingPreview label="Loading imports…">
<IntakeContent />
</LoadingPreview>
);
}
function IntakeContent() {
const { actor, setRole, flush, isSignedIn } = useReview();
const [files, setFiles] = useState<IntakeFiles>(empty);
const [preview, setPreview] = useState<ReturnType<typeof parseSnapNegativeBatch> | null>(null);
const [versions, setVersions] = useState<Record<string, number>>({});
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [names, setNames] = useState<Record<string, string>>({});
async function loadFixtures() {
setBusy(true);
setError("");
try {
const entries = await Promise.all(
Object.entries(SNAP_NEGATIVE_FIXTURE_FILES).map(async ([key, name]) => {
const response = await fetch(`/fixtures/snap-negative/${name}`);
if (!response.ok) throw new Error("Unable to load synthetic fixtures.");
return [key, await response.text()];
}),
);
setFiles(Object.fromEntries(entries) as IntakeFiles);
setNames(SNAP_NEGATIVE_FIXTURE_FILES);
setPreview(null);
} catch (cause) {
setError(String(cause));
} finally {
setBusy(false);
}
}
async function inspect() {
setBusy(true);
setError("");
try {
if (!(await flush())) throw new Error("Save the current review before intake.");
const response = await fetch("/api/reviews", { cache: "no-store" });
if (!response.ok) throw new Error("Unable to check existing reviews.");
const saved = await response.json();
setVersions(
Object.fromEntries(
saved.map(
(row: {
program: string;
reviewNumber: string;
sampleMonth: string;
version: number;
}) => [businessKey(row), row.version],
),
),
);
setPreview(parseSnapNegativeBatch({ ...files, fileNames: names }));
} catch (cause) {
setError(String(cause));
} finally {
setBusy(false);
}
}
async function promote(review: NonNullable<typeof preview>["cases"][number]) {
setBusy(true);
setError("");
try {
if (!(await flush())) throw new Error("Save the current review before promotion.");
const response = await fetch("/api/imports/snap-negative", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
files: { ...files, fileNames: names },
identity: reviewIdentity(review),
expectedVersion: versions[businessKey(review)] ?? null,
requestId: crypto.randomUUID(),
}),
});
const result = await response.json();
if (!response.ok) throw new Error(result.error);
// A full navigation reloads even when this review is already the provider's selected case.
window.location.assign(`/reviews/${result.review.id}`);
} catch (cause) {
setError(String(cause));
setBusy(false);
}
}
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
<header className="space-y-2">
<h2 className="text-2xl font-semibold">Load sample delimited files</h2>
<p>
Use the two synthetic sample files below or files carrying the same synthetic attestation.
Each sampled row supplies identity and action facts for one review.
</p>
<p className="text-sm text-muted-foreground">
Accepts the confirmed 42-column BIS Negative text layout. Imported reviews require
household, income and computation completion.
</p>
</header>
{!isSignedIn && (
<div className="space-y-2">
<Label htmlFor="intake-role">Acting role</Label>
<select
id="intake-role"
value={actor.role}
onChange={(e) => setRole(e.target.value as DemoRole)}
disabled={busy}
className={cn(fieldClass, "w-fit")}
>
{Object.entries(roleLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<Button type="button" size="sm" onClick={() => void loadFixtures()} disabled={busy}>
Load synthetic files
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void inspect()}
disabled={busy}
>
Preview reviews
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{Object.entries(labels).map(([key, label]) => (
<div key={key} className="space-y-3 rounded-lg border bg-card p-4">
<Label htmlFor={key}>{label}</Label>
<div className="flex flex-wrap items-center gap-2">
<input
id={key}
type="file"
accept=".txt,text/plain"
disabled={busy}
className="sr-only"
onChange={async (e) => {
const file = e.target.files?.[0];
setPreview(null);
setError("");
if (file && file.size > 1_000_000) {
setFiles((value) => ({ ...value, [key]: "" }));
setError("Each synthetic file must be 1 MB or smaller.");
return;
}
setFiles((value) => ({ ...value, [key]: "" }));
if (file) {
const text = await file.text();
setFiles((value) => ({ ...value, [key]: text }));
setNames((value) => ({ ...value, [key]: file.name }));
}
}}
/>
<Button
type="button"
size="sm"
variant="outline"
disabled={busy}
onClick={() => document.getElementById(key)?.click()}
>
Choose file
</Button>
<p className="text-sm text-muted-foreground">{names[key] ?? "No file loaded"}</p>
</div>
<a
className="text-sm underline"
href={`/fixtures/snap-negative/${SNAP_NEGATIVE_FIXTURE_FILES[key as keyof typeof SNAP_NEGATIVE_FIXTURE_FILES]}`}
download
>
Download synthetic {label} file
</a>
</div>
))}
</div>
{error && (
<p role="alert" className="text-destructive">
{error}
</p>
)}
{preview && (
<div className="space-y-3">
<h3 className="font-semibold">Sample batch: {preview.cases.length} reviews</h3>
{preview.findings.map((finding, i) => (
<button
key={i}
className="block text-left text-destructive underline"
onClick={() => document.getElementById(finding.field.split(".")[0])?.focus()}
>
{finding.field}: {finding.message}
</button>
))}
{preview.cases.map((review) => (
<div key={businessKey(review)} className="rounded border p-4">
<p>
{review.reviewNumber} · FFY {fiscalYear(review.sampleMonth)} — {review.clientName} —{" "}
{review.facts.negativeAction}
</p>
<p className="text-sm">
{review.caseNumber} · {review.sampleMonth} · {review.address}
</p>
<details className="my-2 text-sm">
<summary>Source lineage</summary>
{review.lineage.map((source) => (
<div key={source.role}>
<p>
{source.sourceSystem} / {source.role} / row {source.rowNumber}
</p>
<pre className="overflow-x-auto">{source.rawText}</pre>
</div>
))}
</details>
{versions[businessKey(review)] !== undefined && (
<p className="text-sm">
Refresh replaces this review's current facts and completion with intake facts in
Active. Previous snapshots remain in the database.
</p>
)}
<Button
type="button"
size="sm"
disabled={busy || preview.findings.length > 0 || actor.role === "pec"}
onClick={() => void promote(review)}
>
{versions[businessKey(review)] === undefined ? "Promote" : "Refresh"}{" "}
{review.reviewNumber}
</Button>
</div>
))}
</div>
)}
</div>
);
}