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 { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { supabase } from "@/integrations/supabase/client"; import { Download, FileText, Search, Folder, Filter, X, Eye, ExternalLink, Loader2, } from "lucide-react"; import { formatDate } from "@/lib/format"; import { toast } from "sonner"; export const Route = createFileRoute("/files/")({ component: () => ( ), }); // File-type buckets const TYPE_BUCKETS = [ { key: "pdf", label: "PDF", test: (n: string, m: string) => m === "application/pdf" || /\.pdf$/i.test(n) }, { key: "image", label: "Image", test: (n: string, m: string) => m.startsWith("image/") || /\.(png|jpe?g|gif|webp|svg|bmp)$/i.test(n) }, { key: "doc", label: "Word", test: (n: string, m: string) => /word|officedocument\.wordprocessing/i.test(m) || /\.(docx?|rtf)$/i.test(n) }, { key: "sheet", label: "Spreadsheet", test: (n: string, m: string) => /sheet|excel|csv/i.test(m) || /\.(xlsx?|csv|ods)$/i.test(n) }, { key: "slide", label: "Presentation", test: (n: string, m: string) => /presentation|powerpoint/i.test(m) || /\.(pptx?|odp)$/i.test(n) }, { key: "text", label: "Text", test: (n: string, m: string) => m.startsWith("text/") || /\.(txt|md|json|xml|log|yml|yaml)$/i.test(n) }, { key: "audio", label: "Audio", test: (n: string, m: string) => m.startsWith("audio/") || /\.(mp3|wav|m4a|ogg)$/i.test(n) }, { key: "video", label: "Video", test: (n: string, m: string) => m.startsWith("video/") || /\.(mp4|webm|mov|avi)$/i.test(n) }, { key: "archive", label: "Archive", test: (n: string, m: string) => /zip|tar|rar|7z/i.test(m) || /\.(zip|tar|gz|rar|7z)$/i.test(n) }, ] as const; const DATE_RANGES = [ { key: "any", label: "Any time", days: null as number | null }, { key: "7", label: "Last 7 days", days: 7 }, { key: "30", label: "Last 30 days", days: 30 }, { key: "90", label: "Last 90 days", days: 90 }, { key: "365", label: "Last year", days: 365 }, ] as const; function bucketOf(name: string, mime: string) { const n = (name || "").toLowerCase(); const m = (mime || "").toLowerCase(); return TYPE_BUCKETS.find((b) => b.test(n, m))?.key ?? "other"; } function FilesPage() { const [docs, setDocs] = useState([]); const [loading, setLoading] = useState(true); const [q, setQ] = useState(""); // Multi-select filters const [caseFilter, setCaseFilter] = useState>(new Set()); const [uploaderFilter, setUploaderFilter] = useState>(new Set()); const [typeFilter, setTypeFilter] = useState>(new Set()); const [folderFilter, setFolderFilter] = useState>(new Set()); const [dateRange, setDateRange] = useState("any"); // Preview state const [previewDoc, setPreviewDoc] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); const [previewLoading, setPreviewLoading] = useState(false); 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, _bucket: bucketOf(d.name, d.mime_type), })), ); setLoading(false); }; useEffect(() => { load(); }, []); // Build filter option lists from current docs const caseOptions = useMemo(() => { const m = new Map(); docs.forEach((d) => { if (d.case) m.set(d.case.id, { id: d.case.id, label: `${d.case.case_number} — ${d.case.title}` }); }); return Array.from(m.values()).sort((a, b) => a.label.localeCompare(b.label)); }, [docs]); const uploaderOptions = useMemo(() => { const m = new Map(); docs.forEach((d) => { if (d.uploader) m.set(d.uploader.id, { id: d.uploader.id, label: d.uploader.full_name || d.uploader.email }); }); return Array.from(m.values()).sort((a, b) => a.label.localeCompare(b.label)); }, [docs]); const folderOptions = useMemo(() => { const set = new Set(); docs.forEach((d) => set.add(d.folder || "")); return Array.from(set).sort(); }, [docs]); const filtered = useMemo(() => { const s = q.trim().toLowerCase(); const days = DATE_RANGES.find((r) => r.key === dateRange)?.days ?? null; const cutoff = days ? Date.now() - days * 86400_000 : null; return docs.filter((d) => { if (s) { const hay = [d.name, d.folder, d.case?.case_number, d.case?.title, d.case?.client?.name, d.uploader?.full_name, d.uploader?.email] .filter(Boolean) .map((v: string) => String(v).toLowerCase()); if (!hay.some((v) => v.includes(s))) return false; } if (caseFilter.size && (!d.case || !caseFilter.has(d.case.id))) return false; if (uploaderFilter.size && (!d.uploader || !uploaderFilter.has(d.uploader.id))) return false; if (typeFilter.size && !typeFilter.has(d._bucket)) return false; if (folderFilter.size && !folderFilter.has(d.folder || "")) return false; if (cutoff && new Date(d.created_at).getTime() < cutoff) return false; return true; }); }, [docs, q, caseFilter, uploaderFilter, typeFilter, folderFilter, dateRange]); 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"); }; const openPreview = async (doc: any) => { setPreviewDoc(doc); setPreviewUrl(null); setPreviewLoading(true); const { data, error } = await supabase.storage .from("case-documents") .createSignedUrl(doc.storage_path, 300); setPreviewLoading(false); if (error) { toast.error(error.message); setPreviewDoc(null); return; } setPreviewUrl(data.signedUrl); }; const closePreview = () => { setPreviewDoc(null); setPreviewUrl(null); }; const clearAll = () => { setQ(""); setCaseFilter(new Set()); setUploaderFilter(new Set()); setTypeFilter(new Set()); setFolderFilter(new Set()); setDateRange("any"); }; const activeCount = caseFilter.size + uploaderFilter.size + typeFilter.size + folderFilter.size + (dateRange !== "any" ? 1 : 0); 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}
({ id: t.key, label: t.label }))} onChange={setTypeFilter} /> ({ id: f, label: f || "(root)" }))} onChange={setFolderFilter} /> {DATE_RANGES.map((r) => ( ))} {activeCount > 0 && ( )}
{/* Active filter chips */} {activeCount > 0 && (
{Array.from(caseFilter).map((id) => { const o = caseOptions.find((c) => c.id === id); return ( { const n = new Set(caseFilter); n.delete(id); setCaseFilter(n); }} /> ); })} {Array.from(uploaderFilter).map((id) => { const o = uploaderOptions.find((u) => u.id === id); return ( { const n = new Set(uploaderFilter); n.delete(id); setUploaderFilter(n); }} /> ); })} {Array.from(typeFilter).map((id) => { const o = TYPE_BUCKETS.find((t) => t.key === id); return ( { const n = new Set(typeFilter); n.delete(id); setTypeFilter(n); }} /> ); })} {Array.from(folderFilter).map((id) => ( { const n = new Set(folderFilter); n.delete(id); setFolderFilter(n); }} /> ))} {dateRange !== "any" && ( r.key === dateRange)?.label}`} onRemove={() => setDateRange("any")} /> )}
)}
{loading && ( )} {!loading && filtered.length === 0 && ( )} {filtered.map((d) => ( ))}
Name Case Folder Uploaded by Date Size
Loading…
No files match these filters.
{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` : "—"}
!o && closePreview()}> {previewDoc?.name} {previewUrl && (
)}
{previewLoading ? (
Loading preview…
) : previewUrl && previewDoc ? ( ) : null}
); } function FilterPopover({ label, selected, options, onChange, }: { label: string; selected: Set; options: { id: string; label: string }[]; onChange: (s: Set) => void; }) { const [search, setSearch] = useState(""); const filtered = useMemo(() => { const s = search.trim().toLowerCase(); if (!s) return options; return options.filter((o) => o.label.toLowerCase().includes(s)); }, [options, search]); const toggle = (id: string) => { const n = new Set(selected); if (n.has(id)) n.delete(id); else n.add(id); onChange(n); }; return ( {options.length > 8 && ( setSearch(e.target.value)} placeholder={`Search ${label.toLowerCase()}…`} className="h-8 mb-2" /> )}
{filtered.length === 0 ? (

No options

) : ( filtered.map((o) => ( )) )}
{selected.size > 0 && (
)}
); } function FilterChip({ label, onRemove }: { label: string; onRemove: () => void }) { return ( {label} ); } function PreviewBody({ url, doc }: { url: string; doc: any }) { const mime = (doc.mime_type || "").toLowerCase(); const name = (doc.name || "").toLowerCase(); const isImage = mime.startsWith("image/") || /\.(png|jpe?g|gif|webp|svg|bmp)$/i.test(name); const isPdf = mime === "application/pdf" || name.endsWith(".pdf"); const isText = mime.startsWith("text/") || /\.(txt|md|csv|json|log|xml|yml|yaml)$/i.test(name); const isAudio = mime.startsWith("audio/") || /\.(mp3|wav|m4a|ogg)$/i.test(name); const isVideo = mime.startsWith("video/") || /\.(mp4|webm|mov)$/i.test(name); if (isImage) { return (
{doc.name}
); } if (isPdf || isText) { return