'use client'; import { useState, useEffect, useCallback, useRef } from 'react'; import { createClient } from '@/lib/supabase/client'; import { toast } from 'sonner'; import type { Contact, Tag, ContactTag } from '@/types'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { Checkbox } from '@/components/ui/checkbox'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, } from '@/components/ui/dropdown-menu'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from '@/components/ui/dialog'; import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; import { Search, Plus, Upload, MoreHorizontal, Pencil, Trash2, Loader2, Users, ChevronLeft, ChevronRight, SlidersHorizontal, Filter, X, } from 'lucide-react'; import { ContactForm } from '@/components/contacts/contact-form'; import { ContactDetailView } from '@/components/contacts/contact-detail-view'; import { ImportModal } from '@/components/contacts/import-modal'; import { CustomFieldsManager } from '@/components/contacts/custom-fields-manager'; import { useCan } from '@/hooks/use-can'; import { GatedButton } from '@/components/ui/gated-button'; import { useTranslations } from 'next-intl'; const PAGE_SIZE = 25; interface ContactWithTags extends Contact { tags?: Tag[]; } export default function ContactsPage() { const t = useTranslations('Contacts.page'); const supabase = createClient(); const canEdit = useCan('send-messages'); const canEditSettings = useCan('edit-settings'); const [contacts, setContacts] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); const [page, setPage] = useState(0); const [totalCount, setTotalCount] = useState(0); // Tag filter — contacts shown must have ANY of these tags (OR). const [selectedTagIds, setSelectedTagIds] = useState([]); // Modals const [formOpen, setFormOpen] = useState(false); const [editContact, setEditContact] = useState(null); const [editContactTags, setEditContactTags] = useState([]); const [detailOpen, setDetailOpen] = useState(false); const [detailContactId, setDetailContactId] = useState(null); const [importOpen, setImportOpen] = useState(false); const [customFieldsOpen, setCustomFieldsOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); // Bulk selection (page-scoped — only the loaded rows are selectable) const [selected, setSelected] = useState>(new Set()); const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); // All tags for display const [tagsMap, setTagsMap] = useState>({}); // Guards against out-of-order fetch responses: each fetchContacts run // claims a sequence number and only the latest is allowed to commit its // results. Without this, rapidly toggling tag filters could let a slower // earlier request resolve last and render stale rows. const fetchSeq = useRef(0); const fetchTags = useCallback(async () => { const { data } = await supabase.from('tags').select('*'); if (data) { const map: Record = {}; data.forEach((t) => (map[t.id] = t)); setTagsMap(map); // Drop any filter selections whose tag no longer exists (e.g. a tag // deleted elsewhere) so it can't linger invisibly in the query. setSelectedTagIds((prev) => { const pruned = prev.filter((id) => map[id]); return pruned.length === prev.length ? prev : pruned; }); } }, [supabase]); const fetchContacts = useCallback(async () => { const seq = ++fetchSeq.current; setLoading(true); // The visible rows are about to change — drop any selection that // referred to the old page/search results so the bulk bar can't // act on rows the user can no longer see. setSelected(new Set()); const from = page * PAGE_SIZE; const to = from + PAGE_SIZE - 1; const term = search.trim(); let contactRows: Contact[]; let count: number; if (selectedTagIds.length > 0) { // Tag filter active — resolve it server-side (join + distinct + // windowed total count + pagination) so a tag covering many // contacts can't silently truncate the result or overflow an IN // clause. See migration 025_filter_contacts_by_tags. const { data, error } = await supabase.rpc('filter_contacts_by_tags', { p_tag_ids: selectedTagIds, p_search: term || null, p_limit: PAGE_SIZE, p_offset: from, }); if (seq !== fetchSeq.current) return; // superseded by a newer fetch if (error) { toast.error(t('toastFailedLoad')); setLoading(false); return; } const rows = (data ?? []) as { contact: Contact; total_count: number }[]; contactRows = rows.map((r) => r.contact); count = rows.length > 0 ? Number(rows[0].total_count) : 0; } else { let query = supabase .from('contacts') .select('*', { count: 'exact' }) .order('created_at', { ascending: false }) .range(from, to); if (term) { const like = `%${term}%`; query = query.or(`name.ilike.${like},phone.ilike.${like},email.ilike.${like}`); } const { data, count: exactCount, error } = await query; if (seq !== fetchSeq.current) return; // superseded by a newer fetch if (error) { toast.error(t('toastFailedLoad')); setLoading(false); return; } contactRows = data ?? []; count = exactCount ?? 0; } setTotalCount(count); if (contactRows.length === 0) { setContacts([]); setLoading(false); return; } // Fetch tags for these contacts const contactIds = contactRows.map((c) => c.id); const { data: contactTags } = await supabase .from('contact_tags') .select('contact_id, tag_id') .in('contact_id', contactIds); if (seq !== fetchSeq.current) return; // superseded by a newer fetch const tagsByContact: Record = {}; contactTags?.forEach((ct) => { if (!tagsByContact[ct.contact_id]) tagsByContact[ct.contact_id] = []; tagsByContact[ct.contact_id].push(ct.tag_id); }); const enriched: ContactWithTags[] = contactRows.map((c) => ({ ...c, tags: (tagsByContact[c.id] ?? []) .map((tid) => tagsMap[tid]) .filter(Boolean), })); setContacts(enriched); setLoading(false); }, [supabase, page, search, selectedTagIds, tagsMap, t]); // Load-once-on-mount-ish data fetches. Each setter inside runs // inside an async promise completion (Supabase await), not // synchronously in the effect body, so the cascade the lint rule // warns about doesn't apply here. useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect fetchTags(); }, [fetchTags]); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect fetchContacts(); }, [fetchContacts]); function openAddForm() { setEditContact(null); setEditContactTags([]); setFormOpen(true); } async function openEditForm(contact: Contact) { const { data } = await supabase .from('contact_tags') .select('*') .eq('contact_id', contact.id); setEditContact(contact); setEditContactTags(data ?? []); setFormOpen(true); } function openDetail(contactId: string) { setDetailContactId(contactId); setDetailOpen(true); } function confirmDelete(contact: Contact) { setDeleteTarget(contact); setDeleteConfirmOpen(true); } async function handleDelete() { if (!deleteTarget) return; setDeleting(true); const { error } = await supabase .from('contacts') .delete() .eq('id', deleteTarget.id); if (error) { toast.error(t('toastFailedDelete')); } else { toast.success(t('toastDeleted')); fetchContacts(); } setDeleting(false); setDeleteConfirmOpen(false); setDeleteTarget(null); } const allOnPageSelected = contacts.length > 0 && contacts.every((c) => selected.has(c.id)); const someOnPageSelected = contacts.some((c) => selected.has(c.id)); function toggleSelectAll() { setSelected((prev) => { const next = new Set(prev); if (allOnPageSelected) { contacts.forEach((c) => next.delete(c.id)); } else { contacts.forEach((c) => next.add(c.id)); } return next; }); } function toggleSelect(id: string) { setSelected((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } async function handleBulkDelete() { const ids = [...selected]; if (ids.length === 0) return; setDeleting(true); const { error } = await supabase.from('contacts').delete().in('id', ids); if (error) { toast.error(t('toastBulkFailedDelete')); } else { toast.success(t('toastBulkDeleted', { count: ids.length })); setSelected(new Set()); fetchContacts(); } setDeleting(false); setBulkDeleteOpen(false); } const totalPages = Math.ceil(totalCount / PAGE_SIZE); const hasNext = page < totalPages - 1; const hasPrev = page > 0; // Tag filter helpers. Every change resets to page 0 — the result set // shrinks/grows so page N may no longer be valid (mirrors the search box). const allTags = Object.values(tagsMap).sort((a, b) => a.name.localeCompare(b.name) ); const hasActiveFilters = search.trim().length > 0 || selectedTagIds.length > 0; function toggleTagFilter(tagId: string) { setSelectedTagIds((prev) => prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId] ); setPage(0); } function clearTagFilters() { setSelectedTagIds([]); setPage(0); } return (
{/* Header */}

