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>
321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
"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="בקשה להארכת מועד להגשת ערר"
|
||
>
|
||
בל"מ
|
||
</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">בל"מ בלבד</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>
|
||
);
|
||
}
|