Implements the full User Connections & Notification Centre feature: Phase 1 - Database: migrations 039-043 adding umbral_name to users, profile/social fields to settings, notifications table, connection request/user_connection tables, and linked_user_id to people. Phase 2 - Notifications: backend CRUD router + service + 90-day purge, frontend NotificationsPage with All/Unread filter, bell icon in sidebar with unread badge polling every 60s. Phase 3 - Settings: profile fields (phone, mobile, address, company, job_title), social card with accept_connections toggle and per-field sharing defaults, umbral name display with CopyableField. Phase 4 - Connections: timing-safe user search, send/accept/reject flow with atomic status updates, bidirectional UserConnection + Person records, in-app + ntfy notifications, per-receiver pending cap, nginx rate limiting. Phase 5 - People integration: batch-loaded shared profiles (N+1 prevention), Ghost icon for umbral contacts, Umbral filter pill, split Add Person button, shared field indicators (synced labels + Lock icons), disabled form inputs for synced fields on umbral contacts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
350 lines
13 KiB
TypeScript
350 lines
13 KiB
TypeScript
import { useState, useMemo, FormEvent } from 'react';
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import { Star, StarOff, X, Lock } 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();
|
|
|
|
// Helper to resolve a field value — prefer shared_fields for umbral contacts
|
|
const sf = person?.shared_fields;
|
|
const shared = (key: string, fallback: string) =>
|
|
sf && key in sf && sf[key] != null ? String(sf[key]) : fallback;
|
|
|
|
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: shared('email', person?.email || ''),
|
|
phone: shared('phone', person?.phone || ''),
|
|
mobile: shared('mobile', person?.mobile || ''),
|
|
address: shared('address', person?.address || ''),
|
|
birthday: shared('birthday', person?.birthday ? person.birthday.slice(0, 10) : ''),
|
|
category: person?.category || '',
|
|
is_favourite: person?.is_favourite ?? false,
|
|
company: shared('company', person?.company || ''),
|
|
job_title: shared('job_title', person?.job_title || ''),
|
|
notes: person?.notes || '',
|
|
});
|
|
|
|
// Check if a field is synced from an umbral connection (read-only)
|
|
const isShared = (fieldKey: string): boolean => {
|
|
if (!person?.is_umbral_contact || !person.shared_fields) return false;
|
|
return fieldKey in person.shared_fields;
|
|
};
|
|
|
|
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" className="flex items-center gap-1">
|
|
Birthday
|
|
{isShared('birthday') && <Lock className="h-3 w-3 text-violet-400" />}
|
|
</Label>
|
|
{isShared('birthday') ? (
|
|
<Input
|
|
id="birthday"
|
|
value={formData.birthday}
|
|
disabled
|
|
className="opacity-70 cursor-not-allowed"
|
|
/>
|
|
) : (
|
|
<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" className="flex items-center gap-1">
|
|
Mobile
|
|
{isShared('mobile') && <Lock className="h-3 w-3 text-violet-400" />}
|
|
</Label>
|
|
<Input
|
|
id="mobile"
|
|
type="tel"
|
|
value={formData.mobile}
|
|
onChange={(e) => set('mobile', e.target.value)}
|
|
disabled={isShared('mobile')}
|
|
className={isShared('mobile') ? 'opacity-70 cursor-not-allowed' : ''}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="email" className="flex items-center gap-1">
|
|
Email
|
|
{isShared('email') && <Lock className="h-3 w-3 text-violet-400" />}
|
|
</Label>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
value={formData.email}
|
|
onChange={(e) => set('email', e.target.value)}
|
|
disabled={isShared('email')}
|
|
className={isShared('email') ? 'opacity-70 cursor-not-allowed' : ''}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 7: Phone */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="phone" className="flex items-center gap-1">
|
|
Phone
|
|
{isShared('phone') && <Lock className="h-3 w-3 text-violet-400" />}
|
|
</Label>
|
|
<Input
|
|
id="phone"
|
|
type="tel"
|
|
value={formData.phone}
|
|
onChange={(e) => set('phone', e.target.value)}
|
|
placeholder="Landline / work number"
|
|
disabled={isShared('phone')}
|
|
className={isShared('phone') ? 'opacity-70 cursor-not-allowed' : ''}
|
|
/>
|
|
</div>
|
|
|
|
{/* Row 8: Address */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="address" className="flex items-center gap-1">
|
|
Address
|
|
{isShared('address') && <Lock className="h-3 w-3 text-violet-400" />}
|
|
</Label>
|
|
{isShared('address') ? (
|
|
<Input
|
|
id="address"
|
|
value={formData.address}
|
|
disabled
|
|
className="opacity-70 cursor-not-allowed"
|
|
/>
|
|
) : (
|
|
<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" className="flex items-center gap-1">
|
|
Company
|
|
{isShared('company') && <Lock className="h-3 w-3 text-violet-400" />}
|
|
</Label>
|
|
<Input
|
|
id="company"
|
|
value={formData.company}
|
|
onChange={(e) => set('company', e.target.value)}
|
|
disabled={isShared('company')}
|
|
className={isShared('company') ? 'opacity-70 cursor-not-allowed' : ''}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="job_title" className="flex items-center gap-1">
|
|
Job Title
|
|
{isShared('job_title') && <Lock className="h-3 w-3 text-violet-400" />}
|
|
</Label>
|
|
<Input
|
|
id="job_title"
|
|
value={formData.job_title}
|
|
onChange={(e) => set('job_title', e.target.value)}
|
|
disabled={isShared('job_title')}
|
|
className={isShared('job_title') ? 'opacity-70 cursor-not-allowed' : ''}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|