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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user