'use client'; import { useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { Pencil, Archive, RotateCcw, Trophy, XCircle, RefreshCcw, Mail, Phone, AlarmClock, User, } from 'lucide-react'; import { WhatsAppIcon } from '@/components/icons/whatsapp'; 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'; import { DetailHeaderStrip } from '@/components/shared/detail-header-strip'; import { PermissionGate } from '@/components/shared/permission-gate'; import { InterestForm } from '@/components/interests/interest-form'; import { InlineStagePicker } from '@/components/interests/inline-stage-picker'; import { InterestOutcomeDialog } from '@/components/interests/interest-outcome-dialog'; import { AssignedToChip } from '@/components/interests/assigned-to-chip'; import { MultiEoiChip } from '@/components/interests/multi-eoi-chip'; import { DealPulseChip } from '@/components/interests/deal-pulse-chip'; import { apiFetch } from '@/lib/api/client'; import { formatOutcome } from '@/lib/constants'; import { cn } from '@/lib/utils'; const OUTCOME_BADGE: Record = { won: { label: 'Won', className: 'bg-emerald-100 text-emerald-700' }, lost_other_marina: { label: 'Lost - other marina', className: 'bg-rose-100 text-rose-700' }, lost_unqualified: { label: 'Lost - unqualified', className: 'bg-rose-100 text-rose-700' }, lost_no_response: { label: 'Lost - no response', className: 'bg-rose-100 text-rose-700' }, lost_other: { label: 'Lost - other', className: 'bg-rose-100 text-rose-700' }, cancelled: { label: 'Cancelled', className: 'bg-slate-200 text-slate-700' }, }; // Catch-all so an unknown outcome (e.g. a future `lost_no_berth` enum) still // renders as a closed-state badge instead of falling back to the open-state // stage picker. Lost-* gets a rose tint; everything else gets neutral slate. function resolveOutcomeBadge(outcome: string | null | undefined) { if (!outcome) return null; const known = OUTCOME_BADGE[outcome]; if (known) return known; const isLoss = outcome.startsWith('lost'); return { label: formatOutcome(outcome) ?? outcome, className: isLoss ? 'bg-rose-100 text-rose-700' : 'bg-slate-200 text-slate-700', }; } const CATEGORY_LABELS: Record = { general_interest: 'General', specific_qualified: 'Specific qualified', hot_lead: 'Hot lead', }; interface InterestDetailHeaderProps { portSlug: string; interest: { 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; /** Pending/snoozed reminders attached to this interest. Drives the * alarm-bell badge on the header - surfaces follow-ups so the rep * doesn't have to remember to check /reminders. */ activeReminderCount?: number; berthId: string | null; berthMooringNumber: string | null; yachtId: string | null; pipelineStage: string; leadCategory: string | null; source: string | null; notes: string | null; reminderEnabled: boolean; reminderDays: number | null; archivedAt: string | null; outcome?: string | null; outcomeReason?: string | null; dateLastContact?: string | null; dateFirstContact?: string | null; dateEoiSent?: string | null; dateEoiSigned?: string | null; dateReservationSigned?: string | null; dateContractSent?: string | null; dateContractSigned?: string | null; dateDepositReceived?: string | null; eoiDocStatus?: string | null; reservationDocStatus?: string | null; contractDocStatus?: string | null; /** Activity-log entries in the last 7 days — drives deal-pulse +5 signal. */ recentActivityCount?: number | null; /** Sales rep who owns this deal — populated by the AssignedToChip. */ assignedTo?: string | null; assignedToName?: string | null; tags?: Array<{ id: string; name: string; color: string }>; }; } function formatLastContactAge(iso: string): string { const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000); if (days <= 0) return 'today'; if (days === 1) return 'yesterday'; if (days < 30) return `${days}d ago`; if (days < 365) return `${Math.floor(days / 30)}mo ago`; return `${Math.floor(days / 365)}y ago`; } export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeaderProps) { const queryClient = useQueryClient(); const [editOpen, setEditOpen] = useState(false); const [archiveOpen, setArchiveOpen] = useState(false); const [outcomeDialog, setOutcomeDialog] = useState(null); // (Upload-paper-signed-EOI dialog moved to the EOI tab.) const isArchived = !!interest.archivedAt; const outcomeBadge = resolveOutcomeBadge(interest.outcome); 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: {} }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['interests', interest.id] }); queryClient.invalidateQueries({ queryKey: ['interests'] }); // F26: confirm to the user that the action ran — pre-fix the // button gave no feedback and reps weren't sure if it took. toast.success('Outcome cleared — interest is open again.'); }, }); const archiveMutation = useMutation({ mutationFn: () => apiFetch(`/api/v1/interests/${interest.id}`, { method: 'DELETE' }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['interests', interest.id] }); queryClient.invalidateQueries({ queryKey: ['interests'] }); setArchiveOpen(false); }, }); const restoreMutation = useMutation({ mutationFn: () => apiFetch(`/api/v1/interests/${interest.id}/restore`, { method: 'POST' }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['interests', interest.id] }); queryClient.invalidateQueries({ queryKey: ['interests'] }); setArchiveOpen(false); }, }); const meta: Array<{ key: string; node: React.ReactNode }> = []; if (interest.berthMooringNumber) { meta.push({ key: 'berth', node: ( Berth {interest.berthMooringNumber} ), }); } if (interest.leadCategory) { meta.push({ key: 'cat', node: {CATEGORY_LABELS[interest.leadCategory] ?? interest.leadCategory}, }); } if (interest.source) { meta.push({ key: 'src', node: {interest.source}, }); } if (interest.dateLastContact) { meta.push({ key: 'last', node: ( Last contact {formatLastContactAge(interest.dateLastContact)} ), }); } return ( <>

{interest.clientName ?? 'Unknown Client'}

{isArchived && ( Archived )} {outcomeBadge ? ( {outcomeBadge.label} ) : ( {interest.pipelineStage} } > )} {(interest.activeReminderCount ?? 0) > 0 ? ( {interest.activeReminderCount} ) : null}
{meta.length > 0 ? (

{meta.map((m, i) => ( {i > 0 ? ( · ) : null} {m.node} ))}

) : null} {interest.tags && interest.tags.length > 0 && (
{interest.tags.map((tag) => ( ))}
)} {/* 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.clientId ? (
{interest.clientId ? ( ) : null} {interest.clientPrimaryEmail ? ( ) : null} {interest.clientPrimaryPhone ? ( ) : null} {whatsappNumber ? ( ) : null}
) : null}
{/* Top-right actions. Won/Lost are sales-critical and read as text buttons on desktop; Edit/Archive stay icon-only. On mobile, Won/Lost shrink to icon buttons to keep the cluster from wrapping. */}
{isClosed ? ( ) : ( <> {/* Mobile: icon-only with title tooltip + colored fill carries the won/lost meaning (green vs rose). Adding a "Won" / "Lost" text label inline blew out the cluster width and forced the Email/Call/WhatsApp action-chip row above to stack vertically - bad trade. From sm up, the full "Mark won" / "Close as lost" labels read clearly. */} )} {/* The "Upload paper-signed EOI" button used to live here. It's now on the dedicated EOI tab (in both the active-EOI hero and the empty-state CTA row), where it sits next to the document it relates to. The header was a shotgun of actions that didn't all belong; collecting them per-tab is the cleaner UX. */}
{outcomeDialog && ( !open && setOutcomeDialog(null)} /> )} [0]['interest']} /> { if (isArchived) { restoreMutation.mutate(); } else { archiveMutation.mutate(); } }} isLoading={archiveMutation.isPending || restoreMutation.isPending} /> ); }