From b93fdadb59b242d34aa69b690e679806e53922ec Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 9 May 2026 18:37:04 +0200 Subject: [PATCH] feat(berths): link prospect on status change + reason chips from vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When status moves to under_offer or sold, the dialog now surfaces an interest selector below the reason textarea. Picking an interest passes interestId on the PATCH, which the service uses to call setPrimaryBerth — auto-creates a primary interest_berths row if not present, demoting any prior primary in the same transaction so the unique partial index never fires. Cross-port leakage is blocked inside the existing interest-berths helper. Reasons are now offered as quick-pick chips above the textarea, sourced from the new berth_status_change_reasons vocabulary (Wave 5). Clicking a chip fills the textarea so reps stay on the keyboard. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/berths/berth-detail-header.tsx | 73 ++++++++++++++++++- src/lib/services/berths.service.ts | 8 ++ src/lib/validators/berths.ts | 7 ++ 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/src/components/berths/berth-detail-header.tsx b/src/components/berths/berth-detail-header.tsx index 5c9ccdb7..a966ca61 100644 --- a/src/components/berths/berth-detail-header.tsx +++ b/src/components/berths/berth-detail-header.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { Pencil, RefreshCw } from 'lucide-react'; -import { useQueryClient } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -30,6 +30,7 @@ import { BerthForm } from './berth-form'; import { mooringLetterDot } from './mooring-letter-tone'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; +import { useVocabulary } from '@/hooks/use-vocabulary'; import { updateBerthStatusSchema, type UpdateBerthStatusInput } from '@/lib/validators/berths'; import { BERTH_STATUSES } from '@/lib/constants'; @@ -87,6 +88,12 @@ const STATUS_LABELS: Record = { sold: 'Sold', }; +interface InterestOption { + id: string; + clientName: string; + pipelineStage: string; +} + function StatusChangeDialog({ berthId, currentStatus, @@ -99,6 +106,7 @@ function StatusChangeDialog({ onOpenChange: (open: boolean) => void; }) { const queryClient = useQueryClient(); + const reasonChips = useVocabulary('berth_status_change_reasons'); const { register, handleSubmit, @@ -112,6 +120,22 @@ function StatusChangeDialog({ }); const status = watch('status'); + const interestId = watch('interestId'); + const showInterestPicker = status === 'under_offer' || status === 'sold'; + + // Active interests for this port — used to populate the prospect + // selector when status moves to under_offer / sold. Only fetched when + // the picker is actually visible to avoid an unnecessary round-trip + // for available-status changes. + const interestsQuery = useQuery<{ + data: Array<{ id: string; clientName: string; pipelineStage: string }>; + }>({ + queryKey: ['interests', 'status-link-picker'], + queryFn: () => apiFetch('/api/v1/interests?pageSize=200'), + enabled: open && showInterestPicker, + staleTime: 60_000, + }); + const interestOptions: InterestOption[] = interestsQuery.data?.data ?? []; async function onSubmit(data: UpdateBerthStatusInput) { try { @@ -121,6 +145,7 @@ function StatusChangeDialog({ }); queryClient.invalidateQueries({ queryKey: ['berths'] }); queryClient.invalidateQueries({ queryKey: ['berth', berthId] }); + queryClient.invalidateQueries({ queryKey: ['interests'] }); toast.success('Status updated'); reset(); onOpenChange(false); @@ -140,7 +165,12 @@ function StatusChangeDialog({