diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 9701a8f..39bc88b 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -6,7 +6,6 @@ import { QuickAddTime, QuickAddExpense } from "@/components/quick-add/quick-add" import { Briefcase, Users, - FileText, Receipt, ShieldCheck, LogOut, @@ -15,6 +14,7 @@ import { Settings, Wallet, FileSignature, + FolderArchive, } from "lucide-react"; import { cn } from "@/lib/utils"; import type { ReactNode } from "react"; @@ -32,6 +32,7 @@ const NAV: NavItem[] = [ { to: "/cases", label: "Cases", icon: Briefcase }, { to: "/collections", label: "Collections", icon: Wallet }, { to: "/documents", label: "Documents", icon: FileSignature }, + { to: "/files", label: "Files", icon: FolderArchive }, { to: "/invoices", label: "Invoices", icon: Receipt }, { to: "/admin/users", label: "Users", icon: ShieldCheck, adminOnly: true }, { to: "/settings", label: "Settings", icon: Settings, adminOnly: true }, diff --git a/src/components/cases/documents-tab.tsx b/src/components/cases/documents-tab.tsx index 38b827e..b25823b 100644 --- a/src/components/cases/documents-tab.tsx +++ b/src/components/cases/documents-tab.tsx @@ -1,55 +1,204 @@ -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 + const rel: string = (f as File & { webkitRelativePath?: string }).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 +213,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` : "—"} + + + + + + + ))} + + + + +
); } diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 7d68dbc..99ee050 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -524,6 +524,38 @@ export type Database = { }, ] } + document_folders: { + Row: { + case_id: string + created_at: string + created_by: string | null + id: string + path: string + } + Insert: { + case_id: string + created_at?: string + created_by?: string | null + id?: string + path: string + } + Update: { + case_id?: string + created_at?: string + created_by?: string | null + id?: string + path?: string + } + Relationships: [ + { + foreignKeyName: "document_folders_case_id_fkey" + columns: ["case_id"] + isOneToOne: false + referencedRelation: "cases" + referencedColumns: ["id"] + }, + ] + } document_templates: { Row: { body: string @@ -571,6 +603,7 @@ export type Database = { case_id: string created_at: string description: string | null + folder: string id: string mime_type: string | null name: string @@ -582,6 +615,7 @@ export type Database = { case_id: string created_at?: string description?: string | null + folder?: string id?: string mime_type?: string | null name: string @@ -593,6 +627,7 @@ export type Database = { case_id?: string created_at?: string description?: string | null + folder?: string id?: string mime_type?: string | null name?: string diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index febe8ae..dfd6339 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as SettingsRouteImport } from './routes/settings' import { Route as LoginRouteImport } from './routes/login' import { Route as IndexRouteImport } from './routes/index' import { Route as SettingsIndexRouteImport } from './routes/settings.index' +import { Route as FilesIndexRouteImport } from './routes/files.index' import { Route as DocumentsIndexRouteImport } from './routes/documents.index' import { Route as CollectionsIndexRouteImport } from './routes/collections.index' import { Route as ClientsIndexRouteImport } from './routes/clients.index' @@ -55,6 +56,11 @@ const SettingsIndexRoute = SettingsIndexRouteImport.update({ path: '/', getParentRoute: () => SettingsRoute, } as any) +const FilesIndexRoute = FilesIndexRouteImport.update({ + id: '/files/', + path: '/files/', + getParentRoute: () => rootRouteImport, +} as any) const DocumentsIndexRoute = DocumentsIndexRouteImport.update({ id: '/documents/', path: '/documents/', @@ -148,6 +154,7 @@ export interface FileRoutesByFullPath { '/clients/': typeof ClientsIndexRoute '/collections/': typeof CollectionsIndexRoute '/documents/': typeof DocumentsIndexRoute + '/files/': typeof FilesIndexRoute '/settings/': typeof SettingsIndexRoute '/documents/pleading/new': typeof DocumentsPleadingNewRoute '/documents/templates/$templateId': typeof DocumentsTemplatesTemplateIdRoute @@ -169,6 +176,7 @@ export interface FileRoutesByTo { '/clients': typeof ClientsIndexRoute '/collections': typeof CollectionsIndexRoute '/documents': typeof DocumentsIndexRoute + '/files': typeof FilesIndexRoute '/settings': typeof SettingsIndexRoute '/documents/pleading/new': typeof DocumentsPleadingNewRoute '/documents/templates/$templateId': typeof DocumentsTemplatesTemplateIdRoute @@ -192,6 +200,7 @@ export interface FileRoutesById { '/clients/': typeof ClientsIndexRoute '/collections/': typeof CollectionsIndexRoute '/documents/': typeof DocumentsIndexRoute + '/files/': typeof FilesIndexRoute '/settings/': typeof SettingsIndexRoute '/documents/pleading/new': typeof DocumentsPleadingNewRoute '/documents/templates/$templateId': typeof DocumentsTemplatesTemplateIdRoute @@ -216,6 +225,7 @@ export interface FileRouteTypes { | '/clients/' | '/collections/' | '/documents/' + | '/files/' | '/settings/' | '/documents/pleading/new' | '/documents/templates/$templateId' @@ -237,6 +247,7 @@ export interface FileRouteTypes { | '/clients' | '/collections' | '/documents' + | '/files' | '/settings' | '/documents/pleading/new' | '/documents/templates/$templateId' @@ -259,6 +270,7 @@ export interface FileRouteTypes { | '/clients/' | '/collections/' | '/documents/' + | '/files/' | '/settings/' | '/documents/pleading/new' | '/documents/templates/$templateId' @@ -280,6 +292,7 @@ export interface RootRouteChildren { ClientsIndexRoute: typeof ClientsIndexRoute CollectionsIndexRoute: typeof CollectionsIndexRoute DocumentsIndexRoute: typeof DocumentsIndexRoute + FilesIndexRoute: typeof FilesIndexRoute DocumentsPleadingNewRoute: typeof DocumentsPleadingNewRoute DocumentsTemplatesTemplateIdRoute: typeof DocumentsTemplatesTemplateIdRoute DocumentsTemplatesNewRoute: typeof DocumentsTemplatesNewRoute @@ -323,6 +336,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsIndexRouteImport parentRoute: typeof SettingsRoute } + '/files/': { + id: '/files/' + path: '/files' + fullPath: '/files/' + preLoaderRoute: typeof FilesIndexRouteImport + parentRoute: typeof rootRouteImport + } '/documents/': { id: '/documents/' path: '/documents' @@ -461,6 +481,7 @@ const rootRouteChildren: RootRouteChildren = { ClientsIndexRoute: ClientsIndexRoute, CollectionsIndexRoute: CollectionsIndexRoute, DocumentsIndexRoute: DocumentsIndexRoute, + FilesIndexRoute: FilesIndexRoute, DocumentsPleadingNewRoute: DocumentsPleadingNewRoute, DocumentsTemplatesTemplateIdRoute: DocumentsTemplatesTemplateIdRoute, DocumentsTemplatesNewRoute: DocumentsTemplatesNewRoute, diff --git a/src/routes/files.index.tsx b/src/routes/files.index.tsx new file mode 100644 index 0000000..40ecdb6 --- /dev/null +++ b/src/routes/files.index.tsx @@ -0,0 +1,160 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useEffect, useMemo, useState } from "react"; +import { ProtectedLayout } from "@/components/protected-layout"; +import { PageContainer, PageHeader } from "@/components/app-shell"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { supabase } from "@/integrations/supabase/client"; +import { Download, FileText, Search, Folder } from "lucide-react"; +import { formatDate } from "@/lib/format"; +import { toast } from "sonner"; + +export const Route = createFileRoute("/files/")({ + component: () => ( + + + + ), +}); + +function FilesPage() { + const [docs, setDocs] = useState([]); + const [loading, setLoading] = useState(true); + const [q, setQ] = useState(""); + + const load = async () => { + setLoading(true); + const { data, error } = await supabase + .from("documents") + .select("*") + .order("created_at", { ascending: false }) + .limit(1000); + if (error) { toast.error("Could not load files", { description: error.message }); setLoading(false); return; } + + const caseIds = Array.from(new Set((data ?? []).map((d: any) => d.case_id).filter(Boolean))); + const uploaderIds = Array.from(new Set((data ?? []).map((d: any) => d.uploaded_by).filter(Boolean))); + const [{ data: cases }, { data: profs }] = await Promise.all([ + caseIds.length + ? supabase + .from("cases") + .select("id, case_number, title, client:clients(id, name)") + .in("id", caseIds) + : Promise.resolve({ data: [] as any[] }), + uploaderIds.length + ? supabase.from("profiles").select("id, full_name, email").in("id", uploaderIds) + : Promise.resolve({ data: [] as any[] }), + ]); + const caseMap = Object.fromEntries((cases ?? []).map((c: any) => [c.id, c])); + const profMap = Object.fromEntries((profs ?? []).map((p: any) => [p.id, p])); + setDocs( + (data ?? []).map((d: any) => ({ + ...d, + case: caseMap[d.case_id], + uploader: d.uploaded_by ? profMap[d.uploaded_by] : null, + })), + ); + setLoading(false); + }; + + useEffect(() => { load(); }, []); + + const filtered = useMemo(() => { + const s = q.trim().toLowerCase(); + if (!s) return docs; + return docs.filter((d) => + [d.name, d.folder, d.case?.case_number, d.case?.title, d.case?.client?.name, d.uploader?.full_name, d.uploader?.email] + .filter(Boolean) + .some((v: string) => String(v).toLowerCase().includes(s)), + ); + }, [docs, q]); + + 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; } + window.open(data.signedUrl, "_blank"); + }; + + return ( + + + + + + + setQ(e.target.value)} + placeholder="Search by file, folder, case, client, or uploader…" + className="border-0 focus-visible:ring-0 shadow-none" + /> + + {filtered.length} of {docs.length} + + + + + + + + + + + + + + + + + + + + {loading && ( + + )} + {!loading && filtered.length === 0 && ( + + )} + {filtered.map((d) => ( + + + + + + + + + + ))} + +
NameCaseFolderUploaded byDateSize
Loading…
No files found.
+
+ + {d.name} +
+
+ {d.case ? ( + +
{d.case.title}
+
+ {d.case.case_number}{d.case.client ? ` · ${d.case.client.name}` : ""} +
+ + ) : —} +
+ {d.folder ? ( + {d.folder} + ) : root} + {d.uploader?.full_name || d.uploader?.email || "—"}{formatDate(d.created_at)} + {d.size_bytes ? `${(d.size_bytes / 1024).toFixed(1)} KB` : "—"} + + +
+
+
+
+ ); +} diff --git a/supabase/migrations/20260417021117_f95ebbb3-2131-46c7-994a-d09ca4d98059.sql b/supabase/migrations/20260417021117_f95ebbb3-2131-46c7-994a-d09ca4d98059.sql new file mode 100644 index 0000000..831677b --- /dev/null +++ b/supabase/migrations/20260417021117_f95ebbb3-2131-46c7-994a-d09ca4d98059.sql @@ -0,0 +1,20 @@ +ALTER TABLE public.documents ADD COLUMN IF NOT EXISTS folder text NOT NULL DEFAULT ''; +CREATE INDEX IF NOT EXISTS documents_case_folder_idx ON public.documents (case_id, folder); + +CREATE TABLE IF NOT EXISTS public.document_folders ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + case_id uuid NOT NULL REFERENCES public.cases(id) ON DELETE CASCADE, + path text NOT NULL, + created_by uuid, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (case_id, path) +); + +ALTER TABLE public.document_folders ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "df_select_case" ON public.document_folders FOR SELECT TO authenticated + USING (public.can_access_case(case_id, auth.uid())); +CREATE POLICY "df_insert_case" ON public.document_folders FOR INSERT TO authenticated + WITH CHECK (public.can_access_case(case_id, auth.uid())); +CREATE POLICY "df_delete_case" ON public.document_folders FOR DELETE TO authenticated + USING (public.can_access_case(case_id, auth.uid())); \ No newline at end of file