Files
legal-ai/web-ui/src/components/compose/precedent-attacher.tsx
Chaim 6b8f002596 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>
2026-04-11 19:20:45 +00:00

230 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import { Plus, Paperclip } from "lucide-react";
import { toast } from "sonner";
import {
Popover, PopoverContent, PopoverTrigger,
} from "@/components/ui/popover";
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 {
useCreatePrecedent,
usePrecedentLibrarySearch,
uploadPrecedentPdf,
} from "@/lib/api/precedents";
import type { PracticeArea } from "@/lib/practice-area";
/*
* Inline form for adding a new precedent. Opens in a Popover adjacent
* to the trigger button so the user can see the surrounding context
* (the threshold_claim body, the chair editor) while they fill it in.
*
* The citation field has cross-case typeahead: once the user types
* 2+ characters, we hit /api/precedents/search and show distinct
* matches. Picking one prefills quote + chair_note but keeps them
* editable — the new row is a copy, so a customized quote for this
* case doesn't affect the library.
*/
export function PrecedentAttacher({
caseNumber,
sectionId,
practiceArea,
}: {
caseNumber: string;
sectionId: string | null;
practiceArea: PracticeArea | null | undefined;
}) {
const [open, setOpen] = useState(false);
const [citation, setCitation] = useState("");
const [quote, setQuote] = useState("");
const [chairNote, setChairNote] = useState("");
const [pdfFile, setPdfFile] = useState<File | null>(null);
const [submitting, setSubmitting] = useState(false);
const [picked, setPicked] = useState(false);
const create = useCreatePrecedent(caseNumber);
const library = usePrecedentLibrarySearch(
citation,
practiceArea,
/* pause typeahead once the user has picked one and we're just editing */
!picked,
);
const reset = () => {
setCitation("");
setQuote("");
setChairNote("");
setPdfFile(null);
setPicked(false);
};
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!quote.trim() || !citation.trim()) {
toast.error("ציטוט ומראה-מקום חובה");
return;
}
setSubmitting(true);
try {
let pdfDocumentId: string | undefined;
if (pdfFile) {
const res = await uploadPrecedentPdf(caseNumber, pdfFile);
pdfDocumentId = res.document_id;
}
await create.mutateAsync({
quote: quote.trim(),
citation: citation.trim(),
chair_note: chairNote.trim(),
section_id: sectionId ?? undefined,
pdf_document_id: pdfDocumentId,
});
toast.success("נוספה פסיקה");
reset();
setOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : "שגיאה בשמירה");
} finally {
setSubmitting(false);
}
};
return (
<Popover open={open} onOpenChange={(v) => { setOpen(v); if (!v) reset(); }}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="border-dashed border-gold/50 text-gold-deep hover:bg-gold-wash"
>
<Plus className="w-4 h-4 me-1" aria-hidden="true" />
הוסף פסיקה תומכת
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[520px] max-w-[90vw] p-5"
align="start"
dir="rtl"
>
<form onSubmit={onSubmit} className="space-y-3" dir="rtl">
<div>
<Label htmlFor="prec-citation" className="text-navy">
מראה מקום <span className="text-danger">*</span>
</Label>
<Input
id="prec-citation"
value={citation}
onChange={(e) => {
setCitation(e.target.value);
setPicked(false);
}}
placeholder="ערר (ירושלים) 1126-08-25 ... נ' ... (נבו 9.3.2026)"
autoComplete="off"
className="mt-1"
/>
{!picked && library.data && library.data.length > 0 && citation.length >= 2 && (
<ul
className="
mt-1 rounded border border-rule bg-surface shadow-sm
max-h-44 overflow-y-auto divide-y divide-rule
"
role="listbox"
>
{library.data.map((m) => (
<li key={m.id}>
<button
type="button"
onClick={() => {
setCitation(m.citation);
setQuote(m.quote);
setChairNote(m.chair_note || "");
setPicked(true);
}}
className="w-full text-right px-3 py-2 hover:bg-gold-wash/60 transition-colors"
>
<div className="text-[0.78rem] text-gold-deep font-semibold truncate">
{m.citation}
</div>
<div className="text-[0.72rem] text-ink-muted truncate">
{m.quote}
</div>
</button>
</li>
))}
</ul>
)}
</div>
<div>
<Label htmlFor="prec-quote" className="text-navy">
ציטוט <span className="text-danger">*</span>
</Label>
<Textarea
id="prec-quote"
value={quote}
onChange={(e) => setQuote(e.target.value)}
rows={5}
placeholder="הטקסט המדויק שישולב בהחלטה"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="prec-note" className="text-navy">
הערה (אופציונלי)
</Label>
<Textarea
id="prec-note"
value={chairNote}
onChange={(e) => setChairNote(e.target.value)}
rows={2}
placeholder="למה הציטוט הזה תומך בעמדה"
className="mt-1"
/>
</div>
<div>
<Label className="text-navy flex items-center gap-2">
<Paperclip className="w-3.5 h-3.5" aria-hidden="true" />
צירוף קובץ המקור (אופציונלי, לארכיון)
</Label>
<input
type="file"
accept=".pdf,.docx,.doc"
onChange={(e) => setPdfFile(e.target.files?.[0] ?? null)}
className="mt-1 w-full text-sm file:me-3 file:rounded file:border-0 file:bg-rule-soft file:px-3 file:py-1.5 file:text-navy file:text-sm hover:file:bg-rule"
/>
{pdfFile && (
<p className="text-[0.72rem] text-ink-muted mt-1">
{pdfFile.name} · {(pdfFile.size / 1024).toFixed(1)} KB
</p>
)}
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<Button
type="button"
variant="ghost"
onClick={() => { setOpen(false); reset(); }}
disabled={submitting}
>
ביטול
</Button>
<Button
type="submit"
disabled={submitting}
className="bg-navy hover:bg-navy-soft text-parchment"
>
{submitting ? "שומר…" : "שמור פסיקה"}
</Button>
</div>
</form>
</PopoverContent>
</Popover>
);
}