feat: Stage A finalizers + #35/#36/#37 — critical-gap closure
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled

Four parallel sub-agents closed the remaining critical gaps from the
26/05 Stage A/B sprint. Each block independently tested; aggregated here.

## #30/#31 finalizers (sub-agent A)
* Auto-derive practice_area in case_create from case_number prefix
  (1xxx→rishuy_uvniya, 8xxx→betterment_levy, 9xxx→compensation_197);
  default for CaseCreateRequest is now "" (the DB constraint catches
  any stray "appeals_committee").
* practice_area.py: derive_subtype now handles axis-B domain values
  (rishuy_uvniya/betterment_levy/compensation_197) without parsing the
  case number; new helper derive_domain_practice_area().
* Halacha re-extraction verified unnecessary — all 6 reclassified
  records already had is_binding=false and approved halachot.
* Regression tests: 6 cases in tests/test_corpus_constraints.py
  covering practice_area enum, internal-committee chair/district,
  external-upload arar prefix, MCP guard.
* UI: district input → Select dropdown (7 districts) in
  precedent-edit-sheet.tsx, preserving legacy free-text values.

## #37 בל"מ subtypes (sub-agent B)
* 3 new appeal_subtypes: extension_request_{building_permit,
  betterment_levy,compensation}. APPEALS_COMMITTEE_SUBTYPES extended,
  SUBTYPES_BY_AREA mappings added.
* New helpers: is_blam_subject(), is_blam_subtype(),
  derive_subtype_with_blam(case_number, subject, practice_area).
  case_create now uses it to auto-detect "בקשה להארכת מועד" subjects.
* 3 methodology templates under docs/methodology/extension-request-*.md.
* paperclip_client.py mapping updated for the 3 new subtypes
  (extension_request_building_permit→CMP, the other two→CMPA).
* Frontend: bilingual "בל"מ" badge + filter dropdown on cases list +
  detail header; appeal-type-bars collapseBlam() merges בל"מ into its
  parent domain for aggregate bars.
* Wizard auto-detects בל"מ from subject during case creation.
* 3 Berlinger cases (1017/1018/1019-03-26) migrated to
  appeal_subtype=extension_request_building_permit via psql.

## #35 missing_precedents feature (sub-agent C)
* Schema V13: missing_precedents table (citation, case_id, party,
  legal_topic, status, linked_case_law_id, claim_quote, ...) +
  FK constraints + 3 indexes. Applied via psql + idempotent migration.
* 6 db.py service functions, 3 MCP tools, 6 FastAPI endpoints
  (POST/GET/PATCH/DELETE/upload — upload routes by citation prefix
  to ingest_internal_decision or ingest_precedent).
* Next.js page /missing-precedents with 5 status tabs + filters +
  sidebar badge counter + detail drawer with metadata edit + smart
  upload form that switches fields per committee/court.
* Bootstrap: 7 rows imported from the JSON file
  (3 citations × cases, all status=closed with linked_case_law_id).
* legal-researcher.md: new §2ב.5 with missing_precedent_create
  usage + dedup semantics + tool grant.

## #36 legal_arguments aggregation (sub-agent D)
* Schema V14: legal_arguments + legal_argument_propositions M:M.
  Applied via psql.
* New service argument_aggregator.py with two functions —
  aggregate_claims_to_arguments() (Claude CLI / claude_session) and
  get_legal_arguments(). Graceful llm_unavailable handling when CLI
  is missing (containers).
* 2 MCP tools + 2 API endpoints (POST .../aggregate-arguments as
  BackgroundTask, GET .../legal-arguments).
* Frontend: shadcn Accordion + new legal-arguments-panel.tsx with
  hierarchical (party → priority badge → arguments) display, "טיעונים"
  tab on the case page, "חשב/חשב מחדש" buttons.
* scripts/backfill_legal_arguments.py + SCRIPTS.md entry — dry-run
  found 8 candidate cases including 1017/1018/1019.

## Open follow-ups (intentionally deferred)
* npm run api:types in web-ui (CLAUDE.md flow) — recommended before
  the next UI commit; not required for backend deployment.
* Run backfill_legal_arguments.py --apply once the container picks up
  the new aggregator service.
* webhook on missing-precedents upload-close to Paperclip (optional).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 08:34:40 +00:00
parent af651d0135
commit f3cc9ca9d4
33 changed files with 4588 additions and 37 deletions

View File

