Close out the read-only surface before cutover with three families of small fixes that the previous phases left unfinished: - Error boundaries: add src/app/error.tsx (route-segment), global-error.tsx (root crash fallback with its own minimal html/body — no Providers dependency since those may be the thing that crashed), and not-found.tsx for a Hebrew 404 instead of the default Next page. - Accessibility: wire usePathname() into AppShell so the current nav item gets aria-current="page" and a gold underline. Add aria-label + aria-hidden on the icon-only buttons that Phase 5 left text-less (corpus trash, parties-field Plus). Nav gets an aria-label of its own. - Metadata template: title on each route now reads "X · עוזר משפטי" via the layout.tsx title.template. Description localized to Jerusalem. - README: full E2E smoke test checklist covering all 9 screens, plus a backend contract table so future phases know which hook wraps which endpoint. Documents the known Gitea→Coolify webhook issue. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
141 lines
4.6 KiB
TypeScript
141 lines
4.6 KiB
TypeScript
"use client";
|
||
|
||
import { Trash2 } 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";
|
||
|
||
/*
|
||
* Corpus tab: table of all decisions currently in the style corpus, with a
|
||
* single destructive action (remove from corpus). Uses browser confirm() for
|
||
* the confirmation — a full shadcn AlertDialog would be overkill for an
|
||
* admin-only destructive action with a server-side safety net.
|
||
*/
|
||
|
||
function formatChars(n: number) {
|
||
return `${(n / 1000).toFixed(1)}K`;
|
||
}
|
||
|
||
function formatDate(iso: string) {
|
||
if (!iso) return "—";
|
||
try {
|
||
return new Date(iso).toLocaleDateString("he-IL");
|
||
} catch {
|
||
return iso;
|
||
}
|
||
}
|
||
|
||
function Row({ item }: { item: CorpusDecision }) {
|
||
const del = useDeleteCorpusEntry();
|
||
const onDelete = async () => {
|
||
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">
|
||
<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.map((s) => (
|
||
<Badge
|
||
key={s}
|
||
variant="outline"
|
||
className="text-[0.7rem] bg-gold-wash text-gold-deep border-gold/40"
|
||
>
|
||
{s}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="text-ink-soft tabular-nums">
|
||
{formatChars(item.chars)}
|
||
</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();
|
||
|
||
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" />
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{isPending ? (
|
||
[...Array(4)].map((_, i) => (
|
||
<TableRow key={i} className="border-rule">
|
||
{[...Array(6)].map((_, j) => (
|
||
<TableCell key={j}>
|
||
<Skeleton className="h-4 w-24" />
|
||
</TableCell>
|
||
))}
|
||
</TableRow>
|
||
))
|
||
) : data?.length === 0 ? (
|
||
<TableRow>
|
||
<TableCell colSpan={6} className="text-center text-ink-muted py-12">
|
||
הקורפוס ריק
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
data?.map((item) => <Row key={item.id} item={item} />)
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
);
|
||
}
|