Full code review of the case-detail page (14 components) surfaced these,
all fixed here:
Logic bugs
- case-edit-dialog: form.reset ran on the 5s useCase refetch while the dialog
was open, clobbering in-progress edits. Now resets only on open→true.
- status-changer: `selected` never synced to async/external `currentStatus`
(stale dropdown). Reworked to track currentStatus until an explicit pick;
resets to tracking after save.
- decision-blocks-panel: `block.content`/`word_count` accessed without null
guards (endpoint has no response model) → potential render crash. Coerced
with `?? ""` / `?? 0`. `STATUS_LABELS[status]` now falls back to the raw
status instead of rendering literal "undefined".
- document-type-editor: `await mutateAsync()` in async click handlers without
try/catch → unhandled promise rejection. Wrapped (errors still surface via
isError).
Resilience / hygiene
- page.tsx: a transient 5xx on the background poll flipped the WHOLE page to
the error card and discarded loaded data. Now gated on `!data`, plus a
"נסה שוב" retry.
- cases.ts useUpdateCase: invalidated casesKeys.all, which re-invalidated the
detail it had just optimistically patched. Scoped to the list prefix.
RTL correctness (logical properties)
- agent-status-widget `mr-auto`→`ms-auto`; agent-activity-feed `mr-auto`→
`me-auto`, icon `ml-1/ml-2`→`me-1/me-2`, required `*` `mr-1`→`ms-1`;
document-type-editor list `pr-4`→`ps-4`.
Minors
- drafts-panel: `<a href>`→`next/link` (operations + citation links) for SPA
nav. agent-activity-feed: issueMap memoized; comment Textarea aria-label.
upload-sheet: `unknown` status no longer shown as green success (neutral
icon + "רענן לאישור"). citations.ts: case_name typed `string | null`.
Design gate: visual-touching items (RTL gap side, retry button, neutral
upload icon) were chair-authorized via the reviewed-findings approval ("fix
all"); none alter an approved page layout — they are correctness fixes.
tsc clean; eslint clean (1 pre-existing form.watch warning, untouched line).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
217 lines
7.7 KiB
TypeScript
217 lines
7.7 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useState } from "react";
|
||
import { useDropzone } from "react-dropzone";
|
||
import { Upload, FileText, CheckCircle2, XCircle, Loader2 } from "lucide-react";
|
||
import {
|
||
Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger,
|
||
} from "@/components/ui/sheet";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Progress } from "@/components/ui/progress";
|
||
import {
|
||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { useUploadDocument, useProgress, type ProgressEvent } from "@/lib/api/documents";
|
||
import { DOC_TYPE_OPTIONS } from "@/lib/doc-types";
|
||
|
||
/*
|
||
* Upload sheet — drag-drop zone + doc-type selector, with live SSE
|
||
* progress for the most-recent upload. Intentionally sequential:
|
||
* a single file at a time keeps the SSE subscription simple and
|
||
* matches how the FastAPI processor handles one task_id per file.
|
||
*
|
||
* The "auto" option is upload-only — it triggers backend classification.
|
||
* After upload, the inline DocumentTypeEditor shows the resolved doc_type
|
||
* and uses DOC_TYPE_OPTIONS directly (no "auto" entry).
|
||
*/
|
||
|
||
const DOC_TYPES: { value: string; label: string }[] = [
|
||
{ value: "auto", label: "זיהוי אוטומטי" },
|
||
...DOC_TYPE_OPTIONS,
|
||
];
|
||
|
||
type UploadRow = {
|
||
id: string;
|
||
filename: string;
|
||
taskId: string | null;
|
||
error?: string;
|
||
};
|
||
|
||
function statusLabel(event: ProgressEvent | null): string {
|
||
if (!event) return "מתחיל…";
|
||
if (event.status === "queued") return "בתור";
|
||
if (event.status === "processing")
|
||
return event.step ? `בעיבוד · ${event.step}` : "בעיבוד";
|
||
if (event.status === "completed") return "הושלם";
|
||
// TTL expired / subscribed too late — the outcome is genuinely unknown, not
|
||
// a confirmed success. The case-detail refetch is the real source of truth.
|
||
if (event.status === "unknown") return "הסתיים — רענן לאישור";
|
||
if (event.status === "failed") return event.error ?? "נכשל";
|
||
return event.status;
|
||
}
|
||
|
||
function progressPercent(event: ProgressEvent | null): number {
|
||
if (!event) return 5;
|
||
if (event.status === "queued") return 10;
|
||
if (event.status === "processing") return 55;
|
||
if (event.status === "completed") return 100;
|
||
if (event.status === "unknown") return 100;
|
||
if (event.status === "failed") return 100;
|
||
return 25;
|
||
}
|
||
|
||
function UploadRowView({ row, caseNumber }: { row: UploadRow; caseNumber: string }) {
|
||
const progress = useProgress(row.taskId, caseNumber);
|
||
const pct = row.error ? 100 : progressPercent(progress);
|
||
const failed = row.error || progress?.status === "failed";
|
||
const done = progress?.status === "completed";
|
||
// `unknown` = settled-but-indeterminate; render neutral, not green success.
|
||
const indeterminate = progress?.status === "unknown";
|
||
|
||
return (
|
||
<li className="rounded-lg border border-rule bg-parchment/40 px-4 py-3 space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
{done ? (
|
||
<CheckCircle2 className="w-4 h-4 text-success shrink-0" />
|
||
) : failed ? (
|
||
<XCircle className="w-4 h-4 text-danger shrink-0" />
|
||
) : indeterminate ? (
|
||
<CheckCircle2 className="w-4 h-4 text-ink-muted shrink-0" />
|
||
) : (
|
||
<Loader2 className="w-4 h-4 text-gold animate-spin shrink-0" />
|
||
)}
|
||
<FileText className="w-4 h-4 text-ink-muted shrink-0" />
|
||
<span className="text-sm text-ink truncate flex-1" title={row.filename}>
|
||
{row.filename}
|
||
</span>
|
||
<span
|
||
className={`text-[0.72rem] tabular-nums shrink-0 ${
|
||
done ? "text-success" : failed ? "text-danger" : "text-ink-muted"
|
||
}`}
|
||
>
|
||
{row.error ?? statusLabel(progress)}
|
||
</span>
|
||
</div>
|
||
<Progress
|
||
value={pct}
|
||
className={failed ? "[&>div]:bg-danger" : done ? "[&>div]:bg-success" : ""}
|
||
/>
|
||
</li>
|
||
);
|
||
}
|
||
|
||
export function UploadSheet({ caseNumber }: { caseNumber: string }) {
|
||
const [open, setOpen] = useState(false);
|
||
const [docType, setDocType] = useState("auto");
|
||
const [rows, setRows] = useState<UploadRow[]>([]);
|
||
const mutate = useUploadDocument(caseNumber);
|
||
|
||
const onDrop = useCallback(
|
||
async (files: File[]) => {
|
||
for (const file of files) {
|
||
const rowId = crypto.randomUUID();
|
||
setRows((r) => [
|
||
...r,
|
||
{ id: rowId, filename: file.name, taskId: null },
|
||
]);
|
||
try {
|
||
const res = await mutate.mutateAsync({ file, docType });
|
||
setRows((r) =>
|
||
r.map((row) =>
|
||
row.id === rowId ? { ...row, taskId: res.task_id } : row,
|
||
),
|
||
);
|
||
} catch (e) {
|
||
setRows((r) =>
|
||
r.map((row) =>
|
||
row.id === rowId
|
||
? { ...row, error: e instanceof Error ? e.message : "שגיאה" }
|
||
: row,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
},
|
||
[docType, mutate],
|
||
);
|
||
|
||
const dropzone = useDropzone({
|
||
onDrop,
|
||
accept: {
|
||
"application/pdf": [".pdf"],
|
||
"application/msword": [".doc"],
|
||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": [".docx"],
|
||
"text/plain": [".txt"],
|
||
"text/markdown": [".md"],
|
||
},
|
||
maxSize: 50 * 1024 * 1024,
|
||
});
|
||
|
||
return (
|
||
<Sheet open={open} onOpenChange={setOpen}>
|
||
<SheetTrigger asChild>
|
||
<Button variant="outline" size="sm">
|
||
<Upload className="w-4 h-4 me-1" /> העלאת מסמכים
|
||
</Button>
|
||
</SheetTrigger>
|
||
<SheetContent side="left" className="w-full sm:max-w-lg" dir="rtl">
|
||
<SheetHeader>
|
||
<SheetTitle className="text-navy">העלאת מסמכים לתיק {caseNumber}</SheetTitle>
|
||
<SheetDescription className="text-ink-muted">
|
||
PDF, DOCX, DOC, TXT, MD — עד 50MB לקובץ. הקבצים מעובדים ברקע
|
||
והסטטוס מתעדכן בזמן אמת.
|
||
</SheetDescription>
|
||
</SheetHeader>
|
||
|
||
<div className="mt-5 space-y-4 px-4 overflow-y-auto flex-1 min-h-0">
|
||
<div>
|
||
<label className="block text-sm font-medium text-navy mb-1.5">
|
||
סיווג
|
||
</label>
|
||
<Select value={docType} onValueChange={setDocType} dir="rtl">
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
{DOC_TYPES.map((t) => (
|
||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div
|
||
{...dropzone.getRootProps()}
|
||
className={`
|
||
rounded-lg border-2 border-dashed p-8 text-center cursor-pointer
|
||
transition-colors
|
||
${
|
||
dropzone.isDragActive
|
||
? "border-gold bg-gold-wash"
|
||
: "border-rule bg-parchment/40 hover:bg-gold-wash/50 hover:border-gold/60"
|
||
}
|
||
`}
|
||
>
|
||
<input {...dropzone.getInputProps()} />
|
||
<Upload className="w-8 h-8 mx-auto mb-2 text-gold-deep" />
|
||
<p className="text-sm text-navy font-medium">
|
||
{dropzone.isDragActive
|
||
? "שחרר כאן להעלאה"
|
||
: "גרור קבצים או לחץ לבחירה"}
|
||
</p>
|
||
<p className="text-[0.72rem] text-ink-muted mt-1">
|
||
ניתן להעלות מספר קבצים בבת אחת
|
||
</p>
|
||
</div>
|
||
|
||
{rows.length > 0 && (
|
||
<ul className="space-y-2">
|
||
{rows.map((row) => (
|
||
<UploadRowView key={row.id} row={row} caseNumber={caseNumber} />
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
</SheetContent>
|
||
</Sheet>
|
||
);
|
||
}
|