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:
@@ -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>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user