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:
2026-05-21 22:08:41 +02:00
parent 670ca16a05
commit 7ecf4ee813
5 changed files with 528 additions and 58 deletions

View File

@@ -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.

View File

@@ -7,14 +7,13 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Anchor, CheckCircle2, Circle, FileSignature, Send, Wallet } from 'lucide-react';
import { parsePhone } from '@/lib/i18n/phone';
import type { DetailTab } from '@/components/shared/detail-layout';
import { Button } from '@/components/ui/button';
import { DatePicker } from '@/components/ui/date-picker';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { NotesList } from '@/components/shared/notes-list';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { ClientChannelEditor } from '@/components/clients/client-channel-editor';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { RemindersInline } from '@/components/reminders/reminders-inline';
// Legacy `RecommendationList` removed 2026-05-15 — replaced by the same
@@ -43,7 +42,9 @@ import {
LEAD_CATEGORIES,
PIPELINE_STAGES,
SOURCES,
STAGE_BADGE,
canTransitionStage,
stageLabel,
type PipelineStage,
} from '@/lib/constants';
import { InterestEoiTab } from '@/components/interests/interest-eoi-tab';
@@ -133,6 +134,8 @@ interface InterestTabsOptions {
clientPrimaryEmailContactId?: string | null;
clientPrimaryPhone?: string | null;
clientPrimaryPhoneContactId?: string | null;
clientPrimaryPhoneE164?: string | null;
clientPrimaryPhoneCountry?: string | null;
dateFirstContact: string | null;
dateLastContact: string | null;
dateEoiSent: string | null;
@@ -605,8 +608,6 @@ function OverviewTab({
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
// QueryClient lifted to the top of the tab so the inline-edit email +
// phone handlers below can invalidate ['interest', id] on success.
const queryClient = useQueryClient();
// Lift the EOI generate dialog into the Overview so the milestone card
// can launch it inline — same dialog the dedicated EOI tab uses, so the
// editing/confirmation flow is identical regardless of entry point.
@@ -1109,48 +1110,31 @@ function OverviewTab({
<h3 className="text-sm font-medium mb-2">Contact</h3>
<dl>
<EditableRow label="Email">
{interest.clientPrimaryEmailContactId ? (
<InlineEditableField
variant="text"
value={interest.clientPrimaryEmail ?? ''}
onSave={async (next) => {
if (!interest.clientId || !interest.clientPrimaryEmailContactId) return;
await apiFetch(
`/api/v1/clients/${interest.clientId}/contacts/${interest.clientPrimaryEmailContactId}`,
{ method: 'PATCH', body: { value: next } },
);
await queryClient.invalidateQueries({
queryKey: ['interest', interest.id],
});
}}
{interest.clientId ? (
<ClientChannelEditor
clientId={interest.clientId}
channel="email"
primaryContactId={interest.clientPrimaryEmailContactId ?? null}
primaryValue={interest.clientPrimaryEmail ?? null}
invalidateKeys={[['interest', interest.id]]}
/>
) : (
<span className="text-muted-foreground"> - </span>
<span className="text-muted-foreground">-</span>
)}
</EditableRow>
<EditableRow label="Phone">
{interest.clientPrimaryPhoneContactId ? (
<InlineEditableField
variant="text"
value={
interest.clientPrimaryPhone
? (parsePhone(interest.clientPrimaryPhone).international ??
interest.clientPrimaryPhone)
: ''
}
onSave={async (next) => {
if (!interest.clientId || !interest.clientPrimaryPhoneContactId) return;
await apiFetch(
`/api/v1/clients/${interest.clientId}/contacts/${interest.clientPrimaryPhoneContactId}`,
{ method: 'PATCH', body: { value: next } },
);
await queryClient.invalidateQueries({
queryKey: ['interest', interest.id],
});
}}
{interest.clientId ? (
<ClientChannelEditor
clientId={interest.clientId}
channel="phone"
primaryContactId={interest.clientPrimaryPhoneContactId ?? null}
primaryValue={interest.clientPrimaryPhone ?? null}
primaryValueE164={interest.clientPrimaryPhoneE164 ?? null}
primaryValueCountry={interest.clientPrimaryPhoneCountry ?? null}
invalidateKeys={[['interest', interest.id]]}
/>
) : (
<span className="text-muted-foreground"> - </span>
<span className="text-muted-foreground">-</span>
)}
</EditableRow>
{interest.dateFirstContact || interest.dateLastContact ? (
@@ -1234,17 +1218,35 @@ function OverviewTab({
<p className="line-clamp-3 whitespace-pre-wrap text-foreground/90">
{interest.recentNote.content}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{formatDistanceToNowStrict(new Date(interest.recentNote.createdAt), {
addSuffix: true,
})}
{interest.recentNote.authorId
? ` · ${
interest.recentNote.authorId === 'system'
? 'system'
: (interest.recentNote.authorName ?? 'Unknown')
}`
: ''}
<p className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
{/* Stage pill = the deal's current stage. Source-of-truth
interpretation: the note is about the deal as it
stands today; reading it on Overview, "current stage"
answers the implicit "where in the deal is this?". A
historical "stage-at-note-time" lookup would need an
audit_logs read per teaser render — over-engineered for
a context hint. */}
<span
className={cn(
'inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[10px] font-medium',
STAGE_BADGE[interest.pipelineStage as PipelineStage] ??
'bg-muted text-muted-foreground',
)}
>
{stageLabel(interest.pipelineStage)}
</span>
<span>
{formatDistanceToNowStrict(new Date(interest.recentNote.createdAt), {
addSuffix: true,
})}
{interest.recentNote.authorId
? ` · ${
interest.recentNote.authorId === 'system'
? 'system'
: (interest.recentNote.authorName ?? 'Unknown')
}`
: ''}
</span>
</p>
</div>
) : (