feat(case-files): פאנל "קבצי-התיק" בטאב טיוטות — גישה מה-UI לכל תתי-התיקיות
היו"ר לא יכל לגשת לטיוטה שהסוכן שמר בדיסק (interim-decision-draft.md) אלא ב-SSH.
מימוש מוקאפ X17 מאושר (18m): פאנל דפדפן-קבצים בטאב "טיוטות והערות".
Backend (web/app.py) — הכללה של ה-endpoints הקיימים ל**כל** תת-תיקייה:
- `_case_file_folders` — מונה כל תת-תיקיית-תוכן תחת התיק (מקור/מחולץ/מחקר/הגהה/
טיוטות/גיבויים), רקורסיה שכבה אחת לתוך documents/, מדלג על מוסתרות (.git/.claude)
ו-thumbnails. `_resolve_case_file` מאמת folder-key מול הרשימה + חוסם path-traversal.
- `/local-files` מחזיר את כל התיקיות (כולל ריקות, עם files=[]); `/local-files/{folder}/{filename}`
מגיש מכל תיקייה עם media-type לפי סיומת.
Frontend:
- `lib/api/case-files.ts` — useCaseFiles / useCaseFileText / caseFileUrl.
- `CaseFilesBrowser` — עץ-תיקיות (אקורדיון, ריקות מסומנות "ריק") + viewer מובנה
ל-md/txt (Markdown הקיים) + הורדה לכל קובץ. נטען בטאב "טיוטות והערות".
אין response_model → אין תלות ב-api:types (מודול טיפוסים בכתב-יד).
Invariants: G1, G2 (אין מסלול-קבצים מקביל — מכליל את הקיים), אבטחת-נתיב.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import { CaseHeader } from "@/components/cases/case-header";
|
||||
import { CaseEditDialog } from "@/components/cases/case-edit-dialog";
|
||||
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
||||
import { DraftsPanel } from "@/components/cases/drafts-panel";
|
||||
import { CaseFilesBrowser } from "@/components/cases/case-files-browser";
|
||||
import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
|
||||
import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel";
|
||||
import { PositionsPanel } from "@/components/cases/positions-panel";
|
||||
@@ -243,12 +244,17 @@ export default function CaseDetailPage({
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="drafts" className="mt-0">
|
||||
<TabsContent value="drafts" className="mt-0 space-y-4">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<DraftsPanel caseNumber={caseNumber} status={data?.status} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<CaseFilesBrowser caseNumber={caseNumber} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="agents" className="mt-0">
|
||||
|
||||
304
web-ui/src/components/cases/case-files-browser.tsx
Normal file
304
web-ui/src/components/cases/case-files-browser.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
useCaseFiles,
|
||||
useCaseFileText,
|
||||
caseFileUrl,
|
||||
type CaseFileFolder,
|
||||
} from "@/lib/api/case-files";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { formatDateTime } from "@/lib/format-date";
|
||||
import {
|
||||
Folder,
|
||||
FileText,
|
||||
File as FileIcon,
|
||||
Download,
|
||||
ChevronDown,
|
||||
Loader2,
|
||||
FolderOpen,
|
||||
} from "lucide-react";
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────── */
|
||||
|
||||
const FOLDER_LABELS: Record<string, string> = {
|
||||
research: "מחקר וטיוטות",
|
||||
originals: "מסמכי מקור",
|
||||
extracted: "טקסט מחולץ (OCR)",
|
||||
proofread: "לאחר הגהה",
|
||||
drafts: "טיוטות DOCX",
|
||||
backup: "גיבויים",
|
||||
};
|
||||
|
||||
// Extensions the inline viewer can render (everything else → download only).
|
||||
const TEXT_EXT = new Set(["md", "txt", "json", "csv", "log", "markdown"]);
|
||||
|
||||
function fileExt(filename: string): string {
|
||||
return filename.includes(".") ? filename.split(".").pop()!.toLowerCase() : "";
|
||||
}
|
||||
|
||||
function folderLabel(f: CaseFileFolder): string {
|
||||
return FOLDER_LABELS[f.name] ?? f.name;
|
||||
}
|
||||
|
||||
function fmtSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const EXT_TONE: Record<string, string> = {
|
||||
md: "bg-teal-100 text-teal-700",
|
||||
markdown: "bg-teal-100 text-teal-700",
|
||||
txt: "bg-blue-100 text-blue-700",
|
||||
json: "bg-blue-100 text-blue-700",
|
||||
pdf: "bg-red-100 text-red-700",
|
||||
docx: "bg-indigo-100 text-indigo-700",
|
||||
doc: "bg-indigo-100 text-indigo-700",
|
||||
};
|
||||
function extTone(ext: string): string {
|
||||
return EXT_TONE[ext] ?? "bg-gray-100 text-gray-600";
|
||||
}
|
||||
|
||||
type Selected = { folderKey: string; filename: string };
|
||||
|
||||
/* ── Component ───────────────────────────────────────────────── */
|
||||
|
||||
export function CaseFilesBrowser({ caseNumber }: { caseNumber: string }) {
|
||||
const { data, isLoading, error } = useCaseFiles(caseNumber);
|
||||
const folders = useMemo(() => data?.folders ?? [], [data?.folders]);
|
||||
|
||||
const totalFiles = folders.reduce((n, f) => n + f.files.length, 0);
|
||||
|
||||
// Default selection derived (not via an effect): a text file in research,
|
||||
// else any text file. The chair's explicit pick overrides it.
|
||||
const defaultSelected = useMemo<Selected | null>(() => {
|
||||
const pick = (
|
||||
pred: (f: CaseFileFolder, name: string) => boolean,
|
||||
): Selected | null => {
|
||||
for (const f of folders)
|
||||
for (const file of f.files)
|
||||
if (pred(f, file.filename))
|
||||
return { folderKey: f.key, filename: file.filename };
|
||||
return null;
|
||||
};
|
||||
return (
|
||||
pick((f, name) => f.name === "research" && TEXT_EXT.has(fileExt(name))) ??
|
||||
pick((_f, name) => TEXT_EXT.has(fileExt(name)))
|
||||
);
|
||||
}, [folders]);
|
||||
|
||||
const [userSelected, setUserSelected] = useState<Selected | null>(null);
|
||||
const selected = userSelected ?? defaultSelected;
|
||||
|
||||
// A folder is open if explicitly toggled; otherwise the folder of the current
|
||||
// selection (or the first non-empty folder) is open by default.
|
||||
const defaultOpenKey =
|
||||
selected?.folderKey ?? folders.find((f) => f.files.length)?.key ?? null;
|
||||
const [openOverrides, setOpenOverrides] = useState<Map<string, boolean>>(
|
||||
new Map(),
|
||||
);
|
||||
const isOpen = (key: string) =>
|
||||
openOverrides.has(key) ? openOverrides.get(key)! : key === defaultOpenKey;
|
||||
|
||||
const selExt = selected ? fileExt(selected.filename) : "";
|
||||
const selIsText = TEXT_EXT.has(selExt);
|
||||
const {
|
||||
data: fileText,
|
||||
isLoading: textLoading,
|
||||
error: textError,
|
||||
} = useCaseFileText(
|
||||
caseNumber,
|
||||
selIsText ? selected?.folderKey : undefined,
|
||||
selIsText ? selected?.filename : undefined,
|
||||
);
|
||||
|
||||
const toggleFolder = (key: string) => {
|
||||
const next = !isOpen(key);
|
||||
setOpenOverrides((prev) => new Map(prev).set(key, next));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-10 text-ink-faint">
|
||||
<Loader2 className="w-5 h-5 animate-spin me-2" />
|
||||
<span>טוען קבצים...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-10 text-red-500 text-sm">
|
||||
שגיאה בטעינת קבצי-התיק
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-rule rounded-xl overflow-hidden">
|
||||
{/* header */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-rule-soft bg-parchment">
|
||||
<FolderOpen className="w-4 h-4 text-gold-deep" />
|
||||
<h3 className="text-sm font-bold text-navy">קבצי-התיק</h3>
|
||||
<span className="text-[11px] text-ink-muted ms-auto">
|
||||
כל תתי-התיקיות · {totalFiles} קבצים
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-[320px_1fr]">
|
||||
{/* file tree */}
|
||||
<div className="border-b md:border-b-0 md:border-e border-rule-soft max-h-[560px] overflow-y-auto">
|
||||
{folders.map((f) => {
|
||||
const open = isOpen(f.key);
|
||||
return (
|
||||
<div key={f.key} className="border-b border-rule-soft last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFolder(f.key)}
|
||||
aria-expanded={open}
|
||||
className="w-full flex items-center gap-2 px-3.5 py-2.5 hover:bg-sand-soft/60 text-start"
|
||||
>
|
||||
<Folder className="w-4 h-4 text-gold-deep shrink-0" />
|
||||
<span className="text-[0.78rem] font-bold text-navy">
|
||||
{folderLabel(f)}
|
||||
</span>
|
||||
<span className="text-[9px] font-mono text-ink-faint hidden sm:inline">
|
||||
{f.path}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] font-semibold rounded-full px-2 ms-auto ${f.files.length ? "bg-gray-100 text-ink-muted" : "text-ink-light"}`}
|
||||
>
|
||||
{f.files.length || "ריק"}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-ink-faint shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && f.files.length > 0 && (
|
||||
<div className="pb-1 bg-parchment/50">
|
||||
{f.files.map((file) => {
|
||||
const ext = fileExt(file.filename);
|
||||
const isSel =
|
||||
selected?.folderKey === f.key &&
|
||||
selected?.filename === file.filename;
|
||||
const viewable = TEXT_EXT.has(ext);
|
||||
return (
|
||||
<div
|
||||
key={file.filename}
|
||||
className={`flex items-center gap-2.5 py-1.5 pe-3.5 ps-5 cursor-pointer border-s-2 ${isSel ? "bg-gold-wash border-gold" : "border-transparent hover:bg-sand-soft/60"}`}
|
||||
onClick={() =>
|
||||
viewable
|
||||
? setUserSelected({
|
||||
folderKey: f.key,
|
||||
filename: file.filename,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`w-6 h-6 rounded-md flex items-center justify-center shrink-0 ${extTone(ext)}`}
|
||||
>
|
||||
{viewable ? (
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<FileIcon className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[11.5px] font-medium text-ink-soft truncate">
|
||||
{file.filename}
|
||||
</div>
|
||||
<div className="text-[9.5px] text-ink-light">
|
||||
{fmtSize(file.size)} ·{" "}
|
||||
{formatDateTime(file.modified_at * 1000)}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={caseFileUrl(caseNumber, f.key, file.filename)}
|
||||
download={file.filename}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-7 h-7 rounded-md border border-rule bg-surface flex items-center justify-center text-ink-muted hover:text-navy shrink-0"
|
||||
title="הורדה"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{folders.length === 0 && (
|
||||
<div className="text-center py-10 text-ink-faint text-sm">
|
||||
אין קבצים בתיק.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* viewer */}
|
||||
<div className="min-w-0 flex flex-col">
|
||||
{!selected ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center py-16 text-ink-faint text-sm">
|
||||
<FileText className="w-8 h-8 mb-2 opacity-40" />
|
||||
בחר קובץ טקסט מהרשימה לצפייה
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2.5 px-4 py-3 border-b border-rule-soft">
|
||||
<span
|
||||
className={`w-7 h-7 rounded-md flex items-center justify-center shrink-0 ${extTone(selExt)}`}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[12.5px] font-bold text-navy truncate">
|
||||
{selected.filename}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={caseFileUrl(
|
||||
caseNumber,
|
||||
selected.folderKey,
|
||||
selected.filename,
|
||||
)}
|
||||
download={selected.filename}
|
||||
className="ms-auto inline-flex items-center gap-1.5 text-[11.5px] font-semibold text-navy bg-parchment border border-rule rounded-md px-3 py-1.5 hover:bg-sand-soft"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
הורדה
|
||||
</a>
|
||||
</div>
|
||||
<div className="px-5 py-4 overflow-y-auto max-h-[520px] bg-[#fdfcf9]">
|
||||
{!selIsText ? (
|
||||
<div className="text-center py-12 text-sm text-ink-muted">
|
||||
קובץ מסוג <Badge variant="outline">{selExt || "?"}</Badge> —
|
||||
לא ניתן לצפייה מוטבעת. הורד כדי לפתוח.
|
||||
</div>
|
||||
) : textLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-ink-faint">
|
||||
<Loader2 className="w-5 h-5 animate-spin me-2" />
|
||||
טוען תוכן...
|
||||
</div>
|
||||
) : textError ? (
|
||||
<div className="text-center py-12 text-red-500 text-sm">
|
||||
שגיאה בטעינת תוכן הקובץ
|
||||
</div>
|
||||
) : selExt === "md" || selExt === "markdown" ? (
|
||||
<Markdown content={fileText ?? ""} />
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap text-xs text-ink-soft font-mono leading-relaxed">
|
||||
{fileText}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
web-ui/src/lib/api/case-files.ts
Normal file
66
web-ui/src/lib/api/case-files.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Case files API — browse & read every content subfolder of a case on disk.
|
||||
*
|
||||
* Surfaces the agent-produced work files (drafts, OCR text, research, originals…)
|
||||
* that previously lived only on the server filesystem. Backed by the pre-existing
|
||||
* `/local-files` endpoints, generalized to all subfolders (path-safe on the
|
||||
* server via `_resolve_case_file`).
|
||||
*/
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest } from "./client";
|
||||
|
||||
export type CaseFile = {
|
||||
filename: string;
|
||||
size: number;
|
||||
/** Unix epoch seconds (float) of last modification. */
|
||||
modified_at: number;
|
||||
};
|
||||
|
||||
export type CaseFileFolder = {
|
||||
/** Opaque folder key for the read endpoint (e.g. "documents__research"). */
|
||||
key: string;
|
||||
/** Leaf folder name (e.g. "research"). */
|
||||
name: string;
|
||||
/** Case-relative path (e.g. "documents/research"). */
|
||||
path: string;
|
||||
files: CaseFile[];
|
||||
};
|
||||
|
||||
export type CaseFilesResponse = { folders: CaseFileFolder[] };
|
||||
|
||||
export function useCaseFiles(caseNumber: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["case-files", caseNumber ?? ""],
|
||||
queryFn: () =>
|
||||
apiRequest<CaseFilesResponse>(`/api/cases/${caseNumber}/local-files`),
|
||||
enabled: !!caseNumber,
|
||||
});
|
||||
}
|
||||
|
||||
/** Direct URL to a single file — use as an <a href> for download/open. */
|
||||
export function caseFileUrl(
|
||||
caseNumber: string,
|
||||
folderKey: string,
|
||||
filename: string,
|
||||
): string {
|
||||
return `/api/cases/${encodeURIComponent(caseNumber)}/local-files/${encodeURIComponent(folderKey)}/${encodeURIComponent(filename)}`;
|
||||
}
|
||||
|
||||
/** Fetch a text file's contents (md/txt/json) for the inline viewer. */
|
||||
export function useCaseFileText(
|
||||
caseNumber: string | undefined,
|
||||
folderKey: string | undefined,
|
||||
filename: string | undefined,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["case-file-text", caseNumber ?? "", folderKey ?? "", filename ?? ""],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(caseFileUrl(caseNumber!, folderKey!, filename!));
|
||||
if (!res.ok) throw new Error(`שגיאה בטעינת הקובץ (${res.status})`);
|
||||
return res.text();
|
||||
},
|
||||
enabled: !!caseNumber && !!folderKey && !!filename,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user