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:
@@ -505,6 +505,18 @@ CREATE INDEX IF NOT EXISTS idx_appraiser_facts_side ON appraiser_facts(case_id,
|
|||||||
-- No schema change needed — uses existing JSONB metadata column.
|
-- No schema change needed — uses existing JSONB metadata column.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# ── V6: Case archiving ────────────────────────────────────────────
|
||||||
|
|
||||||
|
SCHEMA_V6_SQL = """
|
||||||
|
-- archived_at: timestamp when the case was moved to the archive screen.
|
||||||
|
-- NULL = active (default). Set via POST /api/cases/{case_number}/archive.
|
||||||
|
-- Cleared via POST /api/cases/{case_number}/restore.
|
||||||
|
-- The /api/cases endpoint filters out archived cases by default;
|
||||||
|
-- pass ?include_archived=true (or use /api/cases/archived) to see them.
|
||||||
|
ALTER TABLE cases ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cases_archived ON cases(archived_at) WHERE archived_at IS NOT NULL;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
async def init_schema() -> None:
|
async def init_schema() -> None:
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
@@ -515,7 +527,8 @@ async def init_schema() -> None:
|
|||||||
await conn.execute(SCHEMA_V3_SQL)
|
await conn.execute(SCHEMA_V3_SQL)
|
||||||
await conn.execute(SCHEMA_V4_SQL)
|
await conn.execute(SCHEMA_V4_SQL)
|
||||||
await conn.execute(SCHEMA_V5_SQL)
|
await conn.execute(SCHEMA_V5_SQL)
|
||||||
logger.info("Database schema initialized (v1 + v2 + v3 + v4 + v5)")
|
await conn.execute(SCHEMA_V6_SQL)
|
||||||
|
logger.info("Database schema initialized (v1-v6)")
|
||||||
|
|
||||||
|
|
||||||
# ── Case CRUD ───────────────────────────────────────────────────────
|
# ── Case CRUD ───────────────────────────────────────────────────────
|
||||||
@@ -593,18 +606,27 @@ async def get_case_by_number(case_number: str) -> dict | None:
|
|||||||
return _row_to_case(row)
|
return _row_to_case(row)
|
||||||
|
|
||||||
|
|
||||||
async def list_cases(status: str | None = None, limit: int = 50) -> list[dict]:
|
async def list_cases(
|
||||||
|
status: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
include_archived: bool = False,
|
||||||
|
archived_only: bool = False,
|
||||||
|
) -> list[dict]:
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
where = []
|
||||||
|
args: list = []
|
||||||
if status:
|
if status:
|
||||||
rows = await conn.fetch(
|
where.append(f"status = ${len(args) + 1}")
|
||||||
"SELECT * FROM cases WHERE status = $1 ORDER BY updated_at DESC LIMIT $2",
|
args.append(status)
|
||||||
status, limit,
|
if archived_only:
|
||||||
)
|
where.append("archived_at IS NOT NULL")
|
||||||
else:
|
elif not include_archived:
|
||||||
rows = await conn.fetch(
|
where.append("archived_at IS NULL")
|
||||||
"SELECT * FROM cases ORDER BY updated_at DESC LIMIT $1", limit
|
where_clause = f"WHERE {' AND '.join(where)}" if where else ""
|
||||||
)
|
args.append(limit)
|
||||||
|
sql = f"SELECT * FROM cases {where_clause} ORDER BY updated_at DESC LIMIT ${len(args)}"
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(sql, *args)
|
||||||
return [_row_to_case(r) for r in rows]
|
return [_row_to_case(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
@@ -635,6 +657,30 @@ def _row_to_case(row: asyncpg.Record) -> dict:
|
|||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
async def archive_case(case_id: UUID) -> dict | None:
|
||||||
|
"""Mark a case as archived. Returns updated row, or None if not found."""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"UPDATE cases SET archived_at = now(), updated_at = now() "
|
||||||
|
"WHERE id = $1 RETURNING *",
|
||||||
|
case_id,
|
||||||
|
)
|
||||||
|
return _row_to_case(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def restore_case(case_id: UUID) -> dict | None:
|
||||||
|
"""Clear the archived_at timestamp. Returns updated row, or None if not found."""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"UPDATE cases SET archived_at = NULL, updated_at = now() "
|
||||||
|
"WHERE id = $1 RETURNING *",
|
||||||
|
case_id,
|
||||||
|
)
|
||||||
|
return _row_to_case(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
# ── Document CRUD ───────────────────────────────────────────────────
|
# ── Document CRUD ───────────────────────────────────────────────────
|
||||||
|
|
||||||
async def create_document(
|
async def create_document(
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ type NavItem = {
|
|||||||
|
|
||||||
const NAV_ITEMS: NavItem[] = [
|
const NAV_ITEMS: NavItem[] = [
|
||||||
{ href: "/", label: "בית" },
|
{ href: "/", label: "בית" },
|
||||||
|
{ href: "/archive", label: "ארכיון" },
|
||||||
{ href: "/training", label: "אימון סגנון" },
|
{ href: "/training", label: "אימון סגנון" },
|
||||||
{ href: "/methodology", label: "מתודולוגיה" },
|
{ href: "/methodology", label: "מתודולוגיה" },
|
||||||
{ href: "/skills", label: "מיומנויות" },
|
{ href: "/skills", label: "מיומנויות" },
|
||||||
|
|||||||
120
web-ui/src/components/cases/case-archive-action.tsx
Normal file
120
web-ui/src/components/cases/case-archive-action.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { StatusBadge } from "@/components/cases/status-badge";
|
import { StatusBadge } from "@/components/cases/status-badge";
|
||||||
import { SyncIndicator } from "@/components/cases/sync-indicator";
|
import { SyncIndicator } from "@/components/cases/sync-indicator";
|
||||||
|
import { CaseArchiveAction } from "@/components/cases/case-archive-action";
|
||||||
import {
|
import {
|
||||||
PRACTICE_AREA_LABELS,
|
PRACTICE_AREA_LABELS,
|
||||||
APPEAL_SUBTYPE_LABELS,
|
APPEAL_SUBTYPE_LABELS,
|
||||||
@@ -41,6 +42,14 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
|
|||||||
ערר {data?.case_number ?? "—"}
|
ערר {data?.case_number ?? "—"}
|
||||||
</span>
|
</span>
|
||||||
{data?.status && <StatusBadge status={data.status} />}
|
{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 && (
|
{data?.practice_area && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -52,6 +61,12 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
|
|||||||
)}
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{data?.case_number && (
|
||||||
|
<CaseArchiveAction
|
||||||
|
caseNumber={data.case_number}
|
||||||
|
archivedAt={data.archived_at}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-navy text-xl font-bold leading-snug max-w-2xl mb-0">
|
<h1 className="text-navy text-xl font-bold leading-snug max-w-2xl mb-0">
|
||||||
{data?.title ?? "טוען…"}
|
{data?.title ?? "טוען…"}
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export type Case = {
|
|||||||
expected_outcome?: string | null;
|
expected_outcome?: string | null;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
|
/** ISO timestamp; null when active */
|
||||||
|
archived_at?: string | null;
|
||||||
/* Multi-tenant axis — populated by backfill + server-side derive */
|
/* Multi-tenant axis — populated by backfill + server-side derive */
|
||||||
practice_area?: PracticeArea;
|
practice_area?: PracticeArea;
|
||||||
appeal_subtype?: AppealSubtype;
|
appeal_subtype?: AppealSubtype;
|
||||||
@@ -70,20 +72,60 @@ export type CaseDetail = Case & {
|
|||||||
blocks?: Array<{ code: string; status?: string; char_count?: number }>;
|
blocks?: Array<{ code: string; status?: string; char_count?: number }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CasesScope = "active" | "archived" | "all";
|
||||||
|
|
||||||
export const casesKeys = {
|
export const casesKeys = {
|
||||||
all: ["cases"] as const,
|
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) =>
|
detail: (caseNumber: string) =>
|
||||||
[...casesKeys.all, "detail", caseNumber] as const,
|
[...casesKeys.all, "detail", caseNumber] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useCases(detail = false) {
|
export function useCases(detail = false, scope: CasesScope = "active") {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: casesKeys.list(detail),
|
queryKey: casesKeys.list(detail, scope),
|
||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) => {
|
||||||
apiRequest<Case[]>(`/api/cases${detail ? "?detail=true" : ""}`, {
|
const params = new URLSearchParams();
|
||||||
signal,
|
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 });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
71
web/app.py
71
web/app.py
@@ -36,6 +36,7 @@ _web_dir = Path(__file__).resolve().parent
|
|||||||
sys.path.insert(0, str(_web_dir.parent))
|
sys.path.insert(0, str(_web_dir.parent))
|
||||||
from web.gitea_client import create_repo, setup_remote_and_push
|
from web.gitea_client import create_repo, setup_remote_and_push
|
||||||
from web.paperclip_client import (
|
from web.paperclip_client import (
|
||||||
|
archive_project as pc_archive_project,
|
||||||
create_project as pc_create_project,
|
create_project as pc_create_project,
|
||||||
create_workflow_issue as pc_create_workflow_issue,
|
create_workflow_issue as pc_create_workflow_issue,
|
||||||
get_agents_for_case as pc_get_agents_for_case,
|
get_agents_for_case as pc_get_agents_for_case,
|
||||||
@@ -44,6 +45,7 @@ from web.paperclip_client import (
|
|||||||
get_issue_comments as pc_get_issue_comments,
|
get_issue_comments as pc_get_issue_comments,
|
||||||
get_project_url,
|
get_project_url,
|
||||||
post_comment as pc_post_comment,
|
post_comment as pc_post_comment,
|
||||||
|
restore_project as pc_restore_project,
|
||||||
wake_ceo_agent as pc_wake_ceo,
|
wake_ceo_agent as pc_wake_ceo,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1021,12 +1023,25 @@ async def health():
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/cases")
|
@app.get("/api/cases")
|
||||||
async def list_cases(detail: bool = False):
|
async def list_cases(
|
||||||
"""List existing cases. With detail=true, includes doc counts and integration URLs."""
|
detail: bool = False,
|
||||||
cases = await db.list_cases()
|
include_archived: bool = False,
|
||||||
|
archived_only: bool = False,
|
||||||
|
):
|
||||||
|
"""List existing cases. By default excludes archived (use include_archived=true
|
||||||
|
or archived_only=true to see them). With detail=true, includes doc counts."""
|
||||||
|
cases = await db.list_cases(
|
||||||
|
include_archived=include_archived,
|
||||||
|
archived_only=archived_only,
|
||||||
|
)
|
||||||
if not detail:
|
if not detail:
|
||||||
return [
|
return [
|
||||||
{"case_number": c["case_number"], "title": c["title"], "status": c["status"]}
|
{
|
||||||
|
"case_number": c["case_number"],
|
||||||
|
"title": c["title"],
|
||||||
|
"status": c["status"],
|
||||||
|
"archived_at": c["archived_at"].isoformat() if c.get("archived_at") else None,
|
||||||
|
}
|
||||||
for c in cases
|
for c in cases
|
||||||
]
|
]
|
||||||
# Enhanced listing with document counts
|
# Enhanced listing with document counts
|
||||||
@@ -1049,6 +1064,7 @@ async def list_cases(detail: bool = False):
|
|||||||
"expected_outcome": c.get("expected_outcome", ""),
|
"expected_outcome": c.get("expected_outcome", ""),
|
||||||
"committee_type": c.get("committee_type", ""),
|
"committee_type": c.get("committee_type", ""),
|
||||||
"hearing_date": str(c["hearing_date"]) if c.get("hearing_date") else "",
|
"hearing_date": str(c["hearing_date"]) if c.get("hearing_date") else "",
|
||||||
|
"archived_at": c["archived_at"].isoformat() if c.get("archived_at") else None,
|
||||||
"document_count": doc_count,
|
"document_count": doc_count,
|
||||||
"processing_count": processing_count,
|
"processing_count": processing_count,
|
||||||
"gitea_url": f"https://gitea.nautilus.marcusgroup.org/cases/{c['case_number']}",
|
"gitea_url": f"https://gitea.nautilus.marcusgroup.org/cases/{c['case_number']}",
|
||||||
@@ -1056,6 +1072,53 @@ async def list_cases(detail: bool = False):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cases/{case_number}/archive")
|
||||||
|
async def api_archive_case(case_number: str):
|
||||||
|
"""Move a case to the archive. Also archives the matching Paperclip project."""
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if not case:
|
||||||
|
raise HTTPException(404, f"Case {case_number} not found")
|
||||||
|
|
||||||
|
updated = await db.archive_case(UUID(case["id"]))
|
||||||
|
|
||||||
|
paperclip_result: dict = {"status": "skipped"}
|
||||||
|
try:
|
||||||
|
paperclip_result = await pc_archive_project(case_number)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("paperclip archive sync failed for %s: %s", case_number, e)
|
||||||
|
paperclip_result = {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "archived",
|
||||||
|
"case_number": case_number,
|
||||||
|
"archived_at": updated["archived_at"].isoformat() if updated and updated.get("archived_at") else None,
|
||||||
|
"paperclip": paperclip_result,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cases/{case_number}/restore")
|
||||||
|
async def api_restore_case(case_number: str):
|
||||||
|
"""Restore an archived case. Also restores the matching Paperclip project."""
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if not case:
|
||||||
|
raise HTTPException(404, f"Case {case_number} not found")
|
||||||
|
|
||||||
|
await db.restore_case(UUID(case["id"]))
|
||||||
|
|
||||||
|
paperclip_result: dict = {"status": "skipped"}
|
||||||
|
try:
|
||||||
|
paperclip_result = await pc_restore_project(case_number)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("paperclip restore sync failed for %s: %s", case_number, e)
|
||||||
|
paperclip_result = {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "restored",
|
||||||
|
"case_number": case_number,
|
||||||
|
"paperclip": paperclip_result,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Paperclip Integration API ─────────────────────────────────────
|
# ── Paperclip Integration API ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,59 @@ async def create_project(
|
|||||||
await conn.close()
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def archive_project(case_number: str) -> dict:
|
||||||
|
"""Set archived_at on the Paperclip project matching this case number.
|
||||||
|
|
||||||
|
The project is identified by `name LIKE '%{case_number}%'` (consistent with
|
||||||
|
`create_project`'s lookup). Idempotent — if already archived, returns the
|
||||||
|
existing timestamp.
|
||||||
|
"""
|
||||||
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||||
|
try:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""UPDATE projects
|
||||||
|
SET archived_at = COALESCE(archived_at, now()),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE name LIKE $1 RETURNING id, name, archived_at""",
|
||||||
|
f"%{case_number}%",
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
return {"status": "not_found", "case_number": case_number}
|
||||||
|
return {
|
||||||
|
"status": "archived",
|
||||||
|
"project_id": str(row["id"]),
|
||||||
|
"name": row["name"],
|
||||||
|
"archived_at": row["archived_at"].isoformat() if row["archived_at"] else None,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def restore_project(case_number: str) -> dict:
|
||||||
|
"""Clear archived_at on the Paperclip project matching this case number.
|
||||||
|
|
||||||
|
Idempotent — if already active, returns success without changes.
|
||||||
|
"""
|
||||||
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||||
|
try:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""UPDATE projects
|
||||||
|
SET archived_at = NULL,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE name LIKE $1 RETURNING id, name""",
|
||||||
|
f"%{case_number}%",
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
return {"status": "not_found", "case_number": case_number}
|
||||||
|
return {
|
||||||
|
"status": "restored",
|
||||||
|
"project_id": str(row["id"]),
|
||||||
|
"name": row["name"],
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
async def _create_issue(
|
async def _create_issue(
|
||||||
conn: asyncpg.Connection,
|
conn: asyncpg.Connection,
|
||||||
company_id: str,
|
company_id: str,
|
||||||
|
|||||||
Reference in New Issue
Block a user