feat: Stage A finalizers + #35/#36/#37 — critical-gap closure
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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="בקשה להארכת מועד להגשת ערר"
|
||||
>
|
||||
בל"מ
|
||||
</Badge>
|
||||
)}
|
||||
{data?.case_number && (
|
||||
<CaseArchiveAction
|
||||
caseNumber={data.case_number}
|
||||
|
||||
@@ -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="בקשה להארכת מועד להגשת ערר"
|
||||
>
|
||||
בל"מ
|
||||
</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">בל"מ בלבד</SelectItem>
|
||||
<SelectItem value="regular">ערר רגיל בלבד</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-ink-muted me-auto">
|
||||
{table.getFilteredRowModel().rows.length} תיקים
|
||||
</span>
|
||||
|
||||
222
web-ui/src/components/cases/legal-arguments-panel.tsx
Normal file
222
web-ui/src/components/cases/legal-arguments-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
ניתוב אוטומטי לפי הציטוט:
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
66
web-ui/src/components/ui/accordion.tsx
Normal file
66
web-ui/src/components/ui/accordion.tsx
Normal 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 }
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user