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>
390 lines
12 KiB
TypeScript
390 lines
12 KiB
TypeScript
"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>“{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 (
|
||
<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>
|
||
);
|
||
}
|