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>
291 lines
9.8 KiB
TypeScript
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>
|
|
);
|
|
}
|