Added drag-drop documents

X-Lovable-Edit-ID: edt-85213407-d777-4b53-96cd-189de296bdc8
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:14:02 +00:00
co-authored by renee-png
6 changed files with 541 additions and 73 deletions
+2 -1
View File
@@ -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 },
+303 -72
View File
@@ -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<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
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<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 +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 (
<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>
);
}
+35
View File
@@ -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
+21
View File
@@ -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,
+160
View File
@@ -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: () => (
<ProtectedLayout>
<FilesPage />
</ProtectedLayout>
),
});
function FilesPage() {
const [docs, setDocs] = useState<any[]>([]);
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 (
<PageContainer>
<PageHeader
title="Files"
description="All documents uploaded across the cases you can access."
/>
<Card className="border-border/60 mb-4">
<CardContent className="p-3 flex items-center gap-2">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search by file, folder, case, client, or uploader…"
className="border-0 focus-visible:ring-0 shadow-none"
/>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{filtered.length} of {docs.length}
</span>
</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">Case</th>
<th className="text-left px-4 py-3 font-medium">Folder</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></th>
</tr>
</thead>
<tbody>
{loading && (
<tr><td colSpan={7} className="text-center py-12 text-muted-foreground">Loading…</td></tr>
)}
{!loading && filtered.length === 0 && (
<tr><td colSpan={7} className="text-center py-12 text-muted-foreground">No files found.</td></tr>
)}
{filtered.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">
{d.case ? (
<Link to="/cases/$caseId" params={{ caseId: d.case.id }} className="hover:text-primary">
<div className="text-sm">{d.case.title}</div>
<div className="text-[11px] text-muted-foreground">
{d.case.case_number}{d.case.client ? ` · ${d.case.client.name}` : ""}
</div>
</Link>
) : <span className="text-muted-foreground">—</span>}
</td>
<td className="px-4 py-3 text-muted-foreground">
{d.folder ? (
<span className="inline-flex items-center gap-1.5"><Folder className="h-3.5 w-3.5" />{d.folder}</span>
) : <span className="text-xs">root</span>}
</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 whitespace-nowrap">{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>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</PageContainer>
);
}
@@ -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()));