From 6b8f002596a6968312058e3d2e19e2a962a5ff85 Mon Sep 17 00:00:00 2001 From: Chaim Date: Sat, 11 Apr 2026 19:20:45 +0000 Subject: [PATCH] Precedent attachment UI in the compose screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` which shows a list of `` (citation + blockquote + optional chair note + 📄 chip if a PDF was archived) followed by a `` 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) --- .taskmaster/tasks/tasks.json | 16 +- .../app/cases/[caseNumber]/compose/page.tsx | 38 +++ .../components/compose/precedent-attacher.tsx | 229 ++++++++++++++++++ .../src/components/compose/precedent-card.tsx | 77 ++++++ .../components/compose/precedents-section.tsx | 50 ++++ .../components/compose/subsection-card.tsx | 19 ++ web-ui/src/components/ui/popover.tsx | 89 +++++++ web-ui/src/lib/api/precedents.ts | 140 +++++++++++ 8 files changed, 656 insertions(+), 2 deletions(-) create mode 100644 web-ui/src/components/compose/precedent-attacher.tsx create mode 100644 web-ui/src/components/compose/precedent-card.tsx create mode 100644 web-ui/src/components/compose/precedents-section.tsx create mode 100644 web-ui/src/components/ui/popover.tsx create mode 100644 web-ui/src/lib/api/precedents.ts diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index 1e3069c..91e5f3e 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -896,12 +896,24 @@ "priority": "high", "subtasks": [], "updatedAt": "2026-04-11T17:15:57.831Z" + }, + { + "id": "91", + "title": "Precedent attachment in compose screen", + "description": "Add case_precedents table + FastAPI endpoints + MCP tools + Next.js compose UI for attaching legal precedents (quote + citation + optional archived PDF) to threshold_claims/issues and to the case as a whole. Plan: ~/.claude/plans/woolly-cooking-graham.md", + "details": "", + "testStrategy": "", + "status": "in-progress", + "dependencies": [], + "priority": "high", + "subtasks": [], + "updatedAt": "2026-04-11T19:13:41.219Z" } ], "metadata": { "version": "1.0.0", - "lastModified": "2026-04-11T17:44:08.337Z", - "taskCount": 59, + "lastModified": "2026-04-11T19:13:41.220Z", + "taskCount": 60, "completedCount": 56, "tags": [ "master" diff --git a/web-ui/src/app/cases/[caseNumber]/compose/page.tsx b/web-ui/src/app/cases/[caseNumber]/compose/page.tsx index 8d9950a..0c94382 100644 --- a/web-ui/src/app/cases/[caseNumber]/compose/page.tsx +++ b/web-ui/src/app/cases/[caseNumber]/compose/page.tsx @@ -7,8 +7,10 @@ import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { SubsectionCard } from "@/components/compose/subsection-card"; +import { PrecedentsSection } from "@/components/compose/precedents-section"; import { useCase } from "@/lib/api/cases"; import { useResearchAnalysis } from "@/lib/api/research"; +import { useCasePrecedents } from "@/lib/api/precedents"; function ProseSection({ title, content }: { title: string; content?: string }) { if (!content?.trim()) return null; @@ -32,6 +34,21 @@ export default function ComposePage({ const { caseNumber } = use(params); const caseQuery = useCase(caseNumber); const analysis = useResearchAnalysis(caseNumber); + const precedentsQuery = useCasePrecedents(caseNumber); + + /* Partition the flat list into scopes so each child renders its own slice + * without re-fetching. Done once at the page level. */ + const allPrecedents = precedentsQuery.data ?? []; + const caseLevelPrecedents = allPrecedents.filter((p) => p.section_id === null); + const precedentsBySection = new Map(); + for (const p of allPrecedents) { + if (p.section_id) { + const existing = precedentsBySection.get(p.section_id) ?? []; + existing.push(p); + precedentsBySection.set(p.section_id, existing); + } + } + const practiceArea = caseQuery.data?.practice_area ?? null; const isNotFound = analysis.error instanceof Error && @@ -101,6 +118,23 @@ export default function ComposePage({
{/* Main editable column */}
+ {/* Case-level general precedents */} + + +

פסיקה כללית לדיון

+

+ ציטוטים התומכים בעמדה באופן רוחבי — ישולבו בפתיחת בלוק י (דיון). +

+ +
+
+ {/* Threshold claims */} {analysis.data.threshold_claims && analysis.data.threshold_claims.length > 0 && ( @@ -118,6 +152,8 @@ export default function ComposePage({ caseNumber={caseNumber} item={tc} defaultOpen={i === 0} + precedents={precedentsBySection.get(tc.id) ?? []} + practiceArea={practiceArea} /> ))}
@@ -139,6 +175,8 @@ export default function ComposePage({ key={iss.id} caseNumber={caseNumber} item={iss} + precedents={precedentsBySection.get(iss.id) ?? []} + practiceArea={practiceArea} /> ))}
diff --git a/web-ui/src/components/compose/precedent-attacher.tsx b/web-ui/src/components/compose/precedent-attacher.tsx new file mode 100644 index 0000000..38a6789 --- /dev/null +++ b/web-ui/src/components/compose/precedent-attacher.tsx @@ -0,0 +1,229 @@ +"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(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 ( + { setOpen(v); if (!v) reset(); }}> + + + + +
+
+ + { + 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 && ( +
    + {library.data.map((m) => ( +
  • + +
  • + ))} +
+ )} +
+ +
+ +