Precedent attachment UI in the compose screen

Surface the new POST/GET/DELETE /api/cases/{n}/precedents endpoints
in the compose screen as two insertion points:

1. A new case-level card "פסיקה כללית לדיון" at the top of the
   main column, for precedents that support the discussion intro
   rather than a specific threshold_claim / issue.
2. An inline "פסיקה תומכת" section inside each SubsectionCard,
   below the ChairEditor.

Both insertion points render a `<PrecedentsSection>` which shows a
list of `<PrecedentCard>` (citation + blockquote + optional chair
note + 📄 chip if a PDF was archived) followed by a `<PrecedentAttacher>`
popover trigger.

The Attacher is a Popover with cross-case typeahead: typing 2+
characters into the citation field hits /api/precedents/search and
shows distinct library matches; picking one prefills quote + chair
note but leaves them editable so customizing the quote for this
case doesn't mutate the library. An optional PDF/DOCX/DOC file can
be attached — it uploads first via POST .../upload-pdf and the
returned document_id is passed into the precedent create call.

The parent compose page issues a single useCasePrecedents query
and partitions the result by section_id into a Map so each
SubsectionCard renders its own slice without re-fetching.

shadcn Popover installed as a new primitive. sonner toasts wired
for success/error in both attach and delete flows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 19:20:45 +00:00
parent aa0e608a4a
commit 6b8f002596
8 changed files with 656 additions and 2 deletions

View File

@@ -0,0 +1,140 @@
/**
* Attached-precedent hooks — user-supplied case-law quotes that
* justify chair positions in the compose screen.
*
* Backed by POST/GET/DELETE /api/cases/{n}/precedents and the
* cross-case library search at GET /api/precedents/search. The
* optional PDF archive chains through POST .../upload-pdf before
* precedent creation; that's a plain async function, not a mutation
* hook, because it has no cache invalidation of its own.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest, ApiError } from "./client";
import type { PracticeArea } from "@/lib/practice-area";
export type CasePrecedent = {
id: string;
case_id: string;
section_id: string | null;
quote: string;
citation: string;
chair_note: string;
pdf_document_id: string | null;
practice_area: PracticeArea | null;
created_at: string;
updated_at: string;
};
export type PrecedentCreateInput = {
quote: string;
citation: string;
section_id?: string;
chair_note?: string;
pdf_document_id?: string;
};
export type LibraryMatch = {
id: string;
citation: string;
quote: string;
chair_note: string;
practice_area: PracticeArea | null;
created_at: string;
};
export const precedentKeys = {
all: ["precedents"] as const,
forCase: (caseNumber: string) =>
[...precedentKeys.all, "case", caseNumber] as const,
librarySearch: (q: string, area: string) =>
[...precedentKeys.all, "library", area, q] as const,
};
export function useCasePrecedents(caseNumber: string | undefined) {
return useQuery({
queryKey: precedentKeys.forCase(caseNumber ?? ""),
queryFn: ({ signal }) =>
apiRequest<CasePrecedent[]>(
`/api/cases/${caseNumber}/precedents`,
{ signal },
),
enabled: Boolean(caseNumber),
staleTime: 30_000,
});
}
export function useCreatePrecedent(caseNumber: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: PrecedentCreateInput) =>
apiRequest<CasePrecedent>(`/api/cases/${caseNumber}/precedents`, {
method: "POST",
body: input,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: precedentKeys.forCase(caseNumber) });
qc.invalidateQueries({ queryKey: [...precedentKeys.all, "library"] });
},
});
}
export function useDeletePrecedent(caseNumber: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (precedentId: string) =>
apiRequest<{ deleted: boolean }>(`/api/precedents/${precedentId}`, {
method: "DELETE",
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: precedentKeys.forCase(caseNumber) });
},
});
}
export function usePrecedentLibrarySearch(
query: string,
practiceArea: PracticeArea | null | undefined,
enabled: boolean,
) {
return useQuery({
queryKey: precedentKeys.librarySearch(query, practiceArea ?? ""),
queryFn: ({ signal }) => {
const params = new URLSearchParams({ q: query });
if (practiceArea) params.set("practice_area", practiceArea);
return apiRequest<LibraryMatch[]>(
`/api/precedents/search?${params.toString()}`,
{ signal },
);
},
enabled: enabled && query.trim().length >= 2,
staleTime: 10_000,
placeholderData: (prev) => prev,
});
}
/**
* One-shot PDF archive upload. Returns the new document_id so the
* caller can pass it into useCreatePrecedent. No cache invalidation
* — we only care about the id as a handle.
*/
export async function uploadPrecedentPdf(
caseNumber: string,
file: File,
): Promise<{ document_id: string; filename: string }> {
const fd = new FormData();
fd.append("file", file);
const res = await fetch(
`/api/cases/${encodeURIComponent(caseNumber)}/precedents/upload-pdf`,
{ method: "POST", body: fd },
);
const parsed = await res.json().catch(() => null);
if (!res.ok) {
throw new ApiError(
`Upload failed with ${res.status}`,
res.status,
parsed,
);
}
return parsed as { document_id: string; filename: string };
}