Kyle Pope 85a9882d26 Fix category chips appearing in wrong position
Category chips were rendering as a separate flex row that got pushed to the
far right (between search and add button). Flatten the layout so chips appear
inline immediately after the Categories toggle pill, separated by a divider.

Remove redundant wrapper divs from TodosPage, PeoplePage, LocationsPage —
CategoryFilterBar now owns its own flex-1 sizing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 01:22:28 +08:00

804 lines
29 KiB
TypeScript

import { useState, useMemo, useRef, useEffect } from 'react';
import { useMediaQuery, DESKTOP } 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';
import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay';
// ---------------------------------------------------------------------------
// StatCounter — inline helper
// ---------------------------------------------------------------------------
function StatCounter({
icon: Icon,
iconBg,
iconColor,
label,
value,
}: {
icon: LucideIcon;
iconBg: string;
iconColor: string;
label: string;
value: number;
}) {
return (
<div className="flex items-center gap-2.5">
<div className={`p-1.5 rounded-md ${iconBg}`}>
<Icon className={`h-4 w-4 ${iconColor}`} />
</div>
<div>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">{label}</p>
<p className="font-heading text-xl font-bold tabular-nums">{value}</p>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// 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<Person>[] = [
{
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 (
<div className="flex items-center gap-2.5">
<div
className={`h-7 w-7 rounded-full flex items-center justify-center text-xs font-bold shrink-0 ${getAvatarColor(initialsName)}`}
>
{getInitials(initialsName)}
</div>
<span className="font-medium truncate">{liveName}</span>
{p.is_umbral_contact && (
<Ghost className="h-3.5 w-3.5 text-violet-400 shrink-0" aria-label="Umbral contact" />
)}
</div>
);
},
},
{
key: 'phone',
label: 'Number',
sortable: false,
visibilityLevel: 'essential',
render: (p) => {
const mobile = sf(p, 'mobile');
const phone = sf(p, 'phone');
return <span className="text-muted-foreground truncate">{mobile || phone || '—'}</span>;
},
},
{
key: 'email',
label: 'Email',
sortable: true,
visibilityLevel: 'essential',
render: (p) => {
const email = sf(p, 'email');
return <span className="text-muted-foreground truncate">{email || '—'}</span>;
},
},
{
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 <span className="text-muted-foreground truncate">{parts.join(', ') || '—'}</span>;
},
},
{
key: 'birthday',
label: 'Birthday',
sortable: true,
visibilityLevel: 'filtered',
render: (p) => {
const birthday = sf(p, 'birthday');
return birthday ? (
<span className="text-muted-foreground">{format(parseISO(birthday), 'MMM d')}</span>
) : (
<span className="text-muted-foreground"></span>
);
},
},
{
key: 'category',
label: 'Category',
sortable: true,
visibilityLevel: 'all',
render: (p) =>
p.category ? (
<span
className="px-2 py-0.5 text-xs rounded"
style={{
backgroundColor: 'hsl(var(--accent-color) / 0.1)',
color: 'hsl(var(--accent-color))',
}}
>
{p.category}
</span>
) : (
<span className="text-muted-foreground"></span>
),
},
];
// ---------------------------------------------------------------------------
// 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<HTMLDivElement>(null);
const isDesktop = useMediaQuery(DESKTOP);
const [selectedPersonId, setSelectedPersonId] = useState<number | null>(null);
const [showForm, setShowForm] = useState(false);
const [editingPerson, setEditingPerson] = useState<Person | null>(null);
const [activeFilters, setActiveFilters] = useState<string[]>([]);
const [showPinned, setShowPinned] = useState(true);
const [showUmbralOnly, setShowUmbralOnly] = useState(false);
const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<string>('name');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [showConnectionSearch, setShowConnectionSearch] = useState(false);
const [linkPersonId, setLinkPersonId] = useState<number | null>(null);
const [showAddDropdown, setShowAddDropdown] = useState(false);
const addDropdownRef = useRef<HTMLDivElement>(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<Person[]>('/people');
return data;
},
});
const panelOpen = selectedPersonId !== null;
const visibilityMode = useTableVisibility(tableContainerRef, panelOpen);
const allCategories = useMemo(() => {
const cats = new Set<string>();
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 (
<div className="flex items-center gap-3">
<div
className={`h-10 w-10 rounded-full flex items-center justify-center text-sm font-bold shrink-0 ${getAvatarColor(initialsName)}`}
>
{getInitials(initialsName)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-heading text-lg font-semibold truncate">{
p.is_umbral_contact && p.shared_fields
? [sf(p, 'first_name'), sf(p, 'last_name')].filter(Boolean).join(' ') || p.name
: p.name
}</h3>
{p.is_umbral_contact && (
<Ghost className="h-4 w-4 text-violet-400 shrink-0" />
)}
</div>
<div className="flex items-center gap-2">
{p.is_umbral_contact && p.shared_fields?.umbral_name ? (
<span className="text-xs text-violet-400/80 font-normal">
@{String(p.shared_fields.umbral_name)}
</span>
) : null}
{p.category && (
<span className="text-xs text-muted-foreground">{p.category}</span>
)}
</div>
</div>
</div>
);
};
// Shared field key mapping (panel key -> shared_fields key)
const sharedKeyMap: Record<string, string> = {
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 = () => (
<EntityDetailPanel<Person>
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 ? (
<Button
variant="ghost"
size="sm"
onClick={() => unlinkMutation.mutate(p.id)}
disabled={unlinkMutation.isPending}
className="h-7 text-[11px] text-muted-foreground hover:text-foreground gap-1"
>
<Unlink className="h-3 w-3" />
Unlink
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setLinkPersonId(p.id)}
className="h-7 text-[11px] text-muted-foreground hover:text-foreground gap-1"
>
<Link2 className="h-3 w-3" />
Link
</Button>
)
}
/>
);
return (
<div className="flex flex-col h-full animate-fade-in">
{/* Header */}
<div className="border-b bg-card px-4 md:px-6 min-h-[4rem] flex items-center gap-2 md:gap-4 flex-wrap py-2 md:py-0 md:h-16 md:flex-nowrap shrink-0">
<h1 className="font-heading text-xl md:text-2xl font-bold tracking-tight">People</h1>
<CategoryFilterBar
activeFilters={activeFilters}
pinnedLabel="Favourites"
showPinned={showPinned}
categories={orderedCategories}
onToggleAll={toggleAll}
onTogglePinned={togglePinned}
onToggleCategory={toggleCategory}
onSelectAllCategories={selectAllCategories}
onReorderCategories={reorderCategories}
searchValue={search}
onSearchChange={setSearch}
extraPinnedFilters={[
{
label: 'Umbral',
isActive: showUmbralOnly,
onToggle: () => setShowUmbralOnly((p) => !p),
},
]}
/>
<div className="relative" ref={addDropdownRef}>
<div className="flex">
<Button
onClick={() => setShowForm(true)}
size="sm"
aria-label="Add person"
className="rounded-r-none"
>
<Plus className="h-4 w-4 md:mr-2" /><span className="hidden md:inline">Add Person</span>
</Button>
<Button
size="sm"
onClick={() => setShowAddDropdown((p) => !p)}
aria-label="More add options"
className="rounded-l-none border-l border-background/20 px-1.5"
>
<ChevronDown className="h-3.5 w-3.5" />
</Button>
</div>
{showAddDropdown && (
<div className="absolute right-0 top-full mt-1 w-44 rounded-md border border-border bg-card shadow-lg z-50 py-1">
<button
className="w-full text-left px-3 py-1.5 text-sm hover:bg-card-elevated transition-colors"
onClick={() => { setShowAddDropdown(false); setShowForm(true); }}
>
Standard Contact
</button>
<button
className="w-full text-left px-3 py-1.5 text-sm hover:bg-card-elevated transition-colors flex items-center gap-2"
onClick={() => { setShowAddDropdown(false); setShowConnectionSearch(true); }}
>
<Ghost className="h-3.5 w-3.5 text-violet-400" />
Umbra Contact
</button>
</div>
)}
</div>
</div>
<div className="flex-1 overflow-hidden flex flex-col">
{/* Stat bar */}
{!isLoading && people.length > 0 && (
<div className="px-4 md:px-6 pt-4 pb-2 flex items-start gap-6 shrink-0">
<div className="flex gap-6 shrink-0">
<StatCounter
icon={Users}
iconBg="bg-blue-500/10"
iconColor="text-blue-400"
label="Total"
value={totalCount}
/>
<StatCounter
icon={Star}
iconBg="bg-yellow-500/10"
iconColor="text-yellow-400"
label="Favourites"
value={favouriteCount}
/>
<StatCounter
icon={Cake}
iconBg="bg-pink-500/10"
iconColor="text-pink-400"
label="Upcoming Bdays"
value={upcomingBdayCount}
/>
</div>
{/* Birthday list */}
<div className="flex-1 flex flex-wrap gap-x-4 gap-y-1 overflow-hidden">
{upcomingBirthdays.slice(0, 5).map((p) => (
<span key={p.id} className="text-[11px] text-muted-foreground whitespace-nowrap">
{p.name} {format(getNextBirthday(p.birthday!), 'MMM d')} (
{getDaysUntilBirthday(p.birthday!)}d)
</span>
))}
{upcomingBirthdays.length > 5 && (
<span className="text-[11px] text-muted-foreground">
+{upcomingBirthdays.length - 5} more
</span>
)}
</div>
</div>
)}
{/* Pending requests */}
{hasRequests && (
<div className="px-4 md:px-6 pb-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-muted-foreground uppercase tracking-wider font-medium">
Pending Requests
</span>
<span className="text-[10px] tabular-nums bg-accent/15 text-accent px-1.5 py-0.5 rounded-full font-medium">
{incomingRequests.length + outgoingRequests.length}
</span>
</div>
<div className="space-y-2">
{incomingRequests.length > 0 && outgoingRequests.length > 0 && (
<p className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider">Incoming</p>
)}
{incomingRequests.slice(0, 5).map((req) => (
<ConnectionRequestCard key={req.id} request={req} direction="incoming" />
))}
{incomingRequests.length > 5 && (
<p className="text-xs text-muted-foreground">+{incomingRequests.length - 5} more</p>
)}
{incomingRequests.length > 0 && outgoingRequests.length > 0 && (
<p className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mt-3">Outgoing</p>
)}
{outgoingRequests.slice(0, 5).map((req) => (
<ConnectionRequestCard key={req.id} request={req} direction="outgoing" />
))}
{outgoingRequests.length > 5 && (
<p className="text-xs text-muted-foreground">+{outgoingRequests.length - 5} more</p>
)}
</div>
</div>
)}
{/* Main content: table + panel */}
<div className="flex-1 overflow-hidden flex">
{/* Table */}
<div
ref={tableContainerRef}
className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
}`}
>
<div className="px-4 md:px-6 pb-6">
{isLoading ? (
<EntityTable<Person>
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 ? (
<EmptyState
icon={Users}
title="No contacts yet"
description="Add people to your directory to keep track of contacts and relationships."
actionLabel="Add Person"
onAction={() => setShowForm(true)}
/>
) : (
<EntityTable<Person>
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) => (
<div className={`rounded-lg border p-3 transition-colors ${selectedPersonId === person.id ? 'border-accent/40 bg-accent/5' : 'border-border bg-card hover:bg-card-elevated'}`}>
<div className="flex items-center justify-between mb-1">
<span className="font-medium text-sm truncate flex-1">{person.name}</span>
{person.category && <span className="text-xs text-muted-foreground">{person.category}</span>}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{person.email && <span className="truncate">{person.email}</span>}
{person.phone && <span>{person.phone}</span>}
</div>
</div>
)}
/>
)}
</div>
</div>
{/* Detail panel (desktop) */}
{panelOpen && isDesktop && (
<div
className="overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] w-[45%]"
>
{renderPanel()}
</div>
)}
</div>
</div>
{/* Mobile detail panel overlay */}
{panelOpen && selectedPerson && !isDesktop && (
<MobileDetailOverlay open={true} onClose={() => setSelectedPersonId(null)}>
{renderPanel()}
</MobileDetailOverlay>
)}
{showForm && (
<PersonForm
person={editingPerson}
categories={allCategories}
onClose={handleCloseForm}
/>
)}
<ConnectionSearch
open={showConnectionSearch}
onOpenChange={setShowConnectionSearch}
/>
<ConnectionSearch
open={linkPersonId !== null}
onOpenChange={(open) => { if (!open) setLinkPersonId(null); }}
personId={linkPersonId ?? undefined}
/>
</div>
);
}