'use client'; import { useEffect, useRef } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Loader2, ChevronsUpDown, Check, Plus } from 'lucide-react'; import { toast } from 'sonner'; import { useParams, useRouter } from 'next/navigation'; import { useState } from '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 { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from '@/components/ui/command'; import { Checkbox } from '@/components/ui/checkbox'; import { Separator } from '@/components/ui/separator'; import { TagPicker } from '@/components/shared/tag-picker'; import { ReminderDaysInput } from '@/components/shared/reminder-days-input'; import { YachtForm } from '@/components/yachts/yacht-form'; import { YachtPicker } from '@/components/yachts/yacht-picker'; import { apiFetch } from '@/lib/api/client'; import { useEntityOptions } from '@/hooks/use-entity-options'; import { formatBerthRange } from '@/lib/templates/berth-range'; import type { z } from 'zod'; import { createInterestSchema, type CreateInterestInput } from '@/lib/validators/interests'; import { PIPELINE_STAGES, STAGE_LABELS, LEAD_CATEGORIES, SOURCES } from '@/lib/constants'; import { cn } from '@/lib/utils'; const CATEGORY_LABELS: Record = { general_interest: 'General interest', specific_qualified: 'Specific qualified', hot_lead: 'Hot lead', }; interface InterestFormProps { open: boolean; onOpenChange: (open: boolean) => void; /** * Pre-fill clientId when launching the form from a client detail page. * Ignored when `interest` is provided (edit mode). */ defaultClientId?: string; interest?: { id: string; clientId: string; clientName?: string | null; yachtId?: string | null; berthId?: string | null; berthMooringNumber?: string | null; pipelineStage: string; leadCategory?: string | null; source?: string | null; reminderEnabled?: boolean; reminderDays?: number | null; tags?: Array<{ id: string }>; desiredLengthFt?: string | number | null; desiredWidthFt?: string | number | null; desiredDraftFt?: string | number | null; }; } export function InterestForm({ open, onOpenChange, defaultClientId, interest }: InterestFormProps) { const queryClient = useQueryClient(); const router = useRouter(); const params = useParams<{ portSlug: string }>(); const portSlug = params?.portSlug ?? ''; const isEdit = !!interest; const [clientOpen, setClientOpen] = useState(false); const [berthOpen, setBerthOpen] = useState(false); const [desiredUnit, setDesiredUnit] = useState<'ft' | 'm'>('ft'); const { handleSubmit, watch, setValue, reset, formState: { errors, isSubmitting, isDirty }, } = useForm, unknown, CreateInterestInput>({ resolver: zodResolver(createInterestSchema), defaultValues: { clientId: '', yachtId: undefined, pipelineStage: 'enquiry', // Default a manually-created interest's source to 'manual' so the // rep doesn't have to remember to pick it (mirrors the same // default on client-form.tsx). Inquiry-inbox / website conversion // flows can override via prefill once that path lands here. source: 'manual', reminderEnabled: false, tagIds: [], }, }); const tagIds = watch('tagIds') ?? []; const reminderEnabled = watch('reminderEnabled'); const selectedClientId = watch('clientId'); const selectedBerthId = watch('berthId'); const selectedYachtId = watch('yachtId'); const [createYachtOpen, setCreateYachtOpen] = useState(false); const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false); // Auto-fill pipelineStage + leadCategory based on whether a berth was // picked. Once the rep manually edits either field we stop touching it, // so we don't fight the user. Edit mode skips the auto-fill entirely - // changing the berth on an in-flight interest shouldn't silently demote // it back to "enquiry". const userTouchedStage = useRef(false); const userTouchedCategory = useRef(false); useEffect(() => { if (isEdit) return; const hasBerth = !!selectedBerthId; if (!userTouchedStage.current) { setValue('pipelineStage', hasBerth ? 'qualified' : 'enquiry'); } if (!userTouchedCategory.current) { setValue('leadCategory', hasBerth ? 'specific_qualified' : 'general_interest'); } // setValue is stable from RHF; isEdit doesn't change after mount. // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedBerthId]); function requestClose() { if (isDirty && !isSubmitting && !mutation.isPending) { setDiscardConfirmOpen(true); return; } onOpenChange(false); } // Fetch the selected client's company memberships so the YachtPicker can // include yachts owned by companies the client belongs to (e.g. a // managing-director client whose yachts are titled to the company). const { data: clientDetail } = useQuery<{ data: { companies?: Array<{ company: { id: string } }> }; }>({ queryKey: ['client-detail-for-interest-form', selectedClientId], queryFn: () => apiFetch(`/api/v1/clients/${selectedClientId}`), enabled: !!selectedClientId, }); const memberCompanyIds: string[] = clientDetail?.data.companies?.map((m) => m.company.id) ?? []; const yachtOwnerFilter = selectedClientId ? [ { type: 'client' as const, id: selectedClientId }, ...memberCompanyIds.map((id) => ({ type: 'company' as const, id })), ] : undefined; // Probe whether the selected client (or their member companies) owns any // yachts. When zero, the form swaps the picker for an "Add yacht" CTA so // reps don't get stuck on an empty dropdown wondering what to do. We hit // the same autocomplete endpoint the picker uses but with an empty query // to get the full unfiltered list scoped to the owner filter. // Tags-availability probe - drives whether the whole Tags section // (label + picker) renders. The picker itself returns null when empty, // but the wrapping label/separator needed the same gate. const { data: tagsList } = useQuery<{ data: Array<{ id: string }> }>({ queryKey: ['tag-availability-for-interest-form'], queryFn: () => apiFetch('/api/v1/tags/options'), staleTime: 60_000, }); const tagsAvailable = (tagsList?.data?.length ?? 0) > 0; const { data: yachtCount } = useQuery<{ data: Array<{ id: string }> }>({ queryKey: [ 'yacht-count-for-interest-form', selectedClientId, memberCompanyIds.sort().join(','), ], queryFn: () => { const params = new URLSearchParams({ q: '' }); if (selectedClientId) params.set('ownerClientId', selectedClientId); if (memberCompanyIds.length > 0) { params.set('ownerCompanyIds', memberCompanyIds.join(',')); } return apiFetch(`/api/v1/yachts/autocomplete?${params.toString()}`); }, enabled: !!selectedClientId, }); const hasAnyYachts = (yachtCount?.data?.length ?? 0) > 0; const { options: clientOptions, isLoading: clientsLoading, setSearch: setClientSearch, } = useEntityOptions({ endpoint: '/api/v1/clients/options', labelKey: 'fullName', }); const { options: berthOptions, isLoading: berthsLoading, setSearch: setBerthSearch, } = useEntityOptions({ endpoint: '/api/v1/berths/options', labelKey: 'mooringNumber', }); useEffect(() => { if (interest && open) { reset({ clientId: interest.clientId, yachtId: interest.yachtId ?? undefined, berthId: interest.berthId ?? undefined, pipelineStage: interest.pipelineStage as (typeof PIPELINE_STAGES)[number], leadCategory: interest.leadCategory as (typeof LEAD_CATEGORIES)[number] | undefined, source: interest.source ?? undefined, reminderEnabled: interest.reminderEnabled ?? false, reminderDays: interest.reminderDays ?? undefined, tagIds: interest.tags?.map((t) => t.id) ?? [], desiredLengthFt: interest.desiredLengthFt === null || interest.desiredLengthFt === undefined ? undefined : String(interest.desiredLengthFt), desiredWidthFt: interest.desiredWidthFt === null || interest.desiredWidthFt === undefined ? undefined : String(interest.desiredWidthFt), desiredDraftFt: interest.desiredDraftFt === null || interest.desiredDraftFt === undefined ? undefined : String(interest.desiredDraftFt), }); } else if (!interest && open) { reset({ clientId: defaultClientId ?? '', yachtId: undefined, pipelineStage: 'enquiry', reminderEnabled: false, tagIds: [], }); } }, [interest, defaultClientId, open, reset]); const mutation = useMutation({ mutationFn: async (data: CreateInterestInput) => { // Enrich with the dual-store ft+m values + the entry-unit. The form // tracks the canonical ft via DimensionInput; we compute the matching // m value for the API and stamp the unit so a future edit can render // the rep's literal entry without conversion drift. const enriched: CreateInterestInput = { ...data, desiredLengthM: ftToMStr(data.desiredLengthFt), desiredWidthM: ftToMStr(data.desiredWidthFt), desiredDraftM: ftToMStr(data.desiredDraftFt), desiredLengthUnit: desiredUnit, desiredWidthUnit: desiredUnit, desiredDraftUnit: desiredUnit, }; if (isEdit) { const { tagIds: tIds, ...rest } = enriched; await apiFetch(`/api/v1/interests/${interest!.id}`, { method: 'PATCH', body: rest }); if (tIds) { await apiFetch(`/api/v1/interests/${interest!.id}/tags`, { method: 'PUT', body: { tagIds: tIds }, }); } return { id: interest!.id, created: false }; } const res = await apiFetch<{ data: { id: string } }>('/api/v1/interests', { method: 'POST', body: enriched, }); // Materialise any additional berths the rep picked in the multi- // select. The first (primary) berth is already linked via the create // payload's berthId; everything else gets a follow-up POST to the // junction endpoint. We fire them in parallel - failure on one is // surfaced as a toast but doesn't roll back the interest creation. if (additionalBerthIds.length > 0) { await Promise.allSettled( additionalBerthIds.map((berthId) => apiFetch(`/api/v1/interests/${res.data.id}/berths`, { method: 'POST', body: { berthId, isSpecificInterest: false }, }), ), ); } return { id: res.data.id, created: true }; }, onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['interests'] }); // M-U10: confirm the write landed. toast.success(result.created ? 'Interest created' : 'Interest updated'); onOpenChange(false); // F20: navigate to the new interest's detail page so the rep can // start the workflow immediately. Edits stay in place - no point // re-loading the same row's detail page they just came from. if (result.created && portSlug) { router.push(`/${portSlug}/interests/${result.id}` as never); } }, }); function ftToMStr(ft: string | number | undefined | null): string | undefined { if (ft === undefined || ft === null || ft === '') return undefined; const n = typeof ft === 'number' ? ft : Number(ft); if (!Number.isFinite(n) || n <= 0) return undefined; return String(Math.round(n * 0.3048 * 100) / 100); } const selectedClient = clientOptions.find((c) => c.value === selectedClientId); const selectedBerth = berthOptions.find((b) => b.value === selectedBerthId); // Additional berths (beyond the primary `berthId`) accumulated by the // multi-select. On create, after the interest row exists, each id here // gets a follow-up POST /interests/{id}/berths so they show up in the // linked-berths list with isPrimary=false. The primary berth (the form's // `berthId`) is materialised by the standard create path. Edit mode // doesn't surface this - managing extra berths post-create happens on // the interest detail page's linked-berths section. const [additionalBerthIds, setAdditionalBerthIds] = useState([]); return ( { if (next) { onOpenChange(true); return; } requestClose(); }} > {isEdit ? 'Edit Interest' : 'New Interest'}
mutation.mutate(data))} className="space-y-6 py-6"> {/* Client */}

