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