'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 { 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 { 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 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({ resolver: zodResolver(createInterestSchema), defaultValues: { clientId: '', yachtId: undefined, pipelineStage: 'open', 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); 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; 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: 'open', 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 }, }); } } else { await apiFetch('/api/v1/interests', { method: 'POST', body: enriched }); } }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['interests'] }); onOpenChange(false); }, }); 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); 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); setBerthOpen(false); }} > None {berthOptions.map((option) => ( { setValue('berthId', val); setBerthOpen(false); }} > {option.label} ))}
{selectedClientId && ( )}
setValue('yachtId', id ?? undefined)} ownerFilter={yachtOwnerFilter} disabled={!selectedClientId} placeholder={selectedClientId ? 'Select yacht...' : 'Select a client first'} />

Required before the interest can leave the "Open" 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 */}
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 && ( )}
); } // ── 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`; }