+ {/* 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.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'}
+
+
+