import { useState, useMemo, useRef, useEffect } from 'react'; import { Plus, Users, Star, Cake, Phone, Mail, MapPin } 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'; // --------------------------------------------------------------------------- // 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 parts = [p.first_name, p.last_name].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 // --------------------------------------------------------------------------- const columns: ColumnDef[] = [ { key: 'name', label: 'Name', sortable: true, visibilityLevel: 'essential', render: (p) => { const initialsName = getPersonInitialsName(p); return (
{getInitials(initialsName)}
{p.nickname || p.name}
); }, }, { key: 'phone', label: 'Number', sortable: false, visibilityLevel: 'essential', render: (p) => ( {p.mobile || p.phone || '—'} ), }, { key: 'email', label: 'Email', sortable: true, visibilityLevel: 'essential', render: (p) => ( {p.email || '—'} ), }, { key: 'job_title', label: 'Role', sortable: true, visibilityLevel: 'filtered', render: (p) => { const parts = [p.job_title, p.company].filter(Boolean); return ( {parts.join(', ') || '—'} ); }, }, { key: 'birthday', label: 'Birthday', sortable: true, visibilityLevel: 'filtered', render: (p) => p.birthday ? ( {format(parseISO(p.birthday), 'MMM d')} ) : ( ), }, { key: 'category', label: 'Category', sortable: true, visibilityLevel: 'all', render: (p) => p.category ? ( {p.category} ) : ( ), }, ]; // --------------------------------------------------------------------------- // Panel field config // --------------------------------------------------------------------------- const panelFields: PanelField[] = [ { 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: 'Address', key: 'address', copyable: true, icon: MapPin }, { label: 'Birthday', key: 'birthday_display' }, { label: 'Category', key: 'category' }, { label: 'Company', key: 'company' }, { label: 'Job Title', key: 'job_title' }, { label: 'Notes', key: 'notes', multiline: true }, ]; // --------------------------------------------------------------------------- // PeoplePage // --------------------------------------------------------------------------- export default function PeoplePage() { const queryClient = useQueryClient(); const tableContainerRef = useRef(null); 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 [search, setSearch] = useState(''); const [sortKey, setSortKey] = useState('name'); const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); 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 (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.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, 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: ['dashboard'] }); queryClient.invalidateQueries({ queryKey: ['upcoming'] }); toast.success('Person deleted'); setSelectedPersonId(null); }, onError: (error) => { toast.error(getErrorMessage(error, 'Failed to delete person')); }, }); // 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]); 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.name}

{p.category && ( {p.category} )}
); }; // Panel getValue const getPanelValue = (p: Person, key: string): string | 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={panelFields} 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" /> ); return (
{/* Header */}

People

{/* 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 )}
)} {/* 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} /> )}
{/* Detail panel (desktop) */}
{renderPanel()}
{/* Mobile detail panel overlay */} {panelOpen && selectedPerson && (
setSelectedPersonId(null)} >
e.stopPropagation()} > {renderPanel()}
)} {showForm && ( )}
); }