Warnings fixed: - 3.1: _compute_display_name stale-data bug on all-names-clear - 3.3: Location getValue unsafe type cast replaced with typed helper - 3.5: Explicit updated_at timestamp refresh in locations router - 3.6: Drop deprecated relationship column (migration 021, model, schema, TS type) Suggestions fixed: - 4.1: CategoryAutocomplete keyboard navigation (ArrowUp/Down, Enter, Escape) - 4.2: Mobile detail panel backdrop click-to-close on both pages - 4.3: PersonCreate whitespace bypass in require_some_name validator - 4.5/4.6: Extract SortIcon, DataRow, SectionHeader from EntityTable render body - 4.8: PersonForm sends null instead of empty string for birthday - 4.10: Remove unnecessary executeDelete wrapper in EntityDetailPanel Also includes previously completed fixes from prior session: - 2.1: Remove Z suffix from naive timestamp in formatUpdatedAt - 3.2: Drag-then-click conflict prevention in SortableCategoryChip - 3.4: localStorage JSON shape validation in useCategoryOrder - 4.4: Category chip styling consistency (both pages use inline hsl styles) - 4.9: restrictToHorizontalAxis modifier on CategoryFilterBar drag Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
560 lines
18 KiB
TypeScript
560 lines
18 KiB
TypeScript
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 (
|
|
<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 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<Person>[] = [
|
|
{
|
|
key: 'name',
|
|
label: 'Name',
|
|
sortable: true,
|
|
visibilityLevel: 'essential',
|
|
render: (p) => {
|
|
const initialsName = 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">{p.nickname || p.name}</span>
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: 'phone',
|
|
label: 'Number',
|
|
sortable: false,
|
|
visibilityLevel: 'essential',
|
|
render: (p) => (
|
|
<span className="text-muted-foreground truncate">{p.mobile || p.phone || '—'}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'email',
|
|
label: 'Email',
|
|
sortable: true,
|
|
visibilityLevel: 'essential',
|
|
render: (p) => (
|
|
<span className="text-muted-foreground truncate">{p.email || '—'}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'job_title',
|
|
label: 'Role',
|
|
sortable: true,
|
|
visibilityLevel: 'filtered',
|
|
render: (p) => {
|
|
const parts = [p.job_title, p.company].filter(Boolean);
|
|
return (
|
|
<span className="text-muted-foreground truncate">{parts.join(', ') || '—'}</span>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: 'birthday',
|
|
label: 'Birthday',
|
|
sortable: true,
|
|
visibilityLevel: 'filtered',
|
|
render: (p) =>
|
|
p.birthday ? (
|
|
<span className="text-muted-foreground">{format(parseISO(p.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: '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<HTMLDivElement>(null);
|
|
|
|
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 [search, setSearch] = useState('');
|
|
const [sortKey, setSortKey] = useState<string>('name');
|
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
|
|
|
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 (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 (
|
|
<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">
|
|
<h3 className="font-heading text-lg font-semibold truncate">{p.name}</h3>
|
|
{p.category && (
|
|
<span className="text-xs text-muted-foreground">{p.category}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 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 = () => (
|
|
<EntityDetailPanel<Person>
|
|
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 (
|
|
<div className="flex flex-col h-full">
|
|
{/* Header */}
|
|
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
|
<h1 className="font-heading 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}
|
|
/>
|
|
<div className="flex-1" />
|
|
<Button onClick={() => setShowForm(true)} size="sm" aria-label="Add person">
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Add Person
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-hidden flex flex-col">
|
|
{/* Stat bar */}
|
|
{!isLoading && people.length > 0 && (
|
|
<div className="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>
|
|
)}
|
|
|
|
{/* 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-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}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Detail panel (desktop) */}
|
|
<div
|
|
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
|
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
|
|
}`}
|
|
>
|
|
{renderPanel()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile detail panel overlay */}
|
|
{panelOpen && selectedPerson && (
|
|
<div
|
|
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
|
onClick={() => setSelectedPersonId(null)}
|
|
>
|
|
<div
|
|
className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{renderPanel()}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showForm && (
|
|
<PersonForm
|
|
person={editingPerson}
|
|
categories={allCategories}
|
|
onClose={handleCloseForm}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|