Files
legal-ai/web-ui/src/components/cases/cases-table.tsx
Chaim 251262ab67
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
polish(ui): יישור 5 פריטי קטגוריה-A למוקאפי X17 המאושרים
ליטושים שבהם הקוד פיגר אחרי מוקאפ-מאושר (אין צורך בסבב-עיצוב חדש):
- methodology/golden-ratios: כותרות-טבלה לעברית (Section/Min/Max → בלוק/מינ׳/מקס׳),
  תיקון מחלקות directional (ml-→me-) ו-token שגוי (ink-faint→ink-muted).
- halacha PanelDeliberation: seedline "הכרעתך תיקלט כתווית-הזהב…" (מוקאפ 18).
- cases-table: תווית-זמן-יחסי ליד מועד-הדיון (היום/מחר/בעוד N ימים, מוקאפ 04b).
- operations BURST: הדגשת chip ברירת-המחדל "שבת 18:00 (ברירת-מחדל)" (מוקאפ 02b).
- archive: סלקטור-שנים (נגזר מ-archived_at) לצד סלקטור-הסוג (מוקאפ 05).

נדחו (לא pure-alignment): chips סינון-מקור בפסיקה-חסרה (דורש פרם-סינון
discovery_source ב-backend); סדר/שמות-טאבים באימון+מתודולוגיה (סיכון לדרוס
שמות שנבחרו בכוונה — הסוכן/שיחה).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 19:16:12 +00:00

