feat(uat-batch): Group B Interest detail polish (5 new ships + 2 verified)
B13–B19 from the 2026-05-21 plan. Five new ships; two items already in
place from earlier work but flagged for verification.
Shipped now:
B14 Interest Overview Email + Phone rows: new <ClientChannelEditor>
combobox. Primary value renders inline (free-text for email,
<InlinePhoneField> for phone with country picker). Chevron opens
a popover listing every contact in the channel — promote to
primary, delete non-primaries, or inline-add a new contact.
Backed by the existing /clients/[id]/contacts CRUD + promote-
to-primary endpoints. Wired into the Email + Phone rows on
interest-tabs.tsx Overview.
B15 Inline phone editor: the phone branch of <ClientChannelEditor>
uses <InlinePhoneField> (country code + national-format split).
interests.service.ts now returns `clientPrimaryPhoneCountry` so
the editor can preserve the ISO-3166-1 alpha-2 round-trip.
B16 Client Overview interest summary: PanelVariant of
<ClientPipelineSummary> renders a one-line "Wants L × W × D ·
Source" under each interest's header when constraints / source
are captured. Hidden when both are empty.
<ClientInterestRow> type extended with the new fields; the
/api/v1/interests query already returns them.
B17 Notes Latest-note teaser stage pill: stage-badge chip next to
the "5 minutes ago · Matt" line. Shows the deal's CURRENT
pipelineStage — a stage-at-note-time lookup would require a
per-render audit_logs read, over-engineered for a context hint.
B18 InterestBerthStatusBanner names + links the competing deal:
reuses /berths/[id]/active-interests endpoint shipped in 292a8b5;
one query per conflicting berth via useQueries. Picks the
isPrimary competing interest (falls back to first non-self
row); renders an inline <Link> to the competing detail page.
Already shipped (verified pre-shipped):
B13 Inbox Reminders embedded filter row — `embedded` prop already
wired in reminder-list.tsx.
B19 Qualification auto-confirm intent at stage ≥ EOI — already
handled by computeAutoSatisfied's `stageIdx > qualifiedIdx`
gate (covers eoi / reservation / deposit_paid / contract).
Verified: tsc clean, vitest 1454/1454.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
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';
|
||||
|
||||
@@ -16,6 +18,13 @@ 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
|
||||
@@ -38,34 +47,77 @@ export function InterestBerthStatusBanner({
|
||||
interestOutcome?: string | null;
|
||||
archivedAt?: string | null;
|
||||
}) {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const { data } = useQuery<BerthsResponse>({
|
||||
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;
|
||||
|
||||
const berths = data?.data ?? [];
|
||||
const conflicts = berths.filter((b) => b.status === 'sold' || b.status === 'under_offer');
|
||||
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 (
|
||||
<div
|
||||
role="status"
|
||||
className="flex items-start gap-2 rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-900"
|
||||
>
|
||||
<AlertTriangle className="size-3.5 mt-0.5 shrink-0" aria-hidden />
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">
|
||||
{conflicts.length === 1
|
||||
? `Berth ${conflicts[0]!.mooringNumber} is ${
|
||||
conflicts[0]!.status === 'sold' ? 'Sold' : 'Under Offer'
|
||||
{lines.length === 1
|
||||
? `Berth ${lines[0]!.berth.mooringNumber} is ${
|
||||
lines[0]!.berth.status === 'sold' ? 'Sold' : 'Under Offer'
|
||||
} to another deal.`
|
||||
: `${conflicts.length} linked berths are no longer freely available.`}
|
||||
: `${lines.length} linked berths are no longer freely available.`}
|
||||
</p>
|
||||
{lines.some((l) => l.competing) ? (
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{lines.map(({ berth, competing }) =>
|
||||
competing ? (
|
||||
<li key={berth.id} className="text-rose-900">
|
||||
<span className="font-medium">{berth.mooringNumber}:</span>{' '}
|
||||
<Link
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
href={`/${portSlug}/interests/${competing.interestId}` as any}
|
||||
className="underline-offset-2 hover:underline"
|
||||
>
|
||||
{competing.clientName}
|
||||
</Link>
|
||||
</li>
|
||||
) : null,
|
||||
)}
|
||||
</ul>
|
||||
) : null}
|
||||
<p className="mt-0.5 text-rose-800">
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user