Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
b14a968191
commit
63c202b6f8
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user