'use client'; import { useQueries, useQuery } from '@tanstack/react-query'; import { AlertTriangle } from 'lucide-react'; import Link from 'next/link'; import { useParams } from 'next/navigation'; import { apiFetch } from '@/lib/api/client'; interface BerthRow { id: string; mooringNumber: string; status: string; isPrimary: boolean; } interface BerthsResponse { data: BerthRow[]; } interface CompetingInterest { interestId: string; clientName: string; pipelineStage: string; isPrimary: boolean; } /** * Surfaces when one of the interest's linked berths is sold or under offer * to a different deal. We don't block the rep from proceeding (the user * explicitly wanted v1 to still let the deal advance - the assumption is * that the rep is aware and treating the current deal as a fallback if * the other one falls through), but the banner makes the conflict visible * so they aren't surprised when the rules engine flags it. * * Fires only for active (non-archived, non-closed) interests - banners on * lost deals are noise. */ export function InterestBerthStatusBanner({ interestId, interestPipelineStage, interestOutcome, archivedAt, }: { interestId: string; interestPipelineStage: string; interestOutcome?: string | null; archivedAt?: string | null; }) { const params = useParams<{ portSlug: string }>(); const portSlug = params?.portSlug ?? ''; const { data } = useQuery({ queryKey: ['interest-berths', interestId], queryFn: () => apiFetch(`/api/v1/interests/${interestId}/berths`), }); const berths = data?.data ?? []; const conflicts = berths.filter((b) => b.status === 'sold' || b.status === 'under_offer'); // Resolve the competing deal per conflicting berth via the // `/active-interests` endpoint shipped in 292a8b5. Filtered client-side // to interests OTHER THAN this one so a deal looking at its own berth // doesn't see itself in the banner. const competingQueries = useQueries({ queries: conflicts.map((b) => ({ queryKey: ['berth-competing', b.id, interestId] as const, queryFn: () => apiFetch<{ data: CompetingInterest[] }>(`/api/v1/berths/${b.id}/active-interests`).then( (r) => r.data.filter((row) => row.interestId !== interestId), ), enabled: conflicts.length > 0, staleTime: 30_000, })), }); if (archivedAt || interestOutcome) return null; // The banner is most useful before the rep is committed to the deal - // once contract is in motion, the conflict is moot. if (interestPipelineStage === 'contract') return null; if (conflicts.length === 0) return null; const lines = conflicts.map((b, idx) => { const q = competingQueries[idx]; const competing = (q?.data ?? []).find((c) => c.isPrimary) ?? (q?.data ?? [])[0] ?? null; return { berth: b, competing }; }); return (

{lines.length === 1 ? `Berth ${lines[0]!.berth.mooringNumber} is ${ lines[0]!.berth.status === 'sold' ? 'Sold' : 'Under Offer' } to another deal.` : `${lines.length} linked berths are no longer freely available.`}

{lines.some((l) => l.competing) ? (
    {lines.map(({ berth, competing }) => competing ? (
  • {berth.mooringNumber}:{' '} {competing.clientName}
  • ) : null, )}
) : null}

You can still progress this interest as a backup, but the rep on the other deal owns the primary path. If their deal falls through, this one can step in.

); }