Client & Berth

{/* shouldFilter={false}: server-side search via setClientSearch drives the result set. Without this, cmdk's default filter matches the user's typed text against CommandItem.value (the client UUID) and silently drops every result that doesn't contain the typed substring in its id. */} {clientsLoading ? 'Loading...' : 'No clients found.'} {clientOptions.map((option) => ( { setValue('clientId', val); setClientOpen(false); }} > {option.label} ))} {errors.clientId && (

{errors.clientId.message}

)}
{berthsLoading ? 'Loading...' : 'No berths found.'} { setValue('berthId', undefined); setAdditionalBerthIds([]); }} > None {berthOptions.map((option) => { const isPrimary = selectedBerthId === option.value; const isAdditional = additionalBerthIds.includes(option.value); const isSelected = isPrimary || isAdditional; return ( { // Multi-select toggle. First pick becomes // the primary berthId (the one the API uses // for templates / list views). Subsequent // picks go into additionalBerthIds and are // materialised via POST /berths after the // interest is created. if (isPrimary) { // Demote primary; promote first additional // (if any) to primary so the deal still // has one primary berth. const promote = additionalBerthIds[0]; setValue('berthId', promote ?? undefined); setAdditionalBerthIds(additionalBerthIds.slice(1)); } else if (isAdditional) { setAdditionalBerthIds( additionalBerthIds.filter((id) => id !== val), ); } else if (!selectedBerthId) { setValue('berthId', val); } else { setAdditionalBerthIds([...additionalBerthIds, val]); } }} > {option.label} {isPrimary && ( primary )} ); })}

Pick one or more berths. The first becomes the primary berth (used in templates and list views); the rest get linked as alternates and can be promoted later from the interest detail page.

{selectedClientId && hasAnyYachts && ( )}
{/* Hide the picker entirely when the selected client has no yachts on file (and isn't linked to a company with yachts). An empty dropdown is a dead-end UX - the only useful action in that state is "create a yacht for this client". */} {selectedClientId && !hasAnyYachts ? (

This client has no yachts on file yet.

) : ( setValue('yachtId', id ?? undefined)} ownerFilter={yachtOwnerFilter} disabled={!selectedClientId} placeholder={selectedClientId ? 'Select yacht...' : 'Select a client first'} /> )}

Required before the interest can leave the New Enquiry stage. {memberCompanyIds.length > 0 && ( <> {' '} Includes yachts from {memberCompanyIds.length}{' '} {memberCompanyIds.length === 1 ? 'member company' : 'member companies'}. )}

{/* Pipeline */}

Pipeline

{/* Desired berth dimensions (recommender inputs) */}

Berth size desired

Optional - the recommender treats blank fields as no constraint on that axis.

setValue('desiredLengthFt', v, { shouldDirty: true })} /> setValue('desiredWidthFt', v, { shouldDirty: true })} /> setValue('desiredDraftFt', v, { shouldDirty: true })} />
{/* Reminder */}

Reminder

setValue('reminderEnabled', !!v)} />
{reminderEnabled && (
setValue('reminderDays', v ?? undefined)} />
)}
{/* Tags - TagPicker itself returns null when the port has no tags configured AND the form has nothing selected. We hide the wrapping label + separator in that same case so an empty "Tags" header doesn't sit in the form. */} {(tagIds.length > 0 || tagsAvailable) && ( <>
setValue('tagIds', ids)} />
)} Discard unsaved changes? You've filled in some fields. Closing now will lose them. Keep editing { setDiscardConfirmOpen(false); onOpenChange(false); }} className="bg-destructive text-destructive-foreground hover:bg-destructive/90 focus:ring-destructive" > Discard
{createYachtOpen && selectedClientId && ( setValue('yachtId', y.id, { shouldDirty: true })} /> )}
); } // ── Helpers for the "Berth size desired" section ────────────────────────────── const FT_PER_M = 1 / 0.3048; function round2(n: number): number { return Math.round(n * 100) / 100; } function UnitToggle({ value, onChange }: { value: 'ft' | 'm'; onChange: (v: 'ft' | 'm') => void }) { return (
{(['ft', 'm'] as const).map((u) => ( ))}
); } interface DimensionInputProps { htmlId: string; label: string; placeholder?: string; unit: 'ft' | 'm'; ftValue: string | number | undefined; onChangeFt: (next: string | undefined) => void; } /** * Single dimension input bound to a form value stored in feet. Renders the * value in the rep's chosen display unit and converts back on edit. The form * state stays canonical ft so the recommender (which queries `b.length_ft` * etc.) sees the same number regardless of which unit the rep typed in. * * Local `display` state preserves mid-typing strings like "18." that would * otherwise be lost to round-tripping through Number(). */ function DimensionInput({ htmlId, label, placeholder, unit, ftValue, onChangeFt, }: DimensionInputProps) { const focusedRef = useRef(false); const [display, setDisplay] = useState(() => computeDisplay(ftValue, unit)); // Re-sync from the canonical ft value when it changes externally (form // reset, unit toggle). Skip while focused so we don't fight keystrokes. useEffect(() => { if (focusedRef.current) return; setDisplay(computeDisplay(ftValue, unit)); }, [ftValue, unit]); const altValue = computeAltDisplay(ftValue, unit); return (
{ focusedRef.current = true; }} onBlur={() => { focusedRef.current = false; // Canonicalize the display from the ft source-of-truth on blur so // any mid-typed garbage clears. setDisplay(computeDisplay(ftValue, unit)); }} onChange={(e) => { const raw = e.target.value; setDisplay(raw); if (raw === '') { onChangeFt(undefined); return; } const n = parseFloat(raw); if (!Number.isFinite(n) || n <= 0) { onChangeFt(undefined); return; } const ft = unit === 'ft' ? n : n * FT_PER_M; onChangeFt(String(round2(ft))); }} /> {altValue ? (

≈ {altValue}

) : null}
); } function computeDisplay(ftValue: string | number | undefined, unit: 'ft' | 'm'): string { if (ftValue === undefined || ftValue === null || ftValue === '') return ''; const ft = typeof ftValue === 'number' ? ftValue : Number(ftValue); if (!Number.isFinite(ft)) return ''; const v = unit === 'ft' ? ft : ft * 0.3048; return String(round2(v)); } function computeAltDisplay(ftValue: string | number | undefined, unit: 'ft' | 'm'): string | null { if (ftValue === undefined || ftValue === null || ftValue === '') return null; const ft = typeof ftValue === 'number' ? ftValue : Number(ftValue); if (!Number.isFinite(ft) || ft <= 0) return null; return unit === 'ft' ? `${round2(ft * 0.3048)} m` : `${round2(ft)} ft`; }