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;
berthMooringNumber?: 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 {
@@ -314,6 +322,22 @@ function PanelVariant({ clientId, portSlug }: { clientId: string; portSlug: stri
{STAGE_LABELS[stage]}
</span>
</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">
<StageStepper current={stage} size="xs" />
</div>