UI refresh Stage 5: rebuild People page with table/panel layout
- Rewrote PeoplePage.tsx: EntityTable + EntityDetailPanel + CategoryFilterBar with favourites pinned section, stat bar with birthday list, animated side panel - Rewrote PersonForm.tsx: expanded fields (first/last/nickname, mobile, company, job_title, birthday+age, category autocomplete, LocationPicker, favourite star) - Stripped constants.ts to legacy-only getRelationshipColor, removed RELATIONSHIPS - Deleted PersonCard.tsx (replaced by table rows + detail panel) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
765f692304
commit
f4b1239904
@ -1,41 +1,186 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo, useRef } from 'react';
|
||||||
import { Plus, Users, Cake, Mail, Search } from 'lucide-react';
|
import { Plus, Users, Star, Cake, Phone, Mail, MapPin } from 'lucide-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { parseISO, differenceInDays, addYears } from 'date-fns';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '@/lib/api';
|
import { format, parseISO, differenceInYears } from 'date-fns';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
import type { Person } from '@/types';
|
import type { Person } from '@/types';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { EmptyState } from '@/components/ui/empty-state';
|
||||||
import { RELATIONSHIPS } from './constants';
|
import {
|
||||||
import PersonCard from './PersonCard';
|
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';
|
import PersonForm from './PersonForm';
|
||||||
|
|
||||||
const relationshipFilters = [
|
// ---------------------------------------------------------------------------
|
||||||
{ value: '', label: 'All' },
|
// StatCounter — inline helper
|
||||||
...RELATIONSHIPS.map((r) => ({ value: r, label: r })),
|
// ---------------------------------------------------------------------------
|
||||||
] as const;
|
function StatCounter({
|
||||||
|
icon: Icon,
|
||||||
function countUpcomingBirthdays(people: Person[]): number {
|
iconBg,
|
||||||
const now = new Date();
|
iconColor,
|
||||||
return people.filter((p) => {
|
label,
|
||||||
if (!p.birthday) return false;
|
value,
|
||||||
const bday = parseISO(p.birthday);
|
}: {
|
||||||
// Get this year's birthday
|
icon: LucideIcon;
|
||||||
let next = new Date(now.getFullYear(), bday.getMonth(), bday.getDate());
|
iconBg: string;
|
||||||
// If already passed this year, use next year's
|
iconColor: string;
|
||||||
if (next < now) next = addYears(next, 1);
|
label: string;
|
||||||
return differenceInDays(next, now) <= 30;
|
value: number;
|
||||||
}).length;
|
}) {
|
||||||
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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<string, unknown>)[key];
|
||||||
|
const bVal = (b as unknown as Record<string, unknown>)[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<Person>[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
label: 'Name',
|
||||||
|
sortable: true,
|
||||||
|
visibilityLevel: 'essential',
|
||||||
|
render: (p) => (
|
||||||
|
<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(p.name)}`}
|
||||||
|
>
|
||||||
|
{getInitials(p.name)}
|
||||||
|
</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 bg-accent/10 text-accent">
|
||||||
|
{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' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PeoplePage
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
export default function 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 [showForm, setShowForm] = useState(false);
|
||||||
const [editingPerson, setEditingPerson] = useState<Person | null>(null);
|
const [editingPerson, setEditingPerson] = useState<Person | null>(null);
|
||||||
const [filter, setFilter] = useState('');
|
const [activeFilters, setActiveFilters] = useState<string[]>([]);
|
||||||
|
const [showPinned, setShowPinned] = useState(true);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [sortKey, setSortKey] = useState<string>('name');
|
||||||
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||||
|
|
||||||
const { data: people = [], isLoading } = useQuery({
|
const { data: people = [], isLoading } = useQuery({
|
||||||
queryKey: ['people'],
|
queryKey: ['people'],
|
||||||
@ -45,150 +190,317 @@ export default function PeoplePage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredPeople = useMemo(
|
const panelOpen = selectedPersonId !== null;
|
||||||
() =>
|
const visibilityMode = useTableVisibility(tableContainerRef, panelOpen);
|
||||||
people.filter((person) => {
|
|
||||||
if (filter && person.relationship !== filter) return false;
|
// Unique categories (only those with at least one member)
|
||||||
if (search) {
|
const allCategories = useMemo(() => {
|
||||||
const q = search.toLowerCase();
|
const cats = new Set<string>();
|
||||||
const matchName = person.name.toLowerCase().includes(q);
|
people.forEach((p) => { if (p.category) cats.add(p.category); });
|
||||||
const matchEmail = person.email?.toLowerCase().includes(q);
|
return Array.from(cats).sort();
|
||||||
const matchRelationship = person.relationship?.toLowerCase().includes(q);
|
}, [people]);
|
||||||
if (!matchName && !matchEmail && !matchRelationship) return false;
|
|
||||||
}
|
// Favourites (pinned section) — sorted
|
||||||
return true;
|
const favourites = useMemo(
|
||||||
}),
|
() => sortPeople(people.filter((p) => p.is_favourite), sortKey, sortDir),
|
||||||
[people, filter, search]
|
[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 totalCount = people.length;
|
||||||
const upcomingBirthdays = useMemo(() => countUpcomingBirthdays(people), [people]);
|
const favouriteCount = people.filter((p) => p.is_favourite).length;
|
||||||
const withContactInfo = useMemo(
|
const upcomingBirthdays = useMemo(
|
||||||
() => people.filter((p) => p.email || p.phone).length,
|
() =>
|
||||||
|
people
|
||||||
|
.filter((p) => p.birthday && getDaysUntilBirthday(p.birthday) <= 30)
|
||||||
|
.sort((a, b) => getDaysUntilBirthday(a.birthday!) - getDaysUntilBirthday(b.birthday!)),
|
||||||
[people]
|
[people]
|
||||||
);
|
);
|
||||||
|
const upcomingBdayCount = upcomingBirthdays.length;
|
||||||
|
|
||||||
const handleEdit = (person: Person) => {
|
const selectedPerson = useMemo(
|
||||||
setEditingPerson(person);
|
() => people.find((p) => p.id === selectedPersonId) ?? null,
|
||||||
setShowForm(true);
|
[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 = () => {
|
const handleCloseForm = () => {
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setEditingPerson(null);
|
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<string, unknown>)[key];
|
||||||
|
return val != null ? String(val) : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
|
<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>
|
<h1 className="font-heading text-2xl font-bold tracking-tight">People</h1>
|
||||||
|
<CategoryFilterBar
|
||||||
<div className="flex items-center rounded-md border border-border overflow-hidden ml-4">
|
activeFilters={activeFilters}
|
||||||
{relationshipFilters.map((rf) => (
|
pinnedLabel="Favourites"
|
||||||
<button
|
showPinned={showPinned}
|
||||||
key={rf.value}
|
categories={allCategories}
|
||||||
onClick={() => setFilter(rf.value)}
|
onToggleAll={toggleAll}
|
||||||
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
|
onTogglePinned={togglePinned}
|
||||||
filter === rf.value
|
onToggleCategory={toggleCategory}
|
||||||
? 'bg-accent/15 text-accent'
|
searchValue={search}
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
onSearchChange={setSearch}
|
||||||
}`}
|
/>
|
||||||
style={{
|
|
||||||
backgroundColor:
|
|
||||||
filter === rf.value ? 'hsl(var(--accent-color) / 0.15)' : undefined,
|
|
||||||
color: filter === rf.value ? 'hsl(var(--accent-color))' : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{rf.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative ml-2">
|
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
className="w-52 h-8 pl-8 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
<Button onClick={() => setShowForm(true)} size="sm" aria-label="Add person">
|
||||||
<Button onClick={() => setShowForm(true)} size="sm">
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Person
|
Add Person
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
<div className="flex-1 overflow-hidden flex flex-col">
|
||||||
{/* Summary stats */}
|
{/* Stat bar */}
|
||||||
{!isLoading && people.length > 0 && (
|
{!isLoading && people.length > 0 && (
|
||||||
<div className="grid gap-2.5 grid-cols-3 mb-5">
|
<div className="px-6 pt-4 pb-2 flex items-start gap-6 shrink-0">
|
||||||
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
<div className="flex gap-6 shrink-0">
|
||||||
<CardContent className="p-4 flex items-center gap-3">
|
<StatCounter
|
||||||
<div className="p-1.5 rounded-md bg-blue-500/10">
|
icon={Users}
|
||||||
<Users className="h-4 w-4 text-blue-400" />
|
iconBg="bg-blue-500/10"
|
||||||
</div>
|
iconColor="text-blue-400"
|
||||||
<div>
|
label="Total"
|
||||||
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
|
value={totalCount}
|
||||||
Total
|
/>
|
||||||
</p>
|
<StatCounter
|
||||||
<p className="font-heading text-xl font-bold tabular-nums">{totalCount}</p>
|
icon={Star}
|
||||||
</div>
|
iconBg="bg-yellow-500/10"
|
||||||
</CardContent>
|
iconColor="text-yellow-400"
|
||||||
</Card>
|
label="Favourites"
|
||||||
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
value={favouriteCount}
|
||||||
<CardContent className="p-4 flex items-center gap-3">
|
/>
|
||||||
<div className="p-1.5 rounded-md bg-pink-500/10">
|
<StatCounter
|
||||||
<Cake className="h-4 w-4 text-pink-400" />
|
icon={Cake}
|
||||||
</div>
|
iconBg="bg-pink-500/10"
|
||||||
<div>
|
iconColor="text-pink-400"
|
||||||
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
|
label="Upcoming Bdays"
|
||||||
Upcoming Birthdays
|
value={upcomingBdayCount}
|
||||||
</p>
|
/>
|
||||||
<p className="font-heading text-xl font-bold tabular-nums">{upcomingBirthdays}</p>
|
</div>
|
||||||
</div>
|
{/* Birthday list */}
|
||||||
</CardContent>
|
<div className="flex-1 flex flex-wrap gap-x-4 gap-y-1 overflow-hidden">
|
||||||
</Card>
|
{upcomingBirthdays.slice(0, 5).map((p) => (
|
||||||
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
<span key={p.id} className="text-[11px] text-muted-foreground whitespace-nowrap">
|
||||||
<CardContent className="p-4 flex items-center gap-3">
|
{p.name} — {format(getNextBirthday(p.birthday!), 'MMM d')} (
|
||||||
<div className="p-1.5 rounded-md bg-green-500/10">
|
{getDaysUntilBirthday(p.birthday!)}d)
|
||||||
<Mail className="h-4 w-4 text-green-400" />
|
</span>
|
||||||
</div>
|
))}
|
||||||
<div>
|
{upcomingBirthdays.length > 5 && (
|
||||||
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
|
<span className="text-[11px] text-muted-foreground">
|
||||||
With Contact Info
|
+{upcomingBirthdays.length - 5} more
|
||||||
</p>
|
</span>
|
||||||
<p className="font-heading text-xl font-bold tabular-nums">{withContactInfo}</p>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading ? (
|
{/* Main content: table + panel */}
|
||||||
<GridSkeleton cards={6} />
|
<div className="flex-1 overflow-hidden flex">
|
||||||
) : filteredPeople.length === 0 ? (
|
{/* Table */}
|
||||||
<EmptyState
|
<div
|
||||||
icon={Users}
|
ref={tableContainerRef}
|
||||||
title="No contacts yet"
|
className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
||||||
description="Add people to your directory to keep track of contacts and relationships."
|
panelOpen ? 'w-full lg:w-[55%]' : 'w-full'
|
||||||
actionLabel="Add Person"
|
}`}
|
||||||
onAction={() => setShowForm(true)}
|
>
|
||||||
/>
|
<div className="px-6 pb-6">
|
||||||
) : (
|
{isLoading ? (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<EntityTable<Person>
|
||||||
{filteredPeople.map((person) => (
|
columns={columns}
|
||||||
<PersonCard key={person.id} person={person} onEdit={handleEdit} />
|
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 ? (
|
||||||
|
<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}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<EntityDetailPanel<Person>
|
||||||
|
item={selectedPerson}
|
||||||
|
fields={panelFields}
|
||||||
|
onEdit={() => {
|
||||||
|
setEditingPerson(selectedPerson);
|
||||||
|
setShowForm(true);
|
||||||
|
}}
|
||||||
|
onDelete={() => deleteMutation.mutate()}
|
||||||
|
deleteLoading={deleteMutation.isPending}
|
||||||
|
onClose={() => setSelectedPersonId(null)}
|
||||||
|
renderHeader={(p) => (
|
||||||
|
<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(p.name)}`}
|
||||||
|
>
|
||||||
|
{getInitials(p.name)}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
getUpdatedAt={(p) => p.updated_at}
|
||||||
|
getValue={getPanelValue}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showForm && <PersonForm person={editingPerson} onClose={handleCloseForm} />}
|
{/* Mobile detail panel overlay */}
|
||||||
|
{panelOpen && selectedPerson && (
|
||||||
|
<div className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm">
|
||||||
|
<div className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg">
|
||||||
|
<EntityDetailPanel<Person>
|
||||||
|
item={selectedPerson}
|
||||||
|
fields={panelFields}
|
||||||
|
onEdit={() => {
|
||||||
|
setEditingPerson(selectedPerson);
|
||||||
|
setShowForm(true);
|
||||||
|
}}
|
||||||
|
onDelete={() => deleteMutation.mutate()}
|
||||||
|
deleteLoading={deleteMutation.isPending}
|
||||||
|
onClose={() => setSelectedPersonId(null)}
|
||||||
|
renderHeader={(p) => (
|
||||||
|
<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(p.name)}`}
|
||||||
|
>
|
||||||
|
{getInitials(p.name)}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
getUpdatedAt={(p) => p.updated_at}
|
||||||
|
getValue={getPanelValue}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<PersonForm
|
||||||
|
person={editingPerson}
|
||||||
|
categories={allCategories}
|
||||||
|
onClose={handleCloseForm}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
|
||||||
<div
|
|
||||||
className={`h-10 w-10 rounded-full flex items-center justify-center text-sm font-bold shrink-0 ${getAvatarColor(person.name)}`}
|
|
||||||
>
|
|
||||||
{getInitials(person.name)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h3 className="font-heading text-lg font-semibold leading-none tracking-tight truncate">
|
|
||||||
{person.name}
|
|
||||||
</h3>
|
|
||||||
{person.relationship && (
|
|
||||||
<Badge className={`mt-1.5 ${getRelationshipColor(person.relationship)}`}>
|
|
||||||
{person.relationship}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-1 shrink-0">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => onEdit(person)} className="h-7 w-7" aria-label="Edit person">
|
|
||||||
<Pencil className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
{confirmingDelete ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
aria-label="Confirm delete"
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={deleteMutation.isPending}
|
|
||||||
className="h-7 shrink-0 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
|
|
||||||
>
|
|
||||||
Sure?
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Delete person"
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={deleteMutation.isPending}
|
|
||||||
className="h-7 w-7 hover:bg-destructive/10 hover:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-1.5">
|
|
||||||
{person.email && (
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<Mail className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span className="truncate">{person.email}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{person.phone && (
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<Phone className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
{person.phone}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{person.address && (
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<MapPin className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span className="truncate">{person.address}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{person.birthday && (
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<Calendar className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
{format(parseISO(person.birthday), 'MMM d, yyyy')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{person.notes && (
|
|
||||||
<p className="text-xs text-muted-foreground pt-1 line-clamp-2">{person.notes}</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,6 +1,8 @@
|
|||||||
import { useState, FormEvent } from 'react';
|
import { useState, useMemo, FormEvent } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { Star, StarOff } from 'lucide-react';
|
||||||
|
import { parseISO, differenceInYears } from 'date-fns';
|
||||||
import api, { getErrorMessage } from '@/lib/api';
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
import type { Person } from '@/types';
|
import type { Person } from '@/types';
|
||||||
import {
|
import {
|
||||||
@ -13,37 +15,64 @@ import {
|
|||||||
} from '@/components/ui/sheet';
|
} from '@/components/ui/sheet';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Select } from '@/components/ui/select';
|
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Button } from '@/components/ui/button';
|
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 {
|
interface PersonFormProps {
|
||||||
person: Person | null;
|
person: Person | null;
|
||||||
|
categories: string[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PersonForm({ person, onClose }: PersonFormProps) {
|
export default function PersonForm({ person, categories, onClose }: PersonFormProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
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 || '',
|
email: person?.email || '',
|
||||||
phone: person?.phone || '',
|
phone: person?.phone || '',
|
||||||
|
mobile: person?.mobile || '',
|
||||||
address: person?.address || '',
|
address: person?.address || '',
|
||||||
birthday: person?.birthday || '',
|
birthday: person?.birthday
|
||||||
relationship: person?.relationship || '',
|
? 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 || '',
|
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 = <K extends keyof typeof formData>(key: K, value: (typeof formData)[K]) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async (data: typeof formData) => {
|
mutationFn: async (data: typeof formData) => {
|
||||||
if (person) {
|
if (person) {
|
||||||
const response = await api.put(`/people/${person.id}`, data);
|
const { data: res } = await api.put(`/people/${person.id}`, data);
|
||||||
return response.data;
|
return res;
|
||||||
} else {
|
|
||||||
const response = await api.post('/people', data);
|
|
||||||
return response.data;
|
|
||||||
}
|
}
|
||||||
|
const { data: res } = await api.post('/people', data);
|
||||||
|
return res;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['people'] });
|
queryClient.invalidateQueries({ queryKey: ['people'] });
|
||||||
@ -53,7 +82,9 @@ export default function PersonForm({ person, onClose }: PersonFormProps) {
|
|||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
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) {
|
|||||||
<SheetContent>
|
<SheetContent>
|
||||||
<SheetClose onClick={onClose} />
|
<SheetClose onClick={onClose} />
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>{person ? 'Edit Person' : 'New Person'}</SheetTitle>
|
<div className="flex items-center justify-between">
|
||||||
|
<SheetTitle>{person ? 'Edit Person' : 'New Person'}</SheetTitle>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={`h-8 w-8 ${formData.is_favourite ? 'text-yellow-400' : 'text-muted-foreground'}`}
|
||||||
|
onClick={() => set('is_favourite', !formData.is_favourite)}
|
||||||
|
aria-label={formData.is_favourite ? 'Remove from favourites' : 'Add to favourites'}
|
||||||
|
>
|
||||||
|
{formData.is_favourite ? (
|
||||||
|
<Star className="h-4 w-4 fill-yellow-400" />
|
||||||
|
) : (
|
||||||
|
<StarOff className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-y-auto">
|
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-y-auto">
|
||||||
<div className="px-6 py-5 space-y-4 flex-1">
|
<div className="px-6 py-5 space-y-4 flex-1">
|
||||||
<div className="space-y-2">
|
{/* Row 2: First + Last name */}
|
||||||
<Label htmlFor="name">Name</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="first_name">First Name</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="first_name"
|
||||||
type="email"
|
value={formData.first_name}
|
||||||
value={formData.email}
|
onChange={(e) => set('first_name', e.target.value)}
|
||||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="phone">Phone</Label>
|
<Label htmlFor="last_name">Last Name</Label>
|
||||||
<Input
|
<Input
|
||||||
id="phone"
|
id="last_name"
|
||||||
type="tel"
|
value={formData.last_name}
|
||||||
value={formData.phone}
|
onChange={(e) => set('last_name', e.target.value)}
|
||||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: Nickname */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="address">Address</Label>
|
<Label htmlFor="nickname">Nickname</Label>
|
||||||
<Input
|
<Input
|
||||||
id="address"
|
id="nickname"
|
||||||
value={formData.address}
|
value={formData.nickname}
|
||||||
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
onChange={(e) => set('nickname', e.target.value)}
|
||||||
|
placeholder="Optional display name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Row 4: Birthday + Age */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="birthday">Birthday</Label>
|
<Label htmlFor="birthday">Birthday</Label>
|
||||||
@ -119,34 +159,108 @@ export default function PersonForm({ person, onClose }: PersonFormProps) {
|
|||||||
id="birthday"
|
id="birthday"
|
||||||
type="date"
|
type="date"
|
||||||
value={formData.birthday}
|
value={formData.birthday}
|
||||||
onChange={(e) => setFormData({ ...formData, birthday: e.target.value })}
|
onChange={(e) => set('birthday', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="relationship">Relationship</Label>
|
<Label htmlFor="age">Age</Label>
|
||||||
<Select
|
<Input
|
||||||
id="relationship"
|
id="age"
|
||||||
value={formData.relationship}
|
value={age !== null ? String(age) : ''}
|
||||||
onChange={(e) => setFormData({ ...formData, relationship: e.target.value })}
|
disabled
|
||||||
>
|
placeholder="—"
|
||||||
<option value="">Select...</option>
|
aria-label="Calculated age"
|
||||||
{RELATIONSHIPS.map((r) => (
|
/>
|
||||||
<option key={r} value={r}>
|
|
||||||
{r}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Row 5: Category */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="category">Category</Label>
|
||||||
|
<CategoryAutocomplete
|
||||||
|
id="category"
|
||||||
|
value={formData.category}
|
||||||
|
onChange={(val) => set('category', val)}
|
||||||
|
categories={categories}
|
||||||
|
placeholder="e.g. Friend, Family, Colleague"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 6: Mobile + Email */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="mobile">Mobile</Label>
|
||||||
|
<Input
|
||||||
|
id="mobile"
|
||||||
|
type="tel"
|
||||||
|
value={formData.mobile}
|
||||||
|
onChange={(e) => set('mobile', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => set('email', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 7: Phone */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="phone">Phone</Label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
type="tel"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={(e) => set('phone', e.target.value)}
|
||||||
|
placeholder="Landline / work number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 8: Address */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="address">Address</Label>
|
||||||
|
<LocationPicker
|
||||||
|
id="address"
|
||||||
|
value={formData.address}
|
||||||
|
onChange={(val) => set('address', val)}
|
||||||
|
onSelect={(result) => set('address', result.address || result.name)}
|
||||||
|
placeholder="Search or enter address..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 9: Company + Job Title */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="company">Company</Label>
|
||||||
|
<Input
|
||||||
|
id="company"
|
||||||
|
value={formData.company}
|
||||||
|
onChange={(e) => set('company', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="job_title">Job Title</Label>
|
||||||
|
<Input
|
||||||
|
id="job_title"
|
||||||
|
value={formData.job_title}
|
||||||
|
onChange={(e) => set('job_title', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 10: Notes */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="notes">Notes</Label>
|
<Label htmlFor="notes">Notes</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="notes"
|
id="notes"
|
||||||
value={formData.notes}
|
value={formData.notes}
|
||||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
onChange={(e) => set('notes', e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
|
placeholder="Any additional context..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,16 +1,14 @@
|
|||||||
export const RELATIONSHIPS = ['Family', 'Friend', 'Colleague', 'Partner', 'Other'] as const;
|
// Legacy — kept for backward compatibility during transition
|
||||||
|
|
||||||
export const relationshipColors: Record<string, string> = {
|
|
||||||
Family: 'bg-rose-500/10 text-rose-400 border-rose-500/20',
|
|
||||||
Friend: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
|
||||||
Colleague: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
|
|
||||||
Partner: 'bg-pink-500/10 text-pink-400 border-pink-500/20',
|
|
||||||
Other: 'bg-gray-500/10 text-gray-400 border-gray-500/20',
|
|
||||||
};
|
|
||||||
|
|
||||||
const FALLBACK = 'bg-gray-500/10 text-gray-400 border-gray-500/20';
|
const FALLBACK = 'bg-gray-500/10 text-gray-400 border-gray-500/20';
|
||||||
|
|
||||||
export function getRelationshipColor(relationship: string | undefined): string {
|
export function getRelationshipColor(relationship: string | undefined): string {
|
||||||
if (!relationship) return FALLBACK;
|
if (!relationship) return FALLBACK;
|
||||||
return relationshipColors[relationship] ?? FALLBACK;
|
const colors: Record<string, string> = {
|
||||||
|
Family: 'bg-rose-500/10 text-rose-400 border-rose-500/20',
|
||||||
|
Friend: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
||||||
|
Colleague: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
|
||||||
|
Partner: 'bg-pink-500/10 text-pink-400 border-pink-500/20',
|
||||||
|
Other: FALLBACK,
|
||||||
|
};
|
||||||
|
return colors[relationship] ?? FALLBACK;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user