diff --git a/src/components/cases/documents-tab.tsx b/src/components/cases/documents-tab.tsx index 38b827e..2f0fbb0 100644 --- a/src/components/cases/documents-tab.tsx +++ b/src/components/cases/documents-tab.tsx @@ -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([]); + const [folders, setFolders] = useState([]); + const [path, setPath] = useState(""); // 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(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 = {}; + 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) => { - 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(); + (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) => { + 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(); + const walk = async (entry: any, prefix: string): Promise => { + 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 (
+ {/* Toolbar */} - -
- - setDescription(e.target.value)} placeholder="e.g. Settlement draft v2" maxLength={300} /> + +
+ + {crumbs.map((seg, i) => { + const target = crumbs.slice(0, i + 1).join("/"); + return ( + + + + + ); + })}
- - + +
- - - - - - - - - - - - - - {docs.length === 0 && ( - - )} - {docs.map((d) => ( - - - - - - + {creatingFolder && ( + + + setNewFolderName(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") setCreatingFolder(false); }} + placeholder="Folder name" + maxLength={120} + /> + + + + + )} + + {/* Drop zone + listing */} +
{ 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"}`} + > + + +
NameDescriptionUploaded byDateActions
No documents uploaded.
-
- - {d.name} -
-
- {d.size_bytes ? `${(d.size_bytes / 1024).toFixed(1)} KB` : ""} -
-
{d.description || "—"}{d.uploader?.full_name || d.uploader?.email || "—"}{formatDate(d.created_at)} - - -
+ + + + + + + - ))} - -
NameUploaded byDateSizeActions
-
-
+ + + {subfolders.length === 0 && visibleDocs.length === 0 && ( + + + + Drag & drop files or folders here, or click Upload. + + + )} + {subfolders.map((f) => ( + + + + + Folder + + + + + ))} + {visibleDocs.map((d) => ( + + +
+ + {d.name} +
+ + {d.uploader?.full_name || d.uploader?.email || "—"} + {formatDate(d.created_at)} + + {d.size_bytes ? `${(d.size_bytes / 1024).toFixed(1)} KB` : "—"} + + + + + + + ))} + + + + +
); }