'use client'; import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useRouter, useParams } from 'next/navigation'; import { Loader2 } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { ClientPicker } from '@/components/shared/client-picker'; import { YachtPicker } from '@/components/yachts/yacht-picker'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; import { PIPELINE_STAGES, STAGE_LABELS } from '@/components/clients/pipeline-constants'; interface CatchUpWizardProps { berthId: string | null; open: boolean; onOpenChange: (open: boolean) => void; } type ClientMode = 'existing' | 'new'; interface BerthSummary { id: string; mooringNumber: string; status: string; } const STATUS_TO_STAGES: Record = { under_offer: ['enquiry', 'qualified', 'nurturing', 'eoi', 'reservation'], sold: ['contract'], available: PIPELINE_STAGES, }; /** * #67 Phase 4: catch-up wizard for manually-statused berths. * * MVP scope (intentionally tight): * - Pick existing client OR quick-create with name + email/phone * - Optional yacht link * - Stage picker scoped to the current berth status (sold → contract+won, * under_offer → enquiry...reservation, available → any) * * Doc upload + payment recording (Phases 4.4 / 4.5 of the spec) are * out of scope for the initial cut - once the interest exists, the rep * has the standard interest detail page to upload contracts and record * payments. The wizard's job is to get them from "manual berth, no * interest" to "interest exists, override cleared" in one round-trip. */ export function CatchUpWizard({ berthId, open, onOpenChange }: CatchUpWizardProps) { const router = useRouter(); const params = useParams<{ portSlug: string }>(); const portSlug = params?.portSlug ?? ''; const queryClient = useQueryClient(); const [clientMode, setClientMode] = useState('existing'); const [clientId, setClientId] = useState(null); const [newClientName, setNewClientName] = useState(''); const [newClientEmail, setNewClientEmail] = useState(''); const [newClientPhone, setNewClientPhone] = useState(''); const [yachtId, setYachtId] = useState(null); // A9: stageOverride is the user's explicit choice. When null, the // effective stage derives from the loaded berth's status (under_offer // → eoi, sold → contract). Pre-fix this was a useState seeded to // 'enquiry' which never updated when the berth loaded. const [stageOverride, setStageOverride] = useState(null); // Fetch the berth so the wizard can scope the stage options to what // makes sense for the current manual status. Disabled until open so // closed-state hover/preview doesn't fire the request. const { data: berth } = useQuery<{ data: BerthSummary }>({ queryKey: ['berth', berthId, 'catch-up-summary'], queryFn: () => apiFetch(`/api/v1/berths/${berthId}`), enabled: open && !!berthId, }); const allowedStages = berth ? (STATUS_TO_STAGES[berth.data.status] ?? PIPELINE_STAGES) : []; // Default the stage picker to the "right" default for each status - // sold defaults to contract (and we auto-set outcome=won server-side), // under_offer defaults to eoi since that's the most common pre-deal // status that reps mark manually. const defaultStage = berth?.data.status === 'sold' ? 'contract' : 'eoi'; const pipelineStage = stageOverride ?? defaultStage; const submit = useMutation({ mutationFn: async () => { if (!berthId) throw new Error('berthId missing'); const body: Record = { pipelineStage }; if (clientMode === 'existing') { if (!clientId) throw new Error('Pick a client to continue'); body.clientId = clientId; } else { if (!newClientName.trim()) throw new Error('Enter the client name'); body.newClient = { fullName: newClientName.trim(), email: newClientEmail.trim() || undefined, phone: newClientPhone.trim() || undefined, }; } if (yachtId) body.yachtId = yachtId; if (pipelineStage === 'contract') body.outcome = 'won'; return apiFetch<{ data: { interestId: string; clientId: string } }>( `/api/v1/berths/${berthId}/reconcile`, { method: 'POST', body }, ); }, onSuccess: (res) => { toast.success('Berth reconciled - new interest created'); queryClient.invalidateQueries({ queryKey: ['berths'] }); queryClient.invalidateQueries({ queryKey: ['berths', 'reconcile-queue'] }); queryClient.invalidateQueries({ queryKey: ['interests'] }); onOpenChange(false); if (portSlug && res.data.interestId) { router.push(`/${portSlug}/interests/${res.data.interestId}` as never); } }, onError: (err) => toastError(err), }); function reset() { setClientMode('existing'); setClientId(null); setNewClientName(''); setNewClientEmail(''); setNewClientPhone(''); setYachtId(null); setStageOverride(null); } return ( { if (submit.isPending) return; if (!o) reset(); onOpenChange(o); }} > Catch up berth {berth?.data.mooringNumber ?? ''} Create the backing interest so this berth drops out of the reconciliation queue. You can attach documents and record payments from the new interest's detail page after submission.
setClientMode(v as ClientMode)} className="flex gap-4" >
{clientMode === 'existing' ? ( ) : (
setNewClientName(e.target.value)} placeholder="John Smith" />
setNewClientEmail(e.target.value)} placeholder="client@example.com" />
setNewClientPhone(e.target.value)} placeholder="+1 555 0100" />
)}
{pipelineStage === 'contract' ? (

Stage Contract auto-marks the interest Won since the berth is already flipped to Sold.

) : null}
); }