fix(case-page): resolve full-page review findings (bugs + RTL + resilience)
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>
This commit is contained in:
@@ -39,14 +39,16 @@ export default function CaseDetailPage({
|
||||
params: Promise<{ caseNumber: string }>;
|
||||
}) {
|
||||
const { caseNumber } = use(params);
|
||||
const { data, isPending, error } = useCase(caseNumber);
|
||||
const { data, isPending, error, refetch } = useCase(caseNumber);
|
||||
const startWorkflow = useStartWorkflow(caseNumber);
|
||||
const canStartWorkflow = data?.status === "new" || data?.status === "documents_ready";
|
||||
const expectedOutcomeLabel = data?.expected_outcome
|
||||
? EXPECTED_OUTCOME_LABELS[data.expected_outcome] ?? data.expected_outcome
|
||||
: null;
|
||||
|
||||
if (error) {
|
||||
// Only take over the whole page when there is NO data to show. A transient
|
||||
// 5xx on the 5s background refetch must not blow away an already-loaded page.
|
||||
if (error && !data) {
|
||||
return (
|
||||
<AppShell>
|
||||
<section className="space-y-6">
|
||||
@@ -54,9 +56,14 @@ export default function CaseDetailPage({
|
||||
<CardContent className="px-6 py-6 text-center space-y-3">
|
||||
<p className="text-danger font-semibold">שגיאה בטעינת התיק</p>
|
||||
<p className="text-sm text-ink-muted">{error.message}</p>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/">חזרה לרשימת התיקים</Link>
|
||||
</Button>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button variant="outline" onClick={() => refetch()}>
|
||||
נסה שוב
|
||||
</Button>
|
||||
<Button asChild variant="ghost">
|
||||
<Link href="/">חזרה לרשימת התיקים</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { useRef, useState, useEffect, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -151,7 +151,7 @@ function CommentCard({
|
||||
{identifier}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-[11px] text-ink-faint mr-auto flex items-center gap-1">
|
||||
<span className="text-[11px] text-ink-faint me-auto flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{timeAgo(comment.created_at)}
|
||||
</span>
|
||||
@@ -267,7 +267,7 @@ function AskUserQuestionsForm({
|
||||
<fieldset key={q.id} className="space-y-2">
|
||||
<legend className="text-sm font-semibold text-navy mb-1">
|
||||
{q.prompt}
|
||||
{(q.required ?? true) && <span className="text-rose-600 mr-1">*</span>}
|
||||
{(q.required ?? true) && <span className="text-rose-600 ms-1">*</span>}
|
||||
</legend>
|
||||
<div className="space-y-1.5">
|
||||
{q.options.map((opt) => {
|
||||
@@ -316,7 +316,7 @@ function AskUserQuestionsForm({
|
||||
{pending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-4 h-4 ml-1" />
|
||||
<Send className="w-4 h-4 me-1" />
|
||||
)}
|
||||
{interaction.payload.submitLabel || "שלח תשובה"}
|
||||
</Button>
|
||||
@@ -397,14 +397,14 @@ function RequestConfirmationForm({
|
||||
onClick={handleReject}
|
||||
disabled={pending || (requireReason && !reason.trim())}
|
||||
>
|
||||
<XCircle className="w-4 h-4 ml-1" />
|
||||
<XCircle className="w-4 h-4 me-1" />
|
||||
{rejectLabel}
|
||||
</Button>
|
||||
<Button size="sm" onClick={onAccept} disabled={pending}>
|
||||
{pending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-4 h-4 ml-1" />
|
||||
<CheckCircle2 className="w-4 h-4 me-1" />
|
||||
)}
|
||||
{acceptLabel}
|
||||
</Button>
|
||||
@@ -489,7 +489,7 @@ function SuggestTasksForm({
|
||||
onClick={() => (showReason ? onReject(reason.trim()) : setShowReason(true))}
|
||||
disabled={pending}
|
||||
>
|
||||
<XCircle className="w-4 h-4 ml-1" />
|
||||
<XCircle className="w-4 h-4 me-1" />
|
||||
{showReason ? "אישור דחייה" : "דחייה"}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -500,7 +500,7 @@ function SuggestTasksForm({
|
||||
{pending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-4 h-4 ml-1" />
|
||||
<CheckCircle2 className="w-4 h-4 me-1" />
|
||||
)}
|
||||
אישור משימות נבחרות ({selected.size})
|
||||
</Button>
|
||||
@@ -575,7 +575,7 @@ function InteractionCard({
|
||||
{identifier}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-[11px] text-ink-faint mr-auto flex items-center gap-1">
|
||||
<span className="text-[11px] text-ink-faint me-auto flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{timeAgo(interaction.resolved_at ?? interaction.created_at)}
|
||||
</span>
|
||||
@@ -635,13 +635,13 @@ export function AgentActivityFeed({
|
||||
const [body, setBody] = useState("");
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Build issue_id → identifier map
|
||||
const issueMap = new Map<string, string>();
|
||||
if (data?.issues) {
|
||||
for (const iss of data.issues) {
|
||||
issueMap.set(iss.id, iss.identifier);
|
||||
}
|
||||
}
|
||||
// Build issue_id → identifier map (memoized — the feed refetches every 10s,
|
||||
// and a fresh Map each render would defeat the child cards' memoization).
|
||||
const issueMap = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
for (const iss of data?.issues ?? []) m.set(iss.id, iss.identifier);
|
||||
return m;
|
||||
}, [data?.issues]);
|
||||
|
||||
// Auto-scroll on new comments or interactions
|
||||
const commentCount = data?.comments?.length ?? 0;
|
||||
@@ -669,7 +669,7 @@ export function AgentActivityFeed({
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-ink-faint">
|
||||
<Loader2 className="w-5 h-5 animate-spin ml-2" />
|
||||
<Loader2 className="w-5 h-5 animate-spin me-2" />
|
||||
<span>טוען פעילות סוכנים...</span>
|
||||
</div>
|
||||
);
|
||||
@@ -777,6 +777,7 @@ export function AgentActivityFeed({
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="כתוב הוראה לסוכנים..."
|
||||
aria-label="הוראה לסוכנים"
|
||||
className="min-h-[60px] resize-none text-sm"
|
||||
dir="rtl"
|
||||
onKeyDown={(e) => {
|
||||
@@ -798,7 +799,7 @@ export function AgentActivityFeed({
|
||||
{sendComment.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-4 h-4 ml-1" />
|
||||
<Send className="w-4 h-4 me-1" />
|
||||
)}
|
||||
שלח
|
||||
</Button>
|
||||
|
||||
@@ -33,7 +33,7 @@ function AgentRow({ agent }: { agent: PaperclipAgentStatus }) {
|
||||
className={`w-2 h-2 rounded-full flex-shrink-0 ${statusDot(agent.status)}`}
|
||||
/>
|
||||
<span className="text-xs text-ink truncate">{agent.name}</span>
|
||||
<span className="text-[10px] text-ink-faint mr-auto">
|
||||
<span className="text-[10px] text-ink-faint ms-auto">
|
||||
{STATUS_LABEL[agent.status] ?? agent.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { toast } from "sonner";
|
||||
@@ -54,22 +54,26 @@ export function CaseEditDialog({ data }: { data: CaseDetail }) {
|
||||
},
|
||||
});
|
||||
|
||||
/* Re-sync the form when the underlying case refetches after save */
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
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 ?? "ערר",
|
||||
});
|
||||
}, [open, data, form]);
|
||||
/* 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 {
|
||||
@@ -82,7 +86,7 @@ export function CaseEditDialog({ data }: { data: CaseDetail }) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
עריכת פרטי תיק
|
||||
|
||||
@@ -56,7 +56,7 @@ export function DecisionBlocksPanel({ caseNumber }: { caseNumber: string }) {
|
||||
}
|
||||
if (!data) return null;
|
||||
|
||||
const written = data.blocks.filter((b) => b.word_count > 0).length;
|
||||
const written = data.blocks.filter((b) => (b.word_count ?? 0) > 0).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -101,11 +101,11 @@ export function DecisionBlocksPanel({ caseNumber }: { caseNumber: string }) {
|
||||
{blockLabel(block)}
|
||||
</span>
|
||||
<Badge
|
||||
className={`text-[0.65rem] border ${STATUS_CLASSES[block.status]}`}
|
||||
className={`text-[0.65rem] border ${STATUS_CLASSES[block.status] ?? ""}`}
|
||||
>
|
||||
{STATUS_LABELS[block.status]}
|
||||
{STATUS_LABELS[block.status] ?? block.status}
|
||||
</Badge>
|
||||
{block.word_count > 0 && (
|
||||
{(block.word_count ?? 0) > 0 && (
|
||||
<span className="text-[0.7rem] text-ink-muted tabular-nums">
|
||||
{block.word_count} מילים
|
||||
</span>
|
||||
@@ -137,19 +137,22 @@ function BlockEditor({
|
||||
caseNumber: string;
|
||||
block: DecisionBlock;
|
||||
}) {
|
||||
// The endpoint has no FastAPI response model, so `content` may arrive null
|
||||
// for an empty block — coerce to "" to keep `.trim()` / state safe.
|
||||
const content = block.content ?? "";
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [value, setValue] = useState(block.content);
|
||||
const [value, setValue] = useState(content);
|
||||
const [state, setState] = useState<SaveState>({ kind: "idle" });
|
||||
/* The last content known to be persisted — used to skip no-op saves. */
|
||||
const [baseline, setBaseline] = useState(block.content);
|
||||
const [baseline, setBaseline] = useState(content);
|
||||
const save = useSaveBlock(caseNumber);
|
||||
|
||||
/* Re-sync when the upstream query refetches (e.g. after another save) while
|
||||
* not actively editing. Adjusting state during render — the documented React
|
||||
* pattern for derived-from-props — avoids a setState-in-effect cascade. */
|
||||
if (!editing && block.content !== baseline) {
|
||||
setBaseline(block.content);
|
||||
setValue(block.content);
|
||||
if (!editing && content !== baseline) {
|
||||
setBaseline(content);
|
||||
setValue(content);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
@@ -171,8 +174,8 @@ function BlockEditor({
|
||||
if (!editing) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{block.content.trim() ? (
|
||||
<Markdown content={block.content} />
|
||||
{content.trim() ? (
|
||||
<Markdown content={content} />
|
||||
) : (
|
||||
<p className="text-sm text-ink-muted italic">בלוק ריק.</p>
|
||||
)}
|
||||
|
||||
@@ -90,13 +90,23 @@ export function DocumentTypeEditor({
|
||||
// clear it so it doesn't dangle confusingly in metadata.
|
||||
if (!isAppraisal && appraiserSide) body.appraiser_side = "";
|
||||
|
||||
await patch.mutateAsync({ docId, patch: body });
|
||||
setSaved(true);
|
||||
// Swallow the rejection — errors surface via `patch.isError`; an unhandled
|
||||
// rejection from this async click handler would otherwise leak.
|
||||
try {
|
||||
await patch.mutateAsync({ docId, patch: body });
|
||||
setSaved(true);
|
||||
} catch {
|
||||
/* surfaced via patch.isError */
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExtract() {
|
||||
const result = await extract.mutateAsync();
|
||||
setExtractResult(result);
|
||||
try {
|
||||
const result = await extract.mutateAsync();
|
||||
setExtractResult(result);
|
||||
} catch {
|
||||
/* surfaced via extract.isError */
|
||||
}
|
||||
}
|
||||
|
||||
// Build the on-badge label: "שומה · שמאי הוועדה" when both present.
|
||||
@@ -301,7 +311,7 @@ function PostSaveView({
|
||||
נותרו {extractResult.missing.length} שומות ללא תיוג צד. תייג אותן
|
||||
לפני הפעלת החילוץ:
|
||||
</p>
|
||||
<ul className="list-disc pr-4 space-y-0.5">
|
||||
<ul className="list-disc ps-4 space-y-0.5">
|
||||
{extractResult.missing.map((m) => (
|
||||
<li key={m.document_id} className="truncate">
|
||||
{m.title}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -344,9 +345,9 @@ export function DraftsPanel({
|
||||
</Button>
|
||||
<span className="text-[0.7rem] text-ink-muted">
|
||||
סטטוס הריצה — בדף{" "}
|
||||
<a href="/operations" className="underline">
|
||||
<Link href="/operations" className="underline">
|
||||
התפעול
|
||||
</a>
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -628,7 +629,7 @@ function CitationsSection({ caseNumber }: { caseNumber: string }) {
|
||||
|
||||
<div className="rounded-lg border border-rule overflow-hidden divide-y divide-rule">
|
||||
{data.linked.map((c) => (
|
||||
<a
|
||||
<Link
|
||||
key={`l-${c.citation}`}
|
||||
href={`/precedents/${c.cited_id}`}
|
||||
className="flex items-center gap-2 px-4 py-2.5 text-sm hover:bg-rule-soft/20"
|
||||
@@ -641,7 +642,7 @@ function CitationsSection({ caseNumber }: { caseNumber: string }) {
|
||||
<Badge className="ms-auto bg-success-bg text-success border-success/40 text-[0.65rem] shrink-0">
|
||||
בספרייה
|
||||
</Badge>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
{data.missing.map((c) => (
|
||||
<div
|
||||
|
||||
@@ -25,14 +25,20 @@ export function StatusChanger({
|
||||
caseNumber: string;
|
||||
currentStatus?: CaseStatus;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<CaseStatus | "">(currentStatus ?? "");
|
||||
// `null` = untouched → the dropdown tracks the live `currentStatus` (which
|
||||
// arrives async and changes on the 5s poll / external updates). Only an
|
||||
// explicit pick overrides it, until save resets back to tracking.
|
||||
const [picked, setPicked] = useState<CaseStatus | null>(null);
|
||||
const mutate = useUpdateCase(caseNumber);
|
||||
const effective: CaseStatus | "" = picked ?? currentStatus ?? "";
|
||||
const canSave = Boolean(effective) && effective !== currentStatus;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selected || selected === currentStatus) return;
|
||||
if (!canSave || !effective) return;
|
||||
try {
|
||||
await mutate.mutateAsync({ status: selected });
|
||||
toast.success(`הסטטוס עודכן ל${STATUS_LABELS[selected]}`);
|
||||
await mutate.mutateAsync({ status: effective });
|
||||
toast.success(`הסטטוס עודכן ל${STATUS_LABELS[effective]}`);
|
||||
setPicked(null);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "שגיאה בעדכון הסטטוס");
|
||||
}
|
||||
@@ -43,8 +49,8 @@ export function StatusChanger({
|
||||
<label className="text-[0.72rem] text-ink-muted block">שינוי סטטוס ידני</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={selected || "__current__"}
|
||||
onValueChange={(v) => setSelected(v === "__current__" ? "" : v as CaseStatus)}
|
||||
value={effective || "__current__"}
|
||||
onValueChange={(v) => setPicked(v === "__current__" ? null : v as CaseStatus)}
|
||||
dir="rtl"
|
||||
>
|
||||
<SelectTrigger className="text-[0.75rem] h-8">
|
||||
@@ -68,7 +74,7 @@ export function StatusChanger({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-[0.72rem] px-3 shrink-0"
|
||||
disabled={!selected || selected === currentStatus || mutate.isPending}
|
||||
disabled={!canSave || mutate.isPending}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{mutate.isPending ? "שומר…" : "עדכן"}
|
||||
|
||||
@@ -43,7 +43,9 @@ function statusLabel(event: ProgressEvent | null): string {
|
||||
if (event.status === "processing")
|
||||
return event.step ? `בעיבוד · ${event.step}` : "בעיבוד";
|
||||
if (event.status === "completed") return "הושלם";
|
||||
if (event.status === "unknown") return "הושלם";
|
||||
// TTL expired / subscribed too late — the outcome is genuinely unknown, not
|
||||
// a confirmed success. The case-detail refetch is the real source of truth.
|
||||
if (event.status === "unknown") return "הסתיים — רענן לאישור";
|
||||
if (event.status === "failed") return event.error ?? "נכשל";
|
||||
return event.status;
|
||||
}
|
||||
@@ -62,7 +64,9 @@ function UploadRowView({ row, caseNumber }: { row: UploadRow; caseNumber: string
|
||||
const progress = useProgress(row.taskId, caseNumber);
|
||||
const pct = row.error ? 100 : progressPercent(progress);
|
||||
const failed = row.error || progress?.status === "failed";
|
||||
const done = progress?.status === "completed" || progress?.status === "unknown";
|
||||
const done = progress?.status === "completed";
|
||||
// `unknown` = settled-but-indeterminate; render neutral, not green success.
|
||||
const indeterminate = progress?.status === "unknown";
|
||||
|
||||
return (
|
||||
<li className="rounded-lg border border-rule bg-parchment/40 px-4 py-3 space-y-2">
|
||||
@@ -71,6 +75,8 @@ function UploadRowView({ row, caseNumber }: { row: UploadRow; caseNumber: string
|
||||
<CheckCircle2 className="w-4 h-4 text-success shrink-0" />
|
||||
) : failed ? (
|
||||
<XCircle className="w-4 h-4 text-danger shrink-0" />
|
||||
) : indeterminate ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-ink-muted shrink-0" />
|
||||
) : (
|
||||
<Loader2 className="w-4 h-4 text-gold animate-spin shrink-0" />
|
||||
)}
|
||||
|
||||
@@ -176,14 +176,16 @@ export function useUpdateCase(caseNumber: string | undefined) {
|
||||
body: input,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
/* Patch cached detail and nudge the list to refetch on next focus */
|
||||
/* Patch cached detail, then invalidate only the LIST queries — not
|
||||
* casesKeys.all, which would also invalidate the detail we just patched
|
||||
* and throw away the optimistic merge. */
|
||||
if (caseNumber) {
|
||||
qc.setQueryData<CaseDetail | undefined>(
|
||||
casesKeys.detail(caseNumber),
|
||||
(prev) => (prev ? { ...prev, ...data } : prev),
|
||||
);
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: casesKeys.all });
|
||||
qc.invalidateQueries({ queryKey: [...casesKeys.all, "list"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import { apiRequest } from "./client";
|
||||
export type LinkedCitation = {
|
||||
citation: string;
|
||||
cited_id: string;
|
||||
case_name: string;
|
||||
/** May be null/empty — the panel guards with `c.case_name && …`. */
|
||||
case_name: string | null;
|
||||
court: string;
|
||||
precedent_level: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user