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>
352 lines
13 KiB
TypeScript
352 lines
13 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo, useState } from "react";
|
||
import Link from "next/link";
|
||
import {
|
||
flexRender,
|
||
getCoreRowModel,
|
||
getFilteredRowModel,
|
||
getSortedRowModel,
|
||
useReactTable,
|
||
type ColumnDef,
|
||
type SortingState,
|
||
} from "@tanstack/react-table";
|
||
import { toast } from "sonner";
|
||
import { AppShell } from "@/components/app-shell";
|
||
import { Card, CardContent } from "@/components/ui/card";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import { useCases, useRestoreCase, type Case } from "@/lib/api/cases";
|
||
import { subtypeOf } from "@/components/cases/appeal-type-bars";
|
||
import { APPEAL_SUBTYPE_LABELS, type AppealSubtype } from "@/lib/practice-area";
|
||
import { formatDate, getIsraelYear } from "@/lib/format-date";
|
||
|
||
// type chip styling per mockup 05 (.t-lic / .t-bet / .t-comp)
|
||
const TYPE_CHIP: Record<string, string> = {
|
||
building_permit: "bg-info-bg text-info",
|
||
betterment_levy: "bg-gold-wash text-gold-deep border border-rule",
|
||
compensation_197: "bg-rule-soft text-ink-soft",
|
||
};
|
||
|
||
// outcome chip styling per mockup 05 (.r-acc / .r-rej / .r-part)
|
||
const OUTCOME_CHIP: Record<string, { label: string; cls: string }> = {
|
||
full_acceptance: { label: "התקבל", cls: "bg-success-bg text-success" },
|
||
partial_acceptance: { label: "חלקי", cls: "bg-warn-bg text-warn" },
|
||
rejection: { label: "נדחה", cls: "bg-danger-bg text-danger" },
|
||
};
|
||
|
||
function RestoreButton({ caseNumber }: { caseNumber: string }) {
|
||
const restore = useRestoreCase(caseNumber);
|
||
return (
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={restore.isPending}
|
||
onClick={() => {
|
||
restore.mutate(undefined, {
|
||
onSuccess: (res) => {
|
||
const pc = res.paperclip?.status;
|
||
if (pc === "restored") {
|
||
toast.success(`התיק שוחזר. גם הפרויקט ב-Paperclip שוחזר.`);
|
||
} else if (pc === "not_found") {
|
||
toast.success("התיק שוחזר. לא נמצא פרויקט מתאים ב-Paperclip.");
|
||
} else if (pc === "error") {
|
||
toast.warning(
|
||
`התיק שוחזר ב-legal-ai, אך סנכרון Paperclip נכשל: ${res.paperclip?.message ?? ""}`,
|
||
);
|
||
} else {
|
||
toast.success("התיק שוחזר.");
|
||
}
|
||
},
|
||
onError: (err) =>
|
||
toast.error(err instanceof Error ? err.message : "שגיאה בשחזור"),
|
||
});
|
||
}}
|
||
>
|
||
{restore.isPending ? "משחזר..." : "שחזר"}
|
||
</Button>
|
||
);
|
||
}
|
||
|
||
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-soft max-w-[420px] truncate" title={row.original.title}>
|
||
{row.original.title}
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
accessorKey: "appeal_subtype",
|
||
header: "סוג",
|
||
cell: ({ row }) => {
|
||
const s = subtypeOf(row.original);
|
||
if (!s || s === "unknown")
|
||
return <span className="text-ink-muted">—</span>;
|
||
return (
|
||
<span
|
||
className={`inline-block rounded-full px-2.5 py-0.5 text-[0.72rem] font-semibold whitespace-nowrap ${
|
||
TYPE_CHIP[s] ?? "bg-rule-soft text-ink-soft"
|
||
}`}
|
||
>
|
||
{APPEAL_SUBTYPE_LABELS[s as AppealSubtype]}
|
||
</span>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
accessorKey: "expected_outcome",
|
||
header: "תוצאה",
|
||
cell: ({ row }) => {
|
||
const o = row.original.expected_outcome;
|
||
const chip = o ? OUTCOME_CHIP[o] : undefined;
|
||
if (!chip) return <span className="text-ink-muted">—</span>;
|
||
return (
|
||
<span
|
||
className={`inline-block rounded-full px-2.5 py-0.5 text-[0.72rem] font-semibold whitespace-nowrap ${chip.cls}`}
|
||
>
|
||
{chip.label}
|
||
</span>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
accessorKey: "archived_at",
|
||
header: "תאריך ארכוב",
|
||
cell: ({ row }) => (
|
||
<span className="text-ink-muted text-sm tabular-nums whitespace-nowrap">
|
||
{formatDate(row.original.archived_at)}
|
||
</span>
|
||
),
|
||
},
|
||
{
|
||
id: "actions",
|
||
header: "",
|
||
cell: ({ row }) => <RestoreButton caseNumber={row.original.case_number} />,
|
||
},
|
||
];
|
||
|
||
export default function ArchivePage() {
|
||
const { data, isPending, error } = useCases(true, "archived");
|
||
const [sorting, setSorting] = useState<SortingState>([
|
||
{ id: "archived_at", desc: true },
|
||
]);
|
||
const [globalFilter, setGlobalFilter] = useState("");
|
||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||
const [yearFilter, setYearFilter] = useState<string>("all");
|
||
|
||
const rows = useMemo(() => data ?? [], [data]);
|
||
|
||
// years present in the archive (by archive date) — for the year filter (mockup 05)
|
||
const years = useMemo(() => {
|
||
const set = new Set<string>();
|
||
for (const c of rows) {
|
||
if (!c.archived_at) continue;
|
||
const y = getIsraelYear(c.archived_at);
|
||
if (y !== null) set.add(String(y));
|
||
}
|
||
return [...set].sort((a, b) => Number(b) - Number(a));
|
||
}, [rows]);
|
||
|
||
const table = useReactTable({
|
||
data: rows,
|
||
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)
|
||
);
|
||
},
|
||
});
|
||
|
||
// domain filter applied client-side (subtypeOf collapses בל"מ variants)
|
||
const filteredRows = useMemo(() => {
|
||
let all = table.getFilteredRowModel().rows;
|
||
if (typeFilter !== "all") all = all.filter((r) => subtypeOf(r.original) === typeFilter);
|
||
if (yearFilter !== "all") {
|
||
all = all.filter((r) => {
|
||
const iso = r.original.archived_at;
|
||
return iso != null && String(getIsraelYear(iso)) === yearFilter;
|
||
});
|
||
}
|
||
return all;
|
||
}, [table, typeFilter, yearFilter, globalFilter, sorting, rows]);
|
||
|
||
const total = rows.length;
|
||
const shown = filteredRows.length;
|
||
|
||
return (
|
||
<AppShell>
|
||
<section className="space-y-6">
|
||
<header className="space-y-1.5">
|
||
<nav className="text-[0.78rem] text-ink-muted mb-1">
|
||
<Link href="/" className="hover:text-gold-deep">בית</Link>
|
||
<span aria-hidden> · </span>
|
||
<span className="text-navy">ארכיון</span>
|
||
</nav>
|
||
<div className="space-y-1">
|
||
<div className="text-[0.75rem] uppercase tracking-[0.12em] text-gold-deep">
|
||
ארכיון תיקי ערר
|
||
</div>
|
||
<h1 className="text-navy mb-0">ארכיון</h1>
|
||
<p className="text-ink-muted text-base max-w-2xl leading-relaxed">
|
||
כל ההחלטות שהושלמו — לחיפוש, סינון ועיון. {total} תיקים סגורים.
|
||
שחזור מחזיר את התיק לרשימה הראשית ופותח מחדש את הפרויקט המקביל ב-Paperclip.
|
||
</p>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||
|
||
{/* filter bar — search + domain select + count aligned to end (mockup 05 .filters) */}
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
<Input
|
||
value={globalFilter}
|
||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||
placeholder="חיפוש לפי מספר ערר, כותרת או צד…"
|
||
className="flex-1 min-w-[220px] bg-surface"
|
||
dir="rtl"
|
||
/>
|
||
<select
|
||
value={typeFilter}
|
||
onChange={(e) => setTypeFilter(e.target.value)}
|
||
className="cursor-pointer text-[0.84rem] text-ink-soft bg-surface border border-rule rounded-lg px-3.5 py-2"
|
||
>
|
||
<option value="all">כל סוגי הערר</option>
|
||
<option value="building_permit">רישוי ובנייה</option>
|
||
<option value="betterment_levy">היטל השבחה</option>
|
||
<option value="compensation_197">פיצויים (ס׳ 197)</option>
|
||
</select>
|
||
{years.length > 0 ? (
|
||
<select
|
||
value={yearFilter}
|
||
onChange={(e) => setYearFilter(e.target.value)}
|
||
className="cursor-pointer text-[0.84rem] text-ink-soft bg-surface border border-rule rounded-lg px-3.5 py-2"
|
||
>
|
||
<option value="all">כל השנים</option>
|
||
{years.map((y) => (
|
||
<option key={y} value={y}>{y}</option>
|
||
))}
|
||
</select>
|
||
) : null}
|
||
<span className="ms-auto text-[0.82rem] text-ink-muted tabular-nums">
|
||
מציג {shown} מתוך {total}
|
||
</span>
|
||
</div>
|
||
|
||
{/* clean bordered table — parchment header, gold-wash hover (mockup 05 .card table) */}
|
||
<Card className="bg-surface border-rule shadow-sm overflow-hidden p-0">
|
||
<CardContent className="p-0">
|
||
<Table>
|
||
<TableHeader className="bg-parchment">
|
||
{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-ink-muted font-medium text-[0.78rem] cursor-pointer select-none text-start"
|
||
>
|
||
{flexRender(
|
||
header.column.columnDef.header,
|
||
header.getContext(),
|
||
)}
|
||
{{ asc: " ▲", desc: " ▼" }[
|
||
header.column.getIsSorted() as string
|
||
] ?? ""}
|
||
</TableHead>
|
||
))}
|
||
</TableRow>
|
||
))}
|
||
</TableHeader>
|
||
<TableBody>
|
||
{isPending ? (
|
||
Array.from({ length: 3 }).map((_, i) => (
|
||
<TableRow key={i} className="border-rule-soft">
|
||
{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>
|
||
) : filteredRows.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 || typeFilter !== "all" || yearFilter !== "all"
|
||
? "אין תיקים תואמים לחיפוש"
|
||
: "אין תיקים בארכיון"}
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
filteredRows.map((row) => (
|
||
<TableRow
|
||
key={row.id}
|
||
className="border-rule-soft hover:bg-gold-wash transition-colors"
|
||
>
|
||
{row.getVisibleCells().map((cell) => (
|
||
<TableCell key={cell.id} className="py-3">
|
||
{flexRender(
|
||
cell.column.columnDef.cell,
|
||
cell.getContext(),
|
||
)}
|
||
</TableCell>
|
||
))}
|
||
</TableRow>
|
||
))
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</CardContent>
|
||
</Card>
|
||
</section>
|
||
</AppShell>
|
||
);
|
||
}
|