src/features/reviews/computation-income.tsx
Linked repository file from this build.
"use client";
import { useId, useState, type FocusEvent } from "react";
import { Popover, PopoverTrigger, PopoverContent, PopoverClose } from "@/components/ui/popover";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { fieldClass } from "@/components/ui/input";
import { MAX_INCOME_ROWS } from "@/programs/snap-negative/input-limits";
import { initialPolicy, type SnapPolicy } from "@/programs/snap-negative/policy";
import { selfEmploymentTotals } from "@/programs/snap-negative/calculation";
import type { HouseholdMember, IncomeStream, Scenario } from "@/programs/snap-negative/types";
import { incomeFieldErrors, type IncomeField } from "./income-validation";
import { MoneyInput } from "./money-input";
import {
formatDecimal2FromCents,
isEditableColumn,
type ColumnId,
type EditableScenarioId,
} from "./computation-grid.helpers";
const money = (value: number) =>
`${value < 0 ? "−" : ""}$${formatDecimal2FromCents(Math.abs(value))}`;
const categories = { earned: "Earned", unearned: "Unearned", self_employment: "Self-employment" };
const columnNames: Partial<Record<ColumnId, string>> = {
eligibility: "Eligibility",
compI: "Comp I",
compII: "Comp II",
final: "Final SAQC",
};
function IncomePopover({
row,
members,
income,
policy,
columnLabel,
title,
onConfirm,
onCancel,
onRemove,
}: {
row: IncomeStream;
members: HouseholdMember[];
income: IncomeStream[];
policy: SnapPolicy;
columnLabel: string;
title: string;
onConfirm(row: IncomeStream): void;
onCancel(): void;
onRemove?(): void;
}) {
const titleId = useId();
const descriptionId = useId();
const [draft, setDraft] = useState({
...row,
selfEmploymentKind: row.selfEmploymentKind ?? "non_farm",
});
const [attempted, setAttempted] = useState(false);
const [touched, setTouched] = useState<Partial<Record<IncomeField, boolean>>>({});
const errors = incomeFieldErrors(draft, members);
function fieldError(field: IncomeField) {
return attempted || touched[field] ? errors[field] : undefined;
}
function fieldProps(field: IncomeField) {
return {
name: field,
"aria-invalid": fieldError(field) ? true : undefined,
"aria-describedby": fieldError(field) ? `${titleId}-${field}-error` : undefined,
onBlur: (event: FocusEvent<HTMLInputElement | HTMLSelectElement>) => {
// Confirm validates every field together; avoid moving its button between pointerdown and click.
if (
event.relatedTarget instanceof HTMLButtonElement &&
event.relatedTarget.type === "submit"
)
return;
setTouched((current) => ({ ...current, [field]: true }));
},
};
}
function errorText(field: IncomeField) {
return (
fieldError(field) && (
<p id={`${titleId}-${field}-error`} className="text-xs text-destructive" role="alert">
{fieldError(field)}
</p>
)
);
}
function patch(value: Partial<IncomeStream>) {
setDraft((current) => ({ ...current, ...value }));
}
const totals = selfEmploymentTotals([draft], policy);
const columnTotals = selfEmploymentTotals(
[...income.filter((entry) => entry.id !== row.id), draft],
policy,
);
return (
<PopoverContent
align="center"
collisionPadding={12}
aria-labelledby={titleId}
aria-describedby={descriptionId}
className="flex max-h-[var(--radix-popover-content-available-height)] w-[min(24rem,calc(100vw-1.5rem))] flex-col overflow-hidden p-0"
>
<div className="relative shrink-0 border-b px-4 py-3 pr-10">
<h3 id={titleId} className="text-sm font-semibold">
{title}
</h3>
<p id={descriptionId} className="mt-1 text-xs text-muted-foreground">
{columnLabel}. Confirm the row, then save the column.
</p>
<PopoverClose
aria-label="Close income popover"
className="absolute right-2 top-2 rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="size-4" />
</PopoverClose>
</div>
<form
className="flex min-h-0 flex-col"
onSubmit={(event) => {
event.preventDefault();
setAttempted(true);
const invalid = (Object.keys(errors) as IncomeField[]).find((field) => errors[field]);
if (invalid) {
event.currentTarget.querySelector<HTMLElement>(`[name="${invalid}"]`)?.focus();
return;
}
onConfirm({
...draft,
typeCode: draft.typeCode.trim(),
expensesCents: draft.category === "self_employment" ? draft.expensesCents : 0,
});
}}
>
<div className="grid min-h-0 gap-3 overflow-y-auto p-4 sm:grid-cols-2">
<label className="space-y-1 text-sm sm:col-span-2">
Member
<select
{...fieldProps("memberId")}
aria-label="Household member"
className={fieldClass}
value={draft.memberId}
onChange={(event) => patch({ memberId: event.target.value })}
>
{members.map((member) => (
<option value={member.id} key={member.id}>
{member.line} {member.name}
</option>
))}
</select>
{errorText("memberId")}
</label>
<label className="space-y-1 text-sm">
Type
<input
autoComplete="off"
{...fieldProps("typeCode")}
aria-label="Income type code"
className={fieldClass}
value={draft.typeCode}
onChange={(event) => patch({ typeCode: event.target.value })}
/>
{errorText("typeCode")}
</label>
<label className="space-y-1 text-sm">
Category
<select
{...fieldProps("category")}
aria-label="Income category"
className={fieldClass}
value={draft.category}
onChange={(event) =>
patch({ category: event.target.value as IncomeStream["category"] })
}
>
{Object.entries(categories).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
{errorText("category")}
</label>
{draft.category === "self_employment" && (
<label className="space-y-1 text-sm sm:col-span-2">
Business
<select
{...fieldProps("selfEmploymentKind")}
aria-label="Self-employment kind"
className={fieldClass}
value={draft.selfEmploymentKind ?? "non_farm"}
onChange={(event) =>
patch({ selfEmploymentKind: event.target.value as "farm" | "non_farm" })
}
>
<option value="farm">Farm</option>
<option value="non_farm">Non-farm</option>
</select>
{errorText("selfEmploymentKind")}
</label>
)}
<label className="space-y-1 text-sm">
{draft.category === "self_employment" ? "Monthly gross" : "Amount"}
<MoneyInput
{...fieldProps("amountCents")}
ariaLabel={draft.category === "self_employment" ? "Monthly gross" : "Amount"}
syncKey={`${row.id}-amount`}
cents={draft.amountCents}
onDraft={(amountCents) => patch({ amountCents })}
/>
{errorText("amountCents")}
</label>
{draft.category === "self_employment" && (
<>
<label className="space-y-1 text-sm">
{draft.selfEmploymentKind === "farm" ? "Monthly costs" : "Allowable expenses"}
<MoneyInput
{...fieldProps("expensesCents")}
ariaLabel={
draft.selfEmploymentKind === "farm" ? "Monthly costs" : "Allowable expenses"
}
syncKey={`${row.id}-expenses`}
cents={draft.expensesCents}
onDraft={(expensesCents) => patch({ expensesCents })}
/>
{errorText("expensesCents")}
</label>
<div className="space-y-1 rounded-md bg-muted/50 p-3 text-sm tabular-nums sm:col-span-2">
{draft.selfEmploymentKind === "farm" ? (
<>
<p>Net gain: {money(totals.farmGainCents)}</p>
<p>Net loss: {money(totals.farmLossCents)}</p>
</>
) : (
<>
<p>Net before taxes: {money(totals.netBeforeTaxesCents)}</p>
<p>Column farm loss: {money(columnTotals.farmLossCents)}</p>
<p>Column net after farm loss: {money(columnTotals.netCents)}</p>
</>
)}
</div>
</>
)}
</div>
<div className="flex shrink-0 items-center justify-end gap-2 border-t px-4 py-3">
{onRemove && (
<Button
type="button"
variant="ghost"
className="mr-auto text-destructive"
onClick={onRemove}
>
Remove
</Button>
)}
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="submit">Confirm</Button>
</div>
</form>
</PopoverContent>
);
}
export function AddIncomeButton({
members,
scenario,
columnLabel,
policy = initialPolicy,
onAdd,
}: {
members: HouseholdMember[];
scenario: Scenario;
columnLabel: string;
policy?: SnapPolicy;
onAdd(income: IncomeStream[]): void;
}) {
const [newRow, setNewRow] = useState<IncomeStream | null>(null);
const [open, setOpen] = useState(false);
if (!members.length)
return <span className="text-xs text-muted-foreground">Add household members first.</span>;
return (
<div className="flex justify-center">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
disabled={scenario.income.length >= MAX_INCOME_ROWS}
onClick={() => {
if (open) return;
setNewRow({
id: crypto.randomUUID(),
memberId: members[0]!.id,
typeCode: "",
category: "earned",
amountCents: 0,
expensesCents: 0,
description: "",
});
}}
>
Add income
</Button>
</PopoverTrigger>
{newRow && (
<IncomePopover
key={newRow.id}
title="Add income"
columnLabel={columnLabel}
row={newRow}
members={members}
income={scenario.income}
policy={policy}
onCancel={() => setOpen(false)}
onConfirm={(row) => {
onAdd([...scenario.income, row]);
setOpen(false);
}}
/>
)}
</Popover>
</div>
);
}
function IncomeCell({
row,
members,
income,
policy,
columnLabel,
editable,
inputColumn,
onConfirm,
onRemove,
}: {
row: IncomeStream;
members: HouseholdMember[];
income: IncomeStream[];
policy: SnapPolicy;
columnLabel: string;
editable: boolean;
inputColumn: boolean;
onConfirm(row: IncomeStream): void;
onRemove(): void;
}) {
const [open, setOpen] = useState(false);
const [breakdown, setBreakdown] = useState(false);
const [editSession, setEditSession] = useState(0);
const member = members.find((person) => person.id === row.memberId);
const align = inputColumn ? "text-right" : "text-left";
return (
<Popover
open={open && editable}
onOpenChange={(value) => {
if (value) setEditSession((session) => session + 1);
setOpen(value);
}}
>
<div className="space-y-0.5">
<PopoverTrigger asChild>
<button
type="button"
disabled={!editable}
className={`w-full rounded p-1 ${align} enabled:hover:bg-muted`}
>
<span className="block text-xs">
{member ? `${member.line} ${member.name}` : row.memberId}
</span>
<span className="block text-xs text-muted-foreground">
{row.typeCode} · {categories[row.category]}
{row.category === "self_employment"
? ` · ${row.selfEmploymentKind === "farm" ? "Farm" : "Non-farm"}`
: ""}
</span>
<span className="block text-lg tabular-nums">
{breakdown
? `${money(row.amountCents)} − ${money(row.expensesCents)}`
: money(
row.category === "self_employment"
? selfEmploymentTotals([row], policy).netCents
: row.amountCents,
)}
</span>
</button>
</PopoverTrigger>
{row.category === "self_employment" && (
<button
type="button"
onClick={() => setBreakdown((value) => !value)}
className={`text-[0.7rem] text-muted-foreground underline ${align}`}
>
{breakdown ? "Show net" : "Show gross and expenses"}
</button>
)}
</div>
{editable && (
<IncomePopover
key={editSession}
title="Edit income"
columnLabel={columnLabel}
row={row}
members={members}
income={income}
policy={policy}
onConfirm={(value) => {
onConfirm(value);
setOpen(false);
}}
onCancel={() => setOpen(false)}
onRemove={() => {
onRemove();
setOpen(false);
}}
/>
)}
</Popover>
);
}
export function IncomeRow({
rows,
members,
columns,
policy = initialPolicy,
editable,
onPatchIncome,
}: {
rows: (IncomeStream | undefined)[];
members: HouseholdMember[];
policy?: SnapPolicy;
columns: Record<ColumnId, Scenario>;
editable: boolean;
onPatchIncome(scenarioId: EditableScenarioId, income: IncomeStream[]): void;
}) {
return (
<tr className="border-b">
<th scope="row" className="px-2 py-1.5 text-left text-xs font-normal text-muted-foreground">
Income
</th>
{(Object.keys(columns) as ColumnId[]).map((column, index) => {
const row = rows[index];
const scenario = columns[column];
return (
<td
key={column}
className={`px-2 py-1.5 align-top${isEditableColumn(column) ? " text-right" : ""}`}
>
{row ? (
<IncomeCell
key={row.id}
row={row}
members={members}
income={scenario.income}
policy={policy}
columnLabel={columnNames[column] ?? "Discrepancy"}
editable={editable && isEditableColumn(column)}
inputColumn={isEditableColumn(column)}
onConfirm={(value) => {
if (isEditableColumn(column))
onPatchIncome(
column,
scenario.income.map((entry) => (entry.id === row.id ? value : entry)),
);
}}
onRemove={() => {
if (isEditableColumn(column))
onPatchIncome(
column,
scenario.income.filter((entry) => entry.id !== row.id),
);
}}
/>
) : (
<span className="text-muted-foreground">—</span>
)}
</td>
);
})}
</tr>
);
}