Files
legal-ai/web-ui/src/components/cases/cases-table.tsx
Chaim 67c2c43777
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
fix(web-ui): display all human-facing timestamps in Asia/Jerusalem (deterministic)
Storage stays UTC (DB TIMESTAMPTZ, API ISO-UTC) — only the display layer is
localized, and now deterministically: every timestamp renders pinned to
Asia/Jerusalem via a single Intl-based formatter, so SSR (UTC container) and
the browser agree on any runtime. No layout/visible-format change — only the tz.

- New single date formatter web-ui/src/lib/format-date.ts (G2): formatDate /
  formatDateShort / formatDateLong / formatDateTime / formatDateTimeFull /
  formatTime / formatIsoDate + Israel helpers getIsraelYear / israelDayKey /
  israelMidnightMs / israelParts + formatRelative (long/short/tight wording).
- Routed all ad-hoc toLocaleDateString/toLocaleTimeString/toLocaleString +
  hand-rolled new Date(...).get*() / toISOString().slice(0,10) timestamp call
  sites (30 files) through it. Number toLocaleString left untouched.
- Spec: added INV-UI9 to docs/spec/X6 (UTC storage, Asia/Jerusalem display).

Display-only; no layout/design/IA change → Claude Design gate N/A.
Invariants: G2 (single date formatter, no parallel ad-hoc formatting), INV-UI9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:18:09 +00:00

305 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";
import { formatDate, israelMidnightMs, todayIsraelMidnightMs } from "@/lib/format-date";
/** Midnight today in Israel time, in ms — pivot between an upcoming and a past hearing. */
function startOfToday() {
return todayIsraelMidnightMs();
}
/** Day-precision ms (Israel time) for a hearing date, or null when absent/unparseable. */
function hearingMs(iso?: string | null): number | null {
return israelMidnightMs(iso);
}
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>
);
}