From eaa036ab9864d2caa70670228e891939ba1ebd3e Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 17:54:53 +0000 Subject: [PATCH 1/4] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/integrations/supabase/types.ts | 74 +++++++++++++++++++ ...0_b28101a7-eaf4-4b33-9555-3fe0f7810ad8.sql | 62 ++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 supabase/migrations/20260419175450_b28101a7-eaf4-4b33-9555-3fe0f7810ad8.sql diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 321fb7b..229b2b4 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -14,6 +14,80 @@ export type Database = { } public: { Tables: { + archive_categories: { + Row: { + active: boolean + created_at: string + created_by: string | null + id: string + key: string + label: string + sort_order: number + updated_at: string + } + Insert: { + active?: boolean + created_at?: string + created_by?: string | null + id?: string + key: string + label: string + sort_order?: number + updated_at?: string + } + Update: { + active?: boolean + created_at?: string + created_by?: string | null + id?: string + key?: string + label?: string + sort_order?: number + updated_at?: string + } + Relationships: [] + } + archive_records: { + Row: { + batch_id: string | null + category_id: string + created_at: string + created_by: string | null + data: Json + id: string + row_index: number | null + source_filename: string | null + } + Insert: { + batch_id?: string | null + category_id: string + created_at?: string + created_by?: string | null + data?: Json + id?: string + row_index?: number | null + source_filename?: string | null + } + Update: { + batch_id?: string | null + category_id?: string + created_at?: string + created_by?: string | null + data?: Json + id?: string + row_index?: number | null + source_filename?: string | null + } + Relationships: [ + { + foreignKeyName: "archive_records_category_id_fkey" + columns: ["category_id"] + isOneToOne: false + referencedRelation: "archive_categories" + referencedColumns: ["id"] + }, + ] + } call_logs: { Row: { billable: boolean diff --git a/supabase/migrations/20260419175450_b28101a7-eaf4-4b33-9555-3fe0f7810ad8.sql b/supabase/migrations/20260419175450_b28101a7-eaf4-4b33-9555-3fe0f7810ad8.sql new file mode 100644 index 0000000..f22a891 --- /dev/null +++ b/supabase/migrations/20260419175450_b28101a7-eaf4-4b33-9555-3fe0f7810ad8.sql @@ -0,0 +1,62 @@ +-- Categories: list of archive types (cases, time, expenses, plus user-added) +CREATE TABLE public.archive_categories ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + key text NOT NULL UNIQUE, + label text NOT NULL, + sort_order integer NOT NULL DEFAULT 0, + active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + created_by uuid +); + +ALTER TABLE public.archive_categories ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "ac_select_auth" ON public.archive_categories + FOR SELECT TO authenticated USING (true); +CREATE POLICY "ac_insert_admin" ON public.archive_categories + FOR INSERT TO authenticated WITH CHECK (public.is_admin(auth.uid())); +CREATE POLICY "ac_update_admin" ON public.archive_categories + FOR UPDATE TO authenticated USING (public.is_admin(auth.uid())); +CREATE POLICY "ac_delete_admin" ON public.archive_categories + FOR DELETE TO authenticated USING (public.is_admin(auth.uid())); + +CREATE TRIGGER trg_ac_updated BEFORE UPDATE ON public.archive_categories + FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at(); + +-- Records: one row per CSV row, columns preserved verbatim in `data` JSONB +CREATE TABLE public.archive_records ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + category_id uuid NOT NULL REFERENCES public.archive_categories(id) ON DELETE CASCADE, + data jsonb NOT NULL DEFAULT '{}'::jsonb, + source_filename text, + batch_id uuid, + row_index integer, + created_at timestamptz NOT NULL DEFAULT now(), + created_by uuid +); + +CREATE INDEX idx_archive_records_category ON public.archive_records(category_id, created_at DESC); +CREATE INDEX idx_archive_records_batch ON public.archive_records(batch_id); + +ALTER TABLE public.archive_records ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "ar_select_auth" ON public.archive_records + FOR SELECT TO authenticated USING (true); +CREATE POLICY "ar_insert_admin" ON public.archive_records + FOR INSERT TO authenticated WITH CHECK (public.is_admin(auth.uid())); +CREATE POLICY "ar_delete_admin" ON public.archive_records + FOR DELETE TO authenticated USING (public.is_admin(auth.uid())); + +-- Seed default categories +INSERT INTO public.archive_categories (key, label, sort_order) VALUES + ('cases', 'Cases', 10), + ('time_entries', 'Time Entries', 20), + ('expenses', 'Expenses', 30), + ('invoices', 'Invoices', 40), + ('clients', 'Clients', 50), + ('status_updates', 'Status Updates', 60), + ('contacts', 'Contacts', 70), + ('homeowners', 'Homeowners', 80), + ('call_logs', 'Call Logs', 90), + ('documents', 'Documents', 100); \ No newline at end of file From 1c717e82fc2da9a362b8d844e6e13a3859da6236 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 17:55:17 +0000 Subject: [PATCH 2/4] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/app-shell.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index a8c771a..fc9f5ad 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -22,6 +22,7 @@ import { User as UserIcon, Inbox as InboxIcon, CreditCard, + Archive as ArchiveIcon, } from "lucide-react"; import { cn } from "@/lib/utils"; import type { ReactNode } from "react"; @@ -43,6 +44,7 @@ const NAV: NavItem[] = (() => { const dashboard: NavItem = { to: "/", label: "Dashboard", icon: LayoutDashboard }; const rest: NavItem[] = [ { to: "/admin/users", label: "Users", icon: ShieldCheck, adminOnly: true }, + { to: "/archive", label: "Archive", icon: ArchiveIcon }, { to: "/calendar", label: "Calendar", icon: CalendarIcon }, { to: "/cases", label: "Cases", icon: Briefcase }, { to: "/clients", label: "Clients", icon: Users }, From 862b6d2c047f30418fba22e3ecd5bc8c261c8b3d Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 17:56:17 +0000 Subject: [PATCH 3/4] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/archive.index.tsx | 539 +++++++++++++++++++++++++++++++++++ 1 file changed, 539 insertions(+) create mode 100644 src/routes/archive.index.tsx 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; From 0267b300945826edfc995dafb03e6c326dabd411 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 17:56:24 +0000 Subject: [PATCH 4/4] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routeTree.gen.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index a1a797c..e89f5d1 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -28,6 +28,7 @@ import { Route as CollectionsIndexRouteImport } from './routes/collections.index import { Route as ClientsIndexRouteImport } from './routes/clients.index' import { Route as CasesIndexRouteImport } from './routes/cases.index' import { Route as CalendarIndexRouteImport } from './routes/calendar.index' +import { Route as ArchiveIndexRouteImport } from './routes/archive.index' import { Route as SettingsWorkflowsRouteImport } from './routes/settings.workflows' import { Route as SettingsWorkflowRouteImport } from './routes/settings.workflow' import { Route as SettingsSmtpRouteImport } from './routes/settings.smtp' @@ -150,6 +151,11 @@ const CalendarIndexRoute = CalendarIndexRouteImport.update({ path: '/calendar/', getParentRoute: () => rootRouteImport, } as any) +const ArchiveIndexRoute = ArchiveIndexRouteImport.update({ + id: '/archive/', + path: '/archive/', + getParentRoute: () => rootRouteImport, +} as any) const SettingsWorkflowsRoute = SettingsWorkflowsRouteImport.update({ id: '/workflows', path: '/workflows', @@ -308,6 +314,7 @@ export interface FileRoutesByFullPath { '/settings/smtp': typeof SettingsSmtpRoute '/settings/workflow': typeof SettingsWorkflowRoute '/settings/workflows': typeof SettingsWorkflowsRoute + '/archive/': typeof ArchiveIndexRoute '/calendar/': typeof CalendarIndexRoute '/cases/': typeof CasesIndexRoute '/clients/': typeof ClientsIndexRoute @@ -354,6 +361,7 @@ export interface FileRoutesByTo { '/settings/smtp': typeof SettingsSmtpRoute '/settings/workflow': typeof SettingsWorkflowRoute '/settings/workflows': typeof SettingsWorkflowsRoute + '/archive': typeof ArchiveIndexRoute '/calendar': typeof CalendarIndexRoute '/cases': typeof CasesIndexRoute '/clients': typeof ClientsIndexRoute @@ -402,6 +410,7 @@ export interface FileRoutesById { '/settings/smtp': typeof SettingsSmtpRoute '/settings/workflow': typeof SettingsWorkflowRoute '/settings/workflows': typeof SettingsWorkflowsRoute + '/archive/': typeof ArchiveIndexRoute '/calendar/': typeof CalendarIndexRoute '/cases/': typeof CasesIndexRoute '/clients/': typeof ClientsIndexRoute @@ -451,6 +460,7 @@ export interface FileRouteTypes { | '/settings/smtp' | '/settings/workflow' | '/settings/workflows' + | '/archive/' | '/calendar/' | '/cases/' | '/clients/' @@ -497,6 +507,7 @@ export interface FileRouteTypes { | '/settings/smtp' | '/settings/workflow' | '/settings/workflows' + | '/archive' | '/calendar' | '/cases' | '/clients' @@ -544,6 +555,7 @@ export interface FileRouteTypes { | '/settings/smtp' | '/settings/workflow' | '/settings/workflows' + | '/archive/' | '/calendar/' | '/cases/' | '/clients/' @@ -582,6 +594,7 @@ export interface RootRouteChildren { InvoicesInvoiceIdRoute: typeof InvoicesInvoiceIdRoute InvoicesNewRoute: typeof InvoicesNewRouteWithChildren PayIdRoute: typeof PayIdRoute + ArchiveIndexRoute: typeof ArchiveIndexRoute CalendarIndexRoute: typeof CalendarIndexRoute CasesIndexRoute: typeof CasesIndexRoute ClientsIndexRoute: typeof ClientsIndexRoute @@ -737,6 +750,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CalendarIndexRouteImport parentRoute: typeof rootRouteImport } + '/archive/': { + id: '/archive/' + path: '/archive' + fullPath: '/archive/' + preLoaderRoute: typeof ArchiveIndexRouteImport + parentRoute: typeof rootRouteImport + } '/settings/workflows': { id: '/settings/workflows' path: '/workflows' @@ -982,6 +1002,7 @@ const rootRouteChildren: RootRouteChildren = { InvoicesInvoiceIdRoute: InvoicesInvoiceIdRoute, InvoicesNewRoute: InvoicesNewRouteWithChildren, PayIdRoute: PayIdRoute, + ArchiveIndexRoute: ArchiveIndexRoute, CalendarIndexRoute: CalendarIndexRoute, CasesIndexRoute: CasesIndexRoute, ClientsIndexRoute: ClientsIndexRoute,