2026-05-14 23:55:22 +02:00
|
|
|
'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<string, readonly string[]> = {
|
|
|
|
|
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<ClientMode>('existing');
|
|
|
|
|
const [clientId, setClientId] = useState<string | null>(null);
|
|
|
|
|
const [newClientName, setNewClientName] = useState('');
|
|
|
|
|
const [newClientEmail, setNewClientEmail] = useState('');
|
|
|
|
|
const [newClientPhone, setNewClientPhone] = useState('');
|
|
|
|
|
const [yachtId, setYachtId] = useState<string | null>(null);
|
2026-05-15 01:12:20 +02:00
|
|
|
// 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<string | null>(null);
|
2026-05-14 23:55:22 +02:00
|
|
|
|
|
|
|
|
// 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';
|
2026-05-15 01:12:20 +02:00
|
|
|
const pipelineStage = stageOverride ?? defaultStage;
|
2026-05-14 23:55:22 +02:00
|
|
|
|
|
|
|
|
const submit = useMutation({
|
|
|
|
|
mutationFn: async () => {
|
|
|
|
|
if (!berthId) throw new Error('berthId missing');
|
|
|
|
|
const body: Record<string, unknown> = { 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);
|
2026-05-15 01:12:20 +02:00
|
|
|
setStageOverride(null);
|
2026-05-14 23:55:22 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog
|
|
|
|
|
open={open}
|
|
|
|
|
onOpenChange={(o) => {
|
|
|
|
|
if (submit.isPending) return;
|
|
|
|
|
if (!o) reset();
|
|
|
|
|
onOpenChange(o);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<DialogContent className="sm:max-w-lg">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>Catch up berth {berth?.data.mooringNumber ?? ''}</DialogTitle>
|
|
|
|
|
<DialogDescription>
|
|
|
|
|
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.
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label>Client</Label>
|
|
|
|
|
<RadioGroup
|
|
|
|
|
value={clientMode}
|
|
|
|
|
onValueChange={(v) => setClientMode(v as ClientMode)}
|
|
|
|
|
className="flex gap-4"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<RadioGroupItem id="cu-client-existing" value="existing" />
|
|
|
|
|
<Label htmlFor="cu-client-existing" className="text-sm font-normal">
|
|
|
|
|
Pick existing
|
|
|
|
|
</Label>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<RadioGroupItem id="cu-client-new" value="new" />
|
|
|
|
|
<Label htmlFor="cu-client-new" className="text-sm font-normal">
|
|
|
|
|
Quick-create
|
|
|
|
|
</Label>
|
|
|
|
|
</div>
|
|
|
|
|
</RadioGroup>
|
|
|
|
|
{clientMode === 'existing' ? (
|
|
|
|
|
<ClientPicker value={clientId} onChange={setClientId} />
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-2 rounded-md border bg-muted/30 p-3">
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<Label className="text-xs">Full name *</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={newClientName}
|
|
|
|
|
onChange={(e) => setNewClientName(e.target.value)}
|
|
|
|
|
placeholder="John Smith"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<Label className="text-xs">Email</Label>
|
|
|
|
|
<Input
|
|
|
|
|
type="email"
|
|
|
|
|
value={newClientEmail}
|
|
|
|
|
onChange={(e) => setNewClientEmail(e.target.value)}
|
|
|
|
|
placeholder="client@example.com"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<Label className="text-xs">Phone</Label>
|
|
|
|
|
<Input
|
|
|
|
|
type="tel"
|
|
|
|
|
value={newClientPhone}
|
|
|
|
|
onChange={(e) => setNewClientPhone(e.target.value)}
|
|
|
|
|
placeholder="+1 555 0100"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<Label>Linked yacht (optional)</Label>
|
|
|
|
|
<YachtPicker
|
|
|
|
|
value={yachtId}
|
|
|
|
|
onChange={setYachtId}
|
|
|
|
|
ownerFilter={
|
|
|
|
|
clientId && clientMode === 'existing' ? { type: 'client', id: clientId } : undefined
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<Label>Pipeline stage</Label>
|
2026-05-15 01:12:20 +02:00
|
|
|
<Select value={pipelineStage} onValueChange={setStageOverride}>
|
2026-05-14 23:55:22 +02:00
|
|
|
<SelectTrigger>
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{allowedStages.map((s) => (
|
|
|
|
|
<SelectItem key={s} value={s}>
|
|
|
|
|
{STAGE_LABELS[s as keyof typeof STAGE_LABELS] ?? s}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
{pipelineStage === 'contract' ? (
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
Stage <strong>Contract</strong> auto-marks the interest <strong>Won</strong> since
|
|
|
|
|
the berth is already flipped to Sold.
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => onOpenChange(false)}
|
|
|
|
|
disabled={submit.isPending}
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
<Button type="button" onClick={() => submit.mutate()} disabled={submit.isPending}>
|
|
|
|
|
{submit.isPending && <Loader2 className="mr-1.5 size-3.5 animate-spin" aria-hidden />}
|
|
|
|
|
Create interest & clear manual flag
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|