fix(case-page): resolve full-page review findings (bugs + RTL + resilience) #329

Merged
chaim merged 1 commits from worktree-case-page-fixes into main 2026-06-20 18:58:21 +00:00
11 changed files with 115 additions and 74 deletions

View File

@@ -39,14 +39,16 @@ export default function CaseDetailPage({
params: Promise<{ caseNumber: string }>; params: Promise<{ caseNumber: string }>;
}) { }) {
const { caseNumber } = use(params); const { caseNumber } = use(params);
const { data, isPending, error } = useCase(caseNumber); const { data, isPending, error, refetch } = useCase(caseNumber);
const startWorkflow = useStartWorkflow(caseNumber); const startWorkflow = useStartWorkflow(caseNumber);
const canStartWorkflow = data?.status === "new" || data?.status === "documents_ready"; const canStartWorkflow = data?.status === "new" || data?.status === "documents_ready";
const expectedOutcomeLabel = data?.expected_outcome const expectedOutcomeLabel = data?.expected_outcome
? EXPECTED_OUTCOME_LABELS[data.expected_outcome] ?? data.expected_outcome ? EXPECTED_OUTCOME_LABELS[data.expected_outcome] ?? data.expected_outcome
: null; : 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 ( return (
<AppShell> <AppShell>
<section className="space-y-6"> <section className="space-y-6">
@@ -54,9 +56,14 @@ export default function CaseDetailPage({
<CardContent className="px-6 py-6 text-center space-y-3"> <CardContent className="px-6 py-6 text-center space-y-3">
<p className="text-danger font-semibold">שגיאה בטעינת התיק</p> <p className="text-danger font-semibold">שגיאה בטעינת התיק</p>
<p className="text-sm text-ink-muted">{error.message}</p> <p className="text-sm text-ink-muted">{error.message}</p>
<Button asChild variant="outline"> <div className="flex items-center justify-center gap-2">
<Link href="/">חזרה לרשימת התיקים</Link> <Button variant="outline" onClick={() => refetch()}>
</Button> נסה שוב
</Button>
<Button asChild variant="ghost">
<Link href="/">חזרה לרשימת התיקים</Link>
</Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
</section> </section>

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useRef, useState, useEffect } from "react"; import { useRef, useState, useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -151,7 +151,7 @@ function CommentCard({
{identifier} {identifier}
</Badge> </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" /> <Clock className="w-3 h-3" />
{timeAgo(comment.created_at)} {timeAgo(comment.created_at)}
</span> </span>
@@ -267,7 +267,7 @@ function AskUserQuestionsForm({
<fieldset key={q.id} className="space-y-2"> <fieldset key={q.id} className="space-y-2">
<legend className="text-sm font-semibold text-navy mb-1"> <legend className="text-sm font-semibold text-navy mb-1">
{q.prompt} {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> </legend>
<div className="space-y-1.5"> <div className="space-y-1.5">
{q.options.map((opt) => { {q.options.map((opt) => {
@@ -316,7 +316,7 @@ function AskUserQuestionsForm({
{pending ? ( {pending ? (
<Loader2 className="w-4 h-4 animate-spin" /> <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 || "שלח תשובה"} {interaction.payload.submitLabel || "שלח תשובה"}
</Button> </Button>
@@ -397,14 +397,14 @@ function RequestConfirmationForm({
onClick={handleReject} onClick={handleReject}
disabled={pending || (requireReason && !reason.trim())} disabled={pending || (requireReason && !reason.trim())}
> >
<XCircle className="w-4 h-4 ml-1" /> <XCircle className="w-4 h-4 me-1" />
{rejectLabel} {rejectLabel}
</Button> </Button>
<Button size="sm" onClick={onAccept} disabled={pending}> <Button size="sm" onClick={onAccept} disabled={pending}>
{pending ? ( {pending ? (
<Loader2 className="w-4 h-4 animate-spin" /> <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} {acceptLabel}
</Button> </Button>
@@ -489,7 +489,7 @@ function SuggestTasksForm({
onClick={() => (showReason ? onReject(reason.trim()) : setShowReason(true))} onClick={() => (showReason ? onReject(reason.trim()) : setShowReason(true))}
disabled={pending} disabled={pending}
> >
<XCircle className="w-4 h-4 ml-1" /> <XCircle className="w-4 h-4 me-1" />
{showReason ? "אישור דחייה" : "דחייה"} {showReason ? "אישור דחייה" : "דחייה"}
</Button> </Button>
<Button <Button
@@ -500,7 +500,7 @@ function SuggestTasksForm({
{pending ? ( {pending ? (
<Loader2 className="w-4 h-4 animate-spin" /> <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}) אישור משימות נבחרות ({selected.size})
</Button> </Button>
@@ -575,7 +575,7 @@ function InteractionCard({
{identifier} {identifier}
</Badge> </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" /> <Clock className="w-3 h-3" />
{timeAgo(interaction.resolved_at ?? interaction.created_at)} {timeAgo(interaction.resolved_at ?? interaction.created_at)}
</span> </span>
@@ -635,13 +635,13 @@ export function AgentActivityFeed({
const [body, setBody] = useState(""); const [body, setBody] = useState("");
const endRef = useRef<HTMLDivElement>(null); const endRef = useRef<HTMLDivElement>(null);
// Build issue_id → identifier map // Build issue_id → identifier map (memoized — the feed refetches every 10s,
const issueMap = new Map<string, string>(); // and a fresh Map each render would defeat the child cards' memoization).
if (data?.issues) { const issueMap = useMemo(() => {
for (const iss of data.issues) { const m = new Map<string, string>();
issueMap.set(iss.id, iss.identifier); for (const iss of data?.issues ?? []) m.set(iss.id, iss.identifier);
} return m;
} }, [data?.issues]);
// Auto-scroll on new comments or interactions // Auto-scroll on new comments or interactions
const commentCount = data?.comments?.length ?? 0; const commentCount = data?.comments?.length ?? 0;
@@ -669,7 +669,7 @@ export function AgentActivityFeed({
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center py-12 text-ink-faint"> <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> <span>טוען פעילות סוכנים...</span>
</div> </div>
); );
@@ -777,6 +777,7 @@ export function AgentActivityFeed({
value={body} value={body}
onChange={(e) => setBody(e.target.value)} onChange={(e) => setBody(e.target.value)}
placeholder="כתוב הוראה לסוכנים..." placeholder="כתוב הוראה לסוכנים..."
aria-label="הוראה לסוכנים"
className="min-h-[60px] resize-none text-sm" className="min-h-[60px] resize-none text-sm"
dir="rtl" dir="rtl"
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -798,7 +799,7 @@ export function AgentActivityFeed({
{sendComment.isPending ? ( {sendComment.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" /> <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> </Button>

View File

@@ -33,7 +33,7 @@ function AgentRow({ agent }: { agent: PaperclipAgentStatus }) {
className={`w-2 h-2 rounded-full flex-shrink-0 ${statusDot(agent.status)}`} 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-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} {STATUS_LABEL[agent.status] ?? agent.status}
</span> </span>
</div> </div>

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useState } from "react";
import { useForm, Controller } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner"; 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 */ /* Reset to the latest case values only on the open→true transition.
useEffect(() => { * Resetting on every `data` change would clobber in-progress edits, because
if (!open) return; * useCase refetches every 5s (refetchInterval) while the dialog is open. */
form.reset({ const handleOpenChange = (next: boolean) => {
title: data.title ?? "", if (next) {
subject: data.subject ?? "", form.reset({
hearing_date: data.hearing_date ?? "", title: data.title ?? "",
notes: "", subject: data.subject ?? "",
expected_outcome: data.expected_outcome ?? "", hearing_date: data.hearing_date ?? "",
appellants: data.appellants ?? [], notes: "",
respondents: data.respondents ?? [], expected_outcome: data.expected_outcome ?? "",
property_address: data.property_address ?? "", appellants: data.appellants ?? [],
permit_number: data.permit_number ?? "", respondents: data.respondents ?? [],
proceeding_type: data.proceeding_type ?? "ערר", property_address: data.property_address ?? "",
}); permit_number: data.permit_number ?? "",
}, [open, data, form]); proceeding_type: data.proceeding_type ?? "ערר",
});
}
setOpen(next);
};
const onSubmit = form.handleSubmit(async (values) => { const onSubmit = form.handleSubmit(async (values) => {
try { try {
@@ -82,7 +86,7 @@ export function CaseEditDialog({ data }: { data: CaseDetail }) {
}); });
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
עריכת פרטי תיק עריכת פרטי תיק

View File

@@ -56,7 +56,7 @@ export function DecisionBlocksPanel({ caseNumber }: { caseNumber: string }) {
} }
if (!data) return null; 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -101,11 +101,11 @@ export function DecisionBlocksPanel({ caseNumber }: { caseNumber: string }) {
{blockLabel(block)} {blockLabel(block)}
</span> </span>
<Badge <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> </Badge>
{block.word_count > 0 && ( {(block.word_count ?? 0) > 0 && (
<span className="text-[0.7rem] text-ink-muted tabular-nums"> <span className="text-[0.7rem] text-ink-muted tabular-nums">
{block.word_count} מילים {block.word_count} מילים
</span> </span>
@@ -137,19 +137,22 @@ function BlockEditor({
caseNumber: string; caseNumber: string;
block: DecisionBlock; 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 [editing, setEditing] = useState(false);
const [value, setValue] = useState(block.content); const [value, setValue] = useState(content);
const [state, setState] = useState<SaveState>({ kind: "idle" }); const [state, setState] = useState<SaveState>({ kind: "idle" });
/* The last content known to be persisted — used to skip no-op saves. */ /* 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); const save = useSaveBlock(caseNumber);
/* Re-sync when the upstream query refetches (e.g. after another save) while /* Re-sync when the upstream query refetches (e.g. after another save) while
* not actively editing. Adjusting state during render — the documented React * not actively editing. Adjusting state during render — the documented React
* pattern for derived-from-props — avoids a setState-in-effect cascade. */ * pattern for derived-from-props — avoids a setState-in-effect cascade. */
if (!editing && block.content !== baseline) { if (!editing && content !== baseline) {
setBaseline(block.content); setBaseline(content);
setValue(block.content); setValue(content);
} }
async function handleSave() { async function handleSave() {
@@ -171,8 +174,8 @@ function BlockEditor({
if (!editing) { if (!editing) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{block.content.trim() ? ( {content.trim() ? (
<Markdown content={block.content} /> <Markdown content={content} />
) : ( ) : (
<p className="text-sm text-ink-muted italic">בלוק ריק.</p> <p className="text-sm text-ink-muted italic">בלוק ריק.</p>
)} )}

View File

@@ -90,13 +90,23 @@ export function DocumentTypeEditor({
// clear it so it doesn't dangle confusingly in metadata. // clear it so it doesn't dangle confusingly in metadata.
if (!isAppraisal && appraiserSide) body.appraiser_side = ""; if (!isAppraisal && appraiserSide) body.appraiser_side = "";
await patch.mutateAsync({ docId, patch: body }); // Swallow the rejection — errors surface via `patch.isError`; an unhandled
setSaved(true); // 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() { async function handleExtract() {
const result = await extract.mutateAsync(); try {
setExtractResult(result); const result = await extract.mutateAsync();
setExtractResult(result);
} catch {
/* surfaced via extract.isError */
}
} }
// Build the on-badge label: "שומה · שמאי הוועדה" when both present. // Build the on-badge label: "שומה · שמאי הוועדה" when both present.
@@ -301,7 +311,7 @@ function PostSaveView({
נותרו {extractResult.missing.length} שומות ללא תיוג צד. תייג אותן נותרו {extractResult.missing.length} שומות ללא תיוג צד. תייג אותן
לפני הפעלת החילוץ: לפני הפעלת החילוץ:
</p> </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) => ( {extractResult.missing.map((m) => (
<li key={m.document_id} className="truncate"> <li key={m.document_id} className="truncate">
{m.title} {m.title}

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import Link from "next/link";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -344,9 +345,9 @@ export function DraftsPanel({
</Button> </Button>
<span className="text-[0.7rem] text-ink-muted"> <span className="text-[0.7rem] text-ink-muted">
סטטוס הריצה בדף{" "} סטטוס הריצה בדף{" "}
<a href="/operations" className="underline"> <Link href="/operations" className="underline">
התפעול התפעול
</a> </Link>
</span> </span>
</div> </div>
)} )}
@@ -628,7 +629,7 @@ function CitationsSection({ caseNumber }: { caseNumber: string }) {
<div className="rounded-lg border border-rule overflow-hidden divide-y divide-rule"> <div className="rounded-lg border border-rule overflow-hidden divide-y divide-rule">
{data.linked.map((c) => ( {data.linked.map((c) => (
<a <Link
key={`l-${c.citation}`} key={`l-${c.citation}`}
href={`/precedents/${c.cited_id}`} href={`/precedents/${c.cited_id}`}
className="flex items-center gap-2 px-4 py-2.5 text-sm hover:bg-rule-soft/20" 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 className="ms-auto bg-success-bg text-success border-success/40 text-[0.65rem] shrink-0">
בספרייה בספרייה
</Badge> </Badge>
</a> </Link>
))} ))}
{data.missing.map((c) => ( {data.missing.map((c) => (
<div <div

View File

@@ -25,14 +25,20 @@ export function StatusChanger({
caseNumber: string; caseNumber: string;
currentStatus?: CaseStatus; 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 mutate = useUpdateCase(caseNumber);
const effective: CaseStatus | "" = picked ?? currentStatus ?? "";
const canSave = Boolean(effective) && effective !== currentStatus;
const handleSave = async () => { const handleSave = async () => {
if (!selected || selected === currentStatus) return; if (!canSave || !effective) return;
try { try {
await mutate.mutateAsync({ status: selected }); await mutate.mutateAsync({ status: effective });
toast.success(`הסטטוס עודכן ל${STATUS_LABELS[selected]}`); toast.success(`הסטטוס עודכן ל${STATUS_LABELS[effective]}`);
setPicked(null);
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה בעדכון הסטטוס"); toast.error(e instanceof Error ? e.message : "שגיאה בעדכון הסטטוס");
} }
@@ -43,8 +49,8 @@ export function StatusChanger({
<label className="text-[0.72rem] text-ink-muted block">שינוי סטטוס ידני</label> <label className="text-[0.72rem] text-ink-muted block">שינוי סטטוס ידני</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Select <Select
value={selected || "__current__"} value={effective || "__current__"}
onValueChange={(v) => setSelected(v === "__current__" ? "" : v as CaseStatus)} onValueChange={(v) => setPicked(v === "__current__" ? null : v as CaseStatus)}
dir="rtl" dir="rtl"
> >
<SelectTrigger className="text-[0.75rem] h-8"> <SelectTrigger className="text-[0.75rem] h-8">
@@ -68,7 +74,7 @@ export function StatusChanger({
size="sm" size="sm"
variant="outline" variant="outline"
className="h-8 text-[0.72rem] px-3 shrink-0" className="h-8 text-[0.72rem] px-3 shrink-0"
disabled={!selected || selected === currentStatus || mutate.isPending} disabled={!canSave || mutate.isPending}
onClick={handleSave} onClick={handleSave}
> >
{mutate.isPending ? "שומר…" : "עדכן"} {mutate.isPending ? "שומר…" : "עדכן"}

View File

@@ -43,7 +43,9 @@ function statusLabel(event: ProgressEvent | null): string {
if (event.status === "processing") if (event.status === "processing")
return event.step ? `בעיבוד · ${event.step}` : "בעיבוד"; return event.step ? `בעיבוד · ${event.step}` : "בעיבוד";
if (event.status === "completed") return "הושלם"; 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 ?? "נכשל"; if (event.status === "failed") return event.error ?? "נכשל";
return event.status; return event.status;
} }
@@ -62,7 +64,9 @@ function UploadRowView({ row, caseNumber }: { row: UploadRow; caseNumber: string
const progress = useProgress(row.taskId, caseNumber); const progress = useProgress(row.taskId, caseNumber);
const pct = row.error ? 100 : progressPercent(progress); const pct = row.error ? 100 : progressPercent(progress);
const failed = row.error || progress?.status === "failed"; 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 ( return (
<li className="rounded-lg border border-rule bg-parchment/40 px-4 py-3 space-y-2"> <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" /> <CheckCircle2 className="w-4 h-4 text-success shrink-0" />
) : failed ? ( ) : failed ? (
<XCircle className="w-4 h-4 text-danger shrink-0" /> <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" /> <Loader2 className="w-4 h-4 text-gold animate-spin shrink-0" />
)} )}

View File

@@ -176,14 +176,16 @@ export function useUpdateCase(caseNumber: string | undefined) {
body: input, body: input,
}), }),
onSuccess: (data) => { 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) { if (caseNumber) {
qc.setQueryData<CaseDetail | undefined>( qc.setQueryData<CaseDetail | undefined>(
casesKeys.detail(caseNumber), casesKeys.detail(caseNumber),
(prev) => (prev ? { ...prev, ...data } : prev), (prev) => (prev ? { ...prev, ...data } : prev),
); );
} }
qc.invalidateQueries({ queryKey: casesKeys.all }); qc.invalidateQueries({ queryKey: [...casesKeys.all, "list"] });
}, },
}); });
} }

View File

@@ -9,7 +9,8 @@ import { apiRequest } from "./client";
export type LinkedCitation = { export type LinkedCitation = {
citation: string; citation: string;
cited_id: string; cited_id: string;
case_name: string; /** May be null/empty — the panel guards with `c.case_name && …`. */
case_name: string | null;
court: string; court: string;
precedent_level: string; precedent_level: string;
}; };