diff --git a/src/components/interests/interest-detail-header.tsx b/src/components/interests/interest-detail-header.tsx index 9ca16b6..2f682c9 100644 --- a/src/components/interests/interest-detail-header.tsx +++ b/src/components/interests/interest-detail-header.tsx @@ -2,9 +2,20 @@ import { useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { Pencil, Archive, RotateCcw, Trophy, XCircle, RefreshCcw } from 'lucide-react'; +import { + Pencil, + Archive, + RotateCcw, + Trophy, + XCircle, + RefreshCcw, + Mail, + MessageCircle, + Phone, +} from 'lucide-react'; import Link from 'next/link'; +import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { TagBadge } from '@/components/shared/tag-badge'; import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog'; @@ -36,6 +47,12 @@ interface InterestDetailHeaderProps { id: string; clientId: string; clientName: string | null; + /** Primary contact channels resolved from the linked client. The header + * uses these to render Email / Call / WhatsApp buttons so the rep + * doesn't have to navigate to the client page just to reach out. */ + clientPrimaryEmail?: string | null; + clientPrimaryPhone?: string | null; + clientPrimaryPhoneE164?: string | null; berthId: string | null; berthMooringNumber: string | null; pipelineStage: string; @@ -71,6 +88,16 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade const outcomeBadge = interest.outcome ? OUTCOME_BADGE[interest.outcome] : null; const isClosed = !!interest.outcome; + // Contact deep-links — resolved from the linked client's primary channels. + // wa.me requires the digits-only E.164 number (no leading "+"); fall back to + // stripping non-digits from the display value when the canonical form is + // missing. + const whatsappNumber = interest.clientPrimaryPhoneE164 + ? interest.clientPrimaryPhoneE164.replace(/^\+/, '') + : interest.clientPrimaryPhone + ? interest.clientPrimaryPhone.replace(/[^\d]/g, '') + : null; + const reopenMutation = useMutation({ mutationFn: () => apiFetch(`/api/v1/interests/${interest.id}/outcome`, { method: 'DELETE', body: {} }), @@ -200,6 +227,65 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade ))} )} + + {/* Contact deep-links — let the rep email / call / WhatsApp the + client without leaving the interest workspace. Resolved from + the linked client's primary contact channels (server-side + fetch in getInterestById). */} + {interest.clientPrimaryEmail || interest.clientPrimaryPhone || whatsappNumber ? ( +
+ {interest.clientPrimaryEmail ? ( + + ) : null} + {interest.clientPrimaryPhone ? ( + + ) : null} + {whatsappNumber ? ( + + ) : null} +
+ ) : null} {/* Top-right actions. Won/Lost are sales-critical and read as text diff --git a/src/components/interests/interest-detail.tsx b/src/components/interests/interest-detail.tsx index be2f7c6..701d574 100644 --- a/src/components/interests/interest-detail.tsx +++ b/src/components/interests/interest-detail.tsx @@ -16,6 +16,18 @@ interface InterestData { portId: string; clientId: string; clientName: string | null; + /** Linked client's primary email (display value). Powers the header + * "Email" button and the EOI prereq checklist. */ + clientPrimaryEmail: string | null; + /** Linked client's primary phone (display value). Powers the header + * "Call" button. */ + clientPrimaryPhone: string | null; + /** Linked client's primary phone in E.164 form ("+1XXXXXXXXXX"). Used + * by wa.me to assemble the WhatsApp deep-link. */ + clientPrimaryPhoneE164: string | null; + /** True when the linked client has any primary address row. Used by + * the EOI prereq checklist on the Documents tab. */ + clientHasAddress: boolean; berthId: string | null; berthMooringNumber: string | null; pipelineStage: string; @@ -39,6 +51,8 @@ interface InterestData { archivedAt: string | null; createdAt: string; updatedAt: string; + outcome?: string | null; + outcomeReason?: string | null; tags: Array<{ id: string; name: string; color: string }>; } diff --git a/src/lib/services/interests.service.ts b/src/lib/services/interests.service.ts index ddca80a..de4824f 100644 --- a/src/lib/services/interests.service.ts +++ b/src/lib/services/interests.service.ts @@ -282,9 +282,10 @@ export async function getInterestById(id: string, portId: string) { .from(clients) .where(eq(clients.id, interest.clientId)); - // EOI prerequisites: surface enough of the linked client's primary contact - // and address so the Documents tab can show the readiness checklist - // (Required: name, email, address — Section 2 of the EOI document). + // EOI prerequisites + interest-detail header contact actions: surface the + // linked client's primary email/phone (and the canonical E.164 form for + // wa.me) so the header can render Email / Call / WhatsApp buttons without + // a second fetch, and the Documents tab can show the EOI prereq checklist. const [emailContact] = await db .select({ value: clientContacts.value }) .from(clientContacts) @@ -292,6 +293,18 @@ export async function getInterestById(id: string, portId: string) { .orderBy(desc(clientContacts.isPrimary), desc(clientContacts.updatedAt)) .limit(1); + const [phoneContact] = await db + .select({ value: clientContacts.value, valueE164: clientContacts.valueE164 }) + .from(clientContacts) + .where( + and( + eq(clientContacts.clientId, interest.clientId), + inArray(clientContacts.channel, ['phone', 'whatsapp']), + ), + ) + .orderBy(desc(clientContacts.isPrimary), desc(clientContacts.updatedAt)) + .limit(1); + const [addressRow] = await db .select({ id: clientAddresses.id }) .from(clientAddresses) @@ -319,6 +332,8 @@ export async function getInterestById(id: string, portId: string) { ...interest, clientName: clientRow?.fullName ?? null, clientPrimaryEmail: emailContact?.value ?? null, + clientPrimaryPhone: phoneContact?.value ?? null, + clientPrimaryPhoneE164: phoneContact?.valueE164 ?? null, clientHasAddress: !!addressRow, berthMooringNumber, tags: tagRows,