Case archive/restore with Paperclip sync
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m27s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m27s
Adds a comprehensive archive flow for closed cases — separate /archive
screen in the UI, archive/restore actions on the case detail page, and
automatic two-way sync with Paperclip.
Backend (web/app.py + mcp-server/services/db.py):
- New SCHEMA_V6 migration: cases.archived_at TIMESTAMPTZ + partial index
- list_cases gains include_archived/archived_only flags; default excludes
archived rows so the main /api/cases list hides closed cases
- archive_case / restore_case helpers in db.py
- POST /api/cases/{n}/archive sets archived_at and calls
pc_archive_project (sets Paperclip projects.archived_at via direct DB)
- POST /api/cases/{n}/restore clears archived_at and calls
pc_restore_project (clears Paperclip archived_at)
- archive_project / restore_project in paperclip_client.py — name-based
match consistent with create_project's lookup
Frontend (web-ui):
- cases.ts: scope param ("active"|"archived"|"all") on useCases;
useArchiveCase / useRestoreCase mutations
- /archive page (new): table of archived cases with restore button +
search, sort, empty state matching the editorial aesthetic of /
- case-archive-action.tsx: button on case detail header. Active case →
confirm dialog → archive. Archived case → restore (no confirm).
Toast announces both legal-ai and Paperclip outcomes (synced, not
found in pc, error)
- case-header shows "בארכיון" badge when archived_at is set
- Nav: ארכיון link added to AppShell after בית
Tested end-to-end against the live DB:
- 1130-25 archive → list_cases(include_archived=False) excludes it,
list_cases(archived_only=True) includes it, restore reverses
- pc archive/restore on 1194-25 verified via direct DB lookup
- TypeScript compiles clean
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
268
web-ui/src/app/archive/page.tsx
Normal file
268
web-ui/src/app/archive/page.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
"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 { APPEAL_SUBTYPE_LABELS } from "@/lib/practice-area";
|
||||
|
||||
function formatDate(iso?: string | null) {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("he-IL", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
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 max-w-[420px] truncate" title={row.original.title}>
|
||||
{row.original.title}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "appeal_subtype",
|
||||
header: "תחום",
|
||||
cell: ({ row }) => {
|
||||
const s = row.original.appeal_subtype;
|
||||
if (!s || s === "unknown")
|
||||
return <span className="text-ink-muted">—</span>;
|
||||
return (
|
||||
<span className="text-ink-soft text-sm">{APPEAL_SUBTYPE_LABELS[s]}</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "archived_at",
|
||||
header: "תאריך ארכוב",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-ink-muted text-sm tabular-nums">
|
||||
{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 rows = useMemo(() => data ?? [], [data]);
|
||||
|
||||
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)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<section className="space-y-8">
|
||||
<header className="space-y-1.5">
|
||||
<div className="text-[0.75rem] uppercase tracking-[0.12em] text-gold-deep">
|
||||
ארכיון תיקי ערר
|
||||
</div>
|
||||
<h1 className="text-navy">תיקים סגורים</h1>
|
||||
<p className="text-ink-muted text-base max-w-2xl leading-relaxed">
|
||||
תיקים שסגרו את הטיפול בהם. שחזור מחזיר את התיק לרשימה הראשית
|
||||
ופותח מחדש את הפרויקט המקביל ב-Paperclip.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
placeholder="חיפוש לפי מס׳ ערר או כותרת…"
|
||||
className="max-w-sm bg-surface"
|
||||
dir="rtl"
|
||||
/>
|
||||
<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>
|
||||
{isPending ? (
|
||||
Array.from({ length: 3 }).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
|
||||
? "אין תיקים תואמים לחיפוש"
|
||||
: "אין תיקים בארכיון"}
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user