diff --git a/frontend/src/components/people/PeoplePage.tsx b/frontend/src/components/people/PeoplePage.tsx index 3a4557e..cbbf650 100644 --- a/frontend/src/components/people/PeoplePage.tsx +++ b/frontend/src/components/people/PeoplePage.tsx @@ -1,41 +1,186 @@ -import { useState, useMemo } from 'react'; -import { Plus, Users, Cake, Mail, Search } from 'lucide-react'; -import { useQuery } from '@tanstack/react-query'; -import { parseISO, differenceInDays, addYears } from 'date-fns'; -import api from '@/lib/api'; +import { useState, useMemo, useRef } 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 { Input } from '@/components/ui/input'; -import { Card, CardContent } from '@/components/ui/card'; -import { GridSkeleton } from '@/components/ui/skeleton'; import { EmptyState } from '@/components/ui/empty-state'; -import { RELATIONSHIPS } from './constants'; -import PersonCard from './PersonCard'; +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 PersonForm from './PersonForm'; -const relationshipFilters = [ - { value: '', label: 'All' }, - ...RELATIONSHIPS.map((r) => ({ value: r, label: r })), -] as const; - -function countUpcomingBirthdays(people: Person[]): number { - const now = new Date(); - return people.filter((p) => { - if (!p.birthday) return false; - const bday = parseISO(p.birthday); - // Get this year's birthday - let next = new Date(now.getFullYear(), bday.getMonth(), bday.getDate()); - // If already passed this year, use next year's - if (next < now) next = addYears(next, 1); - return differenceInDays(next, now) <= 30; - }).length; +// --------------------------------------------------------------------------- +// StatCounter — inline helper +// --------------------------------------------------------------------------- +function StatCounter({ + icon: Icon, + iconBg, + iconColor, + label, + value, +}: { + icon: LucideIcon; + iconBg: string; + iconColor: string; + label: string; + value: number; +}) { + return ( +
+
+ +
+
+

{label}

+

{value}

+
+
+ ); } +// --------------------------------------------------------------------------- +// Sort helper +// --------------------------------------------------------------------------- +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 as unknown as Record)[key]; + const bVal = (b as unknown as Record)[key]; + 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) => ( +
+
+ {getInitials(p.name)} +
+ {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' }, +]; + +// --------------------------------------------------------------------------- +// 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 [filter, setFilter] = useState(''); + 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'], @@ -45,150 +190,317 @@ export default function PeoplePage() { }, }); - const filteredPeople = useMemo( - () => - people.filter((person) => { - if (filter && person.relationship !== filter) return false; - if (search) { - const q = search.toLowerCase(); - const matchName = person.name.toLowerCase().includes(q); - const matchEmail = person.email?.toLowerCase().includes(q); - const matchRelationship = person.relationship?.toLowerCase().includes(q); - if (!matchName && !matchEmail && !matchRelationship) return false; - } - return true; - }), - [people, filter, search] + const panelOpen = selectedPersonId !== null; + const visibilityMode = useTableVisibility(tableContainerRef, panelOpen); + + // Unique categories (only those with at least one member) + const allCategories = useMemo(() => { + const cats = new Set(); + people.forEach((p) => { if (p.category) cats.add(p.category); }); + return Array.from(cats).sort(); + }, [people]); + + // 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]); + + // Stats const totalCount = people.length; - const upcomingBirthdays = useMemo(() => countUpcomingBirthdays(people), [people]); - const withContactInfo = useMemo( - () => people.filter((p) => p.email || p.phone).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 handleEdit = (person: Person) => { - setEditingPerson(person); - setShowForm(true); + 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] + ); + }; + + // 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')); + }, + }); + const handleCloseForm = () => { setShowForm(false); setEditingPerson(null); }; + // 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 as unknown as Record)[key]; + return val != null ? String(val) : undefined; + }; + return (
{/* Header */}

People

- -
- {relationshipFilters.map((rf) => ( - - ))} -
- -
- - setSearch(e.target.value)} - className="w-52 h-8 pl-8 text-sm" - /> -
- +
- -
-
- {/* Summary stats */} +
+ {/* Stat bar */} {!isLoading && people.length > 0 && ( -
- - -
- -
-
-

- Total -

-

{totalCount}

-
-
-
- - -
- -
-
-

- Upcoming Birthdays -

-

{upcomingBirthdays}

-
-
-
- - -
- -
-
-

- With Contact Info -

-

{withContactInfo}

-
-
-
+
+
+ + + +
+ {/* 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 + + )} +
)} - {isLoading ? ( - - ) : filteredPeople.length === 0 ? ( - setShowForm(true)} - /> - ) : ( -
- {filteredPeople.map((person) => ( - - ))} + {/* Main content: table + panel */} +
+ {/* Table */} +
+
+ {isLoading ? ( + + columns={columns} + rows={[]} + 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} + rows={filteredPeople} + 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) */} +
+ + item={selectedPerson} + fields={panelFields} + onEdit={() => { + setEditingPerson(selectedPerson); + setShowForm(true); + }} + onDelete={() => deleteMutation.mutate()} + deleteLoading={deleteMutation.isPending} + onClose={() => setSelectedPersonId(null)} + renderHeader={(p) => ( +
+
+ {getInitials(p.name)} +
+
+

{p.name}

+ {p.category && ( + {p.category} + )} +
+
+ )} + getUpdatedAt={(p) => p.updated_at} + getValue={getPanelValue} + /> +
+
- {showForm && } + {/* Mobile detail panel overlay */} + {panelOpen && selectedPerson && ( +
+
+ + item={selectedPerson} + fields={panelFields} + onEdit={() => { + setEditingPerson(selectedPerson); + setShowForm(true); + }} + onDelete={() => deleteMutation.mutate()} + deleteLoading={deleteMutation.isPending} + onClose={() => setSelectedPersonId(null)} + renderHeader={(p) => ( +
+
+ {getInitials(p.name)} +
+
+

{p.name}

+ {p.category && ( + {p.category} + )} +
+
+ )} + getUpdatedAt={(p) => p.updated_at} + getValue={getPanelValue} + /> +
+
+ )} + + {showForm && ( + + )}
); } diff --git a/frontend/src/components/people/PersonCard.tsx b/frontend/src/components/people/PersonCard.tsx deleted file mode 100644 index 2d0c06b..0000000 --- a/frontend/src/components/people/PersonCard.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { useCallback } from 'react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { toast } from 'sonner'; -import { Mail, Phone, MapPin, Calendar, Trash2, Pencil } from 'lucide-react'; -import { format, parseISO } from 'date-fns'; -import api, { getErrorMessage } from '@/lib/api'; -import type { Person } from '@/types'; -import { Card, CardContent, CardHeader } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { useConfirmAction } from '@/hooks/useConfirmAction'; -import { getRelationshipColor } from './constants'; - -interface PersonCardProps { - person: Person; - onEdit: (person: Person) => void; -} - -const QUERY_KEYS = [['people'], ['dashboard'], ['upcoming']] as const; - -// Deterministic color from name hash for avatar -const avatarColors = [ - 'bg-rose-500/20 text-rose-400', - 'bg-blue-500/20 text-blue-400', - 'bg-purple-500/20 text-purple-400', - 'bg-pink-500/20 text-pink-400', - 'bg-teal-500/20 text-teal-400', - 'bg-orange-500/20 text-orange-400', - 'bg-green-500/20 text-green-400', - 'bg-amber-500/20 text-amber-400', -]; - -function getInitials(name: string): string { - const parts = name.trim().split(/\s+/); - if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); - return name.slice(0, 2).toUpperCase(); -} - -function getAvatarColor(name: string): string { - let hash = 0; - for (let i = 0; i < name.length; i++) { - hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0; - } - return avatarColors[Math.abs(hash) % avatarColors.length]; -} - -export default function PersonCard({ person, onEdit }: PersonCardProps) { - const queryClient = useQueryClient(); - - const deleteMutation = useMutation({ - mutationFn: async () => { - await api.delete(`/people/${person.id}`); - }, - onSuccess: () => { - QUERY_KEYS.forEach((key) => queryClient.invalidateQueries({ queryKey: [...key] })); - toast.success('Person deleted'); - }, - onError: (error) => { - toast.error(getErrorMessage(error, 'Failed to delete person')); - }, - }); - - const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]); - const { confirming: confirmingDelete, handleClick: handleDelete } = useConfirmAction(executeDelete); - - return ( - - -
-
-
- {getInitials(person.name)} -
-
-

