Files
legal-ai/web-ui/src/components/cases/documents-panel.tsx
Chaim cc8af903b7
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 36s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
feat(case-ui): חלוקת רשימת-המסמכים בטאב-הסקירה ל-4 קבוצות
חלוקה פנימית (לנוחות בלבד, לא משנה doc_type) של רשימת מסמכי-התיק:
1. עיקריים (appeal/response/appraisal) · 2. נלווים · 3. פרוטוקול ועדת הערר ·
4. לאחר הדיון. הסיווג נגזר בצד-הלקוח מ-doc_type + שני דגלי-metadata.

שני פקדים חדשים בעורך-התיוג:
- מתג "התקבל אחרי הדיון" → metadata.is_post_hearing (כבר נצרך ע"י בלוק-ח);
  גובר על הסיווג-לפי-סוג ומעביר לקבוצה 4.
- בורר "פרוטוקול של: ועדת הערר / ועדה מקומית-מחוזית" → metadata.protocol_scope
  (נראה רק כשהסוג protocol); ברירת-מחדל appeal→קבוצה 3, lower→קבוצה 2.

Backend: PATCH /documents/{id} + MCP document_update מקבלים is_post_hearing
ו-protocol_scope, נשמרים ב-metadata JSONB (אותו מסלול כמו appraiser_side).

עיצוב אושר ב-Claude Design (X17): 18k-case-documents-grouped.html.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 06:52:17 +00:00

458 lines
16 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";
import { formatDateShort as formatDate } from "@/lib/format-date";
import {
documentGroup,
DOC_GROUP_ORDER,
DOC_GROUP_LABELS,
DOC_GROUP_HINTS,
type DocGroup,
} from "@/lib/doc-types";
/*
* 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 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";
const meta = doc.metadata as
| { appraiser_side?: string; is_post_hearing?: boolean; protocol_scope?: string }
| undefined;
const isPostHearing = meta?.is_post_hearing === true;
const protocolScope = meta?.protocol_scope;
const isProtocol = doc.doc_type === "protocol";
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>
{isProtocol && (
<span className="shrink-0 self-center rounded-full border border-rule bg-parchment px-2 py-0.5 text-[0.62rem] font-medium text-navy whitespace-nowrap">
{protocolScope === "lower" ? "ועדה מקומית" : "ועדת הערר"}
</span>
)}
{isPostHearing && (
<span className="shrink-0 self-center rounded-full border border-info/40 bg-info-bg px-2 py-0.5 text-[0.62rem] font-medium text-info whitespace-nowrap">
לאחר הדיון
</span>
)}
{doc.doc_type && (
<DocumentTypeEditor
caseNumber={caseNumber}
docId={doc.id}
docType={doc.doc_type}
appraiserSide={meta?.appraiser_side}
isPostHearing={isPostHearing}
protocolScope={protocolScope}
/>
)}
<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>
);
}
/* ── Group section header ──────────────────────────────────────────
* The list is split into four convenience buckets (see documentGroup).
* The number is the fixed 1-4 position in DOC_GROUP_ORDER so a group keeps
* its identity even when earlier groups are empty and hidden. */
function GroupHeader({ group, count }: { group: DocGroup; count: number }) {
const index = DOC_GROUP_ORDER.indexOf(group) + 1;
return (
<div className="flex items-center gap-2.5 px-2 -mx-2 py-2 border-b border-rule-soft bg-parchment/50">
<span className="w-[19px] h-[19px] shrink-0 rounded-full bg-navy text-cream text-[0.66rem] font-bold flex items-center justify-center tabular-nums">
{index}
</span>
<span className="text-[0.8rem] font-bold text-navy shrink-0">
{DOC_GROUP_LABELS[group]}
</span>
<span className="shrink-0 text-[0.62rem] font-bold text-gold-deep bg-gold-wash border border-rule rounded-full px-1.5 tabular-nums">
{count}
</span>
<span className="text-[0.66rem] text-ink-muted font-medium truncate">
{DOC_GROUP_HINTS[group]}
</span>
</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),
);
// Bucket into the four overview groups. Iterating `sorted` preserves the
// within-group status ordering. Grouping is display-only — see documentGroup.
const grouped = new Map<DocGroup, CaseDocument[]>();
for (const doc of sorted) {
const g = documentGroup(doc.doc_type, doc.metadata);
const bucket = grouped.get(g);
if (bucket) bucket.push(doc);
else grouped.set(g, [doc]);
}
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 space-y-2" dir="rtl">
{DOC_GROUP_ORDER.map((group) => {
const groupDocs = grouped.get(group);
if (!groupDocs || groupDocs.length === 0) return null;
return (
<section key={group}>
<GroupHeader group={group} count={groupDocs.length} />
<ul className="divide-y divide-rule" dir="rtl">
{groupDocs.map((doc) => (
<DocumentRow key={doc.id} doc={doc} caseNumber={caseNumber} />
))}
</ul>
</section>
);
})}
</div>
</div>
</DocumentsShell>
);
}