import { useState, useMemo, useRef, useEffect } from 'react'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft, Ghost, ChevronDown, Unlink, Link2, User2 } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { format, parseISO, differenceInYears } from 'date-fns'; import { toast } from 'sonner'; import api, { getErrorMessage } from '@/lib/api'; import type { Person } from '@/types'; import { Button } from '@/components/ui/button'; import { EmptyState } from '@/components/ui/empty-state'; import { EntityTable, EntityDetailPanel, CategoryFilterBar, } from '@/components/shared'; import type { ColumnDef, PanelField } from '@/components/shared'; import { getInitials, getAvatarColor, getNextBirthday, getDaysUntilBirthday, } from '@/components/shared/utils'; import { useTableVisibility } from '@/hooks/useTableVisibility'; import { useCategoryOrder } from '@/hooks/useCategoryOrder'; import PersonForm from './PersonForm'; import ConnectionSearch from '@/components/connections/ConnectionSearch'; import ConnectionRequestCard from '@/components/connections/ConnectionRequestCard'; import { useConnections } from '@/hooks/useConnections'; // --------------------------------------------------------------------------- // StatCounter — inline helper // --------------------------------------------------------------------------- function StatCounter({ icon: Icon, iconBg, iconColor, label, value, }: { icon: LucideIcon; iconBg: string; iconColor: string; label: string; value: number; }) { return (

{label}

