Files
legal-ai/web-ui/src/app/archive/page.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

352 lines
13 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 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>
);
}