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 { useState } from 'react';
|
||||||
import { Pencil, RefreshCw } from 'lucide-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 { toast } from 'sonner';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
@@ -30,6 +30,7 @@ import { BerthForm } from './berth-form';
|
|||||||
import { mooringLetterDot } from './mooring-letter-tone';
|
import { mooringLetterDot } from './mooring-letter-tone';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { toastError } from '@/lib/api/toast-error';
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
|
import { useVocabulary } from '@/hooks/use-vocabulary';
|
||||||
import { updateBerthStatusSchema, type UpdateBerthStatusInput } from '@/lib/validators/berths';
|
import { updateBerthStatusSchema, type UpdateBerthStatusInput } from '@/lib/validators/berths';
|
||||||
import { BERTH_STATUSES } from '@/lib/constants';
|
import { BERTH_STATUSES } from '@/lib/constants';
|
||||||
|
|
||||||
@@ -87,6 +88,12 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
sold: 'Sold',
|
sold: 'Sold',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface InterestOption {
|
||||||
|
id: string;
|
||||||
|
clientName: string;
|
||||||
|
pipelineStage: string;
|
||||||
|
}
|
||||||
|
|
||||||
function StatusChangeDialog({
|
function StatusChangeDialog({
|
||||||
berthId,
|
berthId,
|
||||||
currentStatus,
|
currentStatus,
|
||||||
@@ -99,6 +106,7 @@ function StatusChangeDialog({
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const reasonChips = useVocabulary('berth_status_change_reasons');
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@@ -112,6 +120,22 @@ function StatusChangeDialog({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const status = watch('status');
|
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) {
|
async function onSubmit(data: UpdateBerthStatusInput) {
|
||||||
try {
|
try {
|
||||||
@@ -121,6 +145,7 @@ function StatusChangeDialog({
|
|||||||
});
|
});
|
||||||
queryClient.invalidateQueries({ queryKey: ['berths'] });
|
queryClient.invalidateQueries({ queryKey: ['berths'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['berth', berthId] });
|
queryClient.invalidateQueries({ queryKey: ['berth', berthId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
||||||
toast.success('Status updated');
|
toast.success('Status updated');
|
||||||
reset();
|
reset();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
@@ -140,7 +165,12 @@ function StatusChangeDialog({
|
|||||||
<Label>New Status</Label>
|
<Label>New Status</Label>
|
||||||
<Select
|
<Select
|
||||||
value={status}
|
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>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -156,8 +186,47 @@ function StatusChangeDialog({
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Reason *</Label>
|
<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} />
|
<Textarea {...register('reason')} placeholder="Reason for status change..." rows={3} />
|
||||||
</div>
|
</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>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
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!;
|
return updated!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,13 @@ export type UpdateBerthInput = z.infer<typeof updateBerthSchema>;
|
|||||||
export const updateBerthStatusSchema = z.object({
|
export const updateBerthStatusSchema = z.object({
|
||||||
status: z.enum(BERTH_STATUSES),
|
status: z.enum(BERTH_STATUSES),
|
||||||
reason: z.string().min(1, 'Reason is required'),
|
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>;
|
export type UpdateBerthStatusInput = z.infer<typeof updateBerthStatusSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user