Kyle Pope 1806e15487 Address all QA review warnings and suggestions for entity pages
Warnings fixed:
- 3.1: _compute_display_name stale-data bug on all-names-clear
- 3.3: Location getValue unsafe type cast replaced with typed helper
- 3.5: Explicit updated_at timestamp refresh in locations router
- 3.6: Drop deprecated relationship column (migration 021, model, schema, TS type)

Suggestions fixed:
- 4.1: CategoryAutocomplete keyboard navigation (ArrowUp/Down, Enter, Escape)
- 4.2: Mobile detail panel backdrop click-to-close on both pages
- 4.3: PersonCreate whitespace bypass in require_some_name validator
- 4.5/4.6: Extract SortIcon, DataRow, SectionHeader from EntityTable render body
- 4.8: PersonForm sends null instead of empty string for birthday
- 4.10: Remove unnecessary executeDelete wrapper in EntityDetailPanel

Also includes previously completed fixes from prior session:
- 2.1: Remove Z suffix from naive timestamp in formatUpdatedAt
- 3.2: Drag-then-click conflict prevention in SortableCategoryChip
- 3.4: localStorage JSON shape validation in useCategoryOrder
- 4.4: Category chip styling consistency (both pages use inline hsl styles)
- 4.9: restrictToHorizontalAxis modifier on CategoryFilterBar drag

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 01:04:20 +08:00

291 lines
9.8 KiB
TypeScript

import { useState, useMemo, FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Star, StarOff, X } from 'lucide-react';
import { parseISO, differenceInYears } from 'date-fns';
import api, { getErrorMessage } from '@/lib/api';
import type { Person } from '@/types';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
} from '@/components/ui/sheet';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import LocationPicker from '@/components/ui/location-picker';
import CategoryAutocomplete from '@/components/shared/CategoryAutocomplete';
import { splitName } from '@/components/shared/utils';
interface PersonFormProps {
person: Person | null;
categories: string[];
onClose: () => void;
}
export default function PersonForm({ person, categories, onClose }: PersonFormProps) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState({
first_name:
person?.first_name ||
(person?.name ? splitName(person.name).firstName : ''),
last_name:
person?.last_name ||
(person?.name ? splitName(person.name).lastName : ''),
nickname: person?.nickname || '',
email: person?.email || '',
phone: person?.phone || '',
mobile: person?.mobile || '',
address: person?.address || '',
birthday: person?.birthday
? person.birthday.slice(0, 10)
: '',
category: person?.category || '',
is_favourite: person?.is_favourite ?? false,
company: person?.company || '',
job_title: person?.job_title || '',
notes: person?.notes || '',
});
const age = useMemo(() => {
if (!formData.birthday) return null;
try {
return differenceInYears(new Date(), parseISO(formData.birthday));
} catch {
return null;
}
}, [formData.birthday]);
const set = <K extends keyof typeof formData>(key: K, value: (typeof formData)[K]) => {
setFormData((prev) => ({ ...prev, [key]: value }));
};
const mutation = useMutation({
mutationFn: async (data: typeof formData) => {
if (person) {
const { data: res } = await api.put(`/people/${person.id}`, data);
return res;
}
const { data: res } = await api.post('/people', data);
return res;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['people'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
toast.success(person ? 'Person updated' : 'Person created');
onClose();
},
onError: (error) => {
toast.error(
getErrorMessage(error, person ? 'Failed to update person' : 'Failed to create person')
);
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
mutation.mutate({ ...formData, birthday: formData.birthday || null } as typeof formData);
};
return (
<Sheet open={true} onOpenChange={onClose}>
<SheetContent>
<SheetHeader>
<div className="flex items-center justify-between">
<SheetTitle>{person ? 'Edit Person' : 'New Person'}</SheetTitle>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className={`h-7 w-7 ${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>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onClose}
aria-label="Close"
className="h-7 w-7 shrink-0"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</SheetHeader>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-y-auto">
<div className="px-6 py-5 space-y-4 flex-1">
{/* Row 2: First + Last name */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="first_name">First Name</Label>
<Input
id="first_name"
value={formData.first_name}
onChange={(e) => set('first_name', e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="last_name">Last Name</Label>
<Input
id="last_name"
value={formData.last_name}
onChange={(e) => set('last_name', e.target.value)}
/>
</div>
</div>
{/* Row 3: Nickname */}
<div className="space-y-2">
<Label htmlFor="nickname">Nickname</Label>
<Input
id="nickname"
value={formData.nickname}
onChange={(e) => set('nickname', e.target.value)}
placeholder="Optional display name"
/>
</div>
{/* Row 4: Birthday + Age */}
<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) => set('birthday', e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="age">Age</Label>
<Input
id="age"
value={age !== null ? String(age) : ''}
disabled
placeholder="—"
aria-label="Calculated age"
/>
</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">
<Label htmlFor="notes">Notes</Label>
<Textarea
id="notes"
value={formData.notes}
onChange={(e) => set('notes', e.target.value)}
rows={3}
placeholder="Any additional context..."
/>
</div>
</div>
<SheetFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : person ? 'Update' : 'Create'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}