Kyle Pope 4dc3c856b0 Add input variant to DatePicker for typeable date fields
DatePicker now supports variant="button" (default, registration DOB)
and variant="input" (typeable text input + calendar icon trigger).
Input variant lets users type dates manually while the calendar icon
opens the same popup picker. Smart blur management prevents onBlur
from firing when focus moves between input, icon, and popup.

9 non-registration usages updated to variant="input".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 02:43:45 +08:00

292 lines
9.9 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 { DatePicker } from '@/components/ui/date-picker';
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" required>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>
<DatePicker
variant="input"
id="birthday"
value={formData.birthday}
onChange={(v) => set('birthday', v)}
/>
</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>
);
}