{value}

); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function getPersonInitialsName(p: Person): string { const firstName = p.is_umbral_contact && p.shared_fields?.first_name ? String(p.shared_fields.first_name) : p.first_name; const lastName = p.is_umbral_contact && p.shared_fields?.last_name ? String(p.shared_fields.last_name) : p.last_name; const parts = [firstName, lastName].filter(Boolean); return parts.length > 0 ? parts.join(' ') : p.name; } function sortPeople(people: Person[], key: string, dir: 'asc' | 'desc'): Person[] { return [...people].sort((a, b) => { let cmp = 0; if (key === 'birthday') { const aD = a.birthday ? getDaysUntilBirthday(a.birthday) : Infinity; const bD = b.birthday ? getDaysUntilBirthday(b.birthday) : Infinity; cmp = aD - bD; } else { const aVal = a[key as keyof Person]; const bVal = b[key as keyof Person]; const aStr = aVal != null ? String(aVal) : ''; const bStr = bVal != null ? String(bVal) : ''; cmp = aStr.localeCompare(bStr); } return dir === 'asc' ? cmp : -cmp; }); } // --------------------------------------------------------------------------- // Column definitions // --------------------------------------------------------------------------- /** Get a field value, preferring shared_fields for umbral contacts. */ function sf(p: Person, key: string): string | null | undefined { if (p.is_umbral_contact && p.shared_fields && key in p.shared_fields) { return p.shared_fields[key] as string | null; } return p[key as keyof Person] as string | null | undefined; } const columns: ColumnDef[] = [ { key: 'name', label: 'Name', sortable: true, visibilityLevel: 'essential', render: (p) => { const firstName = sf(p, 'first_name'); const lastName = sf(p, 'last_name'); const liveName = [firstName, lastName].filter(Boolean).join(' ') || p.nickname || p.name; const initialsName = liveName || getPersonInitialsName(p); return (
{getInitials(initialsName)}
{liveName} {p.is_umbral_contact && ( )}
); }, }, { key: 'phone', label: 'Number', sortable: false, visibilityLevel: 'essential', render: (p) => { const mobile = sf(p, 'mobile'); const phone = sf(p, 'phone'); return {mobile || phone || '—'}; }, }, { key: 'email', label: 'Email', sortable: true, visibilityLevel: 'essential', render: (p) => { const email = sf(p, 'email'); return {email || '—'}; }, }, { key: 'job_title', label: 'Role', sortable: true, visibilityLevel: 'filtered', render: (p) => { const jobTitle = sf(p, 'job_title'); const company = sf(p, 'company'); const parts = [jobTitle, company].filter(Boolean); return {parts.join(', ') || '—'}; }, }, { key: 'birthday', label: 'Birthday', sortable: true, visibilityLevel: 'filtered', render: (p) => { const birthday = sf(p, 'birthday'); return birthday ? ( {format(parseISO(birthday), 'MMM d')} ) : ( ); }, }, { key: 'category', label: 'Category', sortable: true, visibilityLevel: 'all', render: (p) => p.category ? ( {p.category} ) : ( ), }, ]; // --------------------------------------------------------------------------- // Panel field config // --------------------------------------------------------------------------- const panelFields: PanelField[] = [ { label: 'Preferred Name', key: 'preferred_name', icon: User2 }, { label: 'Mobile', key: 'mobile', copyable: true, icon: Phone }, { label: 'Phone', key: 'phone', copyable: true, icon: Phone }, { label: 'Email', key: 'email', copyable: true, icon: Mail }, { label: 'Birthday', key: 'birthday_display', icon: Cake }, { label: 'Category', key: 'category', icon: Tag }, { label: 'Company', key: 'company', icon: Building2 }, { label: 'Job Title', key: 'job_title', icon: Briefcase }, { label: 'Address', key: 'address', copyable: true, icon: MapPin, fullWidth: true }, { label: 'Notes', key: 'notes', multiline: true, icon: AlignLeft, fullWidth: true }, ]; // --------------------------------------------------------------------------- // PeoplePage // --------------------------------------------------------------------------- export default function PeoplePage() { const queryClient = useQueryClient(); const tableContainerRef = useRef(null); const isDesktop = useMediaQuery('(min-width: 1024px)'); const [selectedPersonId, setSelectedPersonId] = useState(null); const [showForm, setShowForm] = useState(false); const [editingPerson, setEditingPerson] = useState(null); const [activeFilters, setActiveFilters] = useState([]); const [showPinned, setShowPinned] = useState(true); const [showUmbralOnly, setShowUmbralOnly] = useState(false); const [search, setSearch] = useState(''); const [sortKey, setSortKey] = useState('name'); const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); const [showConnectionSearch, setShowConnectionSearch] = useState(false); const [linkPersonId, setLinkPersonId] = useState(null); const [showAddDropdown, setShowAddDropdown] = useState(false); const addDropdownRef = useRef(null); const { incomingRequests, outgoingRequests } = useConnections(); const hasRequests = incomingRequests.length > 0 || outgoingRequests.length > 0; const { data: people = [], isLoading } = useQuery({ queryKey: ['people'], queryFn: async () => { const { data } = await api.get('/people'); return data; }, }); const panelOpen = selectedPersonId !== null; const visibilityMode = useTableVisibility(tableContainerRef, panelOpen); const allCategories = useMemo(() => { const cats = new Set(); people.forEach((p) => { if (p.category) cats.add(p.category); }); return Array.from(cats).sort(); }, [people]); const { orderedCategories, reorder: reorderCategories } = useCategoryOrder('people', allCategories); // Favourites (pinned section) — sorted const favourites = useMemo( () => sortPeople(people.filter((p) => p.is_favourite), sortKey, sortDir), [people, sortKey, sortDir] ); // Filtered non-favourites const filteredPeople = useMemo(() => { let list = showPinned ? people.filter((p) => !p.is_favourite) : people; if (showUmbralOnly) { list = list.filter((p) => p.is_umbral_contact); } if (activeFilters.length > 0) { list = list.filter((p) => p.category && activeFilters.includes(p.category)); } if (search) { const q = search.toLowerCase(); list = list.filter( (p) => p.name.toLowerCase().includes(q) || p.first_name?.toLowerCase().includes(q) || p.last_name?.toLowerCase().includes(q) || p.nickname?.toLowerCase().includes(q) || p.email?.toLowerCase().includes(q) || p.mobile?.toLowerCase().includes(q) || p.phone?.toLowerCase().includes(q) || p.company?.toLowerCase().includes(q) || p.category?.toLowerCase().includes(q) ); } return sortPeople(list, sortKey, sortDir); }, [people, showPinned, showUmbralOnly, activeFilters, search, sortKey, sortDir]); // Build row groups for the table — ordered by custom category order const groups = useMemo(() => { if (activeFilters.length <= 1) { const label = activeFilters.length === 1 ? activeFilters[0] : 'All'; return [{ label, rows: filteredPeople }]; } // Use orderedCategories to control section order, filtered to active only return orderedCategories .filter((cat) => activeFilters.includes(cat)) .map((cat) => ({ label: cat, rows: filteredPeople.filter((p) => p.category === cat), })) .filter((g) => g.rows.length > 0); }, [activeFilters, filteredPeople, orderedCategories]); // Stats const totalCount = people.length; const favouriteCount = people.filter((p) => p.is_favourite).length; const upcomingBirthdays = useMemo( () => people .filter((p) => p.birthday && getDaysUntilBirthday(p.birthday) <= 30) .sort((a, b) => getDaysUntilBirthday(a.birthday!) - getDaysUntilBirthday(b.birthday!)), [people] ); const upcomingBdayCount = upcomingBirthdays.length; const selectedPerson = useMemo( () => people.find((p) => p.id === selectedPersonId) ?? null, [selectedPersonId, people] ); // Sort handler const handleSort = (key: string) => { if (sortKey === key) { setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); } else { setSortKey(key); setSortDir('asc'); } }; // Filter handlers const toggleAll = () => setActiveFilters([]); const togglePinned = () => setShowPinned((p) => !p); const toggleCategory = (cat: string) => { setActiveFilters((prev) => prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat] ); }; const selectAllCategories = () => { const allSelected = orderedCategories.every((c) => activeFilters.includes(c)); setActiveFilters(allSelected ? [] : [...orderedCategories]); }; // Delete mutation const deleteMutation = useMutation({ mutationFn: async () => { await api.delete(`/people/${selectedPersonId}`); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['people'] }); queryClient.invalidateQueries({ queryKey: ['connections'] }); queryClient.invalidateQueries({ queryKey: ['dashboard'] }); queryClient.invalidateQueries({ queryKey: ['upcoming'] }); toast.success('Person deleted'); setSelectedPersonId(null); }, onError: (error) => { toast.error(getErrorMessage(error, 'Failed to delete person')); }, }); // Unlink umbral contact mutation const unlinkMutation = useMutation({ mutationFn: async (personId: number) => { const { data } = await api.put(`/people/${personId}/unlink`); return data; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['people'] }); queryClient.invalidateQueries({ queryKey: ['connections'] }); toast.success('Contact unlinked — converted to standard contact'); }, onError: (error) => { toast.error(getErrorMessage(error, 'Failed to unlink contact')); }, }); // Toggle favourite mutation const toggleFavouriteMutation = useMutation({ mutationFn: async (person: Person) => { await api.put(`/people/${person.id}`, { is_favourite: !person.is_favourite }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['people'] }); }, onError: (error) => { toast.error(getErrorMessage(error, 'Failed to update favourite')); }, }); // Escape key closes detail panel useEffect(() => { if (!panelOpen) return; const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setSelectedPersonId(null); }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, [panelOpen]); // Close add dropdown on outside click useEffect(() => { if (!showAddDropdown) return; const handler = (e: MouseEvent) => { if (addDropdownRef.current && !addDropdownRef.current.contains(e.target as Node)) { setShowAddDropdown(false); } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [showAddDropdown]); const handleCloseForm = () => { setShowForm(false); setEditingPerson(null); }; // Panel header renderer (shared between desktop and mobile) const renderPersonHeader = (p: Person) => { const initialsName = getPersonInitialsName(p); return (
{getInitials(initialsName)}

