src/features/reviews/computation-controls.tsx
Linked repository file from this build.
"use client";
import { fieldClass } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { createContext, useContext, useState, type ReactNode } from "react";
import { MIN_HOUSEHOLD_SIZE, MAX_HOUSEHOLD_SIZE } from "@/programs/snap-negative/input-limits";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { Scenario, SuaType } from "@/programs/snap-negative/types";
import {
RESULT_ROWS,
centsDiffer,
formatCentsDiffNote,
formatDecimal2FromCents,
isEditableColumn,
resultValue,
type CalcCell,
type ColumnId,
type EditableScenarioId,
} from "./computation-grid.helpers";
import { MoneyInput } from "./money-input";
export const ComputationDiffsContext = createContext(false);
/** Match input boxes: same left edge and vertical centerline. */
const readCell = "flex min-h-8 w-full flex-col justify-center px-2 text-left tabular-nums";
const COLUMN_LABELS: Partial<Record<ColumnId, string>> = {
eligibility: "Eligibility",
compI: "Comp I",
compII: "Comp II",
final: "Final SAQC",
};
const suaOptions: { value: SuaType; label: string }[] = [
{ value: "heating", label: "Heating" },
{ value: "non_heating", label: "Non-heating" },
{ value: "limited", label: "Limited" },
{ value: "telephone", label: "Telephone" },
{ value: "none", label: "None" },
];
function intDraft(value: number) {
return value === 0 ? "" : String(value);
}
function IntInput({
value,
onCommit,
disabled,
id,
ariaLabel,
min = MIN_HOUSEHOLD_SIZE,
max = MAX_HOUSEHOLD_SIZE,
syncKey,
}: {
value: number;
onCommit(value: number): void;
disabled?: boolean;
id?: string;
ariaLabel?: string;
min?: number;
max?: number;
syncKey?: string;
}) {
return (
<IntInputInner
key={`${syncKey ?? ""}-${value}`}
value={value}
onCommit={onCommit}
disabled={disabled}
id={id}
ariaLabel={ariaLabel}
min={min}
max={max}
/>
);
}
function IntInputInner({
value,
onCommit,
disabled,
id,
ariaLabel,
min = MIN_HOUSEHOLD_SIZE,
max = MAX_HOUSEHOLD_SIZE,
}: {
value: number;
onCommit(value: number): void;
disabled?: boolean;
id?: string;
ariaLabel?: string;
min?: number;
max?: number;
}) {
const [draft, setDraft] = useState(() => intDraft(value));
const [rejectMessage, setRejectMessage] = useState("");
function commit() {
const trimmed = draft.trim();
const parsed = Number(trimmed);
if (!trimmed || !Number.isInteger(parsed) || parsed < min || parsed > max) {
setDraft(intDraft(value));
setRejectMessage(`Invalid. Kept ${value}.`);
return;
}
setRejectMessage("");
if (parsed !== value) onCommit(parsed);
else setDraft(intDraft(value));
}
return (
<div className="space-y-0.5">
<input
id={id}
aria-label={ariaLabel}
type="text"
inputMode="numeric"
placeholder="0"
value={draft}
readOnly={disabled}
aria-invalid={rejectMessage ? true : undefined}
aria-describedby={rejectMessage && id ? `${id}-error` : undefined}
onFocus={(event) => event.currentTarget.select()}
onClick={(event) => event.currentTarget.select()}
onMouseDown={(event) => {
if (disabled) return;
event.preventDefault();
event.currentTarget.focus();
event.currentTarget.select();
}}
onChange={(event) => {
setRejectMessage("");
setDraft(event.target.value);
}}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
}}
className={`${fieldClass} min-w-[3rem] text-right`}
style={{ textAlign: "right" }}
/>
{rejectMessage && (
<p
id={id ? `${id}-error` : undefined}
className="text-[0.7rem] leading-tight text-destructive"
>
{rejectMessage}
</p>
)}
</div>
);
}
function ReadMoney({
cents,
baselineCents,
column,
}: {
cents: number;
baselineCents: number;
column: ColumnId;
}) {
const diffNote =
useContext(ComputationDiffsContext) && column !== "eligibility"
? formatCentsDiffNote(baselineCents, cents)
: null;
const showDiffs = useContext(ComputationDiffsContext);
const highlight = showDiffs && column !== "eligibility" && centsDiffer(baselineCents, cents);
return (
<div className={`${readCell}${highlight ? " rounded bg-warning/15" : ""}`}>
<span>${formatDecimal2FromCents(cents)}</span>
{diffNote && (
<span className="mt-0.5 block text-[0.7rem] leading-tight text-warning">{diffNote}</span>
)}
</div>
);
}
function ReadBool({
value,
baseline,
column,
}: {
value: boolean;
baseline: boolean;
column: ColumnId;
}) {
const showDiffs = useContext(ComputationDiffsContext);
const highlight = showDiffs && column !== "eligibility" && value !== baseline;
return (
<span className={`${readCell}${highlight ? " rounded bg-warning/15 font-medium" : ""}`}>
{value ? "Yes" : "No"}
{highlight && (
<span className="mt-0.5 block text-[0.7rem] leading-tight text-warning">
≠ Eligibility ({baseline ? "Yes" : "No"})
</span>
)}
</span>
);
}
export function ResultCell({
cell,
baseline,
column,
rowKey,
}: {
cell: CalcCell;
baseline: CalcCell;
column: ColumnId;
rowKey: (typeof RESULT_ROWS)[number]["key"];
}) {
if (cell.kind === "blocked" || cell.kind === "error" || cell.kind === "empty") {
return (
<span className={readCell} aria-label="No current calculation">
—
</span>
);
}
if (resultValue(cell.result, rowKey) === undefined)
return (
<span className={readCell} title="Not part of this saved calculation version">
—
</span>
);
if (baseline.kind !== "ok") {
const value = resultValue(cell.result, rowKey);
if (rowKey === "eligible") return <span className={readCell}>{value ? "Yes" : "No"}</span>;
return <ReadMoney cents={value as number} baselineCents={0} column="eligibility" />;
}
const value = resultValue(cell.result, rowKey);
if (rowKey === "eligible") {
const base = resultValue(baseline.result, rowKey);
return <ReadBool value={value as boolean} baseline={base as boolean} column={column} />;
}
const cents = value as number;
const baseCents = (resultValue(baseline.result, rowKey) as number | undefined) ?? cents;
return <ReadMoney cents={cents} baselineCents={baseCents} column={column} />;
}
export function InputRows({
educationEnabled,
gridId,
columns,
eligibility,
editable,
onPatch,
}: {
educationEnabled: boolean;
gridId: string;
columns: Record<ColumnId, Scenario>;
eligibility: Scenario;
editable: boolean;
onPatch(scenarioId: EditableScenarioId, patch: Partial<Scenario>): void;
}) {
function moneyField(key: keyof Scenario, label: string, available = true) {
return [
{
key,
label,
render: (col: ColumnId, scenario: Scenario) =>
isEditableColumn(col) ? (
<MoneyInput
id={`${gridId}-${col}-${key}`}
ariaLabel={`${COLUMN_LABELS[col]} ${label}`}
syncKey={`${col}-${key}-${scenario[key] as number}`}
cents={(scenario[key] as number | undefined) ?? 0}
disabled={!editable || !available}
className="min-w-[5.5rem]"
onCommit={(cents) => onPatch(col, { [key]: cents } as Partial<Scenario>)}
/>
) : (
<ReadMoney
cents={(scenario[key] as number | undefined) ?? 0}
baselineCents={(eligibility[key] as number | undefined) ?? 0}
column={col}
/>
),
},
];
}
function boolField(key: keyof Scenario, label: string) {
return [
{
key,
label,
render: (col: ColumnId, scenario: Scenario) =>
isEditableColumn(col) ? (
<label className="flex items-center gap-2 text-xs">
<Switch
checked={scenario[key] as boolean}
disabled={!editable}
aria-label={`${COLUMN_LABELS[col]} ${label}`}
onCheckedChange={(checked) => onPatch(col, { [key]: checked } as Partial<Scenario>)}
/>
{scenario[key] ? "Yes" : "No"}
</label>
) : (
<ReadBool
value={scenario[key] as boolean}
baseline={eligibility[key] as boolean}
column={col}
/>
),
},
];
}
const rows: {
key: string;
label: string;
render(col: ColumnId, scenario: Scenario): ReactNode;
}[] = [
{
key: "householdSize",
label: "Household size",
render: (col, scenario) =>
isEditableColumn(col) ? (
<IntInput
id={`${gridId}-${col}-hh`}
ariaLabel={`${COLUMN_LABELS[col]} Household size`}
syncKey={`${col}-${scenario.householdSize}`}
value={scenario.householdSize}
disabled={!editable}
onCommit={(householdSize) => onPatch(col, { householdSize })}
/>
) : (
<CompareInt
value={scenario.householdSize}
baseline={eligibility.householdSize}
column={col}
/>
),
},
...moneyField(
"educationGrantCents",
"Line 2 · Education grant / monthly income",
educationEnabled,
),
...moneyField(
"educationExpensesCents",
"Line 3 · Tuition, fees and expenses",
educationEnabled,
),
{
key: "applicationDay",
label: "Application day (blank = full month)",
render: (col, scenario) =>
isEditableColumn(col) ? (
<ApplicationDayInput
key={`${col}-${scenario.applicationDay ?? "blank"}`}
id={`${gridId}-${col}-applicationDay`}
value={scenario.applicationDay ?? null}
disabled={!editable || !educationEnabled}
onCommit={(applicationDay) => onPatch(col, { applicationDay })}
/>
) : (
<span className={readCell}>{scenario.applicationDay ?? "Full month"}</span>
),
},
...moneyField("medicalDeductionCents", "Medical deduction"),
...moneyField("dependentCareCents", "Dependent care"),
...moneyField("childSupportCents", "Child support paid"),
...moneyField("rentCents", "Rent / mortgage"),
...moneyField("taxesInsuranceCents", "Taxes / insurance"),
...moneyField("installationCents", "Installation of utilities"),
...moneyField("otherShelterCents", "Other shelter"),
{
key: "sua",
label: "SUA type",
render: (col, scenario) =>
isEditableColumn(col) ? (
<Select
value={scenario.sua}
disabled={!editable}
onValueChange={(value) => onPatch(col, { sua: value as SuaType })}
>
<SelectTrigger
id={`${gridId}-${col}-sua`}
size="sm"
aria-label={`${COLUMN_LABELS[col]} SUA type`}
className="w-full justify-end text-xs"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{suaOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<CompareText
value={suaOptions.find((o) => o.value === scenario.sua)?.label ?? scenario.sua}
baseline={suaOptions.find((o) => o.value === eligibility.sua)?.label ?? eligibility.sua}
column={col}
rawMatch={scenario.sua === eligibility.sua}
/>
),
},
...boolField("elderlyDisabled", "Elderly / disabled household"),
...boolField("categoricalEligibility", "Categorical eligibility"),
...boolField("homeless", "Homeless deduction"),
];
return (
<>
{rows.map((row) => (
<tr key={row.key} className="border-b">
<th
scope="row"
className="px-2 py-1.5 text-left text-xs font-normal text-muted-foreground"
>
{row.label}
</th>
{(Object.keys(columns) as ColumnId[]).map((col) => (
<td key={col} className="px-2 py-1.5 align-top">
{row.render(col, columns[col])}
</td>
))}
</tr>
))}
</>
);
}
function CompareInt({
value,
baseline,
column,
}: {
value: number;
baseline: number;
column: ColumnId;
}) {
const showDiffs = useContext(ComputationDiffsContext);
const highlight = showDiffs && column !== "eligibility" && value !== baseline;
return (
<div className={`${readCell}${highlight ? " rounded bg-warning/15" : ""}`}>
{value}
{highlight && (
<span className="mt-0.5 block text-[0.7rem] text-warning">≠ Eligibility ({baseline})</span>
)}
</div>
);
}
function CompareText({
value,
baseline,
column,
rawMatch,
}: {
value: string;
baseline: string;
column: ColumnId;
rawMatch: boolean;
}) {
const showDiffs = useContext(ComputationDiffsContext);
const highlight = showDiffs && column !== "eligibility" && !rawMatch;
return (
<div className={`${readCell}${highlight ? " rounded bg-warning/15" : ""}`}>
{value}
{highlight && (
<span className="mt-0.5 block text-[0.7rem] text-warning">≠ Eligibility ({baseline})</span>
)}
</div>
);
}
function ApplicationDayInput({
id,
value,
disabled,
onCommit,
}: {
id: string;
value: number | null;
disabled: boolean;
onCommit(value: number | null): void;
}) {
const [draft, setDraft] = useState(value === null ? "" : String(value));
const [error, setError] = useState("");
return (
<div className="space-y-1">
<input
id={id}
type="text"
inputMode="numeric"
value={draft}
readOnly={disabled}
aria-label="Application day"
aria-invalid={!!error}
placeholder="Full month"
className={`${fieldClass} text-right`}
style={{ textAlign: "right" }}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
}}
onChange={(event) => setDraft(event.target.value)}
onBlur={() => {
const term = draft.trim();
const next = term === "" ? null : Number(term);
if (
term &&
(!/^\d+$/.test(term) ||
next === null ||
!Number.isInteger(next) ||
next < 1 ||
next > 31)
) {
setError("Use a day from 1 to 31, or leave blank. Previous value kept.");
setDraft(value === null ? "" : String(value));
return;
}
setError("");
onCommit(next);
}}
/>
{error && (
<p role="alert" className="text-xs text-destructive">
{error}
</p>
)}
</div>
);
}