diff --git a/src/routes/archive.index.tsx b/src/routes/archive.index.tsx new file mode 100644 index 0000000..5538bf6 --- /dev/null +++ b/src/routes/archive.index.tsx @@ -0,0 +1,539 @@ +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 } 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; +} + +function ArchivePage() { + const { isAdmin } = useAuth(); + const [categories, setCategories] = useState([]); + const [activeId, setActiveId] = useState(""); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + const [uploadOpen, setUploadOpen] = useState(false); + const [newCatOpen, setNewCatOpen] = useState(false); + + 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 loadRows = async (categoryId: string) => { + if (!categoryId) return; + setLoading(true); + const { data, error } = await supabase + .from("archive_records") + .select("*") + .eq("category_id", categoryId) + .order("created_at", { ascending: false }) + .limit(1000); + if (error) { + toast.error(error.message); + setLoading(false); + return; + } + setRows((data ?? []) as ArchiveRow[]); + setLoading(false); + }; + + useEffect(() => { + loadCategories(); + }, []); + + useEffect(() => { + if (activeId) loadRows(activeId); + }, [activeId]); + + 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 filteredRows = useMemo(() => { + if (!search.trim()) return rows; + const s = search.toLowerCase(); + return rows.filter((r) => JSON.stringify(r.data).toLowerCase().includes(s)); + }, [rows, search]); + + const deleteRow = async (id: string) => { + 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)); + toast.success("Record deleted"); + }; + + const deleteAllInCategory = async () => { + if (!activeCategory) return; + if (!confirm(`Delete ALL ${rows.length} 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([]); + 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" + /> +
+ {filteredRows.length} of {rows.length} 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 && } + + + + {filteredRows.map((r) => ( + + {columns.map((c) => ( + + ))} + + {isAdmin && ( + + )} + + ))} + +
+ {c} + Imported
+ {String(r.data?.[c] ?? "")} + + {formatDateTime(r.created_at)} + {r.source_filename && ( +
+ + {r.source_filename} +
+ )} +
+ +
+
+ )} +
+
+ + {activeCategory && ( + { + setUploadOpen(false); + loadRows(activeCategory.id); + }} + /> + )} + + 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); + }, + }); + }; + + 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 (first {preview.length} rows) +
+
+ + + + {Object.keys(preview[0]).map((k) => ( + + ))} + + + + {preview.map((row, i) => ( + + {Object.keys(preview[0]).map((k) => ( + + ))} + + ))} + +
+ {k} +
+ {row[k]} +
+
+
+ )} + + {progress && ( +
+ Uploading {progress.done.toLocaleString()} / {progress.total.toLocaleString()} rows… +
+ )} +
+ + + + + +
+
+ ); +} + +void Badge;