Add document preview, delete, and fix scroll in documents panel
Documents tab was limited to ~9 visible items due to fixed max-height without overflow-hidden. Now uses 70vh with proper overflow. Added click-to-preview (shows extracted text in dialog) and delete button with confirmation dialog + backend DELETE endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -747,6 +747,22 @@ async def update_decision(decision_id: UUID, **fields) -> None:
|
||||
await conn.execute(sql, decision_id, *values)
|
||||
|
||||
|
||||
# ── Document deletion ──────────────────────────────────────────────
|
||||
|
||||
async def delete_document(doc_id: UUID) -> bool:
|
||||
"""Delete a document and all its chunks. Returns True if deleted."""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
await conn.execute(
|
||||
"DELETE FROM document_chunks WHERE document_id = $1", doc_id
|
||||
)
|
||||
result = await conn.execute(
|
||||
"DELETE FROM documents WHERE id = $1", doc_id
|
||||
)
|
||||
return int(result.split()[-1]) > 0
|
||||
|
||||
|
||||
# ── Chunks & Vectors ───────────────────────────────────────────────
|
||||
|
||||
async def delete_document_chunks(document_id: UUID) -> int:
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { CheckCircle2, Clock, Loader2, XCircle } from "lucide-react";
|
||||
import type { CaseDetail } from "@/lib/api/cases";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Eye,
|
||||
Loader2,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/api/client";
|
||||
import { casesKeys } from "@/lib/api/cases";
|
||||
import type { CaseDetail, CaseDocument } from "@/lib/api/cases";
|
||||
|
||||
/*
|
||||
* Document list for the case detail "מסמכים" tab. Uses the real document
|
||||
@@ -90,8 +111,218 @@ function filenameFromPath(path: string): string {
|
||||
return parts[parts.length - 1] || path;
|
||||
}
|
||||
|
||||
export function DocumentsPanel({ data }: { data?: CaseDetail }) {
|
||||
/* ── Document text preview dialog ──────────────────────────────── */
|
||||
|
||||
function DocumentPreviewDialog({
|
||||
doc,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
doc: CaseDocument;
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
}) {
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadText = async () => {
|
||||
if (text !== null) return; // already loaded
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiRequest<{ text: string }>(`/api/documents/${doc.id}/text`);
|
||||
setText(res.text || "(ריק)");
|
||||
} catch {
|
||||
setError("לא ניתן לטעון את תוכן המסמך");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (v: boolean) => {
|
||||
if (v) loadText();
|
||||
if (!v) {
|
||||
setText(null);
|
||||
setError(null);
|
||||
}
|
||||
onOpenChange(v);
|
||||
};
|
||||
|
||||
const displayName = doc.title || filenameFromPath(doc.file_path);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[80vh] flex flex-col" dir="rtl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-right">{displayName}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-gold" />
|
||||
<span className="ms-2 text-ink-muted text-sm">טוען מסמך...</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-center py-12 text-danger text-sm">{error}</div>
|
||||
)}
|
||||
{text !== null && !loading && (
|
||||
<ScrollArea className="h-[60vh] overflow-hidden" dir="rtl">
|
||||
<pre className="whitespace-pre-wrap text-sm text-ink leading-relaxed font-sans p-2">
|
||||
{text}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter showCloseButton />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Delete confirmation dialog ────────────────────────────────── */
|
||||
|
||||
function DeleteConfirmDialog({
|
||||
doc,
|
||||
caseNumber,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
doc: CaseDocument;
|
||||
caseNumber: string;
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiRequest(`/api/cases/${caseNumber}/documents/${doc.id}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: casesKeys.all });
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
const displayName = doc.title || filenameFromPath(doc.file_path);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent dir="rtl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-right">מחיקת מסמך</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-ink-muted text-right">
|
||||
האם למחוק את המסמך <strong>“{displayName}”</strong>?
|
||||
<br />
|
||||
פעולה זו אינה ניתנת לביטול.
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin me-1" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4 me-1" />
|
||||
)}
|
||||
מחק
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
ביטול
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Single document row ───────────────────────────────────────── */
|
||||
|
||||
function DocumentRow({
|
||||
doc,
|
||||
caseNumber,
|
||||
}: {
|
||||
doc: CaseDocument;
|
||||
caseNumber: string;
|
||||
}) {
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const displayName = doc.title || filenameFromPath(doc.file_path);
|
||||
const canPreview =
|
||||
doc.extraction_status === "completed" || doc.extraction_status === "proofread";
|
||||
|
||||
return (
|
||||
<>
|
||||
<li className="py-3 flex items-start gap-3 hover:bg-gold-wash/30 transition-colors px-2 -mx-2 rounded group">
|
||||
<StatusIcon status={doc.extraction_status} />
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 min-w-0 space-y-0.5 text-right cursor-pointer hover:underline decoration-gold/40 underline-offset-2 disabled:cursor-default disabled:no-underline"
|
||||
disabled={!canPreview}
|
||||
onClick={() => canPreview && setPreviewOpen(true)}
|
||||
title={canPreview ? "לחץ לצפייה במסמך" : "המסמך עדיין לא עובד"}
|
||||
>
|
||||
<div className="text-ink font-medium truncate flex items-center gap-1.5">
|
||||
{canPreview && <Eye className="w-3.5 h-3.5 text-ink-muted shrink-0" />}
|
||||
<span>{displayName}</span>
|
||||
</div>
|
||||
<div className="text-[0.72rem] text-ink-muted flex items-center gap-3 flex-wrap">
|
||||
{doc.page_count != null && (
|
||||
<span className="tabular-nums">{doc.page_count} עמ׳</span>
|
||||
)}
|
||||
{doc.created_at && <span>{formatDate(doc.created_at)}</span>}
|
||||
</div>
|
||||
</button>
|
||||
{doc.doc_type && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`rounded-full px-2 py-0.5 text-[0.7rem] shrink-0 ${doctypeTone(doc.doc_type)}`}
|
||||
>
|
||||
{doctypeLabel(doc.doc_type)}
|
||||
</Badge>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 p-1 rounded text-ink-muted/40 hover:text-danger hover:bg-danger-bg transition-colors opacity-0 group-hover:opacity-100"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
title="מחק מסמך"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</li>
|
||||
{previewOpen && (
|
||||
<DocumentPreviewDialog
|
||||
doc={doc}
|
||||
open={previewOpen}
|
||||
onOpenChange={setPreviewOpen}
|
||||
/>
|
||||
)}
|
||||
{deleteOpen && (
|
||||
<DeleteConfirmDialog
|
||||
doc={doc}
|
||||
caseNumber={caseNumber}
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main panel ────────────────────────────────────────────────── */
|
||||
|
||||
export function DocumentsPanel({
|
||||
data,
|
||||
}: {
|
||||
data?: CaseDetail;
|
||||
}) {
|
||||
const docs = data?.documents ?? [];
|
||||
const caseNumber = data?.case_number ?? "";
|
||||
|
||||
if (docs.length === 0) {
|
||||
return (
|
||||
@@ -154,38 +385,11 @@ export function DocumentsPanel({ data }: { data?: CaseDetail }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScrollArea className="max-h-[520px]" dir="rtl">
|
||||
<ScrollArea className="max-h-[70vh] overflow-hidden" dir="rtl">
|
||||
<ul className="divide-y divide-rule" dir="rtl">
|
||||
{sorted.map((doc) => {
|
||||
const displayName = doc.title || filenameFromPath(doc.file_path);
|
||||
return (
|
||||
<li
|
||||
key={doc.id}
|
||||
className="py-3 flex items-start gap-3 hover:bg-gold-wash/30 transition-colors px-2 -mx-2 rounded"
|
||||
>
|
||||
<StatusIcon status={doc.extraction_status} />
|
||||
<div className="flex-1 min-w-0 space-y-0.5 text-right">
|
||||
<div className="text-ink font-medium truncate" title={displayName}>
|
||||
{displayName}
|
||||
</div>
|
||||
<div className="text-[0.72rem] text-ink-muted flex items-center gap-3 flex-wrap">
|
||||
{doc.page_count != null && (
|
||||
<span className="tabular-nums">{doc.page_count} עמ׳</span>
|
||||
)}
|
||||
{doc.created_at && <span>{formatDate(doc.created_at)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{doc.doc_type && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`rounded-full px-2 py-0.5 text-[0.7rem] shrink-0 ms-auto ${doctypeTone(doc.doc_type)}`}
|
||||
>
|
||||
{doctypeLabel(doc.doc_type)}
|
||||
</Badge>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{sorted.map((doc) => (
|
||||
<DocumentRow key={doc.id} doc={doc} caseNumber={caseNumber} />
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
25
web/app.py
25
web/app.py
@@ -2436,6 +2436,31 @@ async def api_reprocess_document(case_number: str, doc_id: str):
|
||||
return {"status": "reprocessing"}
|
||||
|
||||
|
||||
@app.delete("/api/cases/{case_number}/documents/{doc_id}")
|
||||
async def api_delete_document(case_number: str, doc_id: str):
|
||||
"""Delete a single document from a case (including its chunks and file)."""
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
||||
|
||||
case_id = UUID(case["id"])
|
||||
document_id = UUID(doc_id)
|
||||
doc = await db.get_document(document_id)
|
||||
if not doc or UUID(doc["case_id"]) != case_id:
|
||||
raise HTTPException(404, "מסמך לא נמצא בתיק")
|
||||
|
||||
# Try to remove the physical file
|
||||
file_path = doc.get("file_path")
|
||||
if file_path:
|
||||
import pathlib
|
||||
p = pathlib.Path(file_path)
|
||||
if p.exists():
|
||||
p.unlink(missing_ok=True)
|
||||
|
||||
await db.delete_document(document_id)
|
||||
return {"deleted": True, "doc_id": doc_id}
|
||||
|
||||
|
||||
# ── Chair feedback endpoints ──────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user