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

@@ -0,0 +1,390 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ChevronDown, Loader2, Plus, Star, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlinePhoneField } from '@/components/shared/inline-phone-field';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { cn } from '@/lib/utils';
import { parsePhone } from '@/lib/i18n/phone';
type Channel = 'email' | 'phone';
export interface ContactRow {
id: string;
channel: 'email' | 'phone' | 'whatsapp' | 'other';
value: string;
valueE164: string | null;
label: string | null;
isPrimary: boolean;
}
interface Props {
clientId: string;
/**
* Channel filter — picker shows only `email` (or `phone` + `whatsapp` for
* phone-style channels). Edits / promotions stay scoped to the chosen
* channel.
*/
channel: Channel;
/** Server-resolved primary contact for this channel (drives the inline
* value rendering when the picker isn't open). */
primaryContactId: string | null;
primaryValue: string | null;
/** Phone channel only — E.164 form + ISO-3166-1 alpha-2 country code so the
* inline phone editor can preserve the national-format roundtrip. */
primaryValueE164?: string | null;
primaryValueCountry?: string | null;
/** Query keys to invalidate after any mutation succeeds — the parent
* detail view is usually keyed on `['interest', interestId]` or
* `['clients', clientId]` so the picker can't hard-code which to bump. */
invalidateKeys?: ReadonlyArray<readonly unknown[]>;
}
/**
* Combobox-style editor for the Email + Phone rows on the Interest /
* Client Overview. Renders the current primary value with an inline
* edit-on-click; the chevron opens a popover listing every contact in
* the channel so the rep can:
* - promote a different contact to primary
* - edit any contact's value inline
* - add a new contact (defaults to non-primary; can be flagged primary
* at create time)
* - delete a non-primary contact
*
* Backed by:
* GET /api/v1/clients/[id]/contacts
* POST /api/v1/clients/[id]/contacts
* PATCH /api/v1/clients/[id]/contacts/[contactId]
* DELETE /api/v1/clients/[id]/contacts/[contactId]
* POST /api/v1/clients/[id]/contacts/[contactId]/promote-to-primary
*
* Phone channel shows `whatsapp` rows alongside `phone` so the picker
* works as the rep's "all the ways I can call this client" surface.
*/
export function ClientChannelEditor({
clientId,
channel,
primaryContactId,
primaryValue,
primaryValueE164,
primaryValueCountry,
invalidateKeys = [],
}: Props) {
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const acceptedChannels: ContactRow['channel'][] =
channel === 'email' ? ['email'] : ['phone', 'whatsapp'];
const { data, isLoading } = useQuery<{ data: ContactRow[] }>({
queryKey: ['client-contacts', clientId, channel],
queryFn: () => apiFetch<{ data: ContactRow[] }>(`/api/v1/clients/${clientId}/contacts`),
enabled: open,
staleTime: 30_000,
});
const contacts = (data?.data ?? []).filter((c) => acceptedChannels.includes(c.channel));
function invalidate() {
void qc.invalidateQueries({ queryKey: ['client-contacts', clientId, channel] });
for (const key of invalidateKeys) {
void qc.invalidateQueries({ queryKey: key as unknown[] });
}
}
const formatPhone = (v: string) => parsePhone(v).international ?? v;
const displayValue = (row: ContactRow) =>
row.channel === 'phone' || row.channel === 'whatsapp' ? formatPhone(row.value) : row.value;
return (
<div className="flex items-center gap-1">
{primaryContactId ? (
channel === 'phone' ? (
// Phone-specific editor: country picker + national-format input so
// the rep doesn't have to type the +44 / +1 prefix manually. The
// service auto-derives `valueE164` + `valueCountry` from the
// submitted shape, then the `value` (display) form is updated.
<InlinePhoneField
e164={primaryValueE164 ?? primaryValue ?? null}
country={primaryValueCountry ?? null}
onSave={async (next) => {
try {
await apiFetch(`/api/v1/clients/${clientId}/contacts/${primaryContactId}`, {
method: 'PATCH',
body: {
value: next.e164 ?? '',
valueE164: next.e164 ?? null,
valueCountry: next.country,
},
});
invalidate();
} catch (err) {
toastError(err);
}
}}
/>
) : (
<InlineEditableField
variant="text"
value={primaryValue ?? ''}
onSave={async (next) => {
try {
await apiFetch(`/api/v1/clients/${clientId}/contacts/${primaryContactId}`, {
method: 'PATCH',
body: { value: next },
});
invalidate();
} catch (err) {
toastError(err);
}
}}
/>
)
) : (
<span className="text-muted-foreground">-</span>
)}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
aria-label={`More ${channel} contacts`}
>
<ChevronDown className="h-3.5 w-3.5" aria-hidden />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-80 p-2">
<div className="mb-1 px-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{channel === 'email' ? 'Email contacts' : 'Phone & WhatsApp contacts'}
</div>
{isLoading ? (
<div className="flex items-center gap-2 px-2 py-3 text-sm text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
Loading
</div>
) : contacts.length === 0 ? (
<div className="px-2 py-3 text-sm text-muted-foreground">
No {channel} contacts yet.
</div>
) : (
<ul className="divide-y">
{contacts.map((c) => (
<ContactRowItem
key={c.id}
clientId={clientId}
row={c}
invalidate={invalidate}
displayValue={displayValue(c)}
/>
))}
</ul>
)}
<AddContactRow
clientId={clientId}
channel={channel}
existingCount={contacts.length}
invalidate={invalidate}
/>
</PopoverContent>
</Popover>
</div>
);
}
function ContactRowItem({
clientId,
row,
displayValue,
invalidate,
}: {
clientId: string;
row: ContactRow;
displayValue: string;
invalidate: () => void;
}) {
const promote = useMutation({
mutationFn: async () => {
await apiFetch(`/api/v1/clients/${clientId}/contacts/${row.id}/promote-to-primary`, {
method: 'POST',
});
},
onSuccess: () => {
toast.success('Primary updated');
invalidate();
},
onError: (err) => toastError(err),
});
const remove = useMutation({
mutationFn: async () => {
await apiFetch(`/api/v1/clients/${clientId}/contacts/${row.id}`, { method: 'DELETE' });
},
onSuccess: () => {
toast.success('Contact removed');
invalidate();
},
onError: (err) => toastError(err),
});
return (
<li className="px-1 py-1.5">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5">
{row.isPrimary ? (
<Star
className="h-3 w-3 shrink-0 text-amber-500"
aria-label="Primary"
aria-hidden={false}
/>
) : (
<span className="h-3 w-3 shrink-0" aria-hidden />
)}
<span className="truncate text-sm">{displayValue}</span>
{row.label ? (
<span className="shrink-0 rounded-sm bg-muted px-1 text-[10px] text-muted-foreground">
{row.label}
</span>
) : null}
</div>
<div className="flex items-center gap-0.5">
{!row.isPrimary ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => promote.mutate()}
disabled={promote.isPending}
title="Set as primary"
className={cn('h-6 px-1.5 text-[11px]')}
>
Make primary
</Button>
) : null}
{!row.isPrimary ? (
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={() => remove.mutate()}
disabled={remove.isPending}
aria-label="Remove contact"
title="Remove this contact"
>
<Trash2 className="h-3 w-3" aria-hidden />
</Button>
) : null}
</div>
</div>
</li>
);
}
function AddContactRow({
clientId,
channel,
existingCount,
invalidate,
}: {
clientId: string;
channel: Channel;
existingCount: number;
invalidate: () => void;
}) {
const [adding, setAdding] = useState(false);
const [value, setValue] = useState('');
const [setPrimary, setSetPrimary] = useState(false);
const create = useMutation({
mutationFn: async () => {
const v = value.trim();
if (!v) throw new Error(`Enter a${channel === 'email' ? 'n email' : ' phone number'}.`);
await apiFetch(`/api/v1/clients/${clientId}/contacts`, {
method: 'POST',
body: { channel, value: v, isPrimary: setPrimary || existingCount === 0 },
});
},
onSuccess: () => {
toast.success(`${channel === 'email' ? 'Email' : 'Phone'} added`);
setValue('');
setSetPrimary(false);
setAdding(false);
invalidate();
},
onError: (err) => toastError(err),
});
if (!adding) {
return (
<Button
type="button"
variant="ghost"
size="sm"
className="mt-1 w-full justify-start gap-1.5 text-xs"
onClick={() => setAdding(true)}
>
<Plus className="h-3.5 w-3.5" aria-hidden />
Add {channel === 'email' ? 'an email' : 'a phone number'}
</Button>
);
}
return (
<div className="mt-1 space-y-2 rounded-md border bg-muted/30 p-2">
<div className="space-y-1">
<Label htmlFor={`new-${channel}`} className="text-[11px] uppercase tracking-wide">
New {channel}
</Label>
<Input
id={`new-${channel}`}
type={channel === 'email' ? 'email' : 'tel'}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={channel === 'email' ? 'name@example.com' : '+1 555 123 4567'}
className="h-8 text-sm"
/>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={setPrimary}
onChange={(e) => setSetPrimary(e.target.checked)}
/>
Set as primary
</label>
<div className="flex items-center justify-end gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setAdding(false);
setValue('');
setSetPrimary(false);
}}
disabled={create.isPending}
>
Cancel
</Button>
<Button
type="button"
size="sm"
onClick={() => create.mutate()}
disabled={create.isPending || !value.trim()}
>
{create.isPending ? <Loader2 className="mr-1 h-3 w-3 animate-spin" aria-hidden /> : null}
Add
</Button>
</div>
</div>
);
}

