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>
178 lines
6.3 KiB
TypeScript
178 lines
6.3 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { Trash2, Sparkles } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import {
|
||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||
} from "@/components/ui/table";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { useCorpus, useDeleteCorpusEntry, type CorpusDecision } from "@/lib/api/training";
|
||
import { CorpusDetailDrawer } from "./corpus-detail-drawer";
|
||
import { formatDateShort as formatDate } from "@/lib/format-date";
|
||
|
||
/*
|
||
* Corpus tab: table of all decisions currently in the style corpus.
|
||
*
|
||
* Click any row → opens CorpusDetailDrawer with the enriched metadata
|
||
* + edit UI. The trash button is now in its own narrow column and uses
|
||
* stopPropagation so deleting a row doesn't also open the drawer.
|
||
*
|
||
* We use browser confirm() for the destructive action rather than a
|
||
* full shadcn AlertDialog because this is a single admin operation
|
||
* gated by an API-level safety net (FK cascade is best-effort but
|
||
* style_corpus DELETE returns 404 on missing rows, so the worst case
|
||
* is a no-op).
|
||
*/
|
||
|
||
function formatChars(n: number) {
|
||
return `${(n / 1000).toFixed(1)}K`;
|
||
}
|
||
|
||
function Row({
|
||
item, onOpen,
|
||
}: { item: CorpusDecision; onOpen: () => void }) {
|
||
const del = useDeleteCorpusEntry();
|
||
const onDelete = async (e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
if (!window.confirm(`למחוק את החלטה ${item.decision_number} מהקורפוס?`)) return;
|
||
try {
|
||
await del.mutateAsync(item.id);
|
||
toast.success("נמחק מהקורפוס");
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "שגיאה במחיקה");
|
||
}
|
||
};
|
||
|
||
return (
|
||
<TableRow
|
||
className="border-rule hover:bg-gold-wash/30 cursor-pointer"
|
||
onClick={onOpen}
|
||
>
|
||
<TableCell className="font-semibold text-navy tabular-nums">
|
||
{item.decision_number || "—"}
|
||
</TableCell>
|
||
<TableCell className="text-ink-muted tabular-nums">
|
||
{formatDate(item.decision_date)}
|
||
</TableCell>
|
||
<TableCell>
|
||
{item.subject_categories.length === 0 ? (
|
||
<span className="text-ink-light">—</span>
|
||
) : (
|
||
<div className="flex flex-wrap gap-1">
|
||
{item.subject_categories.slice(0, 3).map((s) => (
|
||
<Badge key={s} variant="outline"
|
||
className="text-[0.7rem] bg-gold-wash text-gold-deep border-gold/40">
|
||
{s}
|
||
</Badge>
|
||
))}
|
||
{item.subject_categories.length > 3 && (
|
||
<span className="text-[0.7rem] text-ink-muted">
|
||
+{item.subject_categories.length - 3}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="text-[0.78rem] text-ink-soft">
|
||
<div className="flex items-center gap-2">
|
||
<span className="truncate">{item.legal_citation || "—"}</span>
|
||
{item.lessons_count > 0 && (
|
||
<Badge variant="outline"
|
||
className="text-[0.7rem] bg-info-bg text-info border-info/40 shrink-0">
|
||
<Sparkles className="w-3 h-3 me-0.5" />
|
||
{item.lessons_count}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="text-ink-soft tabular-nums">
|
||
{formatChars(item.chars)}
|
||
{item.page_count > 0 && (
|
||
<span className="text-ink-muted text-[0.72rem] ms-1">
|
||
· {item.page_count} ע׳
|
||
</span>
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="text-ink-muted tabular-nums text-[0.78rem]">
|
||
{formatDate(item.created_at)}
|
||
</TableCell>
|
||
<TableCell className="text-end">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={onDelete}
|
||
disabled={del.isPending}
|
||
aria-label={`הסר את ${item.decision_number || "החלטה זו"} מהקורפוס`}
|
||
className="text-danger hover:text-danger hover:bg-danger-bg"
|
||
>
|
||
<Trash2 className="w-4 h-4" aria-hidden="true" />
|
||
</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
}
|
||
|
||
export function CorpusPanel() {
|
||
const { data, isPending, error } = useCorpus();
|
||
const [selected, setSelected] = useState<CorpusDecision | null>(null);
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center">
|
||
{error.message}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
|
||
<Table>
|
||
<TableHeader className="bg-rule-soft/60">
|
||
<TableRow className="border-rule">
|
||
<TableHead className="text-navy text-right">מס׳ החלטה</TableHead>
|
||
<TableHead className="text-navy text-right">תאריך</TableHead>
|
||
<TableHead className="text-navy text-right">נושאים</TableHead>
|
||
<TableHead className="text-navy text-right">מראה מקום</TableHead>
|
||
<TableHead className="text-navy text-right">תווים / עמודים</TableHead>
|
||
<TableHead className="text-navy text-right">נוסף בתאריך</TableHead>
|
||
<TableHead className="text-navy" />
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{isPending ? (
|
||
[...Array(4)].map((_, i) => (
|
||
<TableRow key={i} className="border-rule">
|
||
{[...Array(7)].map((_, j) => (
|
||
<TableCell key={j}>
|
||
<Skeleton className="h-4 w-24" />
|
||
</TableCell>
|
||
))}
|
||
</TableRow>
|
||
))
|
||
) : data?.length === 0 ? (
|
||
<TableRow>
|
||
<TableCell colSpan={7} className="text-center text-ink-muted py-12">
|
||
הקורפוס ריק
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
data?.map((item) => (
|
||
<Row key={item.id} item={item} onOpen={() => setSelected(item)} />
|
||
))
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
|
||
<CorpusDetailDrawer
|
||
decision={selected}
|
||
onOpenChange={(open) => { if (!open) setSelected(null); }}
|
||
/>
|
||
</>
|
||
);
|
||
}
|