חלוקה פנימית (לנוחות בלבד, לא משנה 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>
458 lines
16 KiB
TypeScript
458 lines
16 KiB
TypeScript
"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>“{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";
|
||
|
||
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>
|
||
);
|
||
}
|