'use client'; import { useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Pencil, Archive, RotateCcw, Trophy, XCircle, RefreshCcw, Mail, MessageCircle, Phone, AlarmClock, } 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'; 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 { apiFetch } from '@/lib/api/client'; 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' }, 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: outcome.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase()), 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; 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; 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); 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'] }); }, }); 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.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. */} )}
{outcomeDialog && ( !open && setOutcomeDialog(null)} /> )} [0]['interest']} /> { if (isArchived) { restoreMutation.mutate(); } else { archiveMutation.mutate(); } }} isLoading={archiveMutation.isPending || restoreMutation.isPending} /> ); }