import { createFileRoute } from "@tanstack/react-router"; import { useEffect, useMemo, useRef, useState } from "react"; import Papa from "papaparse"; import { ProtectedLayout } from "@/components/protected-layout"; import { PageContainer, PageHeader } from "@/components/app-shell"; 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 { Badge } from "@/components/ui/badge"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, } from "@/components/ui/dialog"; import { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/lib/auth"; import { toast } from "sonner"; import { Archive as ArchiveIcon, Plus, Upload, Trash2, FileSpreadsheet, Lock, Unlock } from "lucide-react"; import { formatDateTime } from "@/lib/format"; export const Route = createFileRoute("/archive/")({ component: () => ( ), }); interface Category { id: string; key: string; label: string; sort_order: number; active: boolean; } interface ArchiveRow { id: string; category_id: string; data: Record; source_filename: string | null; batch_id: string | null; row_index: number | null; created_at: string; } const PAGE_SIZE = 200; function ArchivePage() { const { isAdmin } = useAuth(); const [categories, setCategories] = useState([]); const [activeId, setActiveId] = useState(""); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); const [totalCount, setTotalCount] = useState(0); const [uploadOpen, setUploadOpen] = useState(false); const [newCatOpen, setNewCatOpen] = useState(false); const [lockedTabs, setLockedTabs] = useState>(() => { if (typeof window === "undefined") return {}; try { return JSON.parse(localStorage.getItem("archive-locked-tabs") || "{}"); } catch { return {}; } }); const isLocked = activeId ? lockedTabs[activeId] !== false : true; // default locked const toggleLock = () => { if (!activeId) return; const next = { ...lockedTabs, [activeId]: !isLocked }; setLockedTabs(next); try { localStorage.setItem("archive-locked-tabs", JSON.stringify(next)); } catch {} toast.success(next[activeId] ? "Tab locked" : "Tab unlocked"); }; // Debounce search input (300ms) useEffect(() => { const t = setTimeout(() => setDebouncedSearch(search.trim()), 300); return () => clearTimeout(t); }, [search]); const loadCategories = async () => { const { data, error } = await supabase .from("archive_categories") .select("*") .eq("active", true) .order("sort_order"); if (error) { toast.error(error.message); return; } setCategories(data ?? []); if (!activeId && data && data.length > 0) { setActiveId(data[0].id); } }; const buildQuery = (categoryId: string, searchTerm: string) => { let q = supabase .from("archive_records") .select("*", { count: "exact" }) .eq("category_id", categoryId); if (searchTerm) { // Cast jsonb to text and search case-insensitively q = q.ilike("data::text", `%${searchTerm}%`); } return q.order("created_at", { ascending: false }); }; const loadRows = async (categoryId: string, searchTerm: string) => { if (!categoryId) return; setLoading(true); const { data, error, count } = await buildQuery(categoryId, searchTerm).range( 0, PAGE_SIZE - 1, ); if (error) { toast.error(error.message); setLoading(false); return; } setRows((data ?? []) as ArchiveRow[]); setTotalCount(count ?? 0); setLoading(false); }; const loadMore = async () => { if (!activeId || loadingMore) return; setLoadingMore(true); const { data, error } = await buildQuery(activeId, debouncedSearch).range( rows.length, rows.length + PAGE_SIZE - 1, ); if (error) { toast.error(error.message); setLoadingMore(false); return; } setRows((prev) => [...prev, ...((data ?? []) as ArchiveRow[])]); setLoadingMore(false); }; useEffect(() => { loadCategories(); }, []); useEffect(() => { if (activeId) loadRows(activeId, debouncedSearch); // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeId, debouncedSearch]); const activeCategory = categories.find((c) => c.id === activeId); // Compute the union of all keys across rows for table headers const columns = useMemo(() => { const set = new Set(); for (const r of rows) { if (r.data && typeof r.data === "object") { for (const k of Object.keys(r.data)) set.add(k); } } return Array.from(set); }, [rows]); const deleteRow = async (id: string) => { if (isLocked) { toast.error("Tab is locked. Unlock it to delete records."); return; } if (!confirm("Delete this record?")) return; const { error } = await supabase.from("archive_records").delete().eq("id", id); if (error) { toast.error(error.message); return; } setRows((prev) => prev.filter((r) => r.id !== id)); setTotalCount((c) => Math.max(0, c - 1)); toast.success("Record deleted"); }; const deleteAllInCategory = async () => { if (!activeCategory) return; if (isLocked) { toast.error("Tab is locked. Unlock it to clear records."); return; } if (!confirm(`Delete ALL ${totalCount} records in "${activeCategory.label}"? This cannot be undone.`)) return; const { error } = await supabase.from("archive_records").delete().eq("category_id", activeCategory.id); if (error) { toast.error(error.message); return; } setRows([]); setTotalCount(0); toast.success("All records deleted"); }; return ( ) : null } /> {/* Tabs */}
{categories.map((cat) => { const isActive = cat.id === activeId; return ( ); })} {categories.length === 0 && (
No categories yet.
)}
{/* Toolbar */} setSearch(e.target.value)} className="flex-1" />
Showing {rows.length} of {totalCount} {debouncedSearch ? "matching" : ""} rows
{isAdmin && rows.length > 0 && ( )}
{/* Table */} {loading ? (
Loading…
) : rows.length === 0 ? (
No records in this tab yet. {isAdmin && activeCategory && (
Click "Upload CSV" to add records.
)}
) : (
{columns.map((c) => ( ))} {isAdmin && } {rows.map((r) => ( {columns.map((c) => ( ))} {isAdmin && ( )} ))}
{c} Imported
{String(r.data?.[c] ?? "")} {formatDateTime(r.created_at)} {r.source_filename && (
{r.source_filename}
)}
)} {!loading && rows.length > 0 && rows.length < totalCount && (
)}
{activeCategory && ( { setUploadOpen(false); loadRows(activeCategory.id, debouncedSearch); }} /> )} c.key)} onCreated={(id) => { setNewCatOpen(false); loadCategories().then(() => setActiveId(id)); }} />
); } function NewCategoryDialog({ open, onOpenChange, existingKeys, onCreated, }: { open: boolean; onOpenChange: (v: boolean) => void; existingKeys: string[]; onCreated: (id: string) => void; }) { const [label, setLabel] = useState(""); const [submitting, setSubmitting] = useState(false); const submit = async () => { const trimmed = label.trim(); if (!trimmed) { toast.error("Name required"); return; } let key = trimmed.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, ""); if (!key) key = `tab_${Date.now()}`; let suffix = 1; let finalKey = key; while (existingKeys.includes(finalKey)) { suffix += 1; finalKey = `${key}_${suffix}`; } setSubmitting(true); const { data, error } = await supabase .from("archive_categories") .insert({ key: finalKey, label: trimmed, sort_order: 1000 }) .select("id") .single(); setSubmitting(false); if (error) { toast.error(error.message); return; } setLabel(""); toast.success("Tab created"); onCreated(data.id); }; return ( New archive tab Create a new category for archived records.
setLabel(e.target.value)} placeholder="e.g. Closed Files" autoFocus />
); } function UploadDialog({ open, onOpenChange, category, onDone, }: { open: boolean; onOpenChange: (v: boolean) => void; category: Category; onDone: () => void; }) { const [file, setFile] = useState(null); const [preview, setPreview] = useState[]>([]); const [parsing, setParsing] = useState(false); const [uploading, setUploading] = useState(false); const [progress, setProgress] = useState<{ done: number; total: number } | null>(null); const inputRef = useRef(null); const reset = () => { setFile(null); setPreview([]); setProgress(null); if (inputRef.current) inputRef.current.value = ""; }; useEffect(() => { if (!open) reset(); }, [open]); const onPick = async (f: File) => { setFile(f); setParsing(true); Papa.parse>(f, { header: true, skipEmptyLines: true, preview: 5, complete: (result) => { setPreview(result.data ?? []); setParsing(false); }, error: (err) => { toast.error(err.message); setParsing(false); }, }); }; const upload = async () => { if (!file) return; setUploading(true); Papa.parse>(file, { header: true, skipEmptyLines: true, complete: async (result) => { const allRows = result.data ?? []; const batchId = crypto.randomUUID(); const filename = file.name; const total = allRows.length; setProgress({ done: 0, total }); const chunkSize = 500; let done = 0; for (let i = 0; i < allRows.length; i += chunkSize) { const chunk = allRows.slice(i, i + chunkSize).map((data, idx) => ({ category_id: category.id, data, source_filename: filename, batch_id: batchId, row_index: i + idx, })); const { error } = await supabase.from("archive_records").insert(chunk); if (error) { toast.error(`Upload failed at row ${i}: ${error.message}`); setUploading(false); return; } done += chunk.length; setProgress({ done, total }); } toast.success(`Uploaded ${total} rows to ${category.label}`); setUploading(false); onDone(); }, error: (err) => { toast.error(err.message); setUploading(false); }, }); }; const previewKeys = preview.length > 0 ? Object.keys(preview[0]).slice(0, 4) : []; const totalKeys = preview.length > 0 ? Object.keys(preview[0]).length : 0; const previewRows = preview.slice(0, 3); return ( Upload CSV to {category.label} All columns from the CSV are stored as-is. No mapping required.
{ const f = e.target.files?.[0]; if (f) onPick(f); }} />
{parsing &&
Parsing preview…
} {preview.length > 0 && (
Preview ({previewRows.length} {previewRows.length === 1 ? "row" : "rows"}, showing {previewKeys.length} of {totalKeys} columns)
{previewKeys.map((k) => ( ))} {totalKeys > previewKeys.length && ( )} {previewRows.map((row, i) => ( {previewKeys.map((k) => ( ))} {totalKeys > previewKeys.length && ( )} ))}
{k} +{totalKeys - previewKeys.length}
{row[k]} …
)} {progress && (
Uploading {progress.done.toLocaleString()} / {progress.total.toLocaleString()} rows…
)}
); } void Badge;