Files
legal-ai/web-ui/src/components/cases/documents-panel.tsx
Chaim bc1456672b Fix document scroll and preview dialog
ScrollArea (Radix) injected display:table on viewport, preventing
scroll — replaced with plain div + overflow-y-auto. Preview dialog
never loaded text because onOpenChange doesn't fire on initial mount —
replaced with useEffect that fetches on open.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 17:58:22 +00:00

390 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
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
* row shape returned by the FastAPI case_get endpoint — see db.list_documents
* and the `documents` schema in legal_mcp/services/db.py:
* id · case_id · doc_type · title · file_path · extraction_status ·
* page_count · created_at · practice_area · appeal_subtype
*/
const DOC_TYPE_LABELS: Record<string, string> = {
appeal: "כתב ערר",
response: "כתב תשובה",
protocol: "פרוטוקול",
decision: "החלטת ועדה מקומית",
plan: "תכנית",
reference: "חומר רקע",
auto: "—",
};
function doctypeLabel(t: string): string {
return DOC_TYPE_LABELS[t] ?? t;
}
function doctypeTone(t: string): string {
switch (t) {
case "appeal": return "bg-info-bg text-info border-info/40";
case "response": return "bg-gold-wash text-gold-deep border-gold/40";
case "decision": return "bg-success-bg text-success border-success/40";
case "protocol": return "bg-warn-bg text-warn border-warn/40";
default: return "bg-rule-soft text-ink-muted border-rule";
}
}
const STATUS_LABELS: Record<string, string> = {
pending: "בהמתנה",
processing: "בעיבוד",
completed: "הושלם",
proofread: "הוגה",
failed: "נכשל",
error: "שגיאה",
};
/** Sort priority — lower = higher in list */
const STATUS_ORDER: Record<string, number> = {
failed: 0,
error: 0,
processing: 1,
pending: 2,
completed: 3,
proofread: 3,
};
function statusOrder(s: string): number {
return STATUS_ORDER[s] ?? 3;
}
function StatusIcon({ status }: { status: string }) {
switch (status) {
case "completed":
case "proofread":
return <CheckCircle2 className="w-4 h-4 text-success shrink-0" />;
case "processing":
return <Loader2 className="w-4 h-4 text-gold animate-spin shrink-0" />;
case "pending":
return <Clock className="w-4 h-4 text-ink-muted shrink-0" />;
case "failed":
case "error":
return <XCircle className="w-4 h-4 text-danger shrink-0" />;
default:
return <Clock className="w-4 h-4 text-ink-muted shrink-0" />;
}
}
function formatDate(iso: string) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString("he-IL");
} catch {
return iso;
}
}
function filenameFromPath(path: string): string {
const parts = path.split("/");
return parts[parts.length - 1] || path;
}
/* ── 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);
useEffect(() => {
if (!open) {
setText(null);
setError(null);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
apiRequest<{ text: string }>(`/api/documents/${doc.id}/text`)
.then((res) => { if (!cancelled) setText(res.text || "(ריק)"); })
.catch(() => { if (!cancelled) setError("המסמך עדיין לא עובד או שאין בו טקסט"); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [open, doc.id]);
const displayName = doc.title || filenameFromPath(doc.file_path);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<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 && (
<div className="h-[60vh] overflow-y-auto" dir="rtl">
<pre className="whitespace-pre-wrap text-sm text-ink leading-relaxed font-sans p-2">
{text}
</pre>
</div>
)}
</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>&ldquo;{displayName}&rdquo;</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 (
<div className="text-center py-12 text-ink-muted">
<div className="text-gold text-2xl mb-2" aria-hidden="true"></div>
<p className="text-sm">אין מסמכים בתיק זה</p>
</div>
);
}
const sorted = [...docs].sort(
(a, b) => statusOrder(a.extraction_status) - statusOrder(b.extraction_status),
);
const done = docs.filter(
(d) => d.extraction_status === "completed" || d.extraction_status === "proofread",
).length;
const processing = docs.filter((d) => d.extraction_status === "processing").length;
const pending = docs.filter((d) => d.extraction_status === "pending").length;
const failed = docs.filter(
(d) => d.extraction_status === "failed" || d.extraction_status === "error",
).length;
const hasIncomplete = processing > 0 || pending > 0 || failed > 0;
const pct = docs.length > 0 ? Math.round((done / docs.length) * 100) : 0;
return (
<div className="space-y-3">
{hasIncomplete && (
<div className="rounded-lg border border-rule bg-parchment/40 px-4 py-3 space-y-2" dir="rtl">
<div className="flex items-center gap-4 text-[0.78rem] flex-wrap">
{done > 0 && (
<span className="flex items-center gap-1 text-success">
<CheckCircle2 className="w-3.5 h-3.5" />
{done} {STATUS_LABELS.completed}
</span>
)}
{processing > 0 && (
<span className="flex items-center gap-1 text-gold-deep">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{processing} {STATUS_LABELS.processing}
</span>
)}
{pending > 0 && (
<span className="flex items-center gap-1 text-ink-muted">
<Clock className="w-3.5 h-3.5" />
{pending} {STATUS_LABELS.pending}
</span>
)}
{failed > 0 && (
<span className="flex items-center gap-1 text-danger">
<XCircle className="w-3.5 h-3.5" />
{failed} {STATUS_LABELS.failed}
</span>
)}
</div>
<Progress
value={pct}
className={failed > 0 && done === 0 ? "[&>div]:bg-danger" : ""}
/>
</div>
)}
<div className="max-h-[70vh] overflow-y-auto" dir="rtl">
<ul className="divide-y divide-rule" dir="rtl">
{sorted.map((doc) => (
<DocumentRow key={doc.id} doc={doc} caseNumber={caseNumber} />
))}
</ul>
</div>
</div>
);
}