@@ -14,6 +14,7 @@ import { StatusGuide } from "@/components/cases/status-guide";
import { StatusChanger } from "@/components/cases/status-changer";
import { DocumentsPanel } from "@/components/cases/documents-panel";
import { DraftsPanel } from "@/components/cases/drafts-panel";
import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel";
import { AgentActivityFeed } from "@/components/cases/agent-activity-feed";
import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
import { UploadSheet } from "@/components/documents/upload-sheet";
@@ -77,6 +78,9 @@ export default function CaseDetailPage({
<div className="flex items-center justify-between gap-3 mb-1 flex-wrap">
<TabsList className="bg-rule-soft/60">
<TabsTrigger value="overview">סקירה</TabsTrigger>
<TabsTrigger value="arguments">
טיעונים
</TabsTrigger>
<TabsTrigger value="drafts">
טיוטות והערות
</TabsTrigger>
@@ -139,6 +143,10 @@ export default function CaseDetailPage({
<DocumentsPanel data={data} />
</TabsContent>
<TabsContent value="arguments" className="mt-5">
<LegalArgumentsPanel caseNumber={caseNumber} />
</TabsContent>
<TabsContent value="drafts" className="mt-5">
<DraftsPanel
caseNumber={caseNumber}

View File

@@ -0,0 +1,161 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
useMissingPrecedents,
type MissingPrecedentStatus,
} from "@/lib/api/missing-precedents";
import { MissingPrecedentsTable } from "@/components/missing-precedents/missing-precedents-table";
/**
* Missing-precedents page (TaskMaster #35).
*
* Surfaces citations that party briefs invoke but which aren't yet in the
* precedent_library. Four tabs by status; each tab uses the same table
* component with a different filter. Drawer (sheet) opens on row click
* with metadata + upload form that routes to internal_decision_upload
* (ערר/בל"מ citations) or precedent_library_upload (court rulings).
*/
function StatusBadge({ status, count }: { status: MissingPrecedentStatus; count: number }) {
if (!count) return null;
const variants: Record<MissingPrecedentStatus, string> = {
open: "bg-gold-wash text-gold-deep border-gold/40",
uploaded: "bg-rule-soft text-ink-muted border-rule",
closed: "bg-emerald-50 text-emerald-800 border-emerald-300/60",
irrelevant: "bg-rule-soft text-ink-muted border-rule",
};
return (
<Badge
variant="outline"
className={`ms-1 text-[0.65rem] ${variants[status]}`}
>
{count}
</Badge>
);
}
export default function MissingPrecedentsPage() {
const [caseNumber, setCaseNumber] = useState("");
const [legalTopic, setLegalTopic] = useState("");
const counts = useMissingPrecedents({ limit: 1 });
const byStatus = counts.data?.by_status ?? {};
return (
<AppShell>
<section className="space-y-6">
<header>
<nav className="text-[0.78rem] text-ink-muted mb-1">
<Link href="/" className="hover:text-gold-deep">בית</Link>
<span aria-hidden> · </span>
<span className="text-navy">פסיקה חסרה בקורפוס</span>
</nav>
<h1 className="text-navy mb-0">פסיקה חסרה בקורפוס</h1>
<p className="text-ink-muted text-sm mt-1 max-w-3xl">
פסיקות שצוטטו בכתבי הטענות אך אינן עדיין בקורפוס. סוכן המחקר רושם
פערים אוטומטית; היו&quot;ר סוגר אותם על־ידי העלאת המסמך ניתוב
אוטומטי בין הקורפוס הסמכותי (פסקי דין) להחלטות ועדות ערר.
</p>
</header>
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5 space-y-5">
{/* Shared filters */}
<div className="flex items-end gap-3 flex-wrap">
<div className="flex-1 min-w-[200px]">
<label className="text-[0.78rem] text-ink-muted">תיק (מספר ערר)</label>
<Input
value={caseNumber}
onChange={(e) => setCaseNumber(e.target.value)}
placeholder="1017-03-26"
dir="rtl"
/>
</div>
<div className="flex-1 min-w-[200px]">
<label className="text-[0.78rem] text-ink-muted">נושא משפטי</label>
<Input
value={legalTopic}
onChange={(e) => setLegalTopic(e.target.value)}
placeholder="זכות עמידה"
dir="rtl"
/>
</div>
</div>
<Tabs defaultValue="open" dir="rtl">
<TabsList className="bg-rule-soft/60">
<TabsTrigger value="open">
פתוחות
<StatusBadge status="open" count={byStatus.open ?? 0} />
</TabsTrigger>
<TabsTrigger value="uploaded">
הועלו
<StatusBadge status="uploaded" count={byStatus.uploaded ?? 0} />
</TabsTrigger>
<TabsTrigger value="closed">
נסגרו
<StatusBadge status="closed" count={byStatus.closed ?? 0} />
</TabsTrigger>
<TabsTrigger value="irrelevant">
לא רלוונטי
<StatusBadge
status="irrelevant"
count={byStatus.irrelevant ?? 0}
/>
</TabsTrigger>
<TabsTrigger value="all">הכל</TabsTrigger>
</TabsList>
<TabsContent value="open" className="mt-4">
<MissingPrecedentsTable
status="open"
caseNumber={caseNumber.trim() || undefined}
legalTopic={legalTopic.trim() || undefined}
/>
</TabsContent>
<TabsContent value="uploaded" className="mt-4">
<MissingPrecedentsTable
status="uploaded"
caseNumber={caseNumber.trim() || undefined}
legalTopic={legalTopic.trim() || undefined}
/>
</TabsContent>
<TabsContent value="closed" className="mt-4">
<MissingPrecedentsTable
status="closed"
caseNumber={caseNumber.trim() || undefined}
legalTopic={legalTopic.trim() || undefined}
/>
</TabsContent>
<TabsContent value="irrelevant" className="mt-4">
<MissingPrecedentsTable
status="irrelevant"
caseNumber={caseNumber.trim() || undefined}
legalTopic={legalTopic.trim() || undefined}
/>
</TabsContent>
<TabsContent value="all" className="mt-4">
<MissingPrecedentsTable
caseNumber={caseNumber.trim() || undefined}
legalTopic={legalTopic.trim() || undefined}
/>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</section>
</AppShell>
);
}

View File

@@ -15,6 +15,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { GlobalSearch } from "@/components/global-search";
import { headerSubtitle } from "@/components/header-context";
import { useMissingPrecedentsOpenCount } from "@/lib/api/missing-precedents";
/**
* Ezer Mishpati navigation shell — two-row header.
@@ -45,9 +46,10 @@ const NAV_GROUPS: NavGroup[] = [
{
id: "knowledge",
items: [
{ href: "/precedents", label: "ספריית פסיקה" },
{ href: "/training", label: "אימון סגנון" },
{ href: "/methodology", label: "מתודולוגיה" },
{ href: "/precedents", label: "ספריית פסיקה" },
{ href: "/missing-precedents", label: "פסיקה חסרה" },
{ href: "/training", label: "אימון סגנון" },
{ href: "/methodology", label: "מתודולוגיה" },
],
},
];
@@ -240,7 +242,8 @@ function NavLink({ item, active }: { item: NavItem; active: boolean }) {
: "text-parchment/80 hover:text-parchment hover:bg-navy-soft/60"}
`}
>
{item.label}
<span>{item.label}</span>
{item.href === "/missing-precedents" ? <MissingPrecedentsBadge /> : null}
{active && (
<span
className="absolute -bottom-[19px] inset-x-2 h-[2px] bg-gold"
@@ -250,3 +253,18 @@ function NavLink({ item, active }: { item: NavItem; active: boolean }) {
</Link>
);
}
/* Small open-count badge next to "פסיקה חסרה" — only renders when >0
* so the nav stays quiet in normal operation. */
function MissingPrecedentsBadge() {
const { data: openCount } = useMissingPrecedentsOpenCount();
if (!openCount) return null;
return (
<span
className="ms-1 inline-flex items-center justify-center min-w-[1.25rem] h-4 px-1 rounded-full bg-gold text-navy text-[0.65rem] font-semibold"
aria-label={`${openCount} פסיקות חסרות פתוחות`}
>
{openCount}
</span>
);
}

View File

@@ -12,17 +12,35 @@ const BUCKETS: Bucket[] = [
{ key: "compensation_197", label: "פיצויים (ס׳ 197)", color: "var(--color-warn)" },
];
/* For chart aggregation, collapse בל"מ variants back to their parent
* domain — building_permit / betterment_levy / compensation_197. The
* dedicated בל"מ filter in the cases table handles the cross-cutting view. */
function collapseBlam(s: AppealSubtype): AppealSubtype {
if (s === "extension_request_building_permit") return "building_permit";
if (s === "extension_request_betterment_levy") return "betterment_levy";
if (s === "extension_request_compensation") return "compensation_197";
return s;
}
export function subtypeOf(c: Case): AppealSubtype {
return c.appeal_subtype && c.appeal_subtype !== "unknown"
const raw = c.appeal_subtype && c.appeal_subtype !== "unknown"
? c.appeal_subtype
: deriveSubtype(c.case_number);
return collapseBlam(raw);
}
export function AppealTypeBars({ cases }: { cases?: Case[] }) {
/* All seven subtypes initialized to 0 — subtypeOf() collapses בל"מ
* variants back to their parent domain, so the extension_request_*
* counters will remain 0 in practice; they exist here to satisfy the
* Record<AppealSubtype, number> type. */
const counts: Record<AppealSubtype, number> = {
building_permit: 0,
betterment_levy: 0,
compensation_197: 0,
extension_request_building_permit: 0,
extension_request_betterment_levy: 0,
extension_request_compensation: 0,
unknown: 0,
};
(cases ?? []).forEach((c) => {

View File

@@ -8,6 +8,7 @@ import { CreateRepoButton } from "@/components/cases/create-repo-button";
import {
PRACTICE_AREA_LABELS,
APPEAL_SUBTYPE_LABELS,
isBlamSubtype,
} from "@/lib/practice-area";
import type { CaseDetail } from "@/lib/api/cases";
@@ -62,6 +63,15 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
)}
</Badge>
)}
{isBlamSubtype(data?.appeal_subtype) && (
<Badge
variant="outline"
className="rounded-full px-2.5 py-0.5 text-[0.72rem] font-bold bg-warn/10 text-warn-deep border-warn/40"
title="בקשה להארכת מועד להגשת ערר"
>
בל&quot;מ
</Badge>
)}
{data?.case_number && (
<CaseArchiveAction
caseNumber={data.case_number}

View File

@@ -16,7 +16,12 @@ import {
} from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { StatusBadge } from "@/components/cases/status-badge";
import { isBlamSubtype } from "@/lib/practice-area";
import type { Case } from "@/lib/api/cases";
function formatDate(iso?: string) {
@@ -49,8 +54,17 @@ const columns: ColumnDef<Case>[] = [
accessorKey: "title",
header: "כותרת",
cell: ({ row }) => (
<div className="text-ink max-w-[420px] truncate" title={row.original.title}>
{row.original.title}
<div className="text-ink max-w-[420px] truncate flex items-center gap-2" title={row.original.title}>
{isBlamSubtype(row.original.appeal_subtype) && (
<Badge
variant="outline"
className="rounded-full px-1.5 py-0 text-[0.65rem] font-bold bg-warn/10 text-warn-deep border-warn/40 shrink-0"
title="בקשה להארכת מועד להגשת ערר"
>
בל&quot;מ
</Badge>
)}
<span className="truncate">{row.original.title}</span>
</div>
),
},
@@ -94,8 +108,15 @@ export function CasesTable({
{ id: "updated_at", desc: true },
]);
const [globalFilter, setGlobalFilter] = useState("");
/* "all" = all cases; "blam" = only בל"מ; "regular" = exclude בל"מ */
const [blamFilter, setBlamFilter] = useState<"all" | "blam" | "regular">("all");
const data = useMemo(() => cases ?? [], [cases]);
const data = useMemo(() => {
const all = cases ?? [];
if (blamFilter === "blam") return all.filter((c) => isBlamSubtype(c.appeal_subtype));
if (blamFilter === "regular") return all.filter((c) => !isBlamSubtype(c.appeal_subtype));
return all;
}, [cases, blamFilter]);
const table = useReactTable({
data,
@@ -126,6 +147,20 @@ export function CasesTable({
className="max-w-sm bg-surface"
dir="rtl"
/>
<Select
value={blamFilter}
onValueChange={(v) => setBlamFilter(v as "all" | "blam" | "regular")}
dir="rtl"
>
<SelectTrigger className="w-40 bg-surface">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">כל התיקים</SelectItem>
<SelectItem value="blam">בל&quot;מ בלבד</SelectItem>
<SelectItem value="regular">ערר רגיל בלבד</SelectItem>
</SelectContent>
</Select>
<span className="text-sm text-ink-muted me-auto">
{table.getFilteredRowModel().rows.length} תיקים
</span>

View File

@@ -0,0 +1,222 @@
"use client";
import { useMemo } from "react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
PARTY_LABELS_HE,
PRIORITY_LABELS_HE,
PRIORITY_ORDER,
useAggregateArguments,
useLegalArguments,
type LegalArgument,
type LegalArgumentParty,
type LegalArgumentPriority,
} from "@/lib/api/legal-arguments";
import { toast } from "sonner";
import { Loader2, RefreshCw, Sparkles } from "lucide-react";
const PRIORITY_BADGE_TONE: Record<LegalArgumentPriority, string> = {
threshold: "bg-danger-bg/60 text-danger-strong border-danger/40",
substantive: "bg-gold-soft/50 text-navy border-gold/40",
procedural: "bg-rule-soft text-ink border-rule",
relief: "bg-emerald-50 text-emerald-900 border-emerald-200",
};
function groupByPriority(
args: LegalArgument[],
): Record<LegalArgumentPriority, LegalArgument[]> {
const out: Record<LegalArgumentPriority, LegalArgument[]> = {
threshold: [],
substantive: [],
procedural: [],
relief: [],
};
for (const a of args) {
(out[a.priority] ?? out.substantive).push(a);
}
for (const key of PRIORITY_ORDER) {
out[key].sort((x, y) => x.argument_index - y.argument_index);
}
return out;
}
type PartySectionProps = {
party: LegalArgumentParty;
args: LegalArgument[];
};
function PartySection({ party, args }: PartySectionProps) {
const grouped = useMemo(() => groupByPriority(args), [args]);
return (
<div className="space-y-3">
<div className="flex items-baseline justify-between border-b border-rule pb-2">
<h3 className="text-navy text-base font-semibold">
{PARTY_LABELS_HE[party] ?? party}
</h3>
<span className="text-ink-muted text-xs">
{args.length} טיעונים
</span>
</div>
{PRIORITY_ORDER.map((priority) => {
const list = grouped[priority];
if (!list?.length) return null;
return (
<div key={priority} className="space-y-1">
<div className="flex items-center gap-2">
<Badge
variant="outline"
className={`${PRIORITY_BADGE_TONE[priority]} text-xs`}
>
{PRIORITY_LABELS_HE[priority]}
</Badge>
<span className="text-ink-muted text-xs">
{list.length} טיעונים
</span>
</div>
<Accordion type="multiple" className="rounded-md border border-rule bg-surface">
{list.map((arg) => (
<AccordionItem key={arg.id} value={arg.id} className="px-3">
<AccordionTrigger className="text-start">
<div className="flex flex-1 flex-col items-start gap-1">
<span className="text-navy text-sm font-medium leading-tight">
{arg.argument_index}. {arg.argument_title}
</span>
{arg.legal_topic && (
<span className="text-ink-muted text-xs">
{arg.legal_topic}
</span>
)}
</div>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-2 px-1">
<p className="text-ink leading-relaxed whitespace-pre-line">
{arg.argument_body}
</p>
{arg.supporting_claims.length > 0 && (
<p className="text-ink-muted text-xs">
מסתמך על {arg.supporting_claims.length} פרופוזיציות
גולמיות.
</p>
)}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
);
})}
</div>
);
}
type LegalArgumentsPanelProps = {
caseNumber: string;
};
export function LegalArgumentsPanel({ caseNumber }: LegalArgumentsPanelProps) {
const { data, isPending, isError, error } = useLegalArguments(caseNumber);
const aggregate = useAggregateArguments(caseNumber);
const parties = useMemo<LegalArgumentParty[]>(() => {
if (!data?.by_party) return [];
const order: LegalArgumentParty[] = [
"appellant",
"respondent",
"committee",
"permit_applicant",
"unknown",
];
return order.filter((p) => (data.by_party[p]?.length ?? 0) > 0);
}, [data]);
const handleAggregate = (force: boolean) => {
aggregate.mutate(force, {
onSuccess: () => {
toast.success(
force
? "הופעלה חזרה חישוב טיעונים (force). יסתיים תוך דקה."
: "הופעל חישוב טיעונים. רענן בעוד דקה.",
);
},
onError: (e) => toast.error(`שגיאה: ${(e as Error).message}`),
});
};
return (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5 space-y-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h2 className="text-navy text-base font-semibold">
טיעונים משפטיים
</h2>
<p className="text-ink-muted text-xs mt-0.5">
טיעונים מאוגדים מתוך הפרופוזיציות הגולמיות, מקובצים לפי צד וקדימות.
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={aggregate.isPending}
onClick={() => handleAggregate(false)}
>
{aggregate.isPending ? (
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
) : (
<Sparkles className="w-3.5 h-3.5 me-1.5" />
)}
חשב טיעונים
</Button>
<Button
variant="ghost"
size="sm"
disabled={aggregate.isPending || !data?.total}
onClick={() => handleAggregate(true)}
title="חישוב מחדש (מוחק טיעונים קיימים)"
>
<RefreshCw className="w-3.5 h-3.5" />
</Button>
</div>
</div>
{isPending ? (
<div className="space-y-2">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
) : isError ? (
<p className="text-danger text-sm">
שגיאה בטעינת טיעונים: {(error as Error).message}
</p>
) : !data?.total ? (
<p className="text-ink-muted text-sm">
אין טיעונים מאוגדים עדיין. לחץ "חשב טיעונים" כדי להריץ את ה-aggregator.
</p>
) : (
<div className="space-y-6">
{parties.map((party) => (
<PartySection
key={party}
party={party}
args={data.by_party[party] ?? []}
/>
))}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,512 @@
"use client";
import { useEffect, useState } from "react";
import { Upload, Save, Loader2, CheckCircle2 } from "lucide-react";
import { toast } from "sonner";
import {
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
useMissingPrecedent,
useUpdateMissingPrecedent,
useUploadMissingPrecedent,
CITED_BY_PARTY_LABELS,
STATUS_LABELS,
type CitedByParty,
type MissingPrecedentStatus,
type MissingPrecedentPatch,
} from "@/lib/api/missing-precedents";
import {
PRACTICE_AREAS, PRECEDENT_LEVELS, DISTRICTS,
} from "@/components/precedents/practice-area";
type Props = {
id: string | null;
onOpenChange: (open: boolean) => void;
};
const ACCEPT = ".pdf,.docx,.doc,.rtf,.txt,.md";
function isCommitteeCitation(citation: string): boolean {
const norm = citation.trim();
return /^(ערר[\s(]|בל"מ[\s(]|ARAR )/.test(norm);
}
export function MissingPrecedentDetailDrawer({ id, onOpenChange }: Props) {
const open = id !== null;
const { data: mp, isPending } = useMissingPrecedent(id);
const update = useUpdateMissingPrecedent();
const upload = useUploadMissingPrecedent();
// Edit form for metadata.
const [legalTopic, setLegalTopic] = useState("");
const [legalIssue, setLegalIssue] = useState("");
const [cityParty, setCitedByParty] = useState<CitedByParty>("unknown");
const [citedByPartyName, setCitedByPartyName] = useState("");
const [caseName, setCaseName] = useState("");
const [notes, setNotes] = useState("");
const [status, setStatus] = useState<MissingPrecedentStatus>("open");
// Upload form fields.
const [file, setFile] = useState<File | null>(null);
const [decisionDate, setDecisionDate] = useState("");
const [court, setCourt] = useState("");
const [practiceArea, setPracticeArea] = useState<string>("");
const [appealSubtype, setAppealSubtype] = useState("");
const [precedentLevel, setPrecedentLevel] = useState("");
const [chairName, setChairName] = useState("");
const [district, setDistrict] = useState("");
const [committeeCaseNumber, setCommitteeCaseNumber] = useState("");
const [summary, setSummary] = useState("");
// Sync form from record when it loads or id changes.
const [syncedId, setSyncedId] = useState<string | null>(null);
if (mp && mp.id !== syncedId) {
setSyncedId(mp.id);
setLegalTopic(mp.legal_topic ?? "");
setLegalIssue(mp.legal_issue ?? "");
setCitedByParty(mp.cited_by_party ?? "unknown");
setCitedByPartyName(mp.cited_by_party_name ?? "");
setCaseName(mp.case_name ?? "");
setNotes(mp.notes ?? "");
setStatus(mp.status);
}
// Reset on close. The cascading-render warning is the intended side
// effect here — wiping the form when the drawer closes.
useEffect(() => {
if (open) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setFile(null);
setSyncedId(null);
setDecisionDate(""); setCourt(""); setPracticeArea("");
setAppealSubtype(""); setPrecedentLevel(""); setChairName("");
setDistrict(""); setCommitteeCaseNumber(""); setSummary("");
}, [open]);
const handleSaveMetadata = async () => {
if (!mp) return;
const patch: MissingPrecedentPatch = {
legal_topic: legalTopic,
legal_issue: legalIssue,
cited_by_party: cityParty,
cited_by_party_name: citedByPartyName,
case_name: caseName,
notes,
status,
};
try {
await update.mutateAsync({ id: mp.id, patch });
toast.success("הרשומה עודכנה");
} catch (e) {
toast.error("העדכון נכשל");
console.error(e);
}
};
const isCommittee = mp ? isCommitteeCitation(mp.citation) : false;
const handleUpload = async (e: React.FormEvent) => {
e.preventDefault();
if (!mp || !file) {
toast.error("בחר קובץ");
return;
}
if (isCommittee && (!chairName.trim() || !district.trim())) {
toast.error("החלטת ועדת ערר דורשת שם יו״ר ומחוז");
return;
}
try {
const result = await upload.mutateAsync({
id: mp.id,
file,
case_number: isCommittee ? committeeCaseNumber || undefined : undefined,
chair_name: isCommittee ? chairName : undefined,
district: isCommittee ? district : undefined,
case_name: caseName || undefined,
court: court || undefined,
decision_date: decisionDate || undefined,
practice_area: practiceArea || undefined,
appeal_subtype: appealSubtype || undefined,
precedent_level: precedentLevel || undefined,
source_type: isCommittee ? "appeals_committee" : "court_ruling",
summary: summary || undefined,
});
toast.success(
`הפסיקה נכנסה לקורפוס (${result.route === "internal_committee" ? "ועדת ערר" : "פסק דין"}) והרשומה נסגרה.`,
);
onOpenChange(false);
} catch (e: unknown) {
const msg =
e instanceof Error
? e.message
: typeof e === "string"
? e
: "כשל העלאה";
toast.error(msg);
console.error(e);
}
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="left"
className="w-full sm:max-w-2xl overflow-y-auto"
>
<SheetHeader className="space-y-1">
<SheetTitle className="text-navy">
פסיקה חסרה
{mp ? (
<Badge
variant="outline"
className="ms-2 align-middle"
>
{STATUS_LABELS[mp.status]}
</Badge>
) : null}
</SheetTitle>
<SheetDescription>
פרטים מלאים והעלאת הפסיקה לקורפוס.
</SheetDescription>
</SheetHeader>
{isPending || !mp ? (
<div className="space-y-3 px-6 py-4">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-1/2" />
</div>
) : (
<div className="space-y-6 px-6 py-4">
{/* ── Citation block (read-only) ── */}
<section className="space-y-2">
<div className="text-[0.78rem] text-ink-muted">מראה מקום</div>
<div className="text-sm text-navy font-medium bg-rule-soft/40 rounded-md px-3 py-2 leading-relaxed">
{mp.citation}
</div>
{mp.claim_quote ? (
<>
<div className="text-[0.78rem] text-ink-muted mt-3">ציטוט מכתב הטענות</div>
<div className="text-xs text-ink bg-gold-wash/30 border-s-2 border-gold rounded-md px-3 py-2 leading-relaxed">
{mp.claim_quote}
</div>
</>
) : null}
</section>
{/* ── Linked record (if closed) ── */}
{mp.linked_case_law_id ? (
<section className="space-y-1 bg-emerald-50 border border-emerald-200 rounded-lg p-3">
<div className="flex items-center gap-2 text-emerald-800 font-medium text-sm">
<CheckCircle2 className="w-4 h-4" />
מקושר ל
</div>
<div className="text-sm text-emerald-900 truncate">
{mp.linked_case_law_name || "—"}
</div>
<div className="text-[0.72rem] text-emerald-700 truncate">
{mp.linked_case_law_number}
</div>
</section>
) : null}
{/* ── Editable metadata ── */}
<section className="space-y-3">
<h3 className="text-sm font-semibold text-navy">מטא־דאטה</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="legal_topic">נושא משפטי</Label>
<Input
id="legal_topic"
value={legalTopic}
onChange={(e) => setLegalTopic(e.target.value)}
placeholder="זכות עמידה"
dir="rtl"
/>
</div>
<div>
<Label htmlFor="case_name">שם פסיקה</Label>
<Input
id="case_name"
value={caseName}
onChange={(e) => setCaseName(e.target.value)}
placeholder="אנטרים"
dir="rtl"
/>
</div>
</div>
<div>
<Label htmlFor="legal_issue">שאלה משפטית</Label>
<Textarea
id="legal_issue"
value={legalIssue}
onChange={(e) => setLegalIssue(e.target.value)}
rows={2}
dir="rtl"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="cited_by_party">צד מצטט</Label>
<Select
value={cityParty}
onValueChange={(v) => setCitedByParty(v as CitedByParty)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.entries(CITED_BY_PARTY_LABELS) as [CitedByParty, string][]).map(
([v, label]) => (
<SelectItem key={v} value={v}>{label}</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="cited_by_party_name">שם צד</Label>
<Input
id="cited_by_party_name"
value={citedByPartyName}
onChange={(e) => setCitedByPartyName(e.target.value)}
placeholder="לינדאב בע״מ"
dir="rtl"
/>
</div>
</div>
<div>
<Label htmlFor="status">סטטוס</Label>
<Select
value={status}
onValueChange={(v) => setStatus(v as MissingPrecedentStatus)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.entries(STATUS_LABELS) as [
MissingPrecedentStatus,
string,
][]).map(([v, label]) => (
<SelectItem key={v} value={v}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="notes">הערות</Label>
<Textarea
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
dir="rtl"
/>
</div>
<Button
onClick={handleSaveMetadata}
disabled={update.isPending}
variant="outline"
className="border-rule"
>
{update.isPending ? (
<Loader2 className="w-4 h-4 me-1 animate-spin" />
) : (
<Save className="w-4 h-4 me-1" />
)}
שמור פרטים
</Button>
</section>
{/* ── Upload section ── */}
{!mp.linked_case_law_id ? (
<section className="space-y-3 border-t border-rule pt-5">
<h3 className="text-sm font-semibold text-navy">
העלאת הפסיקה לקורפוס
</h3>
<div className="text-[0.78rem] text-ink-muted">
ניתוב אוטומטי לפי הציטוט:&nbsp;
<strong className="text-navy">
{isCommittee ? "החלטת ועדת ערר (internal)" : "פסק דין (library)"}
</strong>
</div>
<form onSubmit={handleUpload} className="space-y-3">
<div>
<Label htmlFor="file">קובץ (PDF / DOCX / RTF / TXT / MD)</Label>
<Input
id="file"
type="file"
accept={ACCEPT}
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
required
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="court">ערכאה</Label>
<Input
id="court"
value={court}
onChange={(e) => setCourt(e.target.value)}
placeholder="בית המשפט העליון"
dir="rtl"
/>
</div>
<div>
<Label htmlFor="decision_date">תאריך</Label>
<Input
id="decision_date"
type="date"
value={decisionDate}
onChange={(e) => setDecisionDate(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="practice_area">תחום</Label>
<Select value={practiceArea} onValueChange={setPracticeArea}>
<SelectTrigger>
<SelectValue placeholder="ללא" />
</SelectTrigger>
<SelectContent>
{PRACTICE_AREAS.map((a) => (
<SelectItem key={a.value} value={a.value}>
{a.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="appeal_subtype">תת־סוג</Label>
<Input
id="appeal_subtype"
value={appealSubtype}
onChange={(e) => setAppealSubtype(e.target.value)}
placeholder="זכות עמידה"
dir="rtl"
/>
</div>
</div>
{isCommittee ? (
<>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="chair_name">
יו״ר <span className="text-danger">*</span>
</Label>
<Input
id="chair_name"
value={chairName}
onChange={(e) => setChairName(e.target.value)}
placeholder="דפנה תמיר"
dir="rtl"
required
/>
</div>
<div>
<Label htmlFor="district">
מחוז <span className="text-danger">*</span>
</Label>
<Select value={district} onValueChange={setDistrict}>
<SelectTrigger>
<SelectValue placeholder="בחר" />
</SelectTrigger>
<SelectContent>
{DISTRICTS.map((d) => (
<SelectItem key={d.value} value={d.value}>
{d.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label htmlFor="committee_case_number">
מספר ערר (לציטוט הקטן)
</Label>
<Input
id="committee_case_number"
value={committeeCaseNumber}
onChange={(e) => setCommitteeCaseNumber(e.target.value)}
placeholder="ערר 1112/22 ..."
dir="rtl"
/>
</div>
</>
) : (
<div>
<Label htmlFor="precedent_level">רמת תקדים</Label>
<Select
value={precedentLevel}
onValueChange={setPrecedentLevel}
>
<SelectTrigger>
<SelectValue placeholder="ללא" />
</SelectTrigger>
<SelectContent>
{PRECEDENT_LEVELS.map((l) => (
<SelectItem key={l.value} value={l.value}>
{l.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div>
<Label htmlFor="summary">תקציר</Label>
<Textarea
id="summary"
value={summary}
onChange={(e) => setSummary(e.target.value)}
rows={2}
dir="rtl"
/>
</div>
<Button
type="submit"
disabled={!file || upload.isPending}
className="bg-navy text-parchment hover:bg-navy-soft"
>
{upload.isPending ? (
<Loader2 className="w-4 h-4 me-1 animate-spin" />
) : (
<Upload className="w-4 h-4 me-1" />
)}
העלאה וסגירה
</Button>
</form>
</section>
) : null}
</div>
)}
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,223 @@
"use client";
import { useState } from "react";
import { Trash2, Upload, Pencil, ExternalLink } from "lucide-react";
import { toast } from "sonner";
import Link from "next/link";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
useMissingPrecedents,
useDeleteMissingPrecedent,
CITED_BY_PARTY_LABELS,
STATUS_LABELS,
type MissingPrecedent,
type MissingPrecedentStatus,
} from "@/lib/api/missing-precedents";
import { MissingPrecedentDetailDrawer } from "./missing-precedent-detail-drawer";
function formatDate(iso: string | null) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString("he-IL");
} catch {
return iso;
}
}
function StatusBadge({ status }: { status: MissingPrecedentStatus }) {
const variants: Record<MissingPrecedentStatus, string> = {
open: "bg-gold-wash text-gold-deep border-gold/40",
uploaded: "bg-rule-soft text-ink-muted border-rule",
closed: "bg-emerald-50 text-emerald-800 border-emerald-300/60",
irrelevant: "bg-rule-soft text-ink-muted border-rule line-through",
};
return (
<Badge variant="outline" className={variants[status]}>
{STATUS_LABELS[status]}
</Badge>
);
}
function TableSkeleton({ cols }: { cols: number }) {
return (
<>
{Array.from({ length: 4 }).map((_, i) => (
<TableRow key={i} className="border-rule">
{Array.from({ length: cols }).map((__, j) => (
<TableCell key={j}>
<Skeleton className="h-4 w-full" />
</TableCell>
))}
</TableRow>
))}
</>
);
}
type Props = {
status?: MissingPrecedentStatus | "";
caseNumber?: string;
legalTopic?: string;
};
export function MissingPrecedentsTable({ status, caseNumber, legalTopic }: Props) {
const [openId, setOpenId] = useState<string | null>(null);
const { data, isPending, error } = useMissingPrecedents({
status: status === "" ? undefined : status,
caseNumber,
legalTopic,
limit: 200,
});
const del = useDeleteMissingPrecedent();
const handleDelete = async (mp: MissingPrecedent) => {
if (!confirm(`למחוק את הרשומה? ${mp.case_name || mp.citation.slice(0, 60)}...`)) {
return;
}
try {
await del.mutateAsync(mp.id);
toast.success("הרשומה נמחקה");
} catch (e) {
toast.error("מחיקה נכשלה");
console.error(e);
}
};
if (error) {
return (
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-4 text-danger text-center text-sm">
{error.message}
</div>
);
}
return (
<>
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-rule-soft/60">
<TableRow className="border-rule">
<TableHead className="text-navy text-right">פסיקה</TableHead>
<TableHead className="text-navy text-right">נושא</TableHead>
<TableHead className="text-navy text-right">תיק</TableHead>
<TableHead className="text-navy text-right">צד מצטט</TableHead>
<TableHead className="text-navy text-right">סטטוס</TableHead>
<TableHead className="text-navy text-right">נוצר</TableHead>
<TableHead className="text-navy" />
</TableRow>
</TableHeader>
<TableBody>
{isPending ? (
<TableSkeleton cols={7} />
) : !data?.items.length ? (
<TableRow className="border-rule">
<TableCell colSpan={7} className="text-center text-ink-muted py-8">
אין פסיקות חסרות בקריטריונים הנוכחיים.
</TableCell>
</TableRow>
) : (
data.items.map((mp) => (
<TableRow
key={mp.id}
className="border-rule hover:bg-rule-soft/30 cursor-pointer"
onClick={() => setOpenId(mp.id)}
>
<TableCell className="max-w-[440px]">
<div className="text-sm text-navy font-medium truncate">
{mp.case_name || mp.citation.split(" ").slice(0, 6).join(" ")}
</div>
<div className="text-[0.72rem] text-ink-muted truncate" dir="rtl">
{mp.citation}
</div>
</TableCell>
<TableCell>
<span className="text-sm text-ink">{mp.legal_topic || "—"}</span>
</TableCell>
<TableCell>
{mp.cited_in_case_number ? (
<Link
href={`/cases/${encodeURIComponent(mp.cited_in_case_number)}`}
onClick={(e) => e.stopPropagation()}
className="text-sm text-navy hover:text-gold-deep inline-flex items-center gap-1"
>
{mp.cited_in_case_number}
<ExternalLink className="w-3 h-3" />
</Link>
) : (
<span className="text-ink-muted text-sm"></span>
)}
</TableCell>
<TableCell className="text-sm text-ink">
{mp.cited_by_party
? CITED_BY_PARTY_LABELS[mp.cited_by_party]
: "—"}
{mp.cited_by_party_name ? (
<div className="text-[0.7rem] text-ink-muted truncate max-w-[160px]">
{mp.cited_by_party_name}
</div>
) : null}
</TableCell>
<TableCell>
<StatusBadge status={mp.status} />
{mp.linked_case_law_number ? (
<div className="text-[0.7rem] text-emerald-700 mt-1">
{mp.linked_case_law_name || mp.linked_case_law_number}
</div>
) : null}
</TableCell>
<TableCell className="text-[0.78rem] text-ink-muted">
{formatDate(mp.created_at)}
</TableCell>
<TableCell className="text-end">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
setOpenId(mp.id);
}}
title={mp.status === "open" ? "העלאה" : "פרטים"}
>
{mp.status === "open" ? (
<Upload className="w-4 h-4" />
) : (
<Pencil className="w-4 h-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleDelete(mp);
}}
disabled={del.isPending}
className="text-danger hover:text-danger"
title="מחיקה"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<MissingPrecedentDetailDrawer
id={openId}
onOpenChange={(open) => {
if (!open) setOpenId(null);
}}
/>
</>
);
}

View File

@@ -24,6 +24,21 @@ export const SOURCE_TYPES = [
{ value: "appeals_committee", label: "החלטת ועדת ערר" },
] as const;
/**
* Districts for ועדות ערר. The chair's committee is ירושלים; the rest
* are listed so that uploaded precedents from peer committees can be
* filed correctly. Order matches what's displayed in the UI dropdown.
*/
export const DISTRICTS = [
{ value: "ירושלים", label: "ירושלים" },
{ value: "מרכז", label: "מרכז" },
{ value: "תל אביב", label: "תל אביב" },
{ value: "צפון", label: "צפון" },
{ value: "דרום", label: "דרום" },
{ value: "חיפה", label: "חיפה" },
{ value: "ארצי", label: "ארצי" },
] as const;
export function practiceAreaLabel(value: string | null | undefined): string {
if (!value) return "—";
const match = PRACTICE_AREAS.find((p) => p.value === value);

View File

@@ -22,7 +22,7 @@ import {
type SourceType,
} from "@/lib/api/precedent-library";
import {
PRACTICE_AREAS, PRECEDENT_LEVELS, SOURCE_TYPES, appealSubtypeLabel,
PRACTICE_AREAS, PRECEDENT_LEVELS, SOURCE_TYPES, DISTRICTS, appealSubtypeLabel,
} from "./practice-area";
import { ExtractedHalachotSection } from "./extracted-halachot";
@@ -188,9 +188,21 @@ export function PrecedentEditSheet({ caseLawId, onOpenChange }: Props) {
</div>
<div className="space-y-1">
<Label htmlFor="district">מחוז</Label>
<Input id="district" value={form.district}
onChange={(e) => setForm({ ...form, district: e.target.value })}
placeholder="ירושלים / תל אביב / מרכז" />
<Select value={form.district || "_none"}
onValueChange={(v) => setForm({ ...form, district: v === "_none" ? "" : v })}>
<SelectTrigger id="district"><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>
<SelectItem value="_none"></SelectItem>
{DISTRICTS.map((d) => (
<SelectItem key={d.value} value={d.value}>{d.label}</SelectItem>
))}
{/* Preserve legacy free-text values that don't match any
known district (e.g. older imports with typos). */}
{form.district && !DISTRICTS.some((d) => d.value === form.district) && (
<SelectItem value={form.district}>{form.district}</SelectItem>
)}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="chair-name">יו"ר</Label>

View File

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b border-rule last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-start text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="text-ink-muted pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -20,7 +20,7 @@ import {
type CaseCreateInput,
} from "@/lib/schemas/case";
import {
PRACTICE_AREAS, APPEAL_SUBTYPES, deriveSubtype,
PRACTICE_AREAS, APPEAL_SUBTYPES, deriveSubtypeWithBlam,
type AppealSubtype,
} from "@/lib/practice-area";
@@ -78,13 +78,16 @@ export function CaseWizard() {
const userTouchedSubtype = useRef(false);
const caseNumber = form.watch("case_number");
const practiceArea = form.watch("practice_area");
const subject = form.watch("subject");
useEffect(() => {
if (userTouchedSubtype.current) return;
const derived = deriveSubtype(caseNumber, practiceArea);
/* derive_subtype_with_blam picks extension_request_* when subject
* matches "בקשה להארכת מועד" / "בל\"מ" / "הארכת מועד להגשת". */
const derived = deriveSubtypeWithBlam(caseNumber, subject, practiceArea);
if (derived !== form.getValues("appeal_subtype")) {
form.setValue("appeal_subtype", derived, { shouldValidate: false });
}
}, [caseNumber, practiceArea, form]);
}, [caseNumber, practiceArea, subject, form]);
const stepIndex = STEPS.findIndex((s) => s.key === step);
const isLast = stepIndex === STEPS.length - 1;

View File

@@ -0,0 +1,111 @@
/**
* Legal Arguments domain — aggregated propositions (claim de-dup).
*
* Each raw "claim" is an extracted proposition from a litigation brief;
* the LLM-driven aggregator groups them by party into 6-12 distinct
* legal arguments. These hooks expose the read + trigger endpoints.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "./client";
export type LegalArgumentParty =
| "appellant"
| "respondent"
| "committee"
| "permit_applicant"
| "unknown";
export type LegalArgumentPriority =
| "threshold"
| "substantive"
| "procedural"
| "relief";
export type LegalArgument = {
id: string;
case_id: string;
party: LegalArgumentParty;
argument_index: number;
argument_title: string;
argument_body: string;
legal_topic: string | null;
priority: LegalArgumentPriority;
cited_precedents?: string[] | null;
created_at?: string;
updated_at?: string;
supporting_claims: string[];
};
export type LegalArgumentsResponse = {
case_number: string;
total: number;
by_party: Partial<Record<LegalArgumentParty, LegalArgument[]>>;
arguments: LegalArgument[];
};
export const legalArgumentsKeys = {
all: ["legal-arguments"] as const,
byCase: (caseNumber: string) =>
[...legalArgumentsKeys.all, caseNumber] as const,
};
export function useLegalArguments(caseNumber: string | undefined) {
return useQuery({
queryKey: legalArgumentsKeys.byCase(caseNumber ?? ""),
queryFn: ({ signal }) =>
apiRequest<LegalArgumentsResponse>(
`/api/cases/${caseNumber}/legal-arguments`,
{ signal },
),
enabled: Boolean(caseNumber),
staleTime: 10_000,
});
}
export type AggregateArgumentsResult = {
status: "started" | string;
case_number: string;
force: boolean;
message: string;
};
export function useAggregateArguments(caseNumber: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: (force: boolean = false) =>
apiRequest<AggregateArgumentsResult>(
`/api/cases/${caseNumber}/aggregate-arguments${force ? "?force=true" : ""}`,
{ method: "POST" },
),
onSuccess: () => {
if (caseNumber) {
qc.invalidateQueries({
queryKey: legalArgumentsKeys.byCase(caseNumber),
});
}
},
});
}
export const PARTY_LABELS_HE: Record<LegalArgumentParty, string> = {
appellant: "עוררים",
respondent: "משיבים",
committee: "ועדה מקומית",
permit_applicant: "מבקשי היתר",
unknown: "צד לא מזוהה",
};
export const PRIORITY_LABELS_HE: Record<LegalArgumentPriority, string> = {
threshold: "סף",
substantive: "מהותי",
procedural: "פגם הליך",
relief: "סעד",
};
export const PRIORITY_ORDER: LegalArgumentPriority[] = [
"threshold",
"substantive",
"procedural",
"relief",
];

View File

@@ -0,0 +1,277 @@
/**
* Missing precedents — citations the parties brought up but that aren't
* yet in the corpus.
*
* Lifecycle: 'open' → researcher logs gap → chair uploads decision via
* the dialog → POST /upload routes to internal_decision_upload (ערר/בל"מ)
* or precedent_library_upload (court rulings), then status flips to
* 'closed' with linked_case_law_id set.
*
* Endpoints touched:
* - POST /api/missing-precedents create (JSON body)
* - GET /api/missing-precedents?status=open list (filters)
* - GET /api/missing-precedents/{id} detail
* - PATCH /api/missing-precedents/{id} metadata edit
* - DELETE /api/missing-precedents/{id} remove
* - POST /api/missing-precedents/{id}/upload multipart upload + close
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError, apiRequest } from "./client";
export type CitedByParty =
| "appellant"
| "respondent"
| "committee"
| "permit_applicant"
| "unknown";
export type MissingPrecedentStatus =
| "open"
| "uploaded"
| "closed"
| "irrelevant";
export type MissingPrecedent = {
id: string;
citation: string;
case_name: string | null;
cited_in_case_id: string | null;
cited_in_case_number: string | null; // joined
cited_in_document_id: string | null;
cited_by_party: CitedByParty | null;
cited_by_party_name: string | null;
legal_topic: string | null;
legal_issue: string | null;
claim_quote: string | null;
status: MissingPrecedentStatus;
linked_case_law_id: string | null;
linked_case_law_number: string | null;
linked_case_law_name: string | null;
closed_at: string | null;
created_at: string;
updated_at: string;
notes: string | null;
};
export type MissingPrecedentListResponse = {
items: MissingPrecedent[];
count: number;
by_status: Partial<Record<MissingPrecedentStatus, number>>;
total_open: number;
};
export type MissingPrecedentCreateInput = {
citation: string;
case_number?: string;
cited_in_document_id?: string;
cited_by_party?: CitedByParty;
cited_by_party_name?: string;
legal_topic?: string;
legal_issue?: string;
claim_quote?: string;
case_name?: string;
notes?: string;
};
export type MissingPrecedentPatch = Partial<{
legal_topic: string;
legal_issue: string;
notes: string;
cited_by_party: CitedByParty;
cited_by_party_name: string;
case_name: string;
status: MissingPrecedentStatus;
citation: string;
claim_quote: string;
}>;
export type MissingPrecedentFilters = {
status?: MissingPrecedentStatus | "";
caseNumber?: string;
caseId?: string;
legalTopic?: string;
limit?: number;
};
export const missingPrecedentKeys = {
all: ["missing-precedents"] as const,
list: (filters: MissingPrecedentFilters) =>
[...missingPrecedentKeys.all, "list", filters] as const,
detail: (id: string) => [...missingPrecedentKeys.all, "detail", id] as const,
};
export function useMissingPrecedents(filters: MissingPrecedentFilters = {}) {
return useQuery({
queryKey: missingPrecedentKeys.list(filters),
queryFn: ({ signal }) => {
const p = new URLSearchParams();
if (filters.status) p.set("status", filters.status);
if (filters.caseNumber) p.set("case_number", filters.caseNumber);
if (filters.caseId) p.set("case_id", filters.caseId);
if (filters.legalTopic) p.set("legal_topic", filters.legalTopic);
if (filters.limit) p.set("limit", String(filters.limit));
const qs = p.toString();
return apiRequest<MissingPrecedentListResponse>(
`/api/missing-precedents${qs ? `?${qs}` : ""}`,
{ signal },
);
},
staleTime: 15_000,
});
}
/** Counter for the sidebar / nav badge — open rows only. */
export function useMissingPrecedentsOpenCount() {
return useQuery({
queryKey: [...missingPrecedentKeys.all, "open-count"] as const,
queryFn: ({ signal }) =>
apiRequest<MissingPrecedentListResponse>(
"/api/missing-precedents?status=open&limit=1",
{ signal },
),
staleTime: 30_000,
select: (data) => data.total_open,
});
}
export function useMissingPrecedent(id: string | null) {
return useQuery({
queryKey: missingPrecedentKeys.detail(id ?? ""),
queryFn: ({ signal }) =>
apiRequest<MissingPrecedent>(
`/api/missing-precedents/${encodeURIComponent(id!)}`,
{ signal },
),
enabled: Boolean(id),
staleTime: 15_000,
});
}
export function useCreateMissingPrecedent() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: MissingPrecedentCreateInput) =>
apiRequest<MissingPrecedent>("/api/missing-precedents", {
method: "POST",
body: input,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: missingPrecedentKeys.all });
},
});
}
export function useUpdateMissingPrecedent() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, patch }: { id: string; patch: MissingPrecedentPatch }) =>
apiRequest<MissingPrecedent>(
`/api/missing-precedents/${encodeURIComponent(id)}`,
{ method: "PATCH", body: patch },
),
onSuccess: (_, { id }) => {
qc.invalidateQueries({ queryKey: missingPrecedentKeys.detail(id) });
qc.invalidateQueries({ queryKey: missingPrecedentKeys.all });
},
});
}
export function useDeleteMissingPrecedent() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiRequest<{ deleted: boolean }>(
`/api/missing-precedents/${encodeURIComponent(id)}`,
{ method: "DELETE" },
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: missingPrecedentKeys.all });
},
});
}
export type MissingPrecedentUploadInput = {
id: string;
file: File;
case_number?: string;
chair_name?: string;
district?: string;
case_name?: string;
court?: string;
decision_date?: string;
practice_area?: string;
appeal_subtype?: string;
subject_tags?: string[];
is_binding?: boolean;
headnote?: string;
summary?: string;
precedent_level?: string;
source_type?: string;
};
export function useUploadMissingPrecedent() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (input: MissingPrecedentUploadInput) => {
const fd = new FormData();
fd.append("file", input.file);
if (input.case_number) fd.append("case_number", input.case_number);
if (input.chair_name) fd.append("chair_name", input.chair_name);
if (input.district) fd.append("district", input.district);
if (input.case_name) fd.append("case_name", input.case_name);
if (input.court) fd.append("court", input.court);
if (input.decision_date) fd.append("decision_date", input.decision_date);
if (input.practice_area) fd.append("practice_area", input.practice_area);
if (input.appeal_subtype) fd.append("appeal_subtype", input.appeal_subtype);
if (input.subject_tags && input.subject_tags.length) {
fd.append("subject_tags", JSON.stringify(input.subject_tags));
}
fd.append("is_binding", String(input.is_binding ?? true));
if (input.headnote) fd.append("headnote", input.headnote);
if (input.summary) fd.append("summary", input.summary);
if (input.precedent_level)
fd.append("precedent_level", input.precedent_level);
if (input.source_type) fd.append("source_type", input.source_type);
const res = await fetch(
`/api/missing-precedents/${encodeURIComponent(input.id)}/upload`,
{ 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 {
missing_precedent: MissingPrecedent;
case_law_id: string;
route: "internal_committee" | "external_upload";
};
},
onSuccess: (_, { id }) => {
qc.invalidateQueries({ queryKey: missingPrecedentKeys.detail(id) });
qc.invalidateQueries({ queryKey: missingPrecedentKeys.all });
qc.invalidateQueries({ queryKey: ["precedent-library"] });
},
});
}
/** Hebrew labels for display. */
export const CITED_BY_PARTY_LABELS: Record<CitedByParty, string> = {
appellant: "עורר",
respondent: "משיב",
committee: "ועדה",
permit_applicant: "מבקש היתר",
unknown: "לא ידוע",
};
export const STATUS_LABELS: Record<MissingPrecedentStatus, string> = {
open: "פתוח",
uploaded: "הועלה",
closed: "נסגר",
irrelevant: "לא רלוונטי",
};

View File

@@ -18,6 +18,10 @@ export type AppealSubtype =
| "building_permit"
| "betterment_levy"
| "compensation_197"
/* בל"מ — בקשה להארכת מועד להגשת ערר. שלושה מסלולים נפרדים. */
| "extension_request_building_permit"
| "extension_request_betterment_levy"
| "extension_request_compensation"
| "unknown";
export const PRACTICE_AREAS: ReadonlyArray<{
@@ -34,12 +38,26 @@ export const APPEAL_SUBTYPES: ReadonlyArray<{
value: AppealSubtype;
label: string;
}> = [
{ value: "building_permit", label: "רישוי ובנייה" },
{ value: "betterment_levy", label: "היטל השבחה" },
{ value: "compensation_197", label: "פיצויים (ס' 197)" },
{ value: "unknown", label: "לא ידוע" },
{ value: "building_permit", label: "רישוי ובנייה" },
{ value: "betterment_levy", label: "היטל השבחה" },
{ value: "compensation_197", label: "פיצויים (ס' 197)" },
{ value: "extension_request_building_permit", label: "בל\"מ — רישוי" },
{ value: "extension_request_betterment_levy", label: "בל\"מ — היטל השבחה" },
{ value: "extension_request_compensation", label: "בל\"מ — פיצויים" },
{ value: "unknown", label: "לא ידוע" },
];
/* בל"מ subtypes — תת-קבוצה. שימוש: badges, filters */
export const BLAM_SUBTYPES: ReadonlySet<AppealSubtype> = new Set([
"extension_request_building_permit",
"extension_request_betterment_levy",
"extension_request_compensation",
]);
export function isBlamSubtype(s?: AppealSubtype | null): boolean {
return s != null && BLAM_SUBTYPES.has(s);
}
export const PRACTICE_AREA_LABELS: Record<PracticeArea, string> =
Object.fromEntries(PRACTICE_AREAS.map((p) => [p.value, p.label])) as Record<
PracticeArea,
@@ -70,3 +88,30 @@ export function deriveSubtype(
if (first === "9") return "compensation_197";
return "unknown";
}
/*
* Detect a בל"מ subject (בקשה להארכת מועד). Mirrors the Python
* `is_blam_subject` in practice_area.py. Accepts common variants.
*/
const BLAM_SUBJECT_RE = /(?:בקשה\s+להארכת\s+מועד|בל["״]מ|הארכת\s+מועד\s+להגשת)/i;
export function isBlamSubject(subject: string): boolean {
return Boolean(subject) && BLAM_SUBJECT_RE.test(subject);
}
/*
* Like deriveSubtype() but also detects בל"מ from the subject. Mirrors
* `derive_subtype_with_blam()` in practice_area.py.
*/
export function deriveSubtypeWithBlam(
caseNumber: string,
subject: string = "",
practiceArea: PracticeArea = "appeals_committee",
): AppealSubtype {
const base = deriveSubtype(caseNumber, practiceArea);
if (!isBlamSubject(subject)) return base;
if (base === "building_permit") return "extension_request_building_permit";
if (base === "betterment_levy") return "extension_request_betterment_levy";
if (base === "compensation_197") return "extension_request_compensation";
return base;
}

View File

@@ -70,6 +70,9 @@ export const caseCreateSchema = z.object({
"building_permit",
"betterment_levy",
"compensation_197",
"extension_request_building_permit",
"extension_request_betterment_levy",
"extension_request_compensation",
"unknown",
] as const satisfies readonly AppealSubtype[]),
});