{t('title')}

{totalCount > 0 ? t('subtitle', { count: totalCount }) : t('subtitleZero')}

{canEditSettings && ( )} setImportOpen(true)} className="border-border text-muted-foreground hover:bg-muted" > {t('importBtn')} {t('addContactBtn')}
{/* Search + tag filter */}
{ setSearch(e.target.value); // Reset pagination when the query changes — the result // set shrinks/grows, page N may no longer be valid. setPage(0); }} placeholder={t('searchPlaceholder')} className="pl-8 bg-card border-border text-foreground placeholder:text-muted-foreground" />
} > {t('filterByTags')} {selectedTagIds.length > 0 && ( {selectedTagIds.length} )}
{t('filterByTags')} {selectedTagIds.length > 0 && ( )}
{allTags.length === 0 ? (

{t('noTagsYet')}

) : (
{allTags.map((tag) => ( ))}
)}
{/* Active tag-filter chips */} {selectedTagIds.length > 0 && (
{selectedTagIds.map((id) => { const tag = tagsMap[id]; if (!tag) return null; return ( {tag.name} ); })}
)}
{/* Bulk action bar */} {selected.size > 0 && (

{t('selectedCount', { count: selected.size })}

setBulkDeleteOpen(true)} > {t('deleteSelected')}
)} {/* Table */}
{t('tableColumns.name')} {t('tableColumns.phone')} {t('tableColumns.email')} {t('tableColumns.company')} {t('tableColumns.tags')} {t('tableColumns.createdAt')} {loading ? (

{t('loading')}

) : contacts.length === 0 ? (

{hasActiveFilters ? t('noContactsMatch') : t('noContactsYet')}

{!hasActiveFilters && ( {t('addFirstContact')} )}
) : ( contacts.map((contact) => ( openDetail(contact.id)} > e.stopPropagation()}> toggleSelect(contact.id)} aria-label={`Select ${contact.name || contact.phone}`} /> {contact.name || {t('unnamed')}} {contact.phone} {contact.email || -} {contact.company || -}
{contact.tags && contact.tags.length > 0 ? ( contact.tags.slice(0, 3).map((tag) => ( {tag.name} )) ) : ( - )} {contact.tags && contact.tags.length > 3 && ( +{contact.tags.length - 3} )}
{new Date(contact.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', })} e.stopPropagation()} /> } > { e.stopPropagation(); openEditForm(contact); }} className="text-popover-foreground focus:bg-muted focus:text-foreground" > {t('editAction')} { e.stopPropagation(); confirmDelete(contact); }} > {t('deleteAction')}
)) )}
{/* Pagination */} {totalPages > 1 && (

{t('showingPagination', { start: page * PAGE_SIZE + 1, end: Math.min((page + 1) * PAGE_SIZE, totalCount), total: totalCount })}

{t('pageCount', { page: page + 1, total: totalPages })}
)} {/* Contact Form Dialog */} { fetchContacts(); fetchTags(); }} onViewExisting={(id) => { setFormOpen(false); openDetail(id); }} /> {/* Contact Detail Sheet */} {/* Import Modal */} {/* Custom Fields Manager (admin+) */} {canEditSettings && ( )} {/* Delete Confirmation */} {t('deleteContactTitle')} {t('deleteContactDesc', { name: deleteTarget?.name || deleteTarget?.phone || '' })} {/* Bulk Delete Confirmation */} {t('deleteBulkTitle')} {t('deleteBulkDesc', { count: selected.size })}
); }