From 7ecf4ee8132265cef0ae9e5f056eef6689cafad0 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 21 May 2026 22:08:41 +0200 Subject: [PATCH] feat(uat-batch): Group B Interest detail polish (5 new ships + 2 verified) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 combobox. Primary value renders inline (free-text for email, 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 uses (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 renders a one-line "Wants L × W × D · Source" under each interest's header when constraints / source are captured. Hidden when both are empty. 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 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) --- .../clients/client-channel-editor.tsx | 390 ++++++++++++++++++ .../clients/client-pipeline-summary.tsx | 24 ++ .../interest-berth-status-banner.tsx | 68 ++- src/components/interests/interest-tabs.tsx | 102 ++--- src/lib/services/interests.service.ts | 2 + 5 files changed, 528 insertions(+), 58 deletions(-) create mode 100644 src/components/clients/client-channel-editor.tsx diff --git a/src/components/clients/client-channel-editor.tsx b/src/components/clients/client-channel-editor.tsx new file mode 100644 index 00000000..1024b771 --- /dev/null +++ b/src/components/clients/client-channel-editor.tsx @@ -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; +} + +/** + * 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 ( +
+ {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. + { + 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); + } + }} + /> + ) : ( + { + try { + await apiFetch(`/api/v1/clients/${clientId}/contacts/${primaryContactId}`, { + method: 'PATCH', + body: { value: next }, + }); + invalidate(); + } catch (err) { + toastError(err); + } + }} + /> + ) + ) : ( + - + )} + + + + + +
+ {channel === 'email' ? 'Email contacts' : 'Phone & WhatsApp contacts'} +
+ {isLoading ? ( +
+ + Loading… +
+ ) : contacts.length === 0 ? ( +
+ No {channel} contacts yet. +
+ ) : ( +
    + {contacts.map((c) => ( + + ))} +
+ )} + +
+
+
+ ); +} + +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 ( +
  • +
    +
    + {row.isPrimary ? ( + + ) : ( + + )} + {displayValue} + {row.label ? ( + + {row.label} + + ) : null} +
    +
    + {!row.isPrimary ? ( + + ) : null} + {!row.isPrimary ? ( + + ) : null} +
    +
    +
  • + ); +} + +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 ( + + ); + } + return ( +
    +
    + + setValue(e.target.value)} + placeholder={channel === 'email' ? 'name@example.com' : '+1 555 123 4567'} + className="h-8 text-sm" + /> +
    + +
    + + +
    +
    + ); +} diff --git a/src/components/clients/client-pipeline-summary.tsx b/src/components/clients/client-pipeline-summary.tsx index be82bd89..bc9a3c87 100644 --- a/src/components/clients/client-pipeline-summary.tsx +++ b/src/components/clients/client-pipeline-summary.tsx @@ -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]} + {/* 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 ? ( +
    + {summary.join(' · ')} +
    + ) : null; + })()}
    diff --git a/src/components/interests/interest-berth-status-banner.tsx b/src/components/interests/interest-berth-status-banner.tsx index 482a08f0..4d26b8f5 100644 --- a/src/components/interests/interest-berth-status-banner.tsx +++ b/src/components/interests/interest-berth-status-banner.tsx @@ -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({ 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 (
    -
    +

    - {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.`}

    + {lines.some((l) => l.competing) ? ( +
      + {lines.map(({ berth, competing }) => + competing ? ( +
    • + {berth.mooringNumber}:{' '} + + {competing.clientName} + +
    • + ) : null, + )} +
    + ) : null}

    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. diff --git a/src/components/interests/interest-tabs.tsx b/src/components/interests/interest-tabs.tsx index 34322342..238aade0 100644 --- a/src/components/interests/interest-tabs.tsx +++ b/src/components/interests/interest-tabs.tsx @@ -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({

    Contact

    - {interest.clientPrimaryEmailContactId ? ( - { - 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 ? ( + ) : ( - - + - )} - {interest.clientPrimaryPhoneContactId ? ( - { - 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 ? ( + ) : ( - - + - )} {interest.dateFirstContact || interest.dateLastContact ? ( @@ -1234,17 +1218,35 @@ function OverviewTab({

    {interest.recentNote.content}

    -

    - {formatDistanceToNowStrict(new Date(interest.recentNote.createdAt), { - addSuffix: true, - })} - {interest.recentNote.authorId - ? ` · ${ - interest.recentNote.authorId === 'system' - ? 'system' - : (interest.recentNote.authorName ?? 'Unknown') - }` - : ''} +

    + {/* 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. */} + + {stageLabel(interest.pipelineStage)} + + + {formatDistanceToNowStrict(new Date(interest.recentNote.createdAt), { + addSuffix: true, + })} + {interest.recentNote.authorId + ? ` · ${ + interest.recentNote.authorId === 'system' + ? 'system' + : (interest.recentNote.authorName ?? 'Unknown') + }` + : ''} +

    ) : ( diff --git a/src/lib/services/interests.service.ts b/src/lib/services/interests.service.ts index 9d33e78b..57ffc41f 100644 --- a/src/lib/services/interests.service.ts +++ b/src/lib/services/interests.service.ts @@ -494,6 +494,7 @@ export async function getInterestById(id: string, portId: string) { id: clientContacts.id, value: clientContacts.value, valueE164: clientContacts.valueE164, + valueCountry: clientContacts.valueCountry, }) .from(clientContacts) .where( @@ -661,6 +662,7 @@ export async function getInterestById(id: string, portId: string) { clientPrimaryPhone: phoneContact?.value ?? null, clientPrimaryPhoneContactId: phoneContact?.id ?? null, clientPrimaryPhoneE164: phoneContact?.valueE164 ?? null, + clientPrimaryPhoneCountry: phoneContact?.valueCountry ?? null, clientHasAddress: !!addressRow, berthId, berthMooringNumber,