- {person.name} -

- {person.relationship && ( - - {person.relationship} - - )} -
-
-
- - {confirmingDelete ? ( - - ) : ( - - )} -
-
-
- - {person.email && ( -
- - {person.email} -
- )} - {person.phone && ( -
- - {person.phone} -
- )} - {person.address && ( -
- - {person.address} -
- )} - {person.birthday && ( -
- - {format(parseISO(person.birthday), 'MMM d, yyyy')} -
- )} - {person.notes && ( -

{person.notes}

- )} -
-
- ); -} diff --git a/frontend/src/components/people/PersonForm.tsx b/frontend/src/components/people/PersonForm.tsx index 6851f01..979eb65 100644 --- a/frontend/src/components/people/PersonForm.tsx +++ b/frontend/src/components/people/PersonForm.tsx @@ -1,6 +1,8 @@ -import { useState, FormEvent } from 'react'; +import { useState, useMemo, FormEvent } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; +import { Star, StarOff } from 'lucide-react'; +import { parseISO, differenceInYears } from 'date-fns'; import api, { getErrorMessage } from '@/lib/api'; import type { Person } from '@/types'; import { @@ -13,37 +15,64 @@ import { } from '@/components/ui/sheet'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; -import { Select } from '@/components/ui/select'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; -import { RELATIONSHIPS } from './constants'; +import LocationPicker from '@/components/ui/location-picker'; +import CategoryAutocomplete from '@/components/shared/CategoryAutocomplete'; +import { splitName } from '@/components/shared/utils'; interface PersonFormProps { person: Person | null; + categories: string[]; onClose: () => void; } -export default function PersonForm({ person, onClose }: PersonFormProps) { +export default function PersonForm({ person, categories, onClose }: PersonFormProps) { const queryClient = useQueryClient(); + const [formData, setFormData] = useState({ - name: person?.name || '', + first_name: + person?.first_name || + (person?.name ? splitName(person.name).firstName : ''), + last_name: + person?.last_name || + (person?.name ? splitName(person.name).lastName : ''), + nickname: person?.nickname || '', email: person?.email || '', phone: person?.phone || '', + mobile: person?.mobile || '', address: person?.address || '', - birthday: person?.birthday || '', - relationship: person?.relationship || '', + birthday: person?.birthday + ? person.birthday.slice(0, 10) + : '', + category: person?.category || '', + is_favourite: person?.is_favourite ?? false, + company: person?.company || '', + job_title: person?.job_title || '', notes: person?.notes || '', }); + const age = useMemo(() => { + if (!formData.birthday) return null; + try { + return differenceInYears(new Date(), parseISO(formData.birthday)); + } catch { + return null; + } + }, [formData.birthday]); + + const set = (key: K, value: (typeof formData)[K]) => { + setFormData((prev) => ({ ...prev, [key]: value })); + }; + const mutation = useMutation({ mutationFn: async (data: typeof formData) => { if (person) { - const response = await api.put(`/people/${person.id}`, data); - return response.data; - } else { - const response = await api.post('/people', data); - return response.data; + const { data: res } = await api.put(`/people/${person.id}`, data); + return res; } + const { data: res } = await api.post('/people', data); + return res; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['people'] }); @@ -53,7 +82,9 @@ export default function PersonForm({ person, onClose }: PersonFormProps) { onClose(); }, onError: (error) => { - toast.error(getErrorMessage(error, person ? 'Failed to update person' : 'Failed to create person')); + toast.error( + getErrorMessage(error, person ? 'Failed to update person' : 'Failed to create person') + ); }, }); @@ -67,51 +98,60 @@ export default function PersonForm({ person, onClose }: PersonFormProps) { - {person ? 'Edit Person' : 'New Person'} +
+ {person ? 'Edit Person' : 'New Person'} + +
+
-
- - setFormData({ ...formData, name: e.target.value })} - required - /> -
- + {/* Row 2: First + Last name */}
- + setFormData({ ...formData, email: e.target.value })} + id="first_name" + value={formData.first_name} + onChange={(e) => set('first_name', e.target.value)} + required />
-
- + setFormData({ ...formData, phone: e.target.value })} + id="last_name" + value={formData.last_name} + onChange={(e) => set('last_name', e.target.value)} />
+ {/* Row 3: Nickname */}
- + setFormData({ ...formData, address: e.target.value })} + id="nickname" + value={formData.nickname} + onChange={(e) => set('nickname', e.target.value)} + placeholder="Optional display name" />
+ {/* Row 4: Birthday + Age */}
@@ -119,34 +159,108 @@ export default function PersonForm({ person, onClose }: PersonFormProps) { id="birthday" type="date" value={formData.birthday} - onChange={(e) => setFormData({ ...formData, birthday: e.target.value })} + onChange={(e) => set('birthday', e.target.value)} />
-
- - + +
+ {/* Row 5: Category */} +
+ + set('category', val)} + categories={categories} + placeholder="e.g. Friend, Family, Colleague" + /> +
+ + {/* Row 6: Mobile + Email */} +
+
+ + set('mobile', e.target.value)} + /> +
+
+ + set('email', e.target.value)} + /> +
+
+ + {/* Row 7: Phone */} +
+ + set('phone', e.target.value)} + placeholder="Landline / work number" + /> +
+ + {/* Row 8: Address */} +
+ + set('address', val)} + onSelect={(result) => set('address', result.address || result.name)} + placeholder="Search or enter address..." + /> +
+ + {/* Row 9: Company + Job Title */} +
+
+ + set('company', e.target.value)} + /> +
+
+ + set('job_title', e.target.value)} + /> +
+
+ + {/* Row 10: Notes */}