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)
|
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 ───────────────────────────────────────────────
|
# ── Chunks & Vectors ───────────────────────────────────────────────
|
||||||
|
|
||||||
async def delete_document_chunks(document_id: UUID) -> int:
|
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 { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { CheckCircle2, Clock, Loader2, XCircle } from "lucide-react";
|
import {
|
||||||
import type { CaseDetail } from "@/lib/api/cases";
|
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
|
* 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;
|
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 docs = data?.documents ?? [];
|
||||||
|
const caseNumber = data?.case_number ?? "";
|
||||||
|
|
||||||
if (docs.length === 0) {
|
if (docs.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -154,38 +385,11 @@ export function DocumentsPanel({ data }: { data?: CaseDetail }) {
|
|||||||
</div>
|
</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">
|
<ul className="divide-y divide-rule" dir="rtl">
|
||||||
{sorted.map((doc) => {
|
{sorted.map((doc) => (
|
||||||
const displayName = doc.title || filenameFromPath(doc.file_path);
|
<DocumentRow key={doc.id} doc={doc} caseNumber={caseNumber} />
|
||||||
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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
</ul>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</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"}
|
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 ──────────────────────────────────────
|
# ── Chair feedback endpoints ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user