321 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type Row,
type SortingState,
} from "@tanstack/react-table";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} 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) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString("he-IL", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
} catch {
return iso;
}
}
/** Midnight today, in ms — the pivot between an upcoming and a past hearing. */
function startOfToday() {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d.getTime();
}
/** Day-precision ms for a hearing date, or null when absent/unparseable. */
function hearingMs(iso?: string | null): number | null {
if (!iso) return null;
const t = new Date(iso).setHours(0, 0, 0, 0);
return Number.isNaN(t) ? null : t;
}
type HearingClass = "soon" | "upcoming" | "past" | "none";
/** Classify a hearing date for the cell's colour cue (within ~7 days = "soon"). */
function classifyHearing(iso?: string | null): HearingClass {
const t = hearingMs(iso);
if (t === null) return "none";
const today = startOfToday();
if (t < today) return "past";
return t - today <= 7 * 86_400_000 ? "soon" : "upcoming";
}
const HEARING_CLASS_STYLE: Record<HearingClass, string> = {
soon: "text-gold-deep font-semibold",
upcoming: "text-navy font-semibold",
past: "text-ink-light",
none: "text-ink-light",
};
/** Relative-time label beside the hearing date (mockup 04b `.hd-rel`). */
function relativeHearing(iso?: string | null): string | null {
const t = hearingMs(iso);
if (t === null) return null;
const days = Math.round((t - startOfToday()) / 86_400_000);
if (days === 0) return "היום";
if (days === 1) return "מחר";
if (days === -1) return "אתמול";
return days > 0 ? `בעוד ${days} ימים` : `לפני ${Math.abs(days)} ימים`;
}
/**
* Default ordering for the case list: the nearest upcoming hearing on top,
* past hearings sinking below it (most-recently-passed first), and cases with
* no hearing date last. Authored as a non-inverting comparator, so the column's
* initial (ascending) sort yields exactly this sequence.
*/
function hearingDateSort(a: Row<Case>, b: Row<Case>): number {
const today = startOfToday();
// group: 0 = upcoming (incl. today), 1 = past, 2 = no date
const rank = (iso?: string | null): { g: number; t: number } => {
const t = hearingMs(iso);
if (t === null) return { g: 2, t: 0 };
return t >= today ? { g: 0, t } : { g: 1, t };
};
const ra = rank(a.original.hearing_date);
const rb = rank(b.original.hearing_date);
if (ra.g !== rb.g) return ra.g - rb.g;
if (ra.g === 0) return ra.t - rb.t; // upcoming: ascending (nearest first)
if (ra.g === 1) return rb.t - ra.t; // past: descending (most recent first)
return 0; // both undated — keep stable order
}
const columns: ColumnDef<Case>[] = [
{
accessorKey: "case_number",
header: "מס׳ ערר",
cell: ({ row }) => (
<Link
href={`/cases/${row.original.case_number}`}
className="text-navy font-semibold hover:text-gold-deep tabular-nums"
>
{row.original.case_number}
</Link>
),
},
{
accessorKey: "title",
header: "כותרת",
cell: ({ row }) => (
<div className="text-ink max-w-[420px] truncate flex items-center gap-2" title={row.original.title}>
{(row.original.proceeding_type === 'בל"מ' || 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>
),
},
{
accessorKey: "status",
header: "סטטוס",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
accessorKey: "document_count",
header: "מסמכים",
cell: ({ row }) => (
<span className="tabular-nums text-ink-soft">
{row.original.document_count ?? "—"}
</span>
),
},
{
accessorKey: "hearing_date",
header: "מועד דיון",
sortingFn: hearingDateSort,
cell: ({ row }) => {
const klass = classifyHearing(row.original.hearing_date);
const rel = relativeHearing(row.original.hearing_date);
return (
<div className="leading-tight">
<span className={`tabular-nums text-sm ${HEARING_CLASS_STYLE[klass]}`}>
{formatDate(row.original.hearing_date ?? undefined)}
</span>
{rel ? (
<span className="block text-[0.7rem] text-ink-muted">{rel}</span>
) : null}
</div>
);
},
},
{
accessorKey: "updated_at",
header: "עודכן",
cell: ({ row }) => (
<span className="text-ink-muted text-sm">{formatDate(row.original.updated_at)}</span>
),
},
];
export function CasesTable({
cases,
loading,
error,
emptyText = "עדיין אין תיקי ערר",
searchPlaceholder = "חיפוש לפי מס׳ ערר או כותרת…",
}: {
cases?: Case[];
loading?: boolean;
error?: Error | null;
emptyText?: string;
searchPlaceholder?: string;
}) {
// Default: nearest upcoming hearing on top; past hearings sink below
// (most-recently-passed first); undated cases last. desc:false keeps the
// hearingDateSort comparator's intended (non-inverted) sequence.
const [sorting, setSorting] = useState<SortingState>([
{ id: "hearing_date", desc: false },
]);
const [globalFilter, setGlobalFilter] = useState("");
/* "all" = all cases; "blam" = only בל"מ; "regular" = exclude בל"מ */
const [blamFilter, setBlamFilter] = useState<"all" | "blam" | "regular">("all");
const data = useMemo(() => {
const all = cases ?? [];
const isBlam = (c: Case) =>
c.proceeding_type === 'בל"מ' || isBlamSubtype(c.appeal_subtype);
if (blamFilter === "blam") return all.filter(isBlam);
if (blamFilter === "regular") return all.filter((c) => !isBlam(c));
return all;
}, [cases, blamFilter]);
const table = useReactTable({
data,
columns,
state: { sorting, globalFilter },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
globalFilterFn: (row, _colId, filterValue: string) => {
if (!filterValue) return true;
const needle = filterValue.toLowerCase();
return (
row.original.case_number.toLowerCase().includes(needle) ||
row.original.title.toLowerCase().includes(needle)
);
},
});
return (
<div className="space-y-3">
<div className="flex items-center gap-3">
<Input
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
placeholder={searchPlaceholder}
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>
</div>
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-rule-soft/60">
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id} className="border-rule">
{hg.headers.map((header) => (
<TableHead
key={header.id}
onClick={header.column.getToggleSortingHandler()}
className="text-navy font-semibold cursor-pointer select-none text-right"
>
{flexRender(header.column.columnDef.header, header.getContext())}
{{ asc: " ▲", desc: " ▼" }[header.column.getIsSorted() as string] ?? ""}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{loading ? (
Array.from({ length: 4 }).map((_, i) => (
<TableRow key={i} className="border-rule">
{columns.map((_c, j) => (
<TableCell key={j}>
<Skeleton className="h-4 w-24" />
</TableCell>
))}
</TableRow>
))
) : error ? (
<TableRow>
<TableCell colSpan={columns.length} className="text-center text-danger py-8">
שגיאה בטעינת תיקים: {error.message}
</TableCell>
</TableRow>
) : table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length} className="text-center text-ink-muted py-12">
<div className="text-gold text-2xl mb-2" aria-hidden></div>
{globalFilter ? "אין תיקים תואמים לחיפוש" : emptyText}
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
className="border-rule hover:bg-gold-wash/40 transition-colors"
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-3">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}