'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 { CountryCombobox } from '@/components/shared/country-combobox'; import { TimezoneCombobox } from '@/components/shared/timezone-combobox'; import { PhoneInput } from '@/components/shared/phone-input'; import { DedupSuggestionPanel } from '@/components/clients/dedup-suggestion-panel'; import { apiFetch } from '@/lib/api/client'; import type { z } from 'zod'; import { createClientSchema, type CreateClientInput } from '@/lib/validators/clients'; import { SOURCES } from '@/lib/constants'; import type { CountryCode } from '@/lib/i18n/countries'; import { primaryTimezoneFor } from '@/lib/i18n/timezones'; interface ClientFormProps { open: boolean; onOpenChange: (open: boolean) => void; /** Optional callback fired when the dedup suggestion panel reports * the user picked an existing client. The form closes; parent is * responsible for navigating to the existing client's detail page * or opening the create-interest dialog pre-filled with that * clientId. Skipped in edit mode. */ onUseExistingClient?: (clientId: string) => void; /** Optional initial values for the create flow — used by the * inquiry-inbox "Convert to client" triage step (P-4.5) so the rep * doesn't retype values they just read in the inbox. The * `sourceInquiryId` is persisted to `clients.source_inquiry_id` on * save, preserving the inquiry → client lineage for reporting. */ prefill?: { fullName?: string; email?: string; phone?: string; source?: 'website' | 'manual' | 'referral' | 'broker' | 'other'; sourceInquiryId?: string; }; /** If provided, form is in edit mode */ client?: { id: string; fullName: string; nationalityIso?: string | null; preferredContactMethod?: string | null; preferredLanguage?: string | null; timezone?: string | null; source?: string | null; sourceDetails?: string | null; contacts?: Array<{ channel: string; value: string; valueE164?: string | null; valueCountry?: string | null; label?: string | null; isPrimary?: boolean; notes?: string | null; }>; tags?: Array<{ id: string }>; }; } export function ClientForm({ open, onOpenChange, client, onUseExistingClient, prefill, }: ClientFormProps) { const queryClient = useQueryClient(); const isEdit = !!client; const { register, handleSubmit, control, watch, setValue, reset, formState: { errors, isSubmitting }, } = useForm, unknown, CreateClientInput>({ resolver: zodResolver(createClientSchema), defaultValues: { fullName: '', contacts: [{ channel: 'email', value: '', isPrimary: true }], tagIds: [], }, }); const { fields, append, remove } = useFieldArray({ control, name: 'contacts' }); const tagIds = watch('tagIds') ?? []; // When the rep picks a country and no timezone is set yet, pre-fill the // timezone with the country's primary IANA zone. Skips when the user has // already chosen a zone explicitly so we never clobber a deliberate pick. const watchedNationality = watch('nationalityIso'); const watchedTimezone = watch('timezone'); useEffect(() => { if (!watchedNationality || watchedTimezone) return; const primary = primaryTimezoneFor(watchedNationality as CountryCode); if (primary) setValue('timezone', primary); }, [watchedNationality, watchedTimezone, setValue]); // Populate form when editing useEffect(() => { if (client && open) { reset({ fullName: client.fullName, nationalityIso: client.nationalityIso ?? 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, valueE164: c.valueE164 ?? undefined, valueCountry: c.valueCountry ?? undefined, label: c.label ?? undefined, isPrimary: c.isPrimary ?? false, notes: c.notes ?? undefined, })) : [{ channel: 'email', value: '', isPrimary: true }], tagIds: client.tags?.map((t) => t.id) ?? [], }); } else if (!client && open) { // P-4.5: when the inquiry-inbox triage flow opens the form via // `?create=1&prefill_*`, hydrate the initial values so the rep // doesn't retype data they just reviewed. `sourceInquiryId` // gets persisted on save (clients.source_inquiry_id column) so // the inquiry → client lineage survives for the conversion- // funnel chart. const contacts: CreateClientInput['contacts'] = []; if (prefill?.email) { contacts.push({ channel: 'email', value: prefill.email, isPrimary: true }); } if (prefill?.phone) { contacts.push({ channel: 'phone', value: prefill.phone, isPrimary: contacts.length === 0, }); } if (contacts.length === 0) { contacts.push({ channel: 'email', value: '', isPrimary: true }); } reset({ fullName: prefill?.fullName ?? '', contacts, source: prefill?.source, sourceInquiryId: prefill?.sourceInquiryId, tagIds: [], }); } }, [client, open, reset, prefill]); const mutation = useMutation({ mutationFn: async (data: CreateClientInput) => { // F19: drop contact rows whose value is empty/whitespace before // submitting. The form pre-adds an "empty primary" contact row // for convenience; reps who only want to record a name shouldn't // be forced to either fill it or delete it. const cleanedContacts = (data.contacts ?? []).filter( (c) => typeof c.value === 'string' && c.value.trim().length > 0, ); if (cleanedContacts.length === 0) { // The API still requires ≥1 contact. The form-level required // marker on the email input also fires HTML5 validation; this // is the fall-back if the rep wiped the value after focus. throw Object.assign(new Error('At least one contact is required.'), { status: 400 }); } // If none of the remaining contacts is flagged primary, promote // the first one — guards against a rep removing the originally- // primary row and leaving an orphan set. if (!cleanedContacts.some((c) => c.isPrimary)) { cleanedContacts[0]!.isPrimary = true; } const payload: CreateClientInput = { ...data, contacts: cleanedContacts }; if (isEdit) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { contacts, tagIds: tIds, ...rest } = payload; 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: payload }); } }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['clients'] }); onOpenChange(false); }, }); return ( {isEdit ? 'Edit Client' : 'New Client'}
mutation.mutate(data))} className="space-y-6 py-6"> {/* Dedup suggestion - only on the create path. Watches the live form values for email / phone / name and surfaces an existing client when one matches. The user can attach the new interest to that client instead of creating a duplicate. */} {!isEdit ? ( c?.channel === 'email')?.value ?? null} phone={ watch('contacts')?.find((c) => c?.channel === 'phone' || c?.channel === 'whatsapp') ?.valueE164 ?? null } name={watch('fullName') ?? null} onUseExisting={(match) => { onUseExistingClient?.(match.clientId); onOpenChange(false); }} /> ) : null} {/* Basic Info */}

Basic Information

{errors.fullName && (

{errors.fullName.message}

)}
setValue('nationalityIso', iso ?? undefined)} data-testid="client-nationality" />
{/* Contacts */}

Contacts

{errors.contacts?.root && (

{errors.contacts.root.message}

)}
{fields.map((field, index) => (
{(() => { const channel = watch(`contacts.${index}.channel`); if (channel === 'phone' || channel === 'whatsapp') { const e164 = watch(`contacts.${index}.valueE164`) ?? null; const country = (watch(`contacts.${index}.valueCountry`) as CountryCode | undefined) ?? undefined; return ( { setValue(`contacts.${index}.value`, v.e164 ?? ''); setValue(`contacts.${index}.valueE164`, v.e164 ?? undefined); setValue(`contacts.${index}.valueCountry`, v.country); }} data-testid={`contact-${index}-phone`} /> ); } return ( ); })()}
{/* Bottom strip: Primary toggle left, delete right. Sits on its own row on every breakpoint so neither control gets squashed by the field columns above. */}
{fields.length > 1 && ( )}
))}
{/* Source & Preferences */}

Source & Preferences

setValue('timezone', tz ?? undefined)} countryHint={(watch('nationalityIso') as CountryCode | undefined) ?? undefined} data-testid="client-timezone" />
{/* Tags */}
setValue('tagIds', ids)} />
); }