View File

@@ -28,6 +28,14 @@ export interface ClientInterestRow {
dateLastContact: string | null; dateLastContact: string | null;
berthMooringNumber?: string | null; berthMooringNumber?: string | null;
yachtName?: string | null; yachtName?: string | null;
/** Requirements surfaced on the Client Overview panel — "Wants L × W × D
* · Source" lets reps see what the deal is looking for without drilling
* into the Interest detail. Fields are nullable when the rep hasn't
* captured constraints yet. */
desiredLengthFt?: string | null;
desiredWidthFt?: string | null;
desiredDraftFt?: string | null;
source?: string | null;
} }
interface InterestsResponse { interface InterestsResponse {
@@ -314,6 +322,22 @@ function PanelVariant({ clientId, portSlug }: { clientId: string; portSlug: stri
{STAGE_LABELS[stage]} {STAGE_LABELS[stage]}
</span> </span>
</div> </div>
{/* Requirements one-liner: "Wants 50ft × 18ft × 8ft · Referral".
Hidden when the rep hasn't captured any constraints yet —
noise reduction over empty placeholders. */}
{(() => {
const dims = [i.desiredLengthFt, i.desiredWidthFt, i.desiredDraftFt]
.filter((v): v is string => Boolean(v))
.map((v) => `${Number(v).toFixed(0)}ft`);
const summary: string[] = [];
if (dims.length > 0) summary.push(`Wants ${dims.join(' × ')}`);
if (i.source) summary.push(i.source);
return summary.length > 0 ? (
<div className="mt-0.5 truncate text-[11px] text-muted-foreground">
{summary.join(' · ')}
</div>
) : null;
})()}
<div className="mt-1"> <div className="mt-1">
<StageStepper current={stage} size="xs" /> <StageStepper current={stage} size="xs" />
</div> </div>

View File

@@ -1,7 +1,9 @@
'use client'; 'use client';
import { useQuery } from '@tanstack/react-query'; import { useQueries, useQuery } from '@tanstack/react-query';
import { AlertTriangle } from 'lucide-react'; import { AlertTriangle } from 'lucide-react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
@@ -16,6 +18,13 @@ interface BerthsResponse {
data: BerthRow[]; 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 * 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 * to a different deal. We don't block the rep from proceeding (the user
@@ -38,34 +47,77 @@ export function InterestBerthStatusBanner({
interestOutcome?: string | null; interestOutcome?: string | null;
archivedAt?: string | null; archivedAt?: string | null;
}) { }) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { data } = useQuery<BerthsResponse>({ const { data } = useQuery<BerthsResponse>({
queryKey: ['interest-berths', interestId], queryKey: ['interest-berths', interestId],
queryFn: () => apiFetch(`/api/v1/interests/${interestId}/berths`), 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; if (archivedAt || interestOutcome) return null;
// The banner is most useful before the rep is committed to the deal — // The banner is most useful before the rep is committed to the deal —
// once contract is in motion, the conflict is moot. // once contract is in motion, the conflict is moot.
if (interestPipelineStage === 'contract') return null; 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; 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 ( return (
<div <div
role="status" 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" 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 /> <AlertTriangle className="size-3.5 mt-0.5 shrink-0" aria-hidden />
<div> <div className="min-w-0">
<p className="font-medium"> <p className="font-medium">
{conflicts.length === 1 {lines.length === 1
? `Berth ${conflicts[0]!.mooringNumber} is ${ ? `Berth ${lines[0]!.berth.mooringNumber} is ${
conflicts[0]!.status === 'sold' ? 'Sold' : 'Under Offer' lines[0]!.berth.status === 'sold' ? 'Sold' : 'Under Offer'
} to another deal.` } to another deal.`
: `${conflicts.length} linked berths are no longer freely available.`} : `${lines.length} linked berths are no longer freely available.`}
</p> </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"> <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 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. 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 { useState } from 'react';
import { Anchor, CheckCircle2, Circle, FileSignature, Send, Wallet } from 'lucide-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 type { DetailTab } from '@/components/shared/detail-layout';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { DatePicker } from '@/components/ui/date-picker'; import { DatePicker } from '@/components/ui/date-picker';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { NotesList } from '@/components/shared/notes-list'; import { NotesList } from '@/components/shared/notes-list';
import { InlineEditableField } from '@/components/shared/inline-editable-field'; 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 { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { RemindersInline } from '@/components/reminders/reminders-inline'; import { RemindersInline } from '@/components/reminders/reminders-inline';
// Legacy `RecommendationList` removed 2026-05-15 — replaced by the same // Legacy `RecommendationList` removed 2026-05-15 — replaced by the same
@@ -43,7 +42,9 @@ import {
LEAD_CATEGORIES, LEAD_CATEGORIES,
PIPELINE_STAGES, PIPELINE_STAGES,
SOURCES, SOURCES,
STAGE_BADGE,
canTransitionStage, canTransitionStage,
stageLabel,
type PipelineStage, type PipelineStage,
} from '@/lib/constants'; } from '@/lib/constants';
import { InterestEoiTab } from '@/components/interests/interest-eoi-tab'; import { InterestEoiTab } from '@/components/interests/interest-eoi-tab';
@@ -133,6 +134,8 @@ interface InterestTabsOptions {
clientPrimaryEmailContactId?: string | null; clientPrimaryEmailContactId?: string | null;
clientPrimaryPhone?: string | null; clientPrimaryPhone?: string | null;
clientPrimaryPhoneContactId?: string | null; clientPrimaryPhoneContactId?: string | null;
clientPrimaryPhoneE164?: string | null;
clientPrimaryPhoneCountry?: string | null;
dateFirstContact: string | null; dateFirstContact: string | null;
dateLastContact: string | null; dateLastContact: string | null;
dateEoiSent: string | null; dateEoiSent: string | null;
@@ -605,8 +608,6 @@ function OverviewTab({
const params = useParams<{ portSlug: string }>(); const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? ''; const portSlug = params?.portSlug ?? '';
// QueryClient lifted to the top of the tab so the inline-edit email + // 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 // 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 // can launch it inline — same dialog the dedicated EOI tab uses, so the
// editing/confirmation flow is identical regardless of entry point. // 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> <h3 className="text-sm font-medium mb-2">Contact</h3>
<dl> <dl>
<EditableRow label="Email"> <EditableRow label="Email">
{interest.clientPrimaryEmailContactId ? ( {interest.clientId ? (
<InlineEditableField <ClientChannelEditor
variant="text" clientId={interest.clientId}
value={interest.clientPrimaryEmail ?? ''} channel="email"
onSave={async (next) => { primaryContactId={interest.clientPrimaryEmailContactId ?? null}
if (!interest.clientId || !interest.clientPrimaryEmailContactId) return; primaryValue={interest.clientPrimaryEmail ?? null}
await apiFetch( invalidateKeys={[['interest', interest.id]]}
`/api/v1/clients/${interest.clientId}/contacts/${interest.clientPrimaryEmailContactId}`,
{ method: 'PATCH', body: { value: next } },
);
await queryClient.invalidateQueries({
queryKey: ['interest', interest.id],
});
}}
/> />
) : ( ) : (
<span className="text-muted-foreground"> - </span> <span className="text-muted-foreground">-</span>
)} )}
</EditableRow> </EditableRow>
<EditableRow label="Phone"> <EditableRow label="Phone">
{interest.clientPrimaryPhoneContactId ? ( {interest.clientId ? (
<InlineEditableField <ClientChannelEditor
variant="text" clientId={interest.clientId}
value={ channel="phone"
interest.clientPrimaryPhone primaryContactId={interest.clientPrimaryPhoneContactId ?? null}
? (parsePhone(interest.clientPrimaryPhone).international ?? primaryValue={interest.clientPrimaryPhone ?? null}
interest.clientPrimaryPhone) primaryValueE164={interest.clientPrimaryPhoneE164 ?? null}
: '' primaryValueCountry={interest.clientPrimaryPhoneCountry ?? null}
} invalidateKeys={[['interest', interest.id]]}
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],
});
}}
/> />
) : ( ) : (
<span className="text-muted-foreground"> - </span> <span className="text-muted-foreground">-</span>
)} )}
</EditableRow> </EditableRow>
{interest.dateFirstContact || interest.dateLastContact ? ( {interest.dateFirstContact || interest.dateLastContact ? (
@@ -1234,17 +1218,35 @@ function OverviewTab({
<p className="line-clamp-3 whitespace-pre-wrap text-foreground/90"> <p className="line-clamp-3 whitespace-pre-wrap text-foreground/90">
{interest.recentNote.content} {interest.recentNote.content}
</p> </p>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
{formatDistanceToNowStrict(new Date(interest.recentNote.createdAt), { {/* Stage pill = the deal's current stage. Source-of-truth
addSuffix: true, interpretation: the note is about the deal as it
})} stands today; reading it on Overview, "current stage"
{interest.recentNote.authorId answers the implicit "where in the deal is this?". A
? ` · ${ historical "stage-at-note-time" lookup would need an
interest.recentNote.authorId === 'system' audit_logs read per teaser render — over-engineered for
? 'system' a context hint. */}
: (interest.recentNote.authorName ?? 'Unknown') <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> </p>
</div> </div>
) : ( ) : (

View File

@@ -494,6 +494,7 @@ export async function getInterestById(id: string, portId: string) {
id: clientContacts.id, id: clientContacts.id,
value: clientContacts.value, value: clientContacts.value,
valueE164: clientContacts.valueE164, valueE164: clientContacts.valueE164,
valueCountry: clientContacts.valueCountry,
}) })
.from(clientContacts) .from(clientContacts)
.where( .where(
@@ -661,6 +662,7 @@ export async function getInterestById(id: string, portId: string) {
clientPrimaryPhone: phoneContact?.value ?? null, clientPrimaryPhone: phoneContact?.value ?? null,
clientPrimaryPhoneContactId: phoneContact?.id ?? null, clientPrimaryPhoneContactId: phoneContact?.id ?? null,
clientPrimaryPhoneE164: phoneContact?.valueE164 ?? null, clientPrimaryPhoneE164: phoneContact?.valueE164 ?? null,
clientPrimaryPhoneCountry: phoneContact?.valueCountry ?? null,
clientHasAddress: !!addressRow, clientHasAddress: !!addressRow,
berthId, berthId,
berthMooringNumber, berthMooringNumber,