Case archive/restore with Paperclip sync
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:
2026-04-27 18:54:52 +00:00
parent 8b816c8b61
commit 2b7f291928
8 changed files with 629 additions and 21 deletions

View 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>
);
}

View File

@@ -24,6 +24,7 @@ type NavItem = {
const NAV_ITEMS: NavItem[] = [
{ href: "/", label: "בית" },
{ href: "/archive", label: "ארכיון" },
{ href: "/training", label: "אימון סגנון" },
{ href: "/methodology", label: "מתודולוגיה" },
{ href: "/skills", label: "מיומנויות" },

View File

@@ -0,0 +1,120 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Archive, RotateCcw, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useArchiveCase, useRestoreCase, type ArchiveResult } from "@/lib/api/cases";
function paperclipMessage(res: ArchiveResult, action: "archive" | "restore"): string {
const verb = action === "archive" ? "אורכן" : "שוחזר";
switch (res.paperclip?.status) {
case "archived":
case "restored":
return `התיק ${verb}. גם הפרויקט ב-Paperclip ${verb}.`;
case "not_found":
return `התיק ${verb}. לא נמצא פרויקט מקביל ב-Paperclip.`;
case "error":
return `התיק ${verb} ב-legal-ai, אך סנכרון Paperclip נכשל${
res.paperclip?.message ? `: ${res.paperclip.message}` : "."
}`;
default:
return `התיק ${verb}.`;
}
}
export function CaseArchiveAction({
caseNumber,
archivedAt,
}: {
caseNumber: string;
archivedAt?: string | null;
}) {
const [open, setOpen] = useState(false);
const archive = useArchiveCase(caseNumber);
const restore = useRestoreCase(caseNumber);
const isArchived = Boolean(archivedAt);
const pending = archive.isPending || restore.isPending;
function handleArchive() {
archive.mutate(undefined, {
onSuccess: (res) => {
setOpen(false);
toast.success(paperclipMessage(res, "archive"));
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : "שגיאה בארכוב"),
});
}
function handleRestore() {
restore.mutate(undefined, {
onSuccess: (res) => {
toast.success(paperclipMessage(res, "restore"));
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : "שגיאה בשחזור"),
});
}
if (isArchived) {
return (
<Button
variant="outline"
size="sm"
disabled={pending}
onClick={handleRestore}
>
{pending ? (
<Loader2 className="me-1 h-3.5 w-3.5 animate-spin" />
) : (
<RotateCcw className="me-1 h-3.5 w-3.5" />
)}
שחזר מהארכיון
</Button>
);
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" disabled={pending}>
{pending ? (
<Loader2 className="me-1 h-3.5 w-3.5 animate-spin" />
) : (
<Archive className="me-1 h-3.5 w-3.5" />
)}
ארכן תיק
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>ארכון תיק {caseNumber}</DialogTitle>
<DialogDescription>
התיק יוסר מרשימת התיקים הראשית ויעבור לעמוד הארכיון. הפרויקט
המקביל ב-Paperclip יסומן גם הוא כארכוב. ניתן לשחזר בכל עת.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">בטל</Button>
</DialogClose>
<Button onClick={handleArchive} disabled={pending}>
{pending ? "מארכן..." : "ארכן"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -3,6 +3,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { StatusBadge } from "@/components/cases/status-badge";
import { SyncIndicator } from "@/components/cases/sync-indicator";
import { CaseArchiveAction } from "@/components/cases/case-archive-action";
import {
PRACTICE_AREA_LABELS,
APPEAL_SUBTYPE_LABELS,
@@ -41,6 +42,14 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
ערר {data?.case_number ?? "—"}
</span>
{data?.status && <StatusBadge status={data.status} />}
{data?.archived_at && (
<Badge
variant="outline"
className="rounded-full px-2.5 py-0.5 text-[0.72rem] font-medium bg-ink-muted/10 text-ink-muted border-ink-muted/30"
>
בארכיון
</Badge>
)}
{data?.practice_area && (
<Badge
variant="outline"
@@ -52,6 +61,12 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
)}
</Badge>
)}
{data?.case_number && (
<CaseArchiveAction
caseNumber={data.case_number}
archivedAt={data.archived_at}
/>
)}
</div>
<h1 className="text-navy text-xl font-bold leading-snug max-w-2xl mb-0">
{data?.title ?? "טוען…"}

View File

@@ -40,6 +40,8 @@ export type Case = {
expected_outcome?: string | null;
created_at?: string;
updated_at?: string;
/** ISO timestamp; null when active */
archived_at?: string | null;
/* Multi-tenant axis — populated by backfill + server-side derive */
practice_area?: PracticeArea;
appeal_subtype?: AppealSubtype;
@@ -70,20 +72,60 @@ export type CaseDetail = Case & {
blocks?: Array<{ code: string; status?: string; char_count?: number }>;
};
export type CasesScope = "active" | "archived" | "all";
export const casesKeys = {
all: ["cases"] as const,
list: (detail: boolean) => [...casesKeys.all, "list", { detail }] as const,
list: (detail: boolean, scope: CasesScope = "active") =>
[...casesKeys.all, "list", { detail, scope }] as const,
detail: (caseNumber: string) =>
[...casesKeys.all, "detail", caseNumber] as const,
};
export function useCases(detail = false) {
export function useCases(detail = false, scope: CasesScope = "active") {
return useQuery({
queryKey: casesKeys.list(detail),
queryFn: ({ signal }) =>
apiRequest<Case[]>(`/api/cases${detail ? "?detail=true" : ""}`, {
signal,
queryKey: casesKeys.list(detail, scope),
queryFn: ({ signal }) => {
const params = new URLSearchParams();
if (detail) params.set("detail", "true");
if (scope === "archived") params.set("archived_only", "true");
else if (scope === "all") params.set("include_archived", "true");
const qs = params.toString();
return apiRequest<Case[]>(`/api/cases${qs ? `?${qs}` : ""}`, { signal });
},
});
}
export type ArchiveResult = {
status: string;
case_number: string;
archived_at?: string | null;
paperclip?: { status: string; project_id?: string; archived_at?: string | null; message?: string };
};
export function useArchiveCase(caseNumber: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: () =>
apiRequest<ArchiveResult>(`/api/cases/${caseNumber}/archive`, {
method: "POST",
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: casesKeys.all });
},
});
}
export function useRestoreCase(caseNumber: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: () =>
apiRequest<ArchiveResult>(`/api/cases/${caseNumber}/restore`, {
method: "POST",
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: casesKeys.all });
},
});
}