Full code review of the case-detail page (14 components) surfaced these,
all fixed here:
Logic bugs
- case-edit-dialog: form.reset ran on the 5s useCase refetch while the dialog
was open, clobbering in-progress edits. Now resets only on open→true.
- status-changer: `selected` never synced to async/external `currentStatus`
(stale dropdown). Reworked to track currentStatus until an explicit pick;
resets to tracking after save.
- decision-blocks-panel: `block.content`/`word_count` accessed without null
guards (endpoint has no response model) → potential render crash. Coerced
with `?? ""` / `?? 0`. `STATUS_LABELS[status]` now falls back to the raw
status instead of rendering literal "undefined".
- document-type-editor: `await mutateAsync()` in async click handlers without
try/catch → unhandled promise rejection. Wrapped (errors still surface via
isError).
Resilience / hygiene
- page.tsx: a transient 5xx on the background poll flipped the WHOLE page to
the error card and discarded loaded data. Now gated on `!data`, plus a
"נסה שוב" retry.
- cases.ts useUpdateCase: invalidated casesKeys.all, which re-invalidated the
detail it had just optimistically patched. Scoped to the list prefix.
RTL correctness (logical properties)
- agent-status-widget `mr-auto`→`ms-auto`; agent-activity-feed `mr-auto`→
`me-auto`, icon `ml-1/ml-2`→`me-1/me-2`, required `*` `mr-1`→`ms-1`;
document-type-editor list `pr-4`→`ps-4`.
Minors
- drafts-panel: `<a href>`→`next/link` (operations + citation links) for SPA
nav. agent-activity-feed: issueMap memoized; comment Textarea aria-label.
upload-sheet: `unknown` status no longer shown as green success (neutral
icon + "רענן לאישור"). citations.ts: case_name typed `string | null`.
Design gate: visual-touching items (RTL gap side, retry button, neutral
upload icon) were chair-authorized via the reviewed-findings approval ("fix
all"); none alter an approved page layout — they are correctness fixes.
tsc clean; eslint clean (1 pre-existing form.watch warning, untouched line).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
259 lines
9.0 KiB
TypeScript
259 lines
9.0 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { useForm, Controller } from "react-hook-form";
|
||
import { zodResolver } from "@hookform/resolvers/zod";
|
||
import { toast } from "sonner";
|
||
import {
|
||
Dialog, DialogContent, DialogDescription, DialogFooter,
|
||
DialogHeader, DialogTitle, DialogTrigger,
|
||
} from "@/components/ui/dialog";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { Label } from "@/components/ui/label";
|
||
import {
|
||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { PartiesField } from "@/components/wizard/parties-field";
|
||
import { useUpdateCase } from "@/lib/api/cases";
|
||
import {
|
||
caseUpdateSchema, expectedOutcomes, proceedingTypes,
|
||
type CaseUpdateInput,
|
||
} from "@/lib/schemas/case";
|
||
import type { CaseDetail } from "@/lib/api/cases";
|
||
|
||
/*
|
||
* Inline edit dialog for all case fields set at creation time.
|
||
* Uses react-hook-form + zod directly (shadcn's <Form> registry entry
|
||
* wasn't available at init time, so the styling is reproduced by hand).
|
||
*/
|
||
|
||
function FieldError({ message }: { message?: string }) {
|
||
if (!message) return null;
|
||
return <p className="text-[0.72rem] text-danger mt-1">{message}</p>;
|
||
}
|
||
|
||
export function CaseEditDialog({ data }: { data: CaseDetail }) {
|
||
const [open, setOpen] = useState(false);
|
||
const mutate = useUpdateCase(data.case_number);
|
||
|
||
const form = useForm<CaseUpdateInput>({
|
||
resolver: zodResolver(caseUpdateSchema),
|
||
defaultValues: {
|
||
title: data.title ?? "",
|
||
subject: data.subject ?? "",
|
||
hearing_date: data.hearing_date ?? "",
|
||
notes: "",
|
||
expected_outcome: data.expected_outcome ?? "",
|
||
appellants: data.appellants ?? [],
|
||
respondents: data.respondents ?? [],
|
||
property_address: data.property_address ?? "",
|
||
permit_number: data.permit_number ?? "",
|
||
proceeding_type: data.proceeding_type ?? "ערר",
|
||
},
|
||
});
|
||
|
||
/* Reset to the latest case values only on the open→true transition.
|
||
* Resetting on every `data` change would clobber in-progress edits, because
|
||
* useCase refetches every 5s (refetchInterval) while the dialog is open. */
|
||
const handleOpenChange = (next: boolean) => {
|
||
if (next) {
|
||
form.reset({
|
||
title: data.title ?? "",
|
||
subject: data.subject ?? "",
|
||
hearing_date: data.hearing_date ?? "",
|
||
notes: "",
|
||
expected_outcome: data.expected_outcome ?? "",
|
||
appellants: data.appellants ?? [],
|
||
respondents: data.respondents ?? [],
|
||
property_address: data.property_address ?? "",
|
||
permit_number: data.permit_number ?? "",
|
||
proceeding_type: data.proceeding_type ?? "ערר",
|
||
});
|
||
}
|
||
setOpen(next);
|
||
};
|
||
|
||
const onSubmit = form.handleSubmit(async (values) => {
|
||
try {
|
||
await mutate.mutateAsync(values);
|
||
toast.success("פרטי התיק עודכנו");
|
||
setOpen(false);
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "שגיאה בעדכון התיק");
|
||
}
|
||
});
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||
<DialogTrigger asChild>
|
||
<Button variant="outline" size="sm">
|
||
עריכת פרטי תיק
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto" dir="rtl">
|
||
<DialogHeader>
|
||
<DialogTitle>עריכת פרטי תיק {data.case_number}</DialogTitle>
|
||
<DialogDescription className="text-ink-muted">
|
||
השינויים נשמרים ישירות ל-DB. שינוי כותרת יסנכרן גם ל-Paperclip.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<form onSubmit={onSubmit} className="space-y-4">
|
||
<div>
|
||
<Label htmlFor="title" className="text-navy">כותרת</Label>
|
||
<Input id="title" {...form.register("title")} className="mt-1" />
|
||
<FieldError message={form.formState.errors.title?.message} />
|
||
</div>
|
||
|
||
<div>
|
||
<Label htmlFor="subject" className="text-navy">נושא</Label>
|
||
<Input id="subject" {...form.register("subject")} className="mt-1" />
|
||
<FieldError message={form.formState.errors.subject?.message} />
|
||
</div>
|
||
|
||
<div>
|
||
<Label className="text-navy">סוג תיק</Label>
|
||
<Controller
|
||
control={form.control}
|
||
name="proceeding_type"
|
||
render={({ field }) => (
|
||
<Select
|
||
value={field.value ?? "ערר"}
|
||
onValueChange={(v) =>
|
||
field.onChange(v as CaseUpdateInput["proceeding_type"])
|
||
}
|
||
dir="rtl"
|
||
>
|
||
<SelectTrigger className="mt-1">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{proceedingTypes.map((p) => (
|
||
<SelectItem key={p.value} value={p.value}>
|
||
{p.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
)}
|
||
/>
|
||
<p className="text-[0.7rem] text-ink-muted mt-1">
|
||
ערר = הליך עיקרי; בל"מ = בקשה להארכת מועד להגשת ערר
|
||
</p>
|
||
</div>
|
||
|
||
<div className="h-px bg-rule" />
|
||
|
||
<Controller
|
||
control={form.control}
|
||
name="appellants"
|
||
render={({ field, fieldState }) => (
|
||
<PartiesField
|
||
label="עוררים"
|
||
value={field.value ?? []}
|
||
onChange={field.onChange}
|
||
error={fieldState.error?.message}
|
||
/>
|
||
)}
|
||
/>
|
||
|
||
<Controller
|
||
control={form.control}
|
||
name="respondents"
|
||
render={({ field, fieldState }) => (
|
||
<PartiesField
|
||
label="משיבים"
|
||
value={field.value ?? []}
|
||
onChange={field.onChange}
|
||
error={fieldState.error?.message}
|
||
/>
|
||
)}
|
||
/>
|
||
|
||
<div className="h-px bg-rule" />
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label htmlFor="property_address" className="text-navy">כתובת הנכס</Label>
|
||
<Input
|
||
id="property_address"
|
||
{...form.register("property_address")}
|
||
className="mt-1"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label htmlFor="permit_number" className="text-navy">מס׳ תכנית/בקשה</Label>
|
||
<Input
|
||
id="permit_number"
|
||
{...form.register("permit_number")}
|
||
className="mt-1"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label htmlFor="hearing_date" className="text-navy">תאריך דיון</Label>
|
||
<Input
|
||
id="hearing_date"
|
||
type="date"
|
||
{...form.register("hearing_date")}
|
||
className="mt-1 tabular-nums"
|
||
/>
|
||
<FieldError message={form.formState.errors.hearing_date?.message} />
|
||
</div>
|
||
|
||
<div>
|
||
<Label className="text-navy">תוצאה צפויה</Label>
|
||
<Select
|
||
value={form.watch("expected_outcome") || "__none__"}
|
||
onValueChange={(v) =>
|
||
form.setValue("expected_outcome", v === "__none__" ? "" : v)
|
||
}
|
||
dir="rtl"
|
||
>
|
||
<SelectTrigger className="mt-1">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{expectedOutcomes.map((o) => (
|
||
<SelectItem key={o.value || "none"} value={o.value || "__none__"}>
|
||
{o.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<Label htmlFor="notes" className="text-navy">הערות (יתווספו לקיים)</Label>
|
||
<Textarea id="notes" rows={3} {...form.register("notes")} className="mt-1" />
|
||
<FieldError message={form.formState.errors.notes?.message} />
|
||
</div>
|
||
|
||
<DialogFooter className="gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
onClick={() => setOpen(false)}
|
||
disabled={mutate.isPending}
|
||
>
|
||
ביטול
|
||
</Button>
|
||
<Button
|
||
type="submit"
|
||
disabled={mutate.isPending}
|
||
className="bg-navy hover:bg-navy-soft text-parchment"
|
||
>
|
||
{mutate.isPending ? "שומר…" : "שמור שינויים"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|