From d2804de0d13e898989488eeae4276cd51e4649ae Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 14 May 2026 23:46:36 +0200 Subject: [PATCH] fix(ux): inline yacht-prereq picker + deprioritize country in client form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/components/clients/client-form.tsx | 31 +++-- .../interests/inline-stage-picker.tsx | 113 +++++++++++++++++- .../interests/interest-detail-header.tsx | 3 + src/components/interests/interest-detail.tsx | 3 + 4 files changed, 130 insertions(+), 20 deletions(-) diff --git a/src/components/clients/client-form.tsx b/src/components/clients/client-form.tsx index 8e85768d..0fed2fb6 100644 --- a/src/components/clients/client-form.tsx +++ b/src/components/clients/client-form.tsx @@ -251,23 +251,12 @@ export function ClientForm({ Basic Information -
-
- - - {errors.fullName && ( -

{errors.fullName.message}

- )} -
- -
- - setValue('nationalityIso', iso ?? undefined)} - data-testid="client-nationality" - /> -
+
+ + + {errors.fullName && ( +

{errors.fullName.message}

+ )}
@@ -450,6 +439,14 @@ export function ClientForm({ +
+ + setValue('nationalityIso', iso ?? undefined)} + data-testid="client-nationality" + /> +
(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(null); + const [yachtPrereqId, setYachtPrereqId] = useState(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({ { - if (mutation.isPending) return; + if (mutation.isPending || linkingYacht) return; setOpen(o); - if (!o) cancelOverride(); + if (!o) { + cancelOverride(); + cancelYachtPrereq(); + } }} > @@ -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. +
+
+ +
+

Yacht required

+

+ A yacht must be linked before leaving Enquiry. Pick one below to move to{' '} + {STAGE_LABELS[yachtPrereqTarget]}. +

+
+
+
+ + setYachtPrereqId(id)} + ownerFilter={clientId ? { type: 'client', id: clientId } : undefined} + disabled={linkingYacht || mutation.isPending} + /> +
+
+ + +
+
+ ) : 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. diff --git a/src/components/interests/interest-detail-header.tsx b/src/components/interests/interest-detail-header.tsx index 372dcc5c..f6c09725 100644 --- a/src/components/interests/interest-detail-header.tsx +++ b/src/components/interests/interest-detail-header.tsx @@ -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 )} diff --git a/src/components/interests/interest-detail.tsx b/src/components/interests/interest-detail.tsx index cc5ea135..a26da936 100644 --- a/src/components/interests/interest-detail.tsx +++ b/src/components/interests/interest-detail.tsx @@ -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;