src/features/policy/page.tsx
Linked repository file from this build.
"use client";
import { useState } from "react";
import { LoadingPreview } from "@/components/loading-preview";
import { AppShell } from "@/components/app-shell";
import { Button } from "@/components/ui/button";
import { initialPolicy, type SnapPolicy } from "@/programs/snap-negative/policy";
import { fieldClass, Input } from "@/components/ui/input";
import { policySchema } from "./schema";
const amountFields = [
["errorThreshold", "Reportable error threshold ($)"],
["shelterCap", "Excess shelter cap ($)"],
["shelterDeductionPercent", "Income applied to shelter (%)"],
["homelessDeduction", "Homeless deduction ($)"],
["minimumBenefit", "Minimum benefit ($)"],
["earnedDeductionPercent", "Earned income deduction (%)"],
["farmExpenseAllowancePercent", "Farm cost allowance (%)"],
["nonFarmExpenseAllowancePercent", "Non-farm expense allowance (%)"],
] as const;
const householdFields = [
["grossLimits", "Gross limit"],
["netLimits", "Net limit"],
["standardDeductions", "Standard deduction"],
["allotments", "Allotment"],
] as const;
const utilityFields = [
["heating", "Heating"],
["non_heating", "Non-heating"],
["limited", "Limited"],
["telephone", "Telephone"],
["none", "None"],
] as const;
export function PolicyPage({ initial }: { initial: SnapPolicy[] }) {
return (
<AppShell title="Policy">
<PolicyContent initial={initial} />
</AppShell>
);
}
export function PolicyLoading() {
return (
<LoadingPreview label="Loading policy…">
<PolicyContent initial={[initialPolicy]} />
</LoadingPreview>
);
}
function PolicyContent({ initial }: { initial: SnapPolicy[] }) {
const [rows, setRows] = useState(initial);
const [selected, setSelected] = useState(initialPolicy.id);
const [draft, setDraft] = useState<SnapPolicy | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const policy = draft ?? rows.find((row) => row.id === selected) ?? initialPolicy;
function patch(value: Partial<SnapPolicy>) {
setDraft((current) => (current ? { ...current, ...value } : current));
}
async function publish() {
if (!draft) return;
const parsed = policySchema.safeParse(draft);
if (!parsed.success) {
setError(
parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(" "),
);
return;
}
setBusy(true);
setError("");
try {
const response = await fetch("/api/policy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(parsed.data),
});
const result = await response.json();
if (!response.ok) throw new Error(result.error ?? "Unable to publish policy.");
setRows((current) => [...current, result]);
setSelected(result.id);
setDraft(null);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Unable to publish policy.");
} finally {
setBusy(false);
}
}
return (
<div className="mx-auto w-full max-w-6xl space-y-4">
<header className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">Policy tables</h1>
<p className="text-sm text-muted-foreground">
Published tables are fixed. A new table applies from its effective month. Existing reviews
keep their saved policy.
</p>
</header>
<div className="flex flex-wrap items-end gap-3 rounded-lg border bg-card p-4 shadow-xs">
<label className="min-w-0 flex-1 space-y-1 text-sm sm:max-w-sm">
Published table
<select
className={fieldClass}
disabled={!!draft}
value={selected}
onChange={(event) => setSelected(event.target.value)}
>
{rows.map((row) => (
<option key={row.id} value={row.id}>
{row.effectiveFrom} · {row.id}
</option>
))}
</select>
</label>
{!draft && (
<Button
onClick={() => {
setError("");
setDraft({
...structuredClone(rows.at(-1)!),
id: crypto.randomUUID(),
effectiveFrom: "",
});
}}
>
New table
</Button>
)}
{draft && (
<Button variant="outline" disabled={busy} onClick={() => setDraft(null)}>
Cancel
</Button>
)}
</div>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<section
className="overflow-hidden rounded-lg border bg-card shadow-xs"
aria-labelledby="policy-rules"
>
<div className="flex items-center justify-between border-b bg-muted/40 px-4 py-3">
<h2 id="policy-rules" className="text-sm font-semibold">
Dates, thresholds & deductions
</h2>
<span className="rounded border bg-background px-2 py-0.5 text-xs text-muted-foreground">
{draft ? "Draft" : "Published"}
</span>
</div>
<div className="grid gap-4 p-4 sm:grid-cols-2 lg:grid-cols-3">
<label className="space-y-1 text-sm">
Effective month
<Input
type="month"
readOnly={!draft}
value={policy.effectiveFrom}
onChange={(event) =>
patch({
effectiveFrom: event.target.value,
...(event.target.value === "2026-10" ? { errorThreshold: 59 } : {}),
})
}
/>
</label>
<label className="space-y-1 text-sm">
Default CAO response days
<input
className={fieldClass}
type="number"
min="1"
max="365"
readOnly={!draft}
placeholder="Not supplied. Set deadline on review."
value={policy.memoResponseDays ?? ""}
onChange={(event) =>
patch({
memoResponseDays: event.target.value ? Number(event.target.value) : null,
})
}
/>
</label>
{amountFields.map(([key, label]) => (
<label key={key} className="space-y-1 text-sm">
{label}
<input
className={fieldClass}
type="number"
min="0"
step="0.01"
readOnly={!draft}
value={policy[key]}
onChange={(event) => patch({ [key]: Number(event.target.value) })}
/>
</label>
))}
</div>
</section>
<section
className="overflow-hidden rounded-lg border bg-card shadow-xs"
aria-labelledby="policy-utilities"
>
<h2 id="policy-utilities" className="border-b bg-muted/40 px-4 py-3 text-sm font-semibold">
Standard utility allowances
</h2>
<div className="grid gap-4 p-4 sm:grid-cols-3 lg:grid-cols-5">
{utilityFields.map(([key, label]) => (
<label key={key} className="space-y-1 text-sm">
{label} SUA ($)
<input
className={fieldClass}
type="number"
min="0"
step="0.01"
readOnly={!draft}
value={policy.sua[key]}
onChange={(event) =>
patch({ sua: { ...policy.sua, [key]: Number(event.target.value) } })
}
/>
</label>
))}
</div>
</section>
<section
className="overflow-hidden rounded-lg border bg-card shadow-xs"
aria-labelledby="policy-households"
>
<h2 id="policy-households" className="border-b bg-muted/40 px-4 py-3 text-sm font-semibold">
Household amounts ($)
</h2>
<div className="overflow-x-auto">
<table className="w-full min-w-[36rem] border-collapse text-sm">
<caption className="sr-only">Household amounts ($)</caption>
<thead className="bg-muted text-xs text-muted-foreground">
<tr>
<th className="p-2 text-left">Size</th>
{householdFields.map(([key, label]) => (
<th className="p-2 text-left" key={key}>
{label}
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: 20 }, (_, index) => (
<tr key={index} className="border-t">
<th className="p-2 text-left font-medium tabular-nums">{index + 1}</th>
{householdFields.map(([key, label]) => (
<td key={key} className="p-1">
<input
aria-label={`${label}, household ${index + 1}`}
className={fieldClass}
type="number"
min="0"
step="0.01"
readOnly={!draft}
value={policy[key][index]}
onChange={(event) =>
patch({
[key]: policy[key].map((value, position) =>
position === index ? Number(event.target.value) : value,
),
})
}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</section>
<section
className="overflow-hidden rounded-lg border bg-card shadow-xs"
aria-labelledby="policy-reference"
>
<h2 id="policy-reference" className="border-b bg-muted/40 px-4 py-3 text-sm font-semibold">
Source & verification
</h2>
<div className="space-y-3 p-4">
<label className="block space-y-1 text-sm">
Source
<input
className={fieldClass}
readOnly={!draft}
value={policy.source}
onChange={(event) => patch({ source: event.target.value })}
/>
</label>
<label className="block space-y-1 text-sm">
Verification
<input
className={fieldClass}
readOnly={!draft}
value={policy.verification}
onChange={(event) => patch({ verification: event.target.value })}
/>
</label>
</div>
</section>
{draft && (
<div className="flex justify-end">
<Button disabled={busy} onClick={() => void publish()}>
{busy ? "Publishing..." : "Publish table"}
</Button>
</div>
)}
</div>
);
}