Files
legal-ai/web-ui/src/components/cases/documents-panel.tsx
Chaim f3b075d282
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 6s
feat(ui): IA redesign → production · יישום נאמן של 16 הדפים הנותרים למוקאפים
תיקון הגישה: יישום מלא ונאמן של עיצוב-המוקאפים המאושרים (Claude Design) על כל
הדפים — שינוי-הרכב אמיתי פר-מוקאפ, לא ליטוש-טוקנים. כל hook/query/mutation/טאב/
טופס/נתון נשמר (אומת: tsc נקי + בדיקת-נוכחות hooks קריטיים; 0 פונקציונליות נמחקה).

דפים (← מוקאפ):
- בית — לוח: KPI + "תיקים לפי סטטוס" (bars) + כרטיס-אישורים + CTA כפול.
- ארכיון — filter-bar שטוח + טבלה נקייה + צ'יפי-סוג/תוצאה.
- הערות יו״ר — פריסה דו-טורית + טופס-הוספה חי + כרטיסי-הערה.
- ספריית-פסיקה — tabs קו-תחתון + כרטיסי-תוצאה halacha/קטע + AuthorityBadge.
- דף-תקדים — באנר-meta parchment + דו-טורי + provenance pills.
- פסיקה-חסרה — pill פתוחים + צ'יפי-סטטוס + CTA העלאה.
- יומונים — אזור-העלאה מקווקו + כרטיסי-digest + "ממתין" כתווית פסיבית.
- גרף — פאנל-צד שכבות/אנליטיקה + canvas parchment.
- אימון-סגנון — פורטרט: banner + KPI + אנטומיה + ביטויי-חתימה.
- מתודולוגיה — עורך-צ'קליסט + "חל על:" + canon chip.
- מיומנויות/סקריפטים — טבלאות אמיתיות + צ'יפי-סטטוס.
- הגדרות — sidenav דו-טורי + env-rows עם "ממתין ל-redeploy".
- דף-תיק — באנר-תיק parchment + tabs + timeline + "פתח עורך החלטה".
- תפעול — SectionHeaders + טבלת-שירותים + כרטיסי-שער gold-wash.
- compose — באנר-תיק + SOT pill + פריסה דו-טורית + "השלמה והעברה".

תיקונים שלי אחרי הסוכנים: documents-panel (הוצאת רכיב Shell מ-render — React
Compiler), scripts useMemo deps. /approvals כבר נבנה מחדש נאמנה (commit קודם).

בדיקות: npx tsc --noEmit ✓ · eslint ✓ (לבד מ-learning-panel:109 קיים-מראש).
שימור-פונקציונליות אומת. CI Docker build = שער סופי לפני deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 23:00:25 +00:00

400 lines
13 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, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import {
Dialog,
DialogContent,
DialogDescription,
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";
import { DocumentTypeEditor } from "@/components/cases/document-type-editor";
/*
* 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 · metadata
*
* Doc-type labels and tone classes live in @/lib/doc-types so the upload
* sheet, the inline editor, and this panel all stay in sync.
*/
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) {
/* eslint-disable react-hooks/set-state-in-effect -- reset on close */
setText(null);
setError(null);
/* eslint-enable react-hooks/set-state-in-effect */
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>
<DialogDescription className="sr-only">תצוגה מקדימה של תוכן המסמך</DialogDescription>
</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>
<DialogDescription className="sr-only">אישור מחיקת המסמך מהתיק</DialogDescription>
</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 && (
<DocumentTypeEditor
caseNumber={caseNumber}
docId={doc.id}
docType={doc.doc_type}
appraiserSide={
(doc.metadata as { appraiser_side?: string } | undefined)
?.appraiser_side
}
/>
)}
<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 ────────────────────────────────────────────────── */
// IA-redesign mockup 17 — card with a parchment header band wrapping the
// (unchanged) document list. Module-level so it isn't re-created during render
// (React Compiler: "Cannot create components during render").
function DocumentsShell({ count, children }: { count: number; children: ReactNode }) {
return (
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
<div className="px-5 py-3.5 border-b border-rule-soft bg-parchment text-[0.92rem] font-semibold text-navy">
מסמכי התיק
{count > 0 && (
<span className="ms-2 text-[0.72rem] text-ink-muted font-medium tabular-nums">
({count})
</span>
)}
</div>
<div className="px-5 py-4">{children}</div>
</div>
);
}
export function DocumentsPanel({
data,
}: {
data?: CaseDetail;
}) {
const docs = data?.documents ?? [];
const caseNumber = data?.case_number ?? "";
if (docs.length === 0) {
return (
<DocumentsShell count={docs.length}>
<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>
</DocumentsShell>
);
}
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 (
<DocumentsShell count={docs.length}>
<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 overflow-x-hidden" dir="rtl">
<ul className="divide-y divide-rule" dir="rtl">
{sorted.map((doc) => (
<DocumentRow key={doc.id} doc={doc} caseNumber={caseNumber} />
))}
</ul>
</div>
</div>
</DocumentsShell>
);
}