src/features/reviews/computation-grid.tsx
Linked repository file from this build.
"use client";
import { memo, useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import { RotateCcw, Save } from "lucide-react";
import { useReview } from "./provider";
import { useReviewPreferences } from "./preferences";
import { buildDiscrepancies } from "./computation-discrepancies";
import { AddIncomeButton, IncomeRow } from "./computation-income";
import { ComputationErrorRow } from "./computation-final-error";
import { ComputationDiffsContext, InputRows, ResultCell } from "./computation-controls";
import { Button } from "@/components/ui/button";
import {
CURRENT_CALC_VERSION,
EDUCATION_CALC_VERSION,
} from "@/programs/snap-negative/rule-versions";
import type {
IncomeStream,
Scenario,
ScenarioId,
SnapReview,
} from "@/programs/snap-negative/types";
import {
RESULT_ROWS,
calcScenario,
applyFinalBlockers,
alignIncomeRows,
draftAfterSavedSync,
hasUnsavedDraft,
isEditableColumn,
scenarioForColumn,
EDITABLE_SCENARIO_IDS,
type CalcCell,
type ColumnId,
type EditableScenarioId,
} from "./computation-grid.helpers";
export interface ComputationGridProps {
review: SnapReview;
editable: boolean;
onSave(scenario: ScenarioId, value: Scenario): Promise<boolean>;
onApplyFinal(): Promise<boolean>;
onClear?(): void;
onReset?(): void;
}
const COLUMN_LABELS: Partial<Record<ColumnId, string>> = {
eligibility: "Eligibility",
compI: "Comp I",
compII: "Comp II",
final: "Final SAQC",
};
const EDITABLE_COLUMNS = EDITABLE_SCENARIO_IDS;
export const ComputationGrid = memo(function ComputationGrid({
review,
editable,
onSave,
onApplyFinal,
onClear,
onReset,
}: ComputationGridProps) {
const gridId = useId();
const scrollRef = useRef<HTMLDivElement>(null);
const theadRef = useRef<HTMLTableSectionElement>(null);
const {
computationDrafts: rawDrafts,
setComputationDrafts: setRawDrafts,
reportError,
} = useReview();
const { showComputationDiffs } = useReviewPreferences();
const [split, setSplit] = useState(false);
const discrepancies = useMemo(() => (split ? buildDiscrepancies(review) : []), [review, split]);
const [saving, setSaving] = useState<Partial<Record<EditableScenarioId, boolean>>>({});
const [applying, setApplying] = useState(false);
const drafts = useMemo(
() =>
Object.fromEntries(
EDITABLE_SCENARIO_IDS.map((id) => [
id,
draftAfterSavedSync(rawDrafts[id], review.facts.scenarios[id]),
]),
) as Record<EditableScenarioId, Scenario | undefined>,
[rawDrafts, review.facts.scenarios],
);
const applyBlockers = applyFinalBlockers(review, drafts, editable);
const columnScenarios = useMemo(
() =>
({
eligibility: scenarioForColumn(review, "eligibility", drafts),
compI: scenarioForColumn(review, "compI", drafts),
compII: scenarioForColumn(review, "compII", drafts),
final: scenarioForColumn(review, "final", drafts),
...Object.fromEntries(
discrepancies.map((item, index) => [`split-${index}`, item.scenario]),
),
}) as Record<ColumnId, Scenario>,
[review, drafts, discrepancies],
);
const columnCalcs = useMemo(
() =>
Object.fromEntries(
(Object.keys(columnScenarios) as ColumnId[]).map((col) => [
col,
calcScenario(review, columnScenarios[col], col),
]),
) as Record<ColumnId, CalcCell>,
[review, columnScenarios],
);
const eligibilityCalc = columnCalcs.eligibility;
const incomeRows = useMemo(
() =>
alignIncomeRows([
columnScenarios.eligibility.income,
columnScenarios.compI.income,
columnScenarios.compII.income,
columnScenarios.final.income,
...discrepancies.map((item) => item.scenario.income),
]),
[columnScenarios, discrepancies],
);
const patchDraft = useCallback(
(scenarioId: EditableScenarioId, patch: Partial<Scenario>) => {
setRawDrafts((prev) => {
const saved = review.facts.scenarios[scenarioId];
const base = prev[scenarioId] ?? saved;
return { ...prev, [scenarioId]: { ...base, ...patch, populated: true } };
});
},
[review.facts.scenarios, setRawDrafts],
);
const updateDraftIncome = useCallback(
(scenarioId: EditableScenarioId, income: IncomeStream[]) => {
patchDraft(scenarioId, { income });
},
[patchDraft],
);
async function handleSave(scenarioId: EditableScenarioId) {
const draft = drafts[scenarioId];
if (!draft || !hasUnsavedDraft(drafts, review, scenarioId)) return;
setSaving((s) => ({ ...s, [scenarioId]: true }));
const savedDraft = draft;
try {
const ok = await onSave(scenarioId, savedDraft);
if (ok) {
const calculation = calcScenario(review, savedDraft, scenarioId);
if (calculation.kind !== "ok") reportError(calculation.message);
}
} catch (cause) {
reportError(cause instanceof Error ? cause.message : "Save failed.");
} finally {
setSaving((s) => ({ ...s, [scenarioId]: false }));
}
}
function handleReset(scenarioId: EditableScenarioId) {
setRawDrafts((prev) => {
const next = { ...prev };
delete next[scenarioId];
return next;
});
}
useLayoutEffect(() => {
const scroll = scrollRef.current;
const thead = theadRef.current;
if (!scroll || !thead) return;
const headings = [...scroll.querySelectorAll<HTMLElement>("[data-sticky-section]")];
const sync = () => {
const headerHeight = thead.getBoundingClientRect().height;
const scrollTop = scroll.getBoundingClientRect().top + scroll.clientTop;
// Read the section bounds before changing any sticky positions.
const positions = headings.map((heading) => {
const end = heading.parentElement!.getBoundingClientRect().bottom - scrollTop;
return Math.min(headerHeight, end - heading.getBoundingClientRect().height);
});
headings.forEach((heading, index) => {
heading.style.top = `${positions[index]}px`;
});
};
sync();
const observer = new ResizeObserver(sync);
observer.observe(thead);
observer.observe(scroll);
for (const heading of headings) observer.observe(heading.parentElement!);
scroll.addEventListener("scroll", sync, { passive: true });
return () => {
observer.disconnect();
scroll.removeEventListener("scroll", sync);
};
}, []);
async function handleApplyFinal() {
if (applyBlockers.length) {
reportError(applyBlockers.join(" "));
return;
}
setApplying(true);
try {
await onApplyFinal();
} catch (cause) {
reportError(cause instanceof Error ? cause.message : "Apply Final Results failed.");
} finally {
setApplying(false);
}
}
return (
<ComputationDiffsContext value={showComputationDiffs}>
<div
data-section="computation"
className="flex h-full min-h-0 w-full flex-col gap-3 overflow-hidden"
>
<div className="flex flex-wrap justify-end gap-2">
<Button
size="sm"
variant="outline"
disabled={!review.facts.final}
onClick={() => setSplit((value) => !value)}
>
{split ? "Close magnitude split" : "Error magnitude split"}
</Button>
{editable && (
<>
<Button size="sm" variant="ghost" onClick={onClear}>
Clear
</Button>
<Button size="sm" variant="ghost" onClick={onReset}>
Reset
</Button>
</>
)}
</div>
<div className="relative isolate min-h-0 flex-1 overflow-hidden rounded-lg border">
<div ref={scrollRef} className="absolute inset-0 z-0 overflow-auto">
<table
id={gridId}
className="computation-table w-full table-fixed border-collapse text-sm tabular-nums"
style={{ minWidth: `${52 + discrepancies.length * 10}rem` }}
aria-label="SNAP computation comparison grid"
>
<caption className="sr-only">
Four-column computation grid comparing Eligibility, Comp I, Comp II, and Final SAQC
</caption>
<thead
ref={theadRef}
className="sticky top-0 z-20 bg-muted shadow-[0_1px_0_0_var(--border)] [&_th]:bg-muted"
>
<tr className="border-b">
<th scope="col" className="min-w-[10rem] px-2 py-2 text-left font-medium">
Field
</th>
{(Object.keys(columnScenarios) as ColumnId[]).map((col) => (
<th
key={col}
scope="col"
className="min-w-[8rem] px-2 py-2 text-left align-top"
>
<div className="font-medium">
{COLUMN_LABELS[col] ?? discrepancies[Number(col.slice(6))]?.label}
</div>
{col.startsWith("split-") && (
<p className="mt-1 text-xs font-normal">
$
{(
Math.abs(discrepancies[Number(col.slice(6))]!.errorCents) / 100
).toFixed(2)}{" "}
{discrepancies[Number(col.slice(6))]!.errorCents > 0
? "Overissuance"
: discrepancies[Number(col.slice(6))]!.errorCents < 0
? "Underissuance"
: "No benefit error"}
</p>
)}
{col === "final" && review.facts.final && (
<p className="mt-1 text-xs font-normal">
Source: {review.facts.final.scenario === "compI" ? "Comp I" : "Comp II"}
</p>
)}
<ColumnActions
column={col}
editable={editable}
unsaved={
isEditableColumn(col) ? hasUnsavedDraft(drafts, review, col) : false
}
saving={isEditableColumn(col) ? !!saving[col] : applying}
onSave={() => isEditableColumn(col) && void handleSave(col)}
onReset={() => isEditableColumn(col) && handleReset(col)}
onApplyFinal={() => void handleApplyFinal()}
/>
</th>
))}
</tr>
</thead>
<tbody>
<SectionRow label="Income by member and type" colSpan={5 + discrepancies.length} />
{incomeRows.map(({ key, rows }) => (
<IncomeRow
key={key}
rows={rows}
members={review.facts.members}
policy={review.policy}
columns={columnScenarios}
editable={editable}
onPatchIncome={updateDraftIncome}
/>
))}
{editable && (
<tr className="border-b">
<td className="px-2 py-2 text-muted-foreground">Add income</td>
{EDITABLE_COLUMNS.map((col) => (
<td key={col} className="px-2 py-2 text-center">
<AddIncomeButton
columnLabel={COLUMN_LABELS[col]!}
policy={review.policy}
members={review.facts.members}
scenario={columnScenarios[col]}
onAdd={(income) => updateDraftIncome(col, income)}
/>
</td>
))}
<td colSpan={1 + discrepancies.length} />
</tr>
)}
</tbody>
<tbody>
<SectionRow
label="Household and deduction inputs"
colSpan={5 + discrepancies.length}
/>
<InputRows
educationEnabled={[CURRENT_CALC_VERSION, EDUCATION_CALC_VERSION].includes(
review.calcVersion,
)}
gridId={gridId}
columns={columnScenarios}
eligibility={columnScenarios.eligibility}
editable={editable}
onPatch={patchDraft}
/>
</tbody>
<tbody>
<SectionRow label="Calculated results" colSpan={5 + discrepancies.length} />
{RESULT_ROWS.map((row) => (
<tr key={row.key} className="border-b">
<th
scope="row"
className="px-2 py-1.5 text-left font-normal text-muted-foreground"
>
{row.label}
</th>
{(Object.keys(columnScenarios) as ColumnId[]).map((col) => (
<td key={col} className="px-2 py-1.5 align-top">
<ResultCell
cell={columnCalcs[col]}
baseline={eligibilityCalc}
column={col}
rowKey={row.key}
/>
</td>
))}
</tr>
))}
<ComputationErrorRow review={review} columns={columnCalcs} />
{discrepancies.length > 0 && (
<tr>
<td
className="p-2 text-xs text-muted-foreground"
colSpan={5 + discrepancies.length}
>
Each split column applies one discrepancy to Eligibility. Errors use absolute
benefit differences, largest first. Split errors need not add to the final
error.
</td>
</tr>
)}
{![CURRENT_CALC_VERSION, EDUCATION_CALC_VERSION].includes(review.calcVersion) && (
<tr className="border-b">
<td
colSpan={5 + discrepancies.length}
className="px-2 py-3 text-xs text-muted-foreground"
>
Education and application-day proration require v3. Ask admin or statistics to
apply new rules.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</ComputationDiffsContext>
);
});
function SectionRow({ label, colSpan }: { label: string; colSpan: number }) {
return (
<tr
data-sticky-section
className="sticky z-10 border-b bg-secondary"
style={{ transition: "none" }}
>
<th
data-section-row
scope="colgroup"
colSpan={colSpan}
className="bg-secondary px-2 py-1.5 text-left text-xs font-semibold tracking-wide text-muted-foreground uppercase shadow-[0_1px_0_0_var(--border)]"
>
{label}
</th>
</tr>
);
}
function ColumnActions({
column,
editable,
unsaved,
saving,
onSave,
onReset,
onApplyFinal,
}: {
column: ColumnId;
editable: boolean;
unsaved: boolean;
saving: boolean;
onSave(): void;
onReset(): void;
onApplyFinal(): void;
}) {
if (isEditableColumn(column)) {
return (
<div className="mt-1 flex flex-wrap gap-1">
<Button
type="button"
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
disabled={!editable || !unsaved || saving}
aria-label={`Save ${COLUMN_LABELS[column]} column`}
onClick={onSave}
>
<Save className="size-3.5" aria-hidden />
Save
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 px-2 text-xs"
disabled={!editable || !unsaved || saving}
aria-label={`Reset ${COLUMN_LABELS[column]} column to last saved values`}
onClick={onReset}
>
<RotateCcw className="size-3.5" aria-hidden />
Reset
</Button>
{unsaved && <span className="w-full text-[0.7rem] text-warning">Unsaved draft</span>}
</div>
);
}
if (column === "final") {
return (
<div className="mt-1 space-y-1">
<Button
type="button"
size="sm"
variant="default"
className="h-7 px-2 text-xs"
disabled={saving}
aria-label="Apply Final Results to Final SAQC column"
onClick={onApplyFinal}
>
Apply Final Results
</Button>
</div>
);
}
return null;
}