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:13:05 +00:00
co-authored by renee-png
parent 8074288b1d
commit cc3aec5ef8
2 changed files with 190 additions and 0 deletions
+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>
);
}