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:
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()));
|
||||
Reference in New Issue
Block a user