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 { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/lib/auth"; import { toast } from "sonner"; import { Plus, Search, Mail, Phone, Building2, ChevronRight, Edit, Trash2, Briefcase, Users, Archive, ArchiveRestore, X } from "lucide-react"; import { ContactFormDialog, CONTACT_TYPES, type ContactRecord } from "@/components/contacts/contact-form-dialog"; import { setArchived } from "@/lib/archive"; export const Route = createFileRoute("/contacts/")({ component: () => ( ), }); interface ContactRow extends Required>, ContactRecord { id: string; created_by: string | null; case_links: number; client_links: number; archived_at: string | null; } function ContactsIndex() { const { user, isAdmin } = useAuth(); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [q, setQ] = useState(""); const [typeFilter, setTypeFilter] = useState("all"); const [view, setView] = useState<"active" | "archived">("active"); const [editing, setEditing] = useState(null); const [open, setOpen] = useState(false); const [selected, setSelected] = useState>(new Set()); const [bulkType, setBulkType] = useState(""); const [letter, setLetter] = useState("all"); const [visibleLimit, setVisibleLimit] = useState(100); const load = async () => { setLoading(true); // Paginate to bypass Supabase's 1000-row default limit const fetchAllContacts = async () => { const pageSize = 1000; let from = 0; const all: any[] = []; while (true) { const { data, error } = await supabase .from("contacts") .select("*") .order("name") .range(from, from + pageSize - 1); if (error) return { data: all, error }; if (!data || data.length === 0) break; all.push(...data); if (data.length < pageSize) break; from += pageSize; } return { data: all, error: null as any }; }; const fetchAllLinks = async (table: "case_contacts" | "client_contacts") => { const pageSize = 1000; let from = 0; const all: any[] = []; while (true) { const { data, error } = await supabase .from(table) .select("contact_id") .range(from, from + pageSize - 1); if (error || !data || data.length === 0) break; all.push(...data); if (data.length < pageSize) break; from += pageSize; } return { data: all }; }; const [{ data, error }, { data: cc }, { data: clc }] = await Promise.all([ fetchAllContacts(), fetchAllLinks("case_contacts"), fetchAllLinks("client_contacts"), ]); if (error) toast.error(error.message); const caseCounts = new Map(); (cc ?? []).forEach((r: any) => caseCounts.set(r.contact_id, (caseCounts.get(r.contact_id) ?? 0) + 1)); const clientCounts = new Map(); (clc ?? []).forEach((r: any) => clientCounts.set(r.contact_id, (clientCounts.get(r.contact_id) ?? 0) + 1)); setRows( (data ?? []).map((c: any) => ({ ...c, case_links: caseCounts.get(c.id) ?? 0, client_links: clientCounts.get(c.id) ?? 0, })), ); setLoading(false); }; useEffect(() => { load(); }, []); const letterOf = (name: string | null | undefined) => { const ch = (name?.[0] ?? "#").toUpperCase(); return /[A-Z]/.test(ch) ? ch : "#"; }; const preLetterFiltered = useMemo(() => { const term = q.trim().toLowerCase(); return rows.filter((r) => { if (view === "archived" ? !r.archived_at : !!r.archived_at) return false; if (typeFilter !== "all" && r.contact_type !== typeFilter) return false; if (!term) return true; return ( r.name.toLowerCase().includes(term) || (r.company ?? "").toLowerCase().includes(term) || (r.email ?? "").toLowerCase().includes(term) || (r.phone ?? "").toLowerCase().includes(term) ); }); }, [rows, q, typeFilter, view]); const letterCounts = useMemo(() => { const counts: Record = { all: preLetterFiltered.length }; for (const r of preLetterFiltered) { const l = letterOf(r.name); counts[l] = (counts[l] ?? 0) + 1; } return counts; }, [preLetterFiltered]); const filtered = useMemo(() => { if (letter === "all") return preLetterFiltered; return preLetterFiltered.filter((r) => letterOf(r.name) === letter); }, [preLetterFiltered, letter]); // Reset paging window when filters change useEffect(() => { setVisibleLimit(100); }, [q, typeFilter, view, letter]); const visibleRows = useMemo(() => filtered.slice(0, visibleLimit), [filtered, visibleLimit]); const hasMore = filtered.length > visibleRows.length; const grouped = useMemo(() => { const map = new Map(); for (const r of visibleRows) { const l = letterOf(r.name); if (!map.has(l)) map.set(l, []); map.get(l)!.push(r); } return Array.from(map.entries()).sort(([a], [b]) => { if (a === "#") return 1; if (b === "#") return -1; return a.localeCompare(b); }); }, [visibleRows]); const archivedCount = rows.filter((r) => r.archived_at).length; const activeCount = rows.length - archivedCount; const typeCounts = useMemo(() => { const base = rows.filter((r) => (view === "archived" ? !!r.archived_at : !r.archived_at)); const counts: Record = { all: base.length }; for (const t of CONTACT_TYPES) { counts[t.value] = base.filter((r) => (r.contact_type ?? "other") === t.value).length; } return counts; }, [rows, view]); const remove = async (id: string, name: string) => { if (!confirm(`Delete contact "${name}"? This will also remove all case and client links.`)) return; const { error } = await supabase.from("contacts").delete().eq("id", id); if (error) { toast.error(error.message); return; } toast.success("Contact deleted"); setRows((r) => r.filter((x) => x.id !== id)); }; const onArchive = async (id: string, archived: boolean) => { if (await setArchived("contacts", id, archived)) load(); }; const startNew = () => { setEditing(null); setOpen(true); }; const startEdit = (c: ContactRow) => { setEditing(c); setOpen(true); }; const toggleOne = (id: string) => { setSelected((s) => { const next = new Set(s); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }; const toggleAllVisible = () => { const ids = visibleRows.map((r) => r.id); const allSelected = ids.every((id) => selected.has(id)); setSelected((s) => { const next = new Set(s); if (allSelected) ids.forEach((id) => next.delete(id)); else ids.forEach((id) => next.add(id)); return next; }); }; const clearSelection = () => setSelected(new Set()); const bulkArchive = async (archived: boolean) => { const ids = Array.from(selected); if (!ids.length) return; const patch = { archived_at: archived ? new Date().toISOString() : null }; const { error } = await (supabase.from("contacts") as any).update(patch).in("id", ids); if (error) { toast.error(error.message); return; } toast.success(`${ids.length} contact${ids.length === 1 ? "" : "s"} ${archived ? "archived" : "restored"}`); clearSelection(); load(); }; const bulkDelete = async () => { const ids = Array.from(selected); if (!ids.length) return; if (!confirm(`Delete ${ids.length} contact${ids.length === 1 ? "" : "s"}? This will also remove all case and client links.`)) return; const { error } = await supabase.from("contacts").delete().in("id", ids); if (error) { toast.error(error.message); return; } toast.success(`${ids.length} contact${ids.length === 1 ? "" : "s"} deleted`); clearSelection(); load(); }; const bulkChangeType = async (newType: string) => { const ids = Array.from(selected); if (!ids.length || !newType) return; const { error } = await supabase.from("contacts").update({ contact_type: newType }).in("id", ids); if (error) { toast.error(error.message); return; } const label = CONTACT_TYPES.find((t) => t.value === newType)?.label ?? newType; toast.success(`${ids.length} contact${ids.length === 1 ? "" : "s"} changed to ${label}`); setBulkType(""); clearSelection(); load(); }; const visibleIds = visibleRows.map((r) => r.id); const allVisibleSelected = visibleIds.length > 0 && visibleIds.every((id) => selected.has(id)); const someVisibleSelected = visibleIds.some((id) => selected.has(id)); return ( New contact } /> setView(v as "active" | "archived")}> Active ({activeCount}) Archived ({archivedCount}) setQ(e.target.value)} className="pl-9" /> setTypeFilter(e.target.value)} className="h-9 rounded-md border border-input bg-transparent px-3 text-sm sm:w-56" > All types {CONTACT_TYPES.map((t) => ( {t.label} ))} setTypeFilter("all")} > All ({typeCounts.all ?? 0}) {CONTACT_TYPES.map((t) => ( setTypeFilter(t.value)} > {t.label} ({typeCounts[t.value] ?? 0}) ))} setLetter("all")} > All ({letterCounts.all ?? 0}) {"ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("").concat("#").map((L) => { const count = letterCounts[L] ?? 0; const disabled = count === 0; return ( setLetter(L)} title={`${L} (${count})`} > {L} ); })} {selected.size > 0 && ( {selected.size} selected bulkArchive(view !== "archived")}> {view === "archived" ? : } {view === "archived" ? "Restore" : "Archive"} {CONTACT_TYPES.map((t) => ( {t.label} ))} Delete Clear )} {filtered.length > 0 && !loading && ( Showing {visibleRows.length} of {filtered.length} )} {loading ? ( Loading… ) : filtered.length === 0 ? ( {rows.length === 0 ? "No contacts yet." : view === "archived" ? "No archived contacts." : "No contacts match your filters."} ) : ( {grouped.map(([letter, items]) => ( {letter} {items.map((c) => { const typeLabel = CONTACT_TYPES.find((t) => t.value === c.contact_type)?.label ?? c.contact_type; const canEdit = isAdmin || c.created_by === user?.id; return ( toggleOne(c.id)} aria-label={`Select ${c.name}`} /> {c.name} {typeLabel} {c.title && {c.title}} {c.company && {c.company}} {c.email && {c.email}} {c.phone && {c.phone}} {c.case_links > 0 && {c.case_links} case{c.case_links === 1 ? "" : "s"}} {c.client_links > 0 && {c.client_links} client{c.client_links === 1 ? "" : "s"}} onArchive(c.id, !c.archived_at)} > {c.archived_at ? : } {canEdit && ( <> startEdit(c)}> remove(c.id, c.name)}> > )} ); })} ))} )} {!loading && hasMore && ( setVisibleLimit((n) => n + 100)}> Load 100 more ({filtered.length - visibleRows.length} remaining) )} load()} /> ); }
Loading…
{rows.length === 0 ? "No contacts yet." : view === "archived" ? "No archived contacts." : "No contacts match your filters."}