feat(berths): link prospect on status change + reason chips from vocabulary

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 18:37:04 +02:00
parent da7ce16344
commit b93fdadb59
3 changed files with 86 additions and 2 deletions

View File

@@ -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<string, string> = {
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({
<Label>New Status</Label>
<Select
value={status}
onValueChange={(v) => setValue('status', v as (typeof BERTH_STATUSES)[number])}
onValueChange={(v) => {
setValue('status', v as (typeof BERTH_STATUSES)[number]);
// Clear the interest pick when moving back to available so
// a stale value doesn't sneak through on submit.
if (v === 'available') setValue('interestId', undefined);
}}
>
<SelectTrigger>
<SelectValue />
@@ -156,8 +186,47 @@ function StatusChangeDialog({
</div>
<div className="space-y-2">
<Label>Reason *</Label>
{reasonChips.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{reasonChips.map((chip) => (
<button
type="button"
key={chip}
onClick={() => setValue('reason', chip, { shouldDirty: true })}
className="rounded-full border border-muted-foreground/20 bg-muted px-2.5 py-0.5 text-xs hover:bg-accent"
>
{chip}
</button>
))}
</div>
)}
<Textarea {...register('reason')} placeholder="Reason for status change..." rows={3} />
</div>
{showInterestPicker && (
<div className="space-y-2">
<Label>Linked prospect (optional)</Label>
<Select
value={interestId ?? '__none__'}
onValueChange={(v) => setValue('interestId', v === '__none__' ? undefined : v)}
>
<SelectTrigger>
<SelectValue placeholder="Select an interest…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> No interest </SelectItem>
{interestOptions.map((opt) => (
<SelectItem key={opt.id} value={opt.id}>
{opt.clientName} · {opt.pipelineStage}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Picking an interest auto-creates a primary berth link if one doesn&apos;t already
exist, so the deal timeline + heat scorer attribute the change correctly.
</p>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel

View File

@@ -299,6 +299,14 @@ export async function updateBerthStatus(
}),
);
// Optional: link the chosen interest as the primary holder of this
// berth. Cross-port checks live inside the helper so a malicious
// interestId from another port can't slip past the status PATCH.
if (data.interestId) {
const { setPrimaryBerth } = await import('@/lib/services/interest-berths.service');
await setPrimaryBerth(data.interestId, id);
}
return updated!;
}

View File

@@ -69,6 +69,13 @@ export type UpdateBerthInput = z.infer<typeof updateBerthSchema>;
export const updateBerthStatusSchema = z.object({
status: z.enum(BERTH_STATUSES),
reason: z.string().min(1, 'Reason is required'),
/**
* Optional: when status moves to under_offer or sold, the rep can pin
* the interest that triggered the change. We auto-create a primary
* interest_berths link for the chosen interest so the timeline +
* heat scorer attribute the deal correctly.
*/
interestId: z.string().min(1).optional(),
});
export type UpdateBerthStatusInput = z.infer<typeof updateBerthStatusSchema>;