Files
legal-ai/web-ui/src/components/cases/cases-table.tsx
Chaim 81050181d7
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
fix(ui): nav & layout — decision-editor in band, compose back-link, wider cases table
Chair-directed navigation/layout fixes (existing components, no new design):

1. "פתח עורך החלטה" moved into the case-page band actions (right after "העלאת
   מסמכים") so it's reachable from EVERY tab, not only סקירה. Removed the now-
   redundant full-width CTA from the overview tab.
2. Prominent "→ חזרה לדף התיק" back-link added to the /compose header (the
   breadcrumb link was too subtle).
3. Home cases table: rail trimmed 360→280px and the title cell made min-w-0 so the
   table gets the width it needs and no longer shows a horizontal scrollbar.

No backend / API change. The larger NEW citation-verification panel stays gated
behind Claude Design (preview 24-citation-verification already pushed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 17:56:21 +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] min-w-0 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>
);
}