fix(ux): inline yacht-prereq picker + deprioritize country in client form

F23: when the rep tries to leave the Enquiry stage on an interest with no yacht linked, the stage popover now switches into an inline yacht-picker view (filtered to the client's own yachts when known). On submit it PATCHes interest.yachtId then chains the stage move, so the prereq fix and the advance happen in one flow instead of the rep bouncing to the validation error toast.
F24: Country moved out of the Basic Information section (next to Full Name *) into Source & Preferences alongside Timezone — country is timezone-hint material, not first-line identity data. Quick-path for a new client is now just name + contact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 23:46:36 +02:00
parent 84468386d9
commit d2804de0d1
4 changed files with 130 additions and 20 deletions

View File

@@ -251,23 +251,12 @@ export function ClientForm({
Basic Information
</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2 space-y-1">
<Label>Full Name *</Label>
<Input {...register('fullName')} placeholder="John Smith" />
{errors.fullName && (
<p className="text-xs text-destructive">{errors.fullName.message}</p>
)}
</div>
<div className="space-y-1">
<Label>Country</Label>
<CountryCombobox
value={watch('nationalityIso')}
onChange={(iso) => setValue('nationalityIso', iso ?? undefined)}
data-testid="client-nationality"
/>
</div>
<div className="space-y-1">
<Label>Full Name *</Label>
<Input {...register('fullName')} placeholder="John Smith" />
{errors.fullName && (
<p className="text-xs text-destructive">{errors.fullName.message}</p>
)}
</div>
</div>
@@ -450,6 +439,14 @@ export function ClientForm({
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Country</Label>
<CountryCombobox
value={watch('nationalityIso')}
onChange={(iso) => setValue('nationalityIso', iso ?? undefined)}
data-testid="client-nationality"
/>
</div>
<div className="space-y-1">
<Label>Timezone</Label>
<TimezoneCombobox

View File

@@ -8,6 +8,7 @@ import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Textarea } from '@/components/ui/textarea';
import { YachtPicker } from '@/components/yachts/yacht-picker';
import {
AlertDialog,
AlertDialogAction,
@@ -40,6 +41,14 @@ interface InlineStagePickerProps {
/** Stop the parent's click propagation when used inside a clickable card. */
stopPropagation?: boolean;
className?: string;
/** Current yacht linked to the interest (null if missing). When the rep
* tries to leave Enquiry without a yacht, the picker switches into an
* inline prereq view that lets them link a yacht and proceed in one
* flow instead of bouncing them out to the form. */
currentYachtId?: string | null;
/** Client owning the interest — scopes the inline yacht-picker so the
* rep only sees yachts that actually belong to this lead. */
clientId?: string;
}
/**
@@ -55,10 +64,19 @@ export function InlineStagePicker({
showChevron = true,
stopPropagation = false,
className,
currentYachtId,
clientId,
}: InlineStagePickerProps) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [pendingStage, setPendingStage] = useState<string | null>(null);
// F23: when the rep picks a non-Enquiry stage without a yacht linked,
// we drop into a yacht-picker view in the popover. yachtPrereqTarget
// holds the stage they were trying to reach; submitting the picker
// PATCHes the yachtId, then chains the original stage move.
const [yachtPrereqTarget, setYachtPrereqTarget] = useState<PipelineStage | null>(null);
const [yachtPrereqId, setYachtPrereqId] = useState<string | null>(null);
const [linkingYacht, setLinkingYacht] = useState(false);
// When a user picks a stage that isn't a legal next step (and has the
// override permission), the popover transitions into a confirm view
// that asks for a reason before committing. Reasons are not exposed
@@ -130,6 +148,15 @@ export function InlineStagePicker({
setOpen(false);
return;
}
// F23: leaving Enquiry without a yacht linked is the one hard prereq
// the service enforces. Instead of bouncing them to the validation
// error toast, drop into an inline yacht-picker so the rep can fix
// it in place. Same flow chains the stage move after the link.
if (stage === 'enquiry' && next !== 'enquiry' && !currentYachtId) {
setYachtPrereqTarget(next);
setYachtPrereqId(null);
return;
}
const isOverride = !canTransitionStage(stage, next);
if (isOverride && canOverride) {
// Switch into the confirm view rather than firing the mutation
@@ -143,6 +170,33 @@ export function InlineStagePicker({
mutation.mutate({ next, reason: null });
}
async function commitYachtPrereq() {
if (!yachtPrereqTarget || !yachtPrereqId) return;
setLinkingYacht(true);
try {
await apiFetch(`/api/v1/interests/${interestId}`, {
method: 'PATCH',
body: { yachtId: yachtPrereqId },
});
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
const target = yachtPrereqTarget;
setYachtPrereqTarget(null);
setYachtPrereqId(null);
setPendingStage(target);
mutation.mutate({ next: target, reason: null });
} catch (err) {
toastError(err);
} finally {
setLinkingYacht(false);
}
}
function cancelYachtPrereq() {
setYachtPrereqTarget(null);
setYachtPrereqId(null);
}
async function unlinkAllAndOpen(target: PipelineStage) {
setUnlinking(true);
try {
@@ -196,9 +250,12 @@ export function InlineStagePicker({
<Popover
open={open}
onOpenChange={(o) => {
if (mutation.isPending) return;
if (mutation.isPending || linkingYacht) return;
setOpen(o);
if (!o) cancelOverride();
if (!o) {
cancelOverride();
cancelYachtPrereq();
}
}}
>
<PopoverTrigger asChild>
@@ -228,7 +285,57 @@ export function InlineStagePicker({
className="w-72 p-0"
onClick={(e) => stopPropagation && e.stopPropagation()}
>
{overrideTarget ? (
{yachtPrereqTarget ? (
// F23: inline yacht-prereq view — only reached when the rep
// picked a non-Enquiry stage without a yacht linked. Surfaces
// a yacht-picker right inside the popover so they can fix
// the prereq and move the stage in one flow.
<div className="p-3 space-y-3">
<div className="flex items-start gap-2">
<AlertTriangle className="size-4 shrink-0 text-amber-600 mt-0.5" aria-hidden />
<div className="text-sm">
<p className="font-medium text-foreground">Yacht required</p>
<p className="text-xs text-muted-foreground">
A yacht must be linked before leaving Enquiry. Pick one below to move to{' '}
{STAGE_LABELS[yachtPrereqTarget]}.
</p>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Linked yacht</label>
<YachtPicker
value={yachtPrereqId}
onChange={(id) => setYachtPrereqId(id)}
ownerFilter={clientId ? { type: 'client', id: clientId } : undefined}
disabled={linkingYacht || mutation.isPending}
/>
</div>
<div className="flex items-center justify-between gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={cancelYachtPrereq}
disabled={linkingYacht || mutation.isPending}
className="gap-1"
>
<ChevronLeft className="size-3.5" aria-hidden />
Back
</Button>
<Button
type="button"
size="sm"
onClick={commitYachtPrereq}
disabled={!yachtPrereqId || linkingYacht || mutation.isPending}
>
{(linkingYacht || mutation.isPending) && (
<Loader2 className="size-3.5 animate-spin mr-1" aria-hidden />
)}
Link yacht & advance
</Button>
</div>
</div>
) : overrideTarget ? (
// Confirm-override view: only reached when the user picked a
// stage that isn't a legal next step. Reason is optional but
// strongly nudged for the audit log.

View File

@@ -80,6 +80,7 @@ interface InterestDetailHeaderProps {
activeReminderCount?: number;
berthId: string | null;
berthMooringNumber: string | null;
yachtId: string | null;
pipelineStage: string;
leadCategory: string | null;
source: string | null;
@@ -243,6 +244,8 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
<InlineStagePicker
interestId={interest.id}
currentStage={interest.pipelineStage}
currentYachtId={interest.yachtId}
clientId={interest.clientId}
/>
</PermissionGate>
)}

View File

@@ -43,6 +43,9 @@ interface InterestData {
} | null;
berthId: string | null;
berthMooringNumber: string | null;
/** Linked yacht — null until the rep ties one to the deal. Required to
* leave Enquiry; surfaced inline in the stage picker as a prereq. */
yachtId: string | null;
/** Yacht-fit dimensions (numeric strings from postgres). Drive the
* recommender panel guard ("Set desired dimensions to see recommendations"). */
desiredLengthFt: string | null;