UI refresh Stage 5: redesign People & Locations pages

- Add badge color constants for relationships and location categories
- Compact h-16 headers with segmented filters replacing dropdowns
- Stat cards (Total, Upcoming Birthdays, With Contact Info / Categories)
- People search expanded to match name, email, and relationship
- Avatar initials with deterministic color hash on PersonCard
- Two-click delete via useConfirmAction on both entity cards
- Error toasts use getErrorMessage for meaningful messages
- Query invalidation includes dashboard and upcoming keys
- PersonForm migrated from Dialog to Sheet with Select dropdown
- LocationForm: import CATEGORIES from constants, invalidate dashboard/upcoming
- Normalize badge colors to text-*-400 pattern (was text-*-500)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-24 18:53:44 +08:00
parent 4d5667a78a
commit 765f692304
8 changed files with 506 additions and 202 deletions

View File

@ -1,24 +1,21 @@
import { useCallback } 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 { MapPin, Trash2, Edit } from 'lucide-react'; import { MapPin, Trash2, Pencil } from 'lucide-react';
import api from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import type { Location } from '@/types'; import type { Location } from '@/types';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useConfirmAction } from '@/hooks/useConfirmAction';
import { getCategoryColor } from './constants';
interface LocationCardProps { interface LocationCardProps {
location: Location; location: Location;
onEdit: (location: Location) => void; onEdit: (location: Location) => void;
} }
const categoryColors: Record<string, string> = { const QUERY_KEYS = [['locations'], ['dashboard'], ['upcoming']] as const;
home: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
work: 'bg-purple-500/10 text-purple-500 border-purple-500/20',
restaurant: 'bg-orange-500/10 text-orange-500 border-orange-500/20',
shop: 'bg-green-500/10 text-green-500 border-green-500/20',
other: 'bg-gray-500/10 text-gray-500 border-gray-500/20',
};
export default function LocationCard({ location, onEdit }: LocationCardProps) { export default function LocationCard({ location, onEdit }: LocationCardProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -28,46 +25,65 @@ export default function LocationCard({ location, onEdit }: LocationCardProps) {
await api.delete(`/locations/${location.id}`); await api.delete(`/locations/${location.id}`);
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['locations'] }); QUERY_KEYS.forEach((key) => queryClient.invalidateQueries({ queryKey: [...key] }));
toast.success('Location deleted'); toast.success('Location deleted');
}, },
onError: () => { onError: (error) => {
toast.error('Failed to delete location'); toast.error(getErrorMessage(error, 'Failed to delete location'));
}, },
}); });
const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]);
const { confirming: confirmingDelete, handleClick: handleDelete } = useConfirmAction(executeDelete);
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1"> <div className="flex-1 min-w-0">
<CardTitle className="text-xl flex items-center gap-2"> <h3 className="font-heading text-lg font-semibold leading-none tracking-tight flex items-center gap-2">
<MapPin className="h-5 w-5" /> <MapPin className="h-4 w-4 shrink-0" />
{location.name} <span className="truncate">{location.name}</span>
</CardTitle> </h3>
<Badge className={categoryColors[location.category]} style={{ marginTop: '0.5rem' }}> <Badge className={`mt-1.5 ${getCategoryColor(location.category)}`}>
{location.category} {location.category}
</Badge> </Badge>
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1 shrink-0">
<Button variant="ghost" size="icon" onClick={() => onEdit(location)}> <Button variant="ghost" size="icon" onClick={() => onEdit(location)} className="h-7 w-7" aria-label="Edit location">
<Edit className="h-4 w-4" /> <Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4" />
</Button> </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 location"
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>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-1.5">
<p className="text-sm text-muted-foreground mb-2">{location.address}</p> {location.address && (
<p className="text-xs text-muted-foreground truncate">{location.address}</p>
)}
{location.notes && ( {location.notes && (
<p className="text-sm text-muted-foreground line-clamp-2">{location.notes}</p> <p className="text-xs text-muted-foreground line-clamp-2">{location.notes}</p>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@ -17,6 +17,7 @@ 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 LocationPicker from '@/components/ui/location-picker'; import LocationPicker from '@/components/ui/location-picker';
import { CATEGORIES } from './constants';
interface LocationFormProps { interface LocationFormProps {
location: Location | null; location: Location | null;
@ -44,6 +45,8 @@ export default function LocationForm({ location, onClose }: LocationFormProps) {
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['locations'] }); queryClient.invalidateQueries({ queryKey: ['locations'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
toast.success(location ? 'Location updated' : 'Location created'); toast.success(location ? 'Location updated' : 'Location created');
onClose(); onClose();
}, },
@ -100,11 +103,11 @@ export default function LocationForm({ location, onClose }: LocationFormProps) {
value={formData.category} value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value as any })} onChange={(e) => setFormData({ ...formData, category: e.target.value as any })}
> >
<option value="home">Home</option> {CATEGORIES.map((c) => (
<option value="work">Work</option> <option key={c} value={c}>
<option value="restaurant">Restaurant</option> {c.charAt(0).toUpperCase() + c.slice(1)}
<option value="shop">Shop</option> </option>
<option value="other">Other</option> ))}
</Select> </Select>
</div> </div>

View File

@ -1,20 +1,27 @@
import { useState } from 'react'; import { useState, useMemo } from 'react';
import { Plus, MapPin } from 'lucide-react'; import { Plus, MapPin, Tag, Search } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api'; import api from '@/lib/api';
import type { Location } from '@/types'; import type { Location } from '@/types';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Select } from '@/components/ui/select'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Card, CardContent } from '@/components/ui/card';
import { GridSkeleton } from '@/components/ui/skeleton'; import { GridSkeleton } from '@/components/ui/skeleton';
import { EmptyState } from '@/components/ui/empty-state'; import { EmptyState } from '@/components/ui/empty-state';
import { CATEGORIES } from './constants';
import LocationCard from './LocationCard'; import LocationCard from './LocationCard';
import LocationForm from './LocationForm'; import LocationForm from './LocationForm';
const categoryFilters = [
{ value: '', label: 'All' },
...CATEGORIES.map((c) => ({ value: c, label: c.charAt(0).toUpperCase() + c.slice(1) })),
] as const;
export default function LocationsPage() { export default function LocationsPage() {
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingLocation, setEditingLocation] = useState<Location | null>(null); const [editingLocation, setEditingLocation] = useState<Location | null>(null);
const [categoryFilter, setCategoryFilter] = useState(''); const [filter, setFilter] = useState('');
const [search, setSearch] = useState('');
const { data: locations = [], isLoading } = useQuery({ const { data: locations = [], isLoading } = useQuery({
queryKey: ['locations'], queryKey: ['locations'],
@ -24,9 +31,26 @@ export default function LocationsPage() {
}, },
}); });
const filteredLocations = categoryFilter const filteredLocations = useMemo(
? locations.filter((loc) => loc.category === categoryFilter) () =>
: locations; locations.filter((loc) => {
if (filter && loc.category !== filter) return false;
if (search) {
const q = search.toLowerCase();
const matchName = loc.name.toLowerCase().includes(q);
const matchAddress = loc.address?.toLowerCase().includes(q);
if (!matchName && !matchAddress) return false;
}
return true;
}),
[locations, filter, search]
);
const totalCount = locations.length;
const categoryCount = useMemo(
() => new Set(locations.map((l) => l.category)).size,
[locations]
);
const handleEdit = (location: Location) => { const handleEdit = (location: Location) => {
setEditingLocation(location); setEditingLocation(location);
@ -40,33 +64,82 @@ export default function LocationsPage() {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="border-b bg-card px-6 py-4"> {/* Header */}
<div className="flex items-center justify-between mb-4"> <div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<h1 className="text-3xl font-bold">Locations</h1> <h1 className="font-heading text-2xl font-bold tracking-tight">Locations</h1>
<Button onClick={() => setShowForm(true)}>
<Plus className="mr-2 h-4 w-4" /> <div className="flex items-center rounded-md border border-border overflow-hidden ml-4">
Add Location {categoryFilters.map((cf) => (
</Button> <button
key={cf.value}
onClick={() => setFilter(cf.value)}
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
filter === cf.value
? 'bg-accent/15 text-accent'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
}`}
style={{
backgroundColor:
filter === cf.value ? 'hsl(var(--accent-color) / 0.15)' : undefined,
color: filter === cf.value ? 'hsl(var(--accent-color))' : undefined,
}}
>
{cf.label}
</button>
))}
</div> </div>
<div className="flex items-center gap-4"> <div className="relative ml-2">
<Label htmlFor="category-filter">Filter by category:</Label> <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Select <Input
id="category-filter" placeholder="Search..."
value={categoryFilter} value={search}
onChange={(e) => setCategoryFilter(e.target.value)} onChange={(e) => setSearch(e.target.value)}
> className="w-52 h-8 pl-8 text-sm"
<option value="">All</option> />
<option value="home">Home</option>
<option value="work">Work</option>
<option value="restaurant">Restaurant</option>
<option value="shop">Shop</option>
<option value="other">Other</option>
</Select>
</div> </div>
<div className="flex-1" />
<Button onClick={() => setShowForm(true)} size="sm">
<Plus className="mr-2 h-4 w-4" />
Add Location
</Button>
</div> </div>
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto px-6 py-5">
{/* Summary stats */}
{!isLoading && locations.length > 0 && (
<div className="grid gap-2.5 grid-cols-2 mb-5">
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
<CardContent className="p-4 flex items-center gap-3">
<div className="p-1.5 rounded-md bg-blue-500/10">
<MapPin className="h-4 w-4 text-blue-400" />
</div>
<div>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
Total
</p>
<p className="font-heading text-xl font-bold tabular-nums">{totalCount}</p>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
<CardContent className="p-4 flex items-center gap-3">
<div className="p-1.5 rounded-md bg-purple-500/10">
<Tag className="h-4 w-4 text-purple-400" />
</div>
<div>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
Categories
</p>
<p className="font-heading text-xl font-bold tabular-nums">{categoryCount}</p>
</div>
</CardContent>
</Card>
</div>
)}
{isLoading ? ( {isLoading ? (
<GridSkeleton cards={6} /> <GridSkeleton cards={6} />
) : filteredLocations.length === 0 ? ( ) : filteredLocations.length === 0 ? (

View File

@ -0,0 +1,16 @@
export const CATEGORIES = ['home', 'work', 'restaurant', 'shop', 'other'] as const;
export const categoryColors: Record<string, string> = {
home: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
work: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
restaurant: 'bg-orange-500/10 text-orange-400 border-orange-500/20',
shop: 'bg-green-500/10 text-green-400 border-green-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';
export function getCategoryColor(category: string | undefined): string {
if (!category) return FALLBACK;
return categoryColors[category] ?? FALLBACK;
}

View File

@ -1,18 +1,40 @@
import { useState } from 'react'; import { useState, useMemo } from 'react';
import { Plus, Users } from 'lucide-react'; import { Plus, Users, Cake, Mail, Search } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { parseISO, differenceInDays, addYears } from 'date-fns';
import api from '@/lib/api'; import api 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 { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { GridSkeleton } from '@/components/ui/skeleton'; 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 PersonCard from './PersonCard'; import PersonCard from './PersonCard';
import PersonForm from './PersonForm'; 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;
}
export default function PeoplePage() { export default function PeoplePage() {
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 [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const { data: people = [], isLoading } = useQuery({ const { data: people = [], isLoading } = useQuery({
@ -23,8 +45,27 @@ export default function PeoplePage() {
}, },
}); });
const filteredPeople = people.filter((person) => const filteredPeople = useMemo(
person.name.toLowerCase().includes(search.toLowerCase()) () =>
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 totalCount = people.length;
const upcomingBirthdays = useMemo(() => countUpcomingBirthdays(people), [people]);
const withContactInfo = useMemo(
() => people.filter((p) => p.email || p.phone).length,
[people]
); );
const handleEdit = (person: Person) => { const handleEdit = (person: Person) => {
@ -39,24 +80,95 @@ export default function PeoplePage() {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="border-b bg-card px-6 py-4"> {/* Header */}
<div className="flex items-center justify-between mb-4"> <div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<h1 className="text-3xl font-bold">People</h1> <h1 className="font-heading text-2xl font-bold tracking-tight">People</h1>
<Button onClick={() => setShowForm(true)}>
<Plus className="mr-2 h-4 w-4" /> <div className="flex items-center rounded-md border border-border overflow-hidden ml-4">
Add Person {relationshipFilters.map((rf) => (
</Button> <button
key={rf.value}
onClick={() => setFilter(rf.value)}
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
filter === rf.value
? 'bg-accent/15 text-accent'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
}`}
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>
<Input <div className="relative ml-2">
placeholder="Search people..." <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
value={search} <Input
onChange={(e) => setSearch(e.target.value)} placeholder="Search..."
className="max-w-md" value={search}
/> onChange={(e) => setSearch(e.target.value)}
className="w-52 h-8 pl-8 text-sm"
/>
</div>
<div className="flex-1" />
<Button onClick={() => setShowForm(true)} size="sm">
<Plus className="mr-2 h-4 w-4" />
Add Person
</Button>
</div> </div>
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto px-6 py-5">
{/* Summary stats */}
{!isLoading && people.length > 0 && (
<div className="grid gap-2.5 grid-cols-3 mb-5">
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
<CardContent className="p-4 flex items-center gap-3">
<div className="p-1.5 rounded-md bg-blue-500/10">
<Users className="h-4 w-4 text-blue-400" />
</div>
<div>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
Total
</p>
<p className="font-heading text-xl font-bold tabular-nums">{totalCount}</p>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
<CardContent className="p-4 flex items-center gap-3">
<div className="p-1.5 rounded-md bg-pink-500/10">
<Cake className="h-4 w-4 text-pink-400" />
</div>
<div>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
Upcoming Birthdays
</p>
<p className="font-heading text-xl font-bold tabular-nums">{upcomingBirthdays}</p>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
<CardContent className="p-4 flex items-center gap-3">
<div className="p-1.5 rounded-md bg-green-500/10">
<Mail className="h-4 w-4 text-green-400" />
</div>
<div>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">
With Contact Info
</p>
<p className="font-heading text-xl font-bold tabular-nums">{withContactInfo}</p>
</div>
</CardContent>
</Card>
</div>
)}
{isLoading ? ( {isLoading ? (
<GridSkeleton cards={6} /> <GridSkeleton cards={6} />
) : filteredPeople.length === 0 ? ( ) : filteredPeople.length === 0 ? (

View File

@ -1,18 +1,48 @@
import { useCallback } 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 { Mail, Phone, MapPin, Calendar, Trash2, Edit } from 'lucide-react'; import { Mail, Phone, MapPin, Calendar, Trash2, Pencil } from 'lucide-react';
import { format } from 'date-fns'; import { format, parseISO } from 'date-fns';
import api from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import type { Person } from '@/types'; import type { Person } from '@/types';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useConfirmAction } from '@/hooks/useConfirmAction';
import { getRelationshipColor } from './constants';
interface PersonCardProps { interface PersonCardProps {
person: Person; person: Person;
onEdit: (person: Person) => void; 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) { export default function PersonCard({ person, onEdit }: PersonCardProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -22,68 +52,94 @@ export default function PersonCard({ person, onEdit }: PersonCardProps) {
await api.delete(`/people/${person.id}`); await api.delete(`/people/${person.id}`);
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['people'] }); QUERY_KEYS.forEach((key) => queryClient.invalidateQueries({ queryKey: [...key] }));
toast.success('Person deleted'); toast.success('Person deleted');
}, },
onError: () => { onError: (error) => {
toast.error('Failed to delete person'); toast.error(getErrorMessage(error, 'Failed to delete person'));
}, },
}); });
const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]);
const { confirming: confirmingDelete, handleClick: handleDelete } = useConfirmAction(executeDelete);
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1"> <div className="flex items-center gap-3 flex-1 min-w-0">
<CardTitle className="text-xl">{person.name}</CardTitle> <div
{person.relationship && ( className={`h-10 w-10 rounded-full flex items-center justify-center text-sm font-bold shrink-0 ${getAvatarColor(person.name)}`}
<Badge variant="outline" style={{ marginTop: '0.5rem' }}>
{person.relationship}
</Badge>
)}
</div>
<div className="flex gap-1">
<Button variant="ghost" size="icon" onClick={() => onEdit(person)}>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
> >
<Trash2 className="h-4 w-4" /> {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> </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>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-1.5">
{person.email && ( {person.email && (
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<Mail className="h-4 w-4" /> <Mail className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{person.email}</span> <span className="truncate">{person.email}</span>
</div> </div>
)} )}
{person.phone && ( {person.phone && (
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<Phone className="h-4 w-4" /> <Phone className="h-3.5 w-3.5 shrink-0" />
{person.phone} {person.phone}
</div> </div>
)} )}
{person.address && ( {person.address && (
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<MapPin className="h-4 w-4" /> <MapPin className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{person.address}</span> <span className="truncate">{person.address}</span>
</div> </div>
)} )}
{person.birthday && ( {person.birthday && (
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<Calendar className="h-4 w-4" /> <Calendar className="h-3.5 w-3.5 shrink-0" />
Birthday: {format(new Date(person.birthday), 'MMM d, yyyy')} {format(parseISO(person.birthday), 'MMM d, yyyy')}
</div> </div>
)} )}
{person.notes && ( {person.notes && (
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">{person.notes}</p> <p className="text-xs text-muted-foreground pt-1 line-clamp-2">{person.notes}</p>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@ -4,17 +4,19 @@ import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import type { Person } from '@/types'; import type { Person } from '@/types';
import { import {
Dialog, Sheet,
DialogContent, SheetContent,
DialogHeader, SheetHeader,
DialogTitle, SheetTitle,
DialogFooter, SheetFooter,
DialogClose, SheetClose,
} from '@/components/ui/dialog'; } 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';
interface PersonFormProps { interface PersonFormProps {
person: Person | null; person: Person | null;
@ -45,6 +47,8 @@ export default function PersonForm({ person, onClose }: PersonFormProps) {
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['people'] }); queryClient.invalidateQueries({ queryKey: ['people'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
toast.success(person ? 'Person updated' : 'Person created'); toast.success(person ? 'Person updated' : 'Person created');
onClose(); onClose();
}, },
@ -59,96 +63,104 @@ export default function PersonForm({ person, onClose }: PersonFormProps) {
}; };
return ( return (
<Dialog open={true} onOpenChange={onClose}> <Sheet open={true} onOpenChange={onClose}>
<DialogContent> <SheetContent>
<DialogClose onClick={onClose} /> <SheetClose onClick={onClose} />
<DialogHeader> <SheetHeader>
<DialogTitle>{person ? 'Edit Person' : 'New Person'}</DialogTitle> <SheetTitle>{person ? 'Edit Person' : 'New Person'}</SheetTitle>
</DialogHeader> </SheetHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-y-auto">
<div className="space-y-2"> <div className="px-6 py-5 space-y-4 flex-1">
<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="space-y-2"> <div className="space-y-2">
<Label htmlFor="email">Email</Label> <Label htmlFor="name">Name</Label>
<Input <Input
id="email" id="name"
type="email" value={formData.name}
value={formData.email} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
onChange={(e) => setFormData({ ...formData, email: e.target.value })} required
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="phone">Phone</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
/>
</div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="phone">Phone</Label> <Label htmlFor="address">Address</Label>
<Input <Input
id="phone" id="address"
type="tel" value={formData.address}
value={formData.phone} onChange={(e) => setFormData({ ...formData, address: e.target.value })}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="birthday">Birthday</Label>
<Input
id="birthday"
type="date"
value={formData.birthday}
onChange={(e) => setFormData({ ...formData, birthday: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="relationship">Relationship</Label>
<Select
id="relationship"
value={formData.relationship}
onChange={(e) => setFormData({ ...formData, relationship: e.target.value })}
>
<option value="">Select...</option>
{RELATIONSHIPS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="notes">Notes</Label>
<Textarea
id="notes"
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
rows={3}
/> />
</div> </div>
</div> </div>
<div className="space-y-2"> <SheetFooter>
<Label htmlFor="address">Address</Label>
<Input
id="address"
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="birthday">Birthday</Label>
<Input
id="birthday"
type="date"
value={formData.birthday}
onChange={(e) => setFormData({ ...formData, birthday: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="relationship">Relationship</Label>
<Input
id="relationship"
value={formData.relationship}
onChange={(e) => setFormData({ ...formData, relationship: e.target.value })}
placeholder="e.g., Friend, Family, Colleague"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="notes">Notes</Label>
<Textarea
id="notes"
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
rows={3}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}> <Button type="button" variant="outline" onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={mutation.isPending}> <Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : person ? 'Update' : 'Create'} {mutation.isPending ? 'Saving...' : person ? 'Update' : 'Create'}
</Button> </Button>
</DialogFooter> </SheetFooter>
</form> </form>
</DialogContent> </SheetContent>
</Dialog> </Sheet>
); );
} }

View File

@ -0,0 +1,16 @@
export const RELATIONSHIPS = ['Family', 'Friend', 'Colleague', 'Partner', 'Other'] as const;
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';
export function getRelationshipColor(relationship: string | undefined): string {
if (!relationship) return FALLBACK;
return relationshipColors[relationship] ?? FALLBACK;
}