{ p.is_umbral_contact && p.shared_fields ? [sf(p, 'first_name'), sf(p, 'last_name')].filter(Boolean).join(' ') || p.name : p.name }

{p.is_umbral_contact && ( )}
{p.is_umbral_contact && p.shared_fields?.umbral_name ? ( @{String(p.shared_fields.umbral_name)} ) : null} {p.category && ( {p.category} )}
); }; // Shared field key mapping (panel key -> shared_fields key) const sharedKeyMap: Record = { preferred_name: 'preferred_name', email: 'email', phone: 'phone', mobile: 'mobile', birthday_display: 'birthday', address: 'address', company: 'company', job_title: 'job_title', }; // Build dynamic panel fields with synced labels for shared fields const dynamicPanelFields = useMemo((): PanelField[] => { if (!selectedPerson?.is_umbral_contact || !selectedPerson.shared_fields) return panelFields; const shared = selectedPerson.shared_fields; return panelFields.map((f) => { const sharedKey = sharedKeyMap[f.key]; if (sharedKey && sharedKey in shared) { return { ...f, label: `${f.label} (synced)` }; } return f; }); }, [selectedPerson]); // Panel getValue — overlays shared fields from connected user const getPanelValue = (p: Person, key: string): string | undefined => { // Check shared fields first for umbral contacts if (p.is_umbral_contact && p.shared_fields) { const sharedKey = sharedKeyMap[key]; if (sharedKey && sharedKey in p.shared_fields) { const sharedVal = p.shared_fields[sharedKey]; if (key === 'birthday_display' && sharedVal) { const bd = String(sharedVal); try { const age = differenceInYears(new Date(), parseISO(bd)); return `${format(parseISO(bd), 'MMM d, yyyy')} (${age})`; } catch { return bd; } } return sharedVal != null ? String(sharedVal) : undefined; } } if (key === 'birthday_display' && p.birthday) { const age = differenceInYears(new Date(), parseISO(p.birthday)); return `${format(parseISO(p.birthday), 'MMM d, yyyy')} (${age})`; } const val = p[key as keyof Person]; return val != null ? String(val) : undefined; }; const renderPanel = () => ( item={selectedPerson} fields={dynamicPanelFields} onEdit={() => { setEditingPerson(selectedPerson); setShowForm(true); }} onDelete={() => deleteMutation.mutate()} deleteLoading={deleteMutation.isPending} onClose={() => setSelectedPersonId(null)} renderHeader={renderPersonHeader} getUpdatedAt={(p) => p.updated_at} getValue={getPanelValue} isFavourite={selectedPerson?.is_favourite} onToggleFavourite={() => selectedPerson && toggleFavouriteMutation.mutate(selectedPerson)} favouriteLabel="favourite" extraActions={(p) => p.is_umbral_contact ? ( ) : ( ) } /> ); return (
{/* Header */}

