Files
legal-ai/web-ui/src/components/cases/documents-panel.tsx
Chaim 67c2c43777
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
fix(web-ui): display all human-facing timestamps in Asia/Jerusalem (deterministic)
Storage stays UTC (DB TIMESTAMPTZ, API ISO-UTC) — only the display layer is
localized, and now deterministically: every timestamp renders pinned to
Asia/Jerusalem via a single Intl-based formatter, so SSR (UTC container) and
the browser agree on any runtime. No layout/visible-format change — only the tz.

- New single date formatter web-ui/src/lib/format-date.ts (G2): formatDate /
  formatDateShort / formatDateLong / formatDateTime / formatDateTimeFull /
  formatTime / formatIsoDate + Israel helpers getIsraelYear / israelDayKey /
  israelMidnightMs / israelParts + formatRelative (long/short/tight wording).
- Routed all ad-hoc toLocaleDateString/toLocaleTimeString/toLocaleString +
  hand-rolled new Date(...).get*() / toISOString().slice(0,10) timestamp call
  sites (30 files) through it. Number toLocaleString left untouched.
- Spec: added INV-UI9 to docs/spec/X6 (UTC storage, Asia/Jerusalem display).

Display-only; no layout/design/IA change → Claude Design gate N/A.
Invariants: G2 (single date formatter, no parallel ad-hoc formatting), INV-UI9.

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

392 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";
import { formatDateShort as formatDate } from "@/lib/format-date";
/*
* 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";
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>
);
}