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>
292 lines
9.9 KiB
TypeScript
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>
|
|
);
|
|
}
|