From 765f6923048a449a7e2b125aa4ba5da87fcaef25 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Tue, 24 Feb 2026 18:53:44 +0800 Subject: [PATCH] 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 --- .../src/components/locations/LocationCard.tsx | 82 +++++---- .../src/components/locations/LocationForm.tsx | 13 +- .../components/locations/LocationsPage.tsx | 133 +++++++++++--- .../src/components/locations/constants.ts | 16 ++ frontend/src/components/people/PeoplePage.tsx | 148 +++++++++++++-- frontend/src/components/people/PersonCard.tsx | 128 +++++++++---- frontend/src/components/people/PersonForm.tsx | 172 ++++++++++-------- frontend/src/components/people/constants.ts | 16 ++ 8 files changed, 506 insertions(+), 202 deletions(-) create mode 100644 frontend/src/components/locations/constants.ts create mode 100644 frontend/src/components/people/constants.ts diff --git a/frontend/src/components/locations/LocationCard.tsx b/frontend/src/components/locations/LocationCard.tsx index 0599843..551d82f 100644 --- a/frontend/src/components/locations/LocationCard.tsx +++ b/frontend/src/components/locations/LocationCard.tsx @@ -1,24 +1,21 @@ +import { useCallback } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; -import { MapPin, Trash2, Edit } from 'lucide-react'; -import api from '@/lib/api'; +import { MapPin, Trash2, Pencil } from 'lucide-react'; +import api, { getErrorMessage } from '@/lib/api'; 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 { Button } from '@/components/ui/button'; +import { useConfirmAction } from '@/hooks/useConfirmAction'; +import { getCategoryColor } from './constants'; interface LocationCardProps { location: Location; onEdit: (location: Location) => void; } -const categoryColors: Record = { - 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', -}; +const QUERY_KEYS = [['locations'], ['dashboard'], ['upcoming']] as const; export default function LocationCard({ location, onEdit }: LocationCardProps) { const queryClient = useQueryClient(); @@ -28,46 +25,65 @@ export default function LocationCard({ location, onEdit }: LocationCardProps) { await api.delete(`/locations/${location.id}`); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['locations'] }); + QUERY_KEYS.forEach((key) => queryClient.invalidateQueries({ queryKey: [...key] })); toast.success('Location deleted'); }, - onError: () => { - toast.error('Failed to delete location'); + onError: (error) => { + toast.error(getErrorMessage(error, 'Failed to delete location')); }, }); + const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]); + const { confirming: confirmingDelete, handleClick: handleDelete } = useConfirmAction(executeDelete); + return (
-
- - - {location.name} - - +
+

+ + {location.name} +

+ {location.category}
-
- - + {confirmingDelete ? ( + + ) : ( + + )}
- -

{location.address}

+ + {location.address && ( +

{location.address}

+ )} {location.notes && ( -

{location.notes}

+

{location.notes}

)}
diff --git a/frontend/src/components/locations/LocationForm.tsx b/frontend/src/components/locations/LocationForm.tsx index 3ee17d6..2d3bf6d 100644 --- a/frontend/src/components/locations/LocationForm.tsx +++ b/frontend/src/components/locations/LocationForm.tsx @@ -17,6 +17,7 @@ import { Select } from '@/components/ui/select'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; import LocationPicker from '@/components/ui/location-picker'; +import { CATEGORIES } from './constants'; interface LocationFormProps { location: Location | null; @@ -44,6 +45,8 @@ export default function LocationForm({ location, onClose }: LocationFormProps) { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['locations'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard'] }); + queryClient.invalidateQueries({ queryKey: ['upcoming'] }); toast.success(location ? 'Location updated' : 'Location created'); onClose(); }, @@ -100,11 +103,11 @@ export default function LocationForm({ location, onClose }: LocationFormProps) { value={formData.category} onChange={(e) => setFormData({ ...formData, category: e.target.value as any })} > - - - - - + {CATEGORIES.map((c) => ( + + ))}
diff --git a/frontend/src/components/locations/LocationsPage.tsx b/frontend/src/components/locations/LocationsPage.tsx index 6ceba20..59b4d81 100644 --- a/frontend/src/components/locations/LocationsPage.tsx +++ b/frontend/src/components/locations/LocationsPage.tsx @@ -1,20 +1,27 @@ -import { useState } from 'react'; -import { Plus, MapPin } from 'lucide-react'; +import { useState, useMemo } from 'react'; +import { Plus, MapPin, Tag, Search } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import api from '@/lib/api'; import type { Location } from '@/types'; import { Button } from '@/components/ui/button'; -import { Select } from '@/components/ui/select'; -import { Label } from '@/components/ui/label'; +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 { CATEGORIES } from './constants'; import LocationCard from './LocationCard'; 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() { const [showForm, setShowForm] = useState(false); const [editingLocation, setEditingLocation] = useState(null); - const [categoryFilter, setCategoryFilter] = useState(''); + const [filter, setFilter] = useState(''); + const [search, setSearch] = useState(''); const { data: locations = [], isLoading } = useQuery({ queryKey: ['locations'], @@ -24,9 +31,26 @@ export default function LocationsPage() { }, }); - const filteredLocations = categoryFilter - ? locations.filter((loc) => loc.category === categoryFilter) - : locations; + const filteredLocations = useMemo( + () => + 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) => { setEditingLocation(location); @@ -40,33 +64,82 @@ export default function LocationsPage() { return (
-
-
-

Locations

- + {/* Header */} +
+

Locations

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

+ Total +

+

{totalCount}

+
+
+
+ + +
+ +
+
+

+ Categories +

+

{categoryCount}

+
+
+
+
+ )} + {isLoading ? ( ) : filteredLocations.length === 0 ? ( diff --git a/frontend/src/components/locations/constants.ts b/frontend/src/components/locations/constants.ts new file mode 100644 index 0000000..4c25945 --- /dev/null +++ b/frontend/src/components/locations/constants.ts @@ -0,0 +1,16 @@ +export const CATEGORIES = ['home', 'work', 'restaurant', 'shop', 'other'] as const; + +export const categoryColors: Record = { + 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; +} diff --git a/frontend/src/components/people/PeoplePage.tsx b/frontend/src/components/people/PeoplePage.tsx index 57301e7..3a4557e 100644 --- a/frontend/src/components/people/PeoplePage.tsx +++ b/frontend/src/components/people/PeoplePage.tsx @@ -1,18 +1,40 @@ -import { useState } from 'react'; -import { Plus, Users } from 'lucide-react'; +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 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 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() { const [showForm, setShowForm] = useState(false); const [editingPerson, setEditingPerson] = useState(null); + const [filter, setFilter] = useState(''); const [search, setSearch] = useState(''); const { data: people = [], isLoading } = useQuery({ @@ -23,8 +45,27 @@ export default function PeoplePage() { }, }); - const filteredPeople = people.filter((person) => - person.name.toLowerCase().includes(search.toLowerCase()) + 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 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) => { @@ -39,24 +80,95 @@ export default function PeoplePage() { return (
-
-
-

People

- + {/* Header */} +
+

People

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

+ Total +

+

{totalCount}

+
+
+
+ + +
+ +
+
+

+ Upcoming Birthdays +

+

{upcomingBirthdays}

+
+
+
+ + +
+ +
+
+

+ With Contact Info +

+

{withContactInfo}

+
+
+
+
+ )} + {isLoading ? ( ) : filteredPeople.length === 0 ? ( diff --git a/frontend/src/components/people/PersonCard.tsx b/frontend/src/components/people/PersonCard.tsx index 016a264..2d0c06b 100644 --- a/frontend/src/components/people/PersonCard.tsx +++ b/frontend/src/components/people/PersonCard.tsx @@ -1,18 +1,48 @@ +import { useCallback } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; -import { Mail, Phone, MapPin, Calendar, Trash2, Edit } from 'lucide-react'; -import { format } from 'date-fns'; -import api from '@/lib/api'; +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, CardTitle } from '@/components/ui/card'; +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(); @@ -22,68 +52,94 @@ export default function PersonCard({ person, onEdit }: PersonCardProps) { await api.delete(`/people/${person.id}`); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['people'] }); + QUERY_KEYS.forEach((key) => queryClient.invalidateQueries({ queryKey: [...key] })); toast.success('Person deleted'); }, - onError: () => { - toast.error('Failed to delete person'); + onError: (error) => { + toast.error(getErrorMessage(error, 'Failed to delete person')); }, }); + const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]); + const { confirming: confirmingDelete, handleClick: handleDelete } = useConfirmAction(executeDelete); + return (
-
- {person.name} - {person.relationship && ( - - {person.relationship} - - )} -
-
- - + {confirmingDelete ? ( + + ) : ( + + )}
- + {person.email && ( -
- +
+ {person.email}
)} {person.phone && ( -
- +
+ {person.phone}
)} {person.address && ( -
- +
+ {person.address}
)} {person.birthday && ( -
- - Birthday: {format(new Date(person.birthday), 'MMM d, yyyy')} +
+ + {format(parseISO(person.birthday), 'MMM d, yyyy')}
)} {person.notes && ( -

{person.notes}

+

{person.notes}

)} diff --git a/frontend/src/components/people/PersonForm.tsx b/frontend/src/components/people/PersonForm.tsx index 2a003d2..6851f01 100644 --- a/frontend/src/components/people/PersonForm.tsx +++ b/frontend/src/components/people/PersonForm.tsx @@ -4,17 +4,19 @@ import { toast } from 'sonner'; import api, { getErrorMessage } from '@/lib/api'; import type { Person } from '@/types'; import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, - DialogClose, -} from '@/components/ui/dialog'; + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetFooter, + SheetClose, +} 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'; interface PersonFormProps { person: Person | null; @@ -45,6 +47,8 @@ export default function PersonForm({ person, onClose }: PersonFormProps) { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['people'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard'] }); + queryClient.invalidateQueries({ queryKey: ['upcoming'] }); toast.success(person ? 'Person updated' : 'Person created'); onClose(); }, @@ -59,96 +63,104 @@ export default function PersonForm({ person, onClose }: PersonFormProps) { }; return ( - - - - - {person ? 'Edit Person' : 'New Person'} - -
-
- - setFormData({ ...formData, name: e.target.value })} - required - /> -
- -
+ + + + + {person ? 'Edit Person' : 'New Person'} + + +
- + setFormData({ ...formData, email: e.target.value })} + id="name" + value={formData.name} + onChange={(e) => setFormData({ ...formData, name: e.target.value })} + required />
+
+
+ + setFormData({ ...formData, email: e.target.value })} + /> +
+ +
+ + setFormData({ ...formData, phone: e.target.value })} + /> +
+
+
- + setFormData({ ...formData, phone: e.target.value })} + id="address" + value={formData.address} + onChange={(e) => setFormData({ ...formData, address: e.target.value })} + /> +
+ +
+
+ + setFormData({ ...formData, birthday: e.target.value })} + /> +
+ +
+ + +
+
+ +
+ +