diff --git a/src/routes/files.index.tsx b/src/routes/files.index.tsx index 40ecdb6..6c3c01f 100644 --- a/src/routes/files.index.tsx +++ b/src/routes/files.index.tsx @@ -5,8 +5,31 @@ 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 } from "lucide-react"; +import { + Download, + FileText, + Search, + Folder, + Filter, + X, + Eye, + ExternalLink, + Loader2, +} from "lucide-react"; import { formatDate } from "@/lib/format"; import { toast } from "sonner"; @@ -18,11 +41,50 @@ export const Route = createFileRoute("/files/")({ ), }); +// 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 @@ -30,7 +92,11 @@ function FilesPage() { .select("*") .order("created_at", { ascending: false }) .limit(1000); - if (error) { toast.error("Could not load files", { description: error.message }); setLoading(false); return; } + 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))); @@ -52,48 +118,239 @@ function FilesPage() { ...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(); }, []); + 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(); - 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 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; } + 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} - + +
+ + 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")} + /> + )} +
+ )}
@@ -113,10 +370,18 @@ function FilesPage() { {loading && ( - Loading… + + + Loading… + + )} {!loading && filtered.length === 0 && ( - No files found. + + + No files match these filters. + + )} {filtered.map((d) => ( @@ -131,23 +396,38 @@ function FilesPage() {
{d.case.title}
- {d.case.case_number}{d.case.client ? ` · ${d.case.client.name}` : ""} + {d.case.case_number} + {d.case.client ? ` · ${d.case.client.name}` : ""}
- ) : —} + ) : ( + — + )} {d.folder ? ( - {d.folder} - ) : root} + + + {d.folder} + + ) : ( + root + )} + + + {d.uploader?.full_name || d.uploader?.email || "—"} - {d.uploader?.full_name || d.uploader?.email || "—"} {formatDate(d.created_at)} {d.size_bytes ? `${(d.size_bytes / 1024).toFixed(1)} KB` : "—"} - + + ))} @@ -155,6 +435,163 @@ function FilesPage() { + + !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