People

setShowUmbralOnly((p) => !p), }, ]} />
{showAddDropdown && (
)}
{/* Stat bar */} {!isLoading && people.length > 0 && (
{/* Birthday list */}
{upcomingBirthdays.slice(0, 5).map((p) => ( {p.name} — {format(getNextBirthday(p.birthday!), 'MMM d')} ( {getDaysUntilBirthday(p.birthday!)}d) ))} {upcomingBirthdays.length > 5 && ( +{upcomingBirthdays.length - 5} more )}
)} {/* Pending requests */} {hasRequests && (
Pending Requests {incomingRequests.length + outgoingRequests.length}
{incomingRequests.length > 0 && outgoingRequests.length > 0 && (

Incoming

)} {incomingRequests.slice(0, 5).map((req) => ( ))} {incomingRequests.length > 5 && (

+{incomingRequests.length - 5} more

)} {incomingRequests.length > 0 && outgoingRequests.length > 0 && (

Outgoing

)} {outgoingRequests.slice(0, 5).map((req) => ( ))} {outgoingRequests.length > 5 && (

+{outgoingRequests.length - 5} more

)}
)} {/* Main content: table + panel */}
{/* Table */}
{isLoading ? ( columns={columns} groups={[]} pinnedRows={[]} pinnedLabel="Favourites" showPinned={false} selectedId={null} onRowClick={() => {}} sortKey={sortKey} sortDir={sortDir} onSort={handleSort} visibilityMode={visibilityMode} loading={true} /> ) : filteredPeople.length === 0 && favourites.length === 0 ? ( setShowForm(true)} /> ) : ( columns={columns} groups={groups} pinnedRows={showPinned ? favourites : []} pinnedLabel="Favourites" showPinned={showPinned} selectedId={selectedPersonId} onRowClick={(id) => setSelectedPersonId((prev) => (prev === id ? null : id)) } sortKey={sortKey} sortDir={sortDir} onSort={handleSort} visibilityMode={visibilityMode} mobileCardRender={(person) => (
{person.name} {person.category && {person.category}}
{person.email && {person.email}} {person.phone && {person.phone}}
)} /> )}
{/* Detail panel (desktop) */} {panelOpen && isDesktop && (
{renderPanel()}
)}
{/* Mobile detail panel overlay */} {panelOpen && selectedPerson && !isDesktop && (
setSelectedPersonId(null)} >
e.stopPropagation()} > {renderPanel()}
)} {showForm && ( )} { if (!open) setLinkPersonId(null); }} personId={linkPersonId ?? undefined} />
); }