Files
pn-new-crm/src/components/clients/client-form.tsx

438 lines
16 KiB
TypeScript
Raw Normal View History

'use client';
import { useEffect } from 'react';
import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
} from '@/components/ui/sheet';
import { Checkbox } from '@/components/ui/checkbox';
import { Separator } from '@/components/ui/separator';
import { TagPicker } from '@/components/shared/tag-picker';
import { apiFetch } from '@/lib/api/client';
import { createClientSchema, type CreateClientInput } from '@/lib/validators/clients';
interface ClientFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** If provided, form is in edit mode */
client?: {
id: string;
fullName: string;
companyName?: string | null;
nationality?: string | null;
isProxy?: boolean;
proxyType?: string | null;
actualOwnerName?: string | null;
yachtName?: string | null;
berthSizeDesired?: string | null;
preferredContactMethod?: string | null;
preferredLanguage?: string | null;
timezone?: string | null;
source?: string | null;
sourceDetails?: string | null;
contacts?: Array<{
channel: string;
value: string;
label?: string | null;
isPrimary?: boolean;
}>;
tags?: Array<{ id: string }>;
};
}
export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
const queryClient = useQueryClient();
const isEdit = !!client;
const {
register,
handleSubmit,
control,
watch,
setValue,
reset,
formState: { errors, isSubmitting },
} = useForm<CreateClientInput>({
resolver: zodResolver(createClientSchema),
defaultValues: {
fullName: '',
contacts: [{ channel: 'email', value: '', isPrimary: true }],
isProxy: false,
tagIds: [],
},
});
const { fields, append, remove } = useFieldArray({ control, name: 'contacts' });
const isProxy = watch('isProxy');
const tagIds = watch('tagIds') ?? [];
// Populate form when editing
useEffect(() => {
if (client && open) {
reset({
fullName: client.fullName,
companyName: client.companyName ?? undefined,
nationality: client.nationality ?? undefined,
isProxy: client.isProxy ?? false,
proxyType: client.proxyType ?? undefined,
actualOwnerName: client.actualOwnerName ?? undefined,
yachtName: client.yachtName ?? undefined,
berthSizeDesired: client.berthSizeDesired ?? undefined,
preferredContactMethod: (client.preferredContactMethod as CreateClientInput['preferredContactMethod']) ?? undefined,
preferredLanguage: client.preferredLanguage ?? undefined,
timezone: client.timezone ?? undefined,
source: (client.source as CreateClientInput['source']) ?? undefined,
sourceDetails: client.sourceDetails ?? undefined,
contacts:
client.contacts && client.contacts.length > 0
? client.contacts.map((c) => ({
channel: c.channel as 'email' | 'phone' | 'whatsapp' | 'other',
value: c.value,
label: c.label ?? undefined,
isPrimary: c.isPrimary ?? false,
}))
: [{ channel: 'email', value: '', isPrimary: true }],
tagIds: client.tags?.map((t) => t.id) ?? [],
});
} else if (!client && open) {
reset({
fullName: '',
contacts: [{ channel: 'email', value: '', isPrimary: true }],
isProxy: false,
tagIds: [],
});
}
}, [client, open, reset]);
const mutation = useMutation({
mutationFn: async (data: CreateClientInput) => {
if (isEdit) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { contacts, tagIds: tIds, ...rest } = data;
await apiFetch(`/api/v1/clients/${client!.id}`, { method: 'PATCH', body: rest });
if (tIds) {
await apiFetch(`/api/v1/clients/${client!.id}/tags`, {
method: 'PUT',
body: { tagIds: tIds },
});
}
} else {
await apiFetch('/api/v1/clients', { method: 'POST', body: data });
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['clients'] });
onOpenChange(false);
},
});
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto">
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit Client' : 'New Client'}</SheetTitle>
</SheetHeader>
<form
onSubmit={handleSubmit((data) => mutation.mutate(data))}
className="space-y-6 py-6"
>
{/* Basic Info */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Basic Information
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2 space-y-1">
<Label>Full Name *</Label>
<Input {...register('fullName')} placeholder="John Smith" />
{errors.fullName && (
<p className="text-xs text-destructive">{errors.fullName.message}</p>
)}
</div>
<div className="space-y-1">
<Label>Company Name</Label>
<Input {...register('companyName')} placeholder="Acme Corp" />
</div>
<div className="space-y-1">
<Label>Nationality</Label>
<Input {...register('nationality')} placeholder="British" />
</div>
</div>
</div>
<Separator />
{/* Contacts */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Contacts
</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
append({ channel: 'email', value: '', isPrimary: false })
}
>
<Plus className="mr-1 h-3.5 w-3.5" />
Add Contact
</Button>
</div>
{errors.contacts?.root && (
<p className="text-xs text-destructive">{errors.contacts.root.message}</p>
)}
<div className="space-y-3">
{fields.map((field, index) => (
<div
key={field.id}
className="grid grid-cols-12 gap-2 items-end p-3 rounded-lg border bg-muted/30"
>
<div className="col-span-3 space-y-1">
<Label className="text-xs">Channel</Label>
<Select
value={watch(`contacts.${index}.channel`)}
onValueChange={(v) =>
setValue(`contacts.${index}.channel`, v as 'email' | 'phone' | 'whatsapp' | 'other')
}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="email">Email</SelectItem>
<SelectItem value="phone">Phone</SelectItem>
<SelectItem value="whatsapp">WhatsApp</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="col-span-5 space-y-1">
<Label className="text-xs">Value</Label>
<Input
{...register(`contacts.${index}.value`)}
className="h-8"
placeholder="email@example.com"
/>
</div>
<div className="col-span-2 space-y-1">
<Label className="text-xs">Label</Label>
<Input
{...register(`contacts.${index}.label`)}
className="h-8"
placeholder="work"
/>
</div>
<div className="col-span-1 flex items-center gap-1 pb-1">
<Checkbox
checked={watch(`contacts.${index}.isPrimary`)}
onCheckedChange={(v) =>
setValue(`contacts.${index}.isPrimary`, !!v)
}
/>
<Label className="text-xs">Primary</Label>
</div>
<div className="col-span-1 flex justify-end pb-1">
{fields.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => remove(index)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
))}
</div>
</div>
<Separator />
{/* Proxy */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Proxy Information
</h3>
<div className="flex items-center gap-2">
<Checkbox
id="isProxy"
checked={watch('isProxy')}
onCheckedChange={(v) => setValue('isProxy', !!v)}
/>
<Label htmlFor="isProxy">This is a proxy client</Label>
</div>
{isProxy && (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label>Proxy Type</Label>
<Select
value={watch('proxyType') ?? ''}
onValueChange={(v) => setValue('proxyType', v)}
>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="broker">Broker</SelectItem>
<SelectItem value="representative">Representative</SelectItem>
<SelectItem value="family_member">Family Member</SelectItem>
<SelectItem value="legal_counsel">Legal Counsel</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Actual Owner Name</Label>
<Input
{...register('actualOwnerName')}
placeholder="Actual owner"
/>
</div>
</div>
)}
</div>
<Separator />
{/* Yacht Details */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Yacht Details
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2 space-y-1">
<Label>Yacht Name</Label>
<Input {...register('yachtName')} placeholder="My Yacht" />
</div>
<div className="space-y-1">
<Label>Berth Size Desired</Label>
<Input {...register('berthSizeDesired')} placeholder="e.g. 30m" />
</div>
</div>
</div>
<Separator />
{/* Source & Preferences */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Source & Preferences
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label>Source</Label>
<Select
value={watch('source') ?? ''}
onValueChange={(v) => setValue('source', v as 'website' | 'manual' | 'referral' | 'broker')}
>
<SelectTrigger>
<SelectValue placeholder="Select source" />
</SelectTrigger>
<SelectContent>
<SelectItem value="website">Website</SelectItem>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="referral">Referral</SelectItem>
<SelectItem value="broker">Broker</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Preferred Contact Method</Label>
<Select
value={watch('preferredContactMethod') ?? ''}
onValueChange={(v) => setValue('preferredContactMethod', v as 'email' | 'phone' | 'whatsapp')}
>
<SelectTrigger>
<SelectValue placeholder="Select method" />
</SelectTrigger>
<SelectContent>
<SelectItem value="email">Email</SelectItem>
<SelectItem value="phone">Phone</SelectItem>
<SelectItem value="whatsapp">WhatsApp</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Preferred Language</Label>
<Input {...register('preferredLanguage')} placeholder="English" />
</div>
<div className="space-y-1">
<Label>Timezone</Label>
<Input {...register('timezone')} placeholder="UTC+0" />
</div>
<div className="col-span-2 space-y-1">
<Label>Source Details</Label>
<Input
{...register('sourceDetails')}
placeholder="Referred by John Doe"
/>
</div>
</div>
</div>
<Separator />
{/* Tags */}
<div className="space-y-2">
<Label>Tags</Label>
<TagPicker
selectedIds={tagIds}
onChange={(ids) => setValue('tagIds', ids)}
/>
</div>
<SheetFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
{(isSubmitting || mutation.isPending) && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{isEdit ? 'Save Changes' : 'Create Client'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}