src/features/reviews/workspace-review-fields.tsx
Linked repository file from this build.
"use client";
import { memo, useCallback, useMemo, useState } from "react";
import { Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { fieldMultilineClass, Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
codedFieldChoices,
elementCodeChoices,
fieldLabel,
natureChoicesForElement,
normalizeReviewAnswers,
requiredFieldsForAnswers,
validateRequiredField,
validateVariancePairs,
} from "@/programs/snap-negative/field-catalog";
import type { ReviewAnswers, SnapReview } from "@/programs/snap-negative/types";
import { canRefuseFinding, openFindingsBlockApproval } from "./commands";
import type { Actor } from "./types";
import { receivedSnapshot } from "./section-state";
import { reviewFieldGroups, type ReviewAnswerFieldKey } from "./workspace-review-field-groups";
function fieldDomId(key: string): string {
return key.replace(/[^a-zA-Z0-9-]/g, "-");
}
function visibleFields(answers: ReviewAnswers): ReviewAnswerFieldKey[] {
const normalized = normalizeReviewAnswers(answers);
const visible = new Set<ReviewAnswerFieldKey>();
for (const group of reviewFieldGroups) {
for (const key of group.fields) {
if (key === "nstrReason" && normalized.disposition !== "2") continue;
if (
key === "daysActionEarlyLate" &&
normalized.actionTimeliness !== "2" &&
normalized.actionTimeliness !== "3"
) {
continue;
}
if (key === "daysNoticeLate" && normalized.noticeTimeliness !== "2") continue;
if (normalized.disposition === "2" && key !== "disposition" && key !== "nstrReason") {
continue;
}
if (
(normalized.disposition === "3" || normalized.disposition === "4") &&
key !== "disposition"
) {
continue;
}
visible.add(key);
}
}
return [...visible];
}
function CodedSelect({
id,
label,
value,
choices,
disabled,
required,
error,
onChange,
}: {
id: string;
label: string;
value: string;
choices: { value: string; label: string }[];
disabled?: boolean;
required?: boolean;
error?: string;
onChange(value: string): void;
}) {
const [open, setOpen] = useState(false);
// Radix needs its item collection for closed-trigger typeahead. Build it on
// first focus instead of eagerly mounting every dropdown in the review form.
const [activated, setActivated] = useState(false);
const selected = choices.find((choice) => choice.value === value);
return (
<div className="space-y-2">
<Label
htmlFor={id}
title={
id === "fs75"
? "Whether the case is in ESAP. The app does not enforce ESAP rules."
: undefined
}
>
{label}
{required && <span className="text-destructive"> *</span>}
</Label>
<Select
open={open}
onOpenChange={setOpen}
value={value}
disabled={disabled}
onValueChange={onChange}
>
<SelectTrigger
id={id}
onFocus={() => setActivated(true)}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${id}-error` : undefined}
className="w-full"
>
<SelectValue placeholder="Select…">
{selected ? `${selected.value} — ${selected.label}` : value || undefined}
</SelectValue>
</SelectTrigger>
{(open || activated) && (
<SelectContent>
{choices.map((choice) => (
<SelectItem key={choice.value} value={choice.value}>
{choice.value} — {choice.label}
</SelectItem>
))}
</SelectContent>
)}
</Select>
{error && (
<p id={`${id}-error`} className="text-xs text-destructive">
{error}
</p>
)}
</div>
);
}
const dateAnswerKeys = new Set<ReviewAnswerFieldKey>([
"dateAssigned",
"dateCompleted",
"dateCleared",
]);
function TextField({
id,
label,
value,
disabled,
required,
error,
multiline,
rows = 4,
type = "text",
onChange,
}: {
id: string;
label: string;
value: string;
disabled?: boolean;
required?: boolean;
error?: string;
multiline?: boolean;
rows?: number;
type?: "text" | "date";
onChange(value: string): void;
}) {
return (
<div className="space-y-2">
<Label
htmlFor={id}
title={
id === "fs75"
? "Whether the case is in ESAP. The app does not enforce ESAP rules."
: undefined
}
>
{label}
{required && <span className="text-destructive"> *</span>}
</Label>
{multiline ? (
<textarea
id={id}
rows={rows}
readOnly={disabled}
value={value}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${id}-error` : undefined}
onChange={(event) => onChange(event.target.value)}
className={fieldMultilineClass}
/>
) : (
<Input
id={id}
type={type}
value={value}
readOnly={disabled}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${id}-error` : undefined}
onChange={(event) => onChange(event.target.value)}
/>
)}
{error && (
<p id={`${id}-error`} className="text-xs text-destructive">
{error}
</p>
)}
</div>
);
}
export const WorkspaceReviewFields = memo(function WorkspaceReviewFields({
review,
actor,
editable,
submitAttempted,
onAnswersChange,
onRefuseFinding,
onFocusField,
}: {
review: SnapReview;
actor: Actor;
editable: boolean;
submitAttempted: boolean;
onAnswersChange(patch: Partial<ReviewAnswers>): void;
onRefuseFinding(findingId: string, reason: string): void;
onFocusField(field: string): void;
}) {
const answers = useMemo(
() => normalizeReviewAnswers(review.facts.answers),
[review.facts.answers],
);
const requiredKeys = useMemo(() => new Set(requiredFieldsForAnswers(answers)), [answers]);
const [refuseDrafts, setRefuseDrafts] = useState<Record<string, string>>({});
const fieldError = useCallback(
(key: ReviewAnswerFieldKey) => {
if (!submitAttempted && !requiredKeys.has(key)) return undefined;
if (!submitAttempted) {
const value = String(answers[key] ?? "").trim();
if (!value && requiredKeys.has(key)) return `${fieldLabel(key)} is required.`;
return undefined;
}
return validateRequiredField(key, answers);
},
[answers, requiredKeys, submitAttempted],
);
const varianceIssues = useMemo(
() => validateVariancePairs(answers.variances),
[answers.variances],
);
const checksStale = review.validation?.revision !== review.revision;
const storedFindings = (review.validation?.findings ?? []).map((finding) =>
checksStale ? { ...finding, refused: undefined } : finding,
);
const canRefuse = canRefuseFinding(actor);
function renderField(key: ReviewAnswerFieldKey) {
const label = fieldLabel(key);
const id = fieldDomId(key);
const required = requiredKeys.has(key);
const error = fieldError(key);
const value = String(answers[key] ?? "");
const choices = codedFieldChoices[key];
if (choices) {
return (
<CodedSelect
id={id}
label={label}
value={value}
choices={choices}
disabled={!editable}
required={required}
error={error}
onChange={(next) => onAnswersChange({ [key]: next })}
/>
);
}
return (
<TextField
id={id}
label={label}
value={value}
disabled={!editable}
required={required}
error={error}
type={dateAnswerKeys.has(key) ? "date" : "text"}
multiline={key === "narrative"}
rows={key === "narrative" ? 8 : 4}
onChange={(next) => onAnswersChange({ [key]: next })}
/>
);
}
const visible = visibleFields(answers);
const visibleSet = new Set(visible);
function renderGroup(group: (typeof reviewFieldGroups)[number]) {
const fields = group.fields.filter((key) => visibleSet.has(key));
if (!fields.length) return null;
return (
<section key={group.title} aria-labelledby={`group-${group.title}`} className="space-y-5">
<SectionHeading
id={`group-${group.title}`}
title={group.title}
disabled={!editable}
resetDisabled={!receivedSnapshot(review, actor)}
onClear={() => onAnswersChange(Object.fromEntries(group.fields.map((key) => [key, ""])))}
onReset={() => {
const source = receivedSnapshot(review, actor);
if (source)
onAnswersChange(
Object.fromEntries(group.fields.map((key) => [key, source.facts.answers[key]])),
);
}}
/>
<div className="grid gap-x-6 gap-y-6 sm:grid-cols-2">
{fields.map((key) => (
<div key={key} className={key === "narrative" ? "sm:col-span-2" : undefined}>
{renderField(key)}
</div>
))}
</div>
</section>
);
}
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden">
<div
className="min-h-0 flex-1 scroll-pt-12 space-y-10 overflow-auto pb-8 pr-1"
data-review-sections
>
{submitAttempted && (
<SubmitSummary review={review} answers={answers} onFocusField={onFocusField} />
)}
{reviewFieldGroups.slice(0, 3).map(renderGroup)}
{answers.disposition === "1" && (
<section aria-labelledby="variances-heading" className="space-y-5">
<SectionHeading
id="variances-heading"
title="Description of variances (IV)"
disabled={!editable}
resetDisabled={!receivedSnapshot(review, actor)}
onClear={() => onAnswersChange({ variances: [] })}
onReset={() =>
onAnswersChange({
variances:
receivedSnapshot(review, actor)?.facts.answers.variances.slice(0, 1) ?? [],
})
}
/>
{(answers.variances.length
? answers.variances.slice(0, 1)
: [{ element: "", nature: "" }]
).map((variance, index) => {
const elementIssue = varianceIssues.find(
(issue) => issue.index === index && issue.field === "element",
);
const natureIssue = varianceIssues.find(
(issue) => issue.index === index && issue.field === "nature",
);
return (
<div key={index} className="grid gap-x-6 gap-y-6 sm:grid-cols-[1fr_1fr_auto]">
<div className="space-y-2">
<Label htmlFor={`variance-element-${index}`}>17. Element code</Label>
<Select
value={variance.element || undefined}
disabled={!editable}
onValueChange={(value) => {
const variances = [{ ...variance }];
variances[index] = { ...variances[index], element: value };
onAnswersChange({ variances });
}}
>
<SelectTrigger
id={`variance-element-${index}`}
aria-invalid={elementIssue ? true : undefined}
>
<SelectValue placeholder="Element…" />
</SelectTrigger>
<SelectContent>
{elementCodeChoices.map((choice) => (
<SelectItem key={choice.value} value={choice.value}>
{choice.value} — {choice.label}
</SelectItem>
))}
</SelectContent>
</Select>
{elementIssue && (
<p className="text-xs text-destructive">{elementIssue.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor={`variance-nature-${index}`}>18. Nature code</Label>
<Select
value={variance.nature || undefined}
disabled={!editable}
onValueChange={(value) => {
const variances = [{ ...variance }];
variances[index] = { ...variances[index], nature: value };
onAnswersChange({ variances });
}}
>
<SelectTrigger
id={`variance-nature-${index}`}
aria-invalid={natureIssue ? true : undefined}
>
<SelectValue placeholder="Nature…" />
</SelectTrigger>
<SelectContent>
{natureChoicesForElement(variance.element).map((choice) => (
<SelectItem key={choice.value} value={choice.value}>
{choice.value} — {choice.label}
</SelectItem>
))}
</SelectContent>
</Select>
{natureIssue && (
<p className="text-xs text-destructive">{natureIssue.message}</p>
)}
</div>
{editable && (
<div className="flex items-end">
<Button
type="button"
size="icon"
variant="ghost"
className="size-8"
aria-label={`Remove variance row ${index + 1}`}
onClick={() =>
onAnswersChange({
variances: answers.variances.filter((_, i) => i !== index),
})
}
>
<Trash2 className="size-3.5" />
</Button>
</div>
)}
</div>
);
})}
{answers.variances.length > 1 && (
<details className="text-sm">
<summary>Historic variance pairs</summary>
{answers.variances.slice(1).map((pair, index) => (
<p key={index}>
{pair.element} / {pair.nature}
</p>
))}
</details>
)}
</section>
)}
{reviewFieldGroups.slice(3).map(renderGroup)}
{storedFindings.length > 0 && (
<section aria-labelledby="findings-heading" className="space-y-5">
<h3
id="findings-heading"
className="sticky top-0 z-10 border-b bg-background pb-3 text-sm font-semibold"
>
{checksStale ? "Previous edit-check findings" : "Stored edit-check findings"}
</h3>
{checksStale && (
<p className="text-sm text-muted-foreground">
These findings predate your edits. Approval and override actions check the latest
saved review before proceeding.
</p>
)}
<ul className="space-y-2">
{storedFindings.map((finding) => (
<li
key={finding.id}
className={`rounded-md border px-3 py-2 text-sm ${finding.refused ? "opacity-70" : ""}`}
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<p>{finding.message}</p>
{finding.refused && (
<p className="mt-1 text-xs text-muted-foreground">
Refused by {finding.refused.actor.name}: {finding.refused.reason}
</p>
)}
</div>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => onFocusField(finding.field)}
>
Go to field
</Button>
</div>
{canRefuse && !finding.refused && (
<div className="mt-2 flex flex-wrap items-end gap-2">
<div className="min-w-[12rem] flex-1 space-y-2">
<Label htmlFor={`refuse-${finding.id}`}>Refuse reason</Label>
<Input
id={`refuse-${finding.id}`}
value={refuseDrafts[finding.id] ?? ""}
onChange={(event) =>
setRefuseDrafts((prev) => ({
...prev,
[finding.id]: event.target.value,
}))
}
/>
</div>
<Button
type="button"
size="sm"
variant="outline"
disabled={!(refuseDrafts[finding.id] ?? "").trim()}
onClick={() => {
const reason = (refuseDrafts[finding.id] ?? "").trim();
if (!reason) return;
onRefuseFinding(finding.id, reason);
setRefuseDrafts((prev) => {
const next = { ...prev };
delete next[finding.id];
return next;
});
}}
>
Refuse finding
</Button>
</div>
)}
</li>
))}
</ul>
{openFindingsBlockApproval(review) &&
(actor.role === "supervisor" || actor.role === "program_manager") && (
<p className="text-xs text-warning">
Open findings block supervisor approval until fixed or refused.
</p>
)}
</section>
)}
</div>
</div>
);
});
function SectionHeading({
id,
title,
disabled,
onReset,
onClear,
resetDisabled,
}: {
id: string;
title: string;
disabled: boolean;
onReset(): void;
onClear(): void;
resetDisabled?: boolean;
}) {
return (
<div className="sticky top-0 z-10 flex items-center justify-between gap-2 border-b bg-background pb-3">
<h3 id={id} className="text-sm font-semibold">
{title}
</h3>
<div className="ml-auto flex gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
disabled={disabled}
onClick={onClear}
aria-label={`Clear ${title.toLowerCase()}`}
>
Clear
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 shrink-0 px-2 text-xs"
disabled={disabled || resetDisabled}
onClick={onReset}
aria-label={`Reset ${title.toLowerCase()}`}
>
Reset
</Button>
</div>
</div>
);
}
function SubmitSummary({
review,
answers,
onFocusField,
}: {
review: SnapReview;
answers: ReviewAnswers;
onFocusField(field: string): void;
}) {
const blockers: { field: string; message: string }[] = [];
for (const key of requiredFieldsForAnswers(answers)) {
const message = validateRequiredField(key, answers);
if (message) blockers.push({ field: key, message });
}
for (const issue of validateVariancePairs(answers.variances)) {
blockers.push({
field: `variances.${issue.index}.${issue.field}`,
message: issue.message,
});
}
if (!review.outcome) {
blockers.push({ field: "outcome", message: "Choose a review outcome before submission." });
}
if (!review.facts.final || review.facts.final.inputsRevision !== review.facts.inputsRevision) {
blockers.push({
field: "computation",
message: "Apply Final Results before submission.",
});
}
if (!blockers.length) return null;
return (
<div
role="alert"
className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm"
>
<p className="font-medium text-destructive">Complete these items before finishing:</p>
<ul className="mt-2 list-inside list-disc space-y-1">
{blockers.map((blocker) => (
<li key={`${blocker.field}-${blocker.message}`}>
<button
type="button"
className="text-left underline-offset-2 hover:underline"
onClick={() => onFocusField(blocker.field)}
>
{blocker.message}
</button>
</li>
))}
</ul>
</div>
);
}