Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-17 02:12:23 +00:00
co-authored by renee-png
parent b14a968191
commit 63c202b6f8
+304 -72
View File
@@ -1,55 +1,205 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, useCallback } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import { Upload, FileText, Download, Trash2, Loader2 } from "lucide-react";
import {
Upload,
FileText,
Download,
Trash2,
Loader2,
Folder,
FolderPlus,
ChevronRight,
Home,
} from "lucide-react";
import { formatDate } from "@/lib/format";
import { toast } from "sonner";
const MAX_BYTES = 50 * 1024 * 1024;
export function CaseDocumentsTab({ caseId }: { caseId: string }) {
const { user } = useAuth();
const [docs, setDocs] = useState<any[]>([]);
const [folders, setFolders] = useState<string[]>([]);
const [path, setPath] = useState<string>(""); // current folder, "" = root
const [uploading, setUploading] = useState(false);
const [description, setDescription] = useState("");
const [dragOver, setDragOver] = useState(false);
const [creatingFolder, setCreatingFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const fileRef = useRef<HTMLInputElement>(null);
const load = async () => {
const { data } = await supabase
.from("documents")
.select("*, uploader:profiles!documents_uploaded_by_fkey(full_name, email)")
.eq("case_id", caseId)
.order("created_at", { ascending: false });
setDocs(data ?? []);
};
const load = useCallback(async () => {
const [{ data: ds, error: de }, { data: fs }] = await Promise.all([
supabase
.from("documents")
.select("*")
.eq("case_id", caseId)
.order("created_at", { ascending: false }),
supabase
.from("document_folders")
.select("path")
.eq("case_id", caseId),
]);
if (de) { toast.error("Could not load documents", { description: de.message }); return; }
useEffect(() => { load(); }, [caseId]);
// Resolve uploader profiles
const uploaderIds = Array.from(new Set((ds ?? []).map((d: any) => d.uploaded_by).filter(Boolean)));
let profileMap: Record<string, any> = {};
if (uploaderIds.length) {
const { data: profs } = await supabase.from("profiles").select("id, full_name, email").in("id", uploaderIds);
profileMap = Object.fromEntries((profs ?? []).map((p: any) => [p.id, p]));
}
setDocs((ds ?? []).map((d: any) => ({ ...d, uploader: d.uploaded_by ? profileMap[d.uploaded_by] : null })));
const onUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 50 * 1024 * 1024) { toast.error("File too large (50MB max)"); return; }
setUploading(true);
const path = `${caseId}/${Date.now()}-${file.name.replace(/[^a-zA-Z0-9._-]/g, "_")}`;
const { error: upErr } = await supabase.storage.from("case-documents").upload(path, file);
if (upErr) { toast.error("Upload failed", { description: upErr.message }); setUploading(false); return; }
// Folders: union of explicit folder rows + folders implied by document paths
const set = new Set<string>();
(fs ?? []).forEach((f: any) => f.path && set.add(f.path));
(ds ?? []).forEach((d: any) => {
const folder = (d.folder || "").trim();
if (folder) {
// ensure all ancestors are listed
const parts = folder.split("/").filter(Boolean);
let acc = "";
for (const p of parts) {
acc = acc ? `${acc}/${p}` : p;
set.add(acc);
}
}
});
setFolders(Array.from(set).sort());
}, [caseId]);
useEffect(() => { load(); }, [load]);
// Visible items: subfolders directly under `path` and files whose folder === path
const subfolders = folders
.filter((f) => {
if (!path) return !f.includes("/");
return f.startsWith(`${path}/`) && !f.slice(path.length + 1).includes("/");
})
.map((f) => ({ full: f, name: path ? f.slice(path.length + 1) : f }));
const visibleDocs = docs.filter((d) => (d.folder || "") === path);
const sanitizeName = (n: string) => n.replace(/[^a-zA-Z0-9._-]/g, "_");
const uploadFile = async (file: File, folderPath: string) => {
if (file.size > MAX_BYTES) { toast.error(`${file.name}: too large (50MB max)`); return false; }
const storagePath = `${caseId}/${Date.now()}-${Math.random().toString(36).slice(2, 6)}-${sanitizeName(file.name)}`;
const { error: upErr } = await supabase.storage.from("case-documents").upload(storagePath, file);
if (upErr) { toast.error(`${file.name}: upload failed`, { description: upErr.message }); return false; }
const { error: insErr } = await supabase.from("documents").insert({
case_id: caseId,
name: file.name,
storage_path: path,
storage_path: storagePath,
mime_type: file.type,
size_bytes: file.size,
description: description.trim() || null,
folder: folderPath,
uploaded_by: user?.id,
});
if (insErr) toast.error("Save failed", { description: insErr.message });
else { toast.success("Uploaded"); setDescription(""); load(); }
if (insErr) { toast.error(`${file.name}: save failed`, { description: insErr.message }); return false; }
return true;
};
const ensureFolder = async (folderPath: string) => {
if (!folderPath) return;
const { error } = await supabase
.from("document_folders")
.upsert({ case_id: caseId, path: folderPath, created_by: user?.id }, { onConflict: "case_id,path" });
if (error && !/duplicate/i.test(error.message)) {
// ignore unique violations, surface others
console.warn(error);
}
};
const handleFiles = async (files: FileList | File[], baseFolder: string) => {
setUploading(true);
const list = Array.from(files);
let ok = 0;
for (const f of list) {
// webkitRelativePath supports folder uploads when input has webkitdirectory
// @ts-expect-error – non-standard but widely supported
const rel: string = f.webkitRelativePath || "";
let folderForFile = baseFolder;
if (rel && rel.includes("/")) {
const parts = rel.split("/");
parts.pop();
const sub = parts.map(sanitizeName).join("/");
folderForFile = baseFolder ? `${baseFolder}/${sub}` : sub;
await ensureFolder(folderForFile);
}
const success = await uploadFile(f, folderForFile);
if (success) ok++;
}
setUploading(false);
if (ok > 0) toast.success(`${ok} file${ok > 1 ? "s" : ""} uploaded`);
load();
if (fileRef.current) fileRef.current.value = "";
};
const onSelectFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files?.length) handleFiles(e.target.files, path);
};
const onDrop = async (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const items = e.dataTransfer?.items;
if (items && items.length && (items[0] as any).webkitGetAsEntry) {
// Walk directory entries to support folder drops
const files: File[] = [];
const folderSet = new Set<string>();
const walk = async (entry: any, prefix: string): Promise<void> => {
if (entry.isFile) {
const file: File = await new Promise((res) => entry.file(res));
// Tag the file with its relative path so handleFiles preserves structure
Object.defineProperty(file, "webkitRelativePath", {
value: prefix ? `${prefix}/${file.name}` : file.name,
configurable: true,
});
files.push(file);
} else if (entry.isDirectory) {
const dirPath = prefix ? `${prefix}/${entry.name}` : entry.name;
folderSet.add(dirPath);
const reader = entry.createReader();
const entries: any[] = await new Promise((res) => reader.readEntries(res));
await Promise.all(entries.map((c) => walk(c, dirPath)));
}
};
const roots: any[] = [];
for (let i = 0; i < items.length; i++) {
const it = items[i];
const ent = (it as any).webkitGetAsEntry?.();
if (ent) roots.push(ent);
}
await Promise.all(roots.map((r) => walk(r, "")));
// Pre-create folders so even empty ones appear
for (const f of folderSet) {
const sanitized = f.split("/").map(sanitizeName).join("/");
await ensureFolder(path ? `${path}/${sanitized}` : sanitized);
}
if (files.length) await handleFiles(files, path);
else { toast.success("Folder created"); load(); }
} else if (e.dataTransfer?.files?.length) {
handleFiles(e.dataTransfer.files, path);
}
};
const createFolder = async () => {
const raw = newFolderName.trim();
if (!raw) return;
const safe = sanitizeName(raw);
const full = path ? `${path}/${safe}` : safe;
await ensureFolder(full);
toast.success("Folder created");
setNewFolderName("");
setCreatingFolder(false);
load();
};
const download = async (doc: any) => {
const { data, error } = await supabase.storage.from("case-documents").createSignedUrl(doc.storage_path, 60);
if (error) { toast.error(error.message); return; }
@@ -64,62 +214,144 @@ export function CaseDocumentsTab({ caseId }: { caseId: string }) {
else { toast.success("Deleted"); load(); }
};
const deleteFolder = async (full: string) => {
const inFolder = docs.filter((d) => (d.folder || "") === full || (d.folder || "").startsWith(`${full}/`));
if (inFolder.length && !confirm(`Folder "${full}" contains ${inFolder.length} file(s). Delete folder and all its files?`)) return;
if (!inFolder.length && !confirm(`Delete empty folder "${full}"?`)) return;
if (inFolder.length) {
await supabase.storage.from("case-documents").remove(inFolder.map((d) => d.storage_path));
await supabase.from("documents").delete().in("id", inFolder.map((d) => d.id));
}
await supabase.from("document_folders").delete().eq("case_id", caseId).or(`path.eq.${full},path.like.${full}/%`);
toast.success("Folder deleted");
load();
};
const crumbs = path ? path.split("/") : [];
return (
<div className="space-y-4">
{/* Toolbar */}
<Card className="border-border/60">
<CardContent className="p-4 flex flex-col sm:flex-row gap-3 items-start sm:items-end">
<div className="flex-1 space-y-1.5">
<Label className="text-xs">Description (optional)</Label>
<Input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="e.g. Settlement draft v2" maxLength={300} />
<CardContent className="p-3 flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1 text-sm flex-1 min-w-0">
<button
onClick={() => setPath("")}
className="flex items-center gap-1 px-2 py-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
>
<Home className="h-3.5 w-3.5" /> Root
</button>
{crumbs.map((seg, i) => {
const target = crumbs.slice(0, i + 1).join("/");
return (
<span key={target} className="flex items-center gap-1">
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
<button
onClick={() => setPath(target)}
className="px-2 py-1 rounded hover:bg-muted text-foreground"
>
{seg}
</button>
</span>
);
})}
</div>
<input ref={fileRef} type="file" className="hidden" onChange={onUpload} />
<Button onClick={() => fileRef.current?.click()} disabled={uploading}>
{uploading ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Upload className="h-4 w-4 mr-2" />}
Upload document
<Button variant="outline" size="sm" onClick={() => setCreatingFolder((v) => !v)}>
<FolderPlus className="h-4 w-4 mr-1.5" /> New folder
</Button>
<input ref={fileRef} type="file" multiple className="hidden" onChange={onSelectFiles} />
<Button size="sm" onClick={() => fileRef.current?.click()} disabled={uploading}>
{uploading ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : <Upload className="h-4 w-4 mr-1.5" />}
Upload
</Button>
</CardContent>
</Card>
<Card className="border-border/60 overflow-hidden">
<CardContent className="p-0">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="text-left px-4 py-3 font-medium">Name</th>
<th className="text-left px-4 py-3 font-medium">Description</th>
<th className="text-left px-4 py-3 font-medium">Uploaded by</th>
<th className="text-left px-4 py-3 font-medium">Date</th>
<th className="text-right px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{docs.length === 0 && (
<tr><td colSpan={5} className="text-center py-12 text-muted-foreground">No documents uploaded.</td></tr>
)}
{docs.map((d) => (
<tr key={d.id} className="border-t hover:bg-muted/30">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{d.name}</span>
</div>
<div className="text-xs text-muted-foreground ml-6">
{d.size_bytes ? `${(d.size_bytes / 1024).toFixed(1)} KB` : ""}
</div>
</td>
<td className="px-4 py-3 text-muted-foreground">{d.description || "—"}</td>
<td className="px-4 py-3 text-muted-foreground">{d.uploader?.full_name || d.uploader?.email || "—"}</td>
<td className="px-4 py-3 text-muted-foreground">{formatDate(d.created_at)}</td>
<td className="px-4 py-3 text-right">
<Button variant="ghost" size="icon" onClick={() => download(d)}><Download className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon" onClick={() => del(d)}><Trash2 className="h-4 w-4 text-destructive" /></Button>
</td>
{creatingFolder && (
<Card className="border-border/60">
<CardContent className="p-3 flex gap-2">
<Input
autoFocus
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") setCreatingFolder(false); }}
placeholder="Folder name"
maxLength={120}
/>
<Button size="sm" onClick={createFolder}>Create</Button>
<Button size="sm" variant="ghost" onClick={() => { setCreatingFolder(false); setNewFolderName(""); }}>Cancel</Button>
</CardContent>
</Card>
)}
{/* Drop zone + listing */}
<div
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={onDrop}
className={`rounded-lg border-2 border-dashed transition-colors ${dragOver ? "border-primary bg-primary/5" : "border-border/60"}`}
>
<Card className="border-0 overflow-hidden bg-transparent shadow-none">
<CardContent className="p-0">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="text-left px-4 py-3 font-medium">Name</th>
<th className="text-left px-4 py-3 font-medium">Uploaded by</th>
<th className="text-left px-4 py-3 font-medium">Date</th>
<th className="text-right px-4 py-3 font-medium">Size</th>
<th className="text-right px-4 py-3 font-medium">Actions</th>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</thead>
<tbody>
{subfolders.length === 0 && visibleDocs.length === 0 && (
<tr>
<td colSpan={5} className="text-center py-16 text-muted-foreground">
<Upload className="h-6 w-6 mx-auto mb-2 opacity-60" />
Drag & drop files or folders here, or click <span className="font-medium">Upload</span>.
</td>
</tr>
)}
{subfolders.map((f) => (
<tr key={`d-${f.full}`} className="border-t hover:bg-muted/30">
<td className="px-4 py-3">
<button onClick={() => setPath(f.full)} className="flex items-center gap-2 font-medium hover:text-primary">
<Folder className="h-4 w-4 text-muted-foreground" />
{f.name}
</button>
</td>
<td colSpan={3} className="px-4 py-3 text-xs text-muted-foreground">Folder</td>
<td className="px-4 py-3 text-right">
<Button variant="ghost" size="icon" onClick={() => deleteFolder(f.full)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</td>
</tr>
))}
{visibleDocs.map((d) => (
<tr key={d.id} className="border-t hover:bg-muted/30">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{d.name}</span>
</div>
</td>
<td className="px-4 py-3 text-muted-foreground">{d.uploader?.full_name || d.uploader?.email || "—"}</td>
<td className="px-4 py-3 text-muted-foreground">{formatDate(d.created_at)}</td>
<td className="px-4 py-3 text-right tabular-nums text-muted-foreground">
{d.size_bytes ? `${(d.size_bytes / 1024).toFixed(1)} KB` : "—"}
</td>
<td className="px-4 py-3 text-right">
<Button variant="ghost" size="icon" onClick={() => download(d)}><Download className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon" onClick={() => del(d)}><Trash2 className="h-4 w-4 text-destructive" /></Button>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</div>
</div>
);
}