'use client'; import Link from 'next/link'; import { useParams } from 'next/navigation'; import { format, formatDistanceToNowStrict } from 'date-fns'; 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 { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { RemindersInline } from '@/components/reminders/reminders-inline'; // Legacy `RecommendationList` removed 2026-05-15 — replaced by the same // rule-based `BerthRecommenderPanel` (already imported above) used on the // Overview tab so the scoring + UI stay consistent. The old component // pulled stale "AI"-style rows that all scored 50% because the underlying // generate endpoint was orphaned. import { BerthRecommenderPanel } from '@/components/interests/berth-recommender-panel'; import { LinkedBerthsList } from '@/components/interests/linked-berths-list'; import { EoiGenerateDialog } from '@/components/documents/eoi-generate-dialog'; // Shared parser for the interest's stringly-typed numeric columns (Drizzle // returns Postgres numeric as string). Used by both the Overview milestone // classifier and the Recommendations tab so the conversion stays // consistent regardless of entry point. function toNum(v: string | null | undefined): number | null { if (v === null || v === undefined) return null; const n = parseFloat(v); return Number.isFinite(n) ? n : null; } import { InterestTimeline } from '@/components/interests/interest-timeline'; import { WonStatusPanel } from '@/components/interests/won-status-panel'; import { SupplementalInfoRequestButton } from '@/components/interests/supplemental-info-request-button'; import { InterestDocumentsTab } from '@/components/interests/interest-documents-tab'; import { LEAD_CATEGORIES, PIPELINE_STAGES, SOURCES, canTransitionStage, type PipelineStage, } from '@/lib/constants'; import { InterestEoiTab } from '@/components/interests/interest-eoi-tab'; import { InterestContactLogTab } from '@/components/interests/interest-contact-log-tab'; import { QualificationChecklist } from '@/components/interests/qualification-checklist'; import { PaymentsSection } from '@/components/interests/payments-section'; import { SkipAheadBanner } from '@/components/interests/skip-ahead-banner'; import { InterestBerthStatusBanner } from '@/components/interests/interest-berth-status-banner'; import { InterestContractTab } from '@/components/interests/interest-contract-tab'; import { InterestReservationTab } from '@/components/interests/interest-reservation-tab'; import { useConfirmation } from '@/hooks/use-confirmation'; import { apiFetch } from '@/lib/api/client'; import { cn } from '@/lib/utils'; type InterestPatchField = | 'leadCategory' | 'source' | 'desiredLengthFt' | 'desiredWidthFt' | 'desiredDraftFt'; const LEAD_CATEGORY_OPTIONS = LEAD_CATEGORIES.map((c) => ({ value: c, label: c.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase()), })); // Convert raw enum values like `waiting_for_signatures` → `Waiting For Signatures`. function humanizeStatus(value: string | null): string | null { if (!value) return null; return value.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase()); } interface InterestTabsOptions { interestId: string; currentUserId?: string; /** Used by the dedicated EOI tab to deep-link to the client's record * for inline edits ("wrong details? edit on the client's page"). */ clientId?: string | null; interest: { pipelineStage: string; /** Drives the recommender panel mounted on the Overview tab. */ desiredLengthFt?: string | null; desiredWidthFt?: string | null; desiredDraftFt?: string | null; /** Unit the rep originally entered the dims in — drives the * recommender header's display so a metric-entered deal doesn't * render as ft. The three columns share an entry unit in practice. */ desiredLengthUnit?: string | null; leadCategory: string | null; source: string | null; eoiStatus: string | null; contractStatus: string | null; depositStatus: string | null; reservationStatus: string | null; /** Captured at reservation-agreement time. Drives the deposit-paid * auto-advance once payment totals catch up. */ depositExpectedAmount?: string | null; depositExpectedCurrency?: string | null; /** Doc-bearing stage sub-status badges — drive the milestone past/current * classification independently of the pipeline stage. NULL until the * matching stage is reached. */ eoiDocStatus?: string | null; reservationDocStatus?: string | null; contractDocStatus?: string | null; /** Final outcome — 'won' surfaces the wrap-up checklist panel. */ outcome?: string | null; /** Interest id — needed for the queryClient.invalidateQueries calls * that fire after an inline contact edit. The parent passes this * through `interestId` already, but the inline-edit handlers below * use the structured object form. */ id: string; /** Linked client id — required for the PATCH /api/v1/clients/[id]/ * contacts/[contactId] flow that the inline Email + Phone editors * use. Null on an unlinked interest (rare but possible). */ clientId: string | null; /** Primary contact channels resolved from the linked client record by * getInterestById — both editable inline. The contact row's id is * exposed alongside so the inline editor can PATCH the right row * without an extra fetch. */ clientPrimaryEmail?: string | null; clientPrimaryEmailContactId?: string | null; clientPrimaryPhone?: string | null; clientPrimaryPhoneContactId?: string | null; dateFirstContact: string | null; dateLastContact: string | null; dateEoiSent: string | null; dateEoiSigned: string | null; dateReservationSigned?: string | null; dateContractSent: string | null; dateContractSigned: string | null; dateDepositReceived: string | null; reminderEnabled: boolean; reminderDays: number | null; reminderLastFired: string | null; /** Count of berths linked via the interest_berths junction — * drives the "Berth Interest" milestone on the Overview tab. */ linkedBerthCount?: number; notes: string | null; /** Surfaced by getInterestById for the Overview "most recent note" * teaser - saves a click into the Notes tab to peek at the latest. */ notesCount?: number; recentNote?: { id: string; content: string; authorId: string; authorName: string | null; createdAt: string; } | null; tags?: Array<{ id: string; name: string; color: string }>; }; } function useInterestPatch(interestId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: async (patch: Partial>) => apiFetch(`/api/v1/interests/${interestId}`, { method: 'PATCH', body: patch, }), onSuccess: () => { qc.invalidateQueries({ queryKey: ['interests', interestId] }); }, }); } type Phase = 'past' | 'current' | 'future'; function useStageMutation(interestId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: async ({ stage, reason, override, milestoneDate, }: { stage: string; reason?: string; override?: boolean; /** Optional ISO date for the milestone column (instead of "now"). */ milestoneDate?: string; }) => apiFetch(`/api/v1/interests/${interestId}/stage`, { method: 'PATCH', body: { pipelineStage: stage, reason, override, milestoneDate }, }), onSuccess: () => { qc.invalidateQueries({ queryKey: ['interests', interestId] }); qc.invalidateQueries({ queryKey: ['interests'] }); }, }); } function EditableRow({ label, children }: { label: string; children: React.ReactNode }) { return (
{label}
{children}
); } function InfoRow({ label, value }: { label: string; value?: string | null }) { if (!value) return null; return (
{label}
{value}
); } function formatDate(date: string | null) { if (!date) return null; return format(new Date(date), 'MMM d, yyyy'); } function relativeDate(date: string | null) { if (!date) return null; return `${formatDistanceToNowStrict(new Date(date))} ago`; } interface MilestoneSectionProps { title: string; icon: React.ComponentType<{ className?: string }>; /** Lifecycle for this milestone, in chronological order. */ steps: Array<{ label: string; date: string | null; /** Stage to advance to when the user clicks the action button for this step. */ advanceStage?: string; /** Optional override for the action label. */ actionLabel?: string; /** Suppress the inline "Mark as…" button for this step. Use when the * parent supplies a richer CTA via `footer` (e.g. Deposit, where we * want the invoice flow to be the primary path). */ hideAutoButton?: boolean; }>; status: string | null; onAdvance: (stage: string, milestoneDate?: string) => void | Promise; isPending: boolean; /** Current pipelineStage. Used to mark steps as done when the pipeline has * moved past their advanceStage even if the date stamp is missing - e.g. * a seed-data interest that started already at eoi_signed will show both * EOI sub-steps as done. Stage truth > date truth. */ currentStage: string; /** When true, this milestone is the next one the user should act on: * card gets a brand-accent ring and the next-step CTA becomes a primary * button. Computed by the parent based on currentStage. */ isActive?: boolean; /** Extra nodes (e.g. "Create deposit invoice" link) rendered below the steps. */ footer?: React.ReactNode; } /** * One milestone section (EOI / Deposit / Contract) - shows a vertical lifecycle * with completed steps checked, the next step exposing a quick "mark as…" * button that bumps the pipeline stage. Each stage flip auto-stamps its date * via the service layer (interests.service.ts). When external systems wire in * (Documenso webhook, paid invoice → deposit, etc.), they patch the same * stage endpoint and these checkmarks light up automatically. */ /** * Button that opens a date-picker popover before advancing a milestone. The * default is today, but the rep can back-date the event (e.g. "deposit * landed yesterday") so the stamped milestone column reflects the real date * rather than the click time. */ function MilestoneAdvanceButton({ label, variant, disabled, onConfirm, }: { label: string; variant: 'default' | 'outline' | 'ghostLink'; disabled?: boolean; onConfirm: (milestoneDate: string) => void; }) { const [open, setOpen] = useState(false); const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10)); return ( {variant === 'ghostLink' ? ( ) : ( )}

Defaults to today — back-date if the event happened earlier.

); } function MilestoneSection({ title, icon: Icon, steps, status, onAdvance, isPending, currentStage, isActive, footer, }: MilestoneSectionProps) { const currentStageIdx = PIPELINE_STAGES.indexOf(currentStage as PipelineStage); // A step counts as done if either: // (a) its `advanceStage` is at or behind the current pipeline stage, OR // (b) it has an explicit date stamp (from a manual mark or webhook). // (a) handles seeded/imported interests that arrived at a later stage // without per-step dates. const doneFlags = steps.map((step) => { if (step.date) return true; if (!step.advanceStage) return false; const stepIdx = PIPELINE_STAGES.indexOf(step.advanceStage as PipelineStage); return stepIdx !== -1 && currentStageIdx !== -1 && currentStageIdx >= stepIdx; }); const firstUnsetIdx = doneFlags.findIndex((d) => !d); return (

{title}

{isActive ? ( Next step ) : null}
{status ? ( {humanizeStatus(status)} ) : null}
    {steps.map((step, i) => { const done = doneFlags[i] ?? false; const isNext = !done && i === firstUnsetIdx; return (
  1. {done ? ( ) : ( )}
    {step.label} {step.date ? ( {formatDate(step.date)} · {relativeDate(step.date)} ) : null}
    {isNext && step.advanceStage && !step.hideAutoButton ? ( onAdvance(step.advanceStage!, date)} /> ) : null}
  2. ); })}
{footer ?
{footer}
: null}
); } /** * Collapsible wrapper for future-phase milestones. Hidden by default so * the overview stays focused on the current stage; expanding lets reps * record skipped milestones (the action click then routes through the * advance() override-confirm). */ function FutureMilestones({ milestones, stageMutation, advance, activeMilestone, currentStage, }: { milestones: Array<{ key: 'berth_interest' | 'eoi' | 'reservation' | 'deposit' | 'contract'; title: string; icon: React.ComponentType<{ className?: string }>; status: string | null; steps: MilestoneSectionProps['steps']; footer?: React.ReactNode; }>; stageMutation: ReturnType; advance: (stage: string) => void | Promise; activeMilestone: 'berth_interest' | 'eoi' | 'reservation' | 'deposit' | 'contract' | null; currentStage: string; }) { const [expanded, setExpanded] = useState(false); return (
{expanded && (
{milestones.map((m) => ( ))}
)}
); } function OverviewTab({ interestId, interest, clientId, }: { interestId: string; interest: InterestTabsOptions['interest']; clientId: string | null; }) { 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. const [eoiGenerateOpen, setEoiGenerateOpen] = useState(false); const mutation = useInterestPatch(interestId); const stageMutation = useStageMutation(interestId); const { confirm, dialog: confirmDialog } = useConfirmation(); const save = (field: InterestPatchField) => async (next: string | null) => { await mutation.mutateAsync({ [field]: next }); }; /** * Advance the pipeline. When the requested target isn't a legal next * step (e.g. user clicked "Mark deposit received" while still on * EOI Sent), prompt for confirmation and pass `override:true` so the * backend transition guard lets the change through. Mirrors the * skip-ahead pattern from the inline stage picker so audit trails * stay consistent regardless of which surface the rep used. */ const advance = async (stage: string, milestoneDate?: string) => { const fromStage = interest.pipelineStage as PipelineStage; const toStage = stage as PipelineStage; const isOverride = fromStage !== toStage && !canTransitionStage(fromStage, toStage); if (isOverride) { const ok = await confirm({ title: 'Skip-ahead stage change', description: `This advances the stage from "${fromStage.replace(/_/g, ' ')}" to "${toStage.replace( /_/g, ' ', )}", which isn't a standard next step. The change will be flagged in the audit log.`, confirmLabel: 'Continue', }); if (!ok) return; } stageMutation.mutate({ stage, reason: isOverride ? 'Skip-ahead from overview milestones' : 'Marked from overview', override: isOverride || undefined, milestoneDate, }); }; // Determine each milestone's phase relative to the current pipeline // stage. The overview hides future-phase milestones by default — it // was visually noisy to see Deposit + Contract cards on a deal still // at the EOI stage, and the empty cards invited mis-clicks. // // Past milestones still render (collapsed history) so reps can see // what's been completed. Future milestones are gated behind a "Show // upcoming milestones" toggle so the rep CAN reach them when a deal // genuinely skips stages — the click then routes through the same // override-confirm flow as the inline stage picker. const stageIdx = PIPELINE_STAGES.indexOf(interest.pipelineStage as PipelineStage); const reservationIdx = PIPELINE_STAGES.indexOf('reservation'); const depositIdx = PIPELINE_STAGES.indexOf('deposit_paid'); // Sub-status carries the "is this milestone's doc actually signed?" bit // for the doc-bearing stages (eoi / reservation / contract). A milestone // is 'past' when stage is BEYOND its index OR when stage equals its index // AND the doc sub-status is 'signed'. const eoiSigned = interest.eoiDocStatus === 'signed'; const reservationSigned = interest.reservationDocStatus === 'signed'; const contractSigned = interest.contractDocStatus === 'signed'; // 2026-05-15: rewrote phase classification so the Overview always // surfaces a CURRENT milestone for the rep, regardless of where the // pipeline-stage column happens to sit. The previous "phase === current // only when stageIdx exactly matches" rule produced an empty Overview // for the qualified + nurturing stages (no milestone marked current, EOI // hidden under "show upcoming") — exactly the gap the rep complained // about. New model: the FIRST not-yet-complete milestone in the fixed // berth_interest → eoi → reservation → deposit → contract order is // 'current'. Everything before is 'past'; everything after is 'future'. const hasLinkedBerth = (interest.linkedBerthCount ?? 0) > 0; const reservationStageReached = stageIdx >= reservationIdx; const depositComplete = stageIdx > depositIdx; const milestoneCompletion = { berth_interest: hasLinkedBerth, eoi: eoiSigned, reservation: reservationSigned, deposit: depositComplete, contract: contractSigned, } as const; const order = ['berth_interest', 'eoi', 'reservation', 'deposit', 'contract'] as const; const firstIncompleteKey = order.find((k) => !milestoneCompletion[k]) ?? null; const phaseFor = (k: (typeof order)[number]): Phase => { if (milestoneCompletion[k]) return 'past'; if (k === firstIncompleteKey) return 'current'; return 'future'; }; const berthInterestPhase: Phase = phaseFor('berth_interest'); const eoiPhase: Phase = phaseFor('eoi'); const reservationPhase: Phase = phaseFor('reservation'); const depositPhase: Phase = phaseFor('deposit'); const contractPhase: Phase = phaseFor('contract'); // Payments-section visibility: useless real estate until a deposit is // actually expected (reservation stage onwards). Reps on enquiry / // qualified / nurturing should see stage-guidance instead. const showPaymentsSection = reservationStageReached; const activeMilestone: 'berth_interest' | 'eoi' | 'reservation' | 'deposit' | 'contract' | null = berthInterestPhase === 'current' ? 'berth_interest' : eoiPhase === 'current' ? 'eoi' : reservationPhase === 'current' ? 'reservation' : depositPhase === 'current' ? 'deposit' : contractPhase === 'current' ? 'contract' : null; // toNum extracted to module scope so the Recommendations tab can use it // alongside the Overview tab. See top of file. const milestones: Array<{ key: 'berth_interest' | 'eoi' | 'reservation' | 'deposit' | 'contract'; phase: Phase; title: string; icon: React.ComponentType<{ className?: string }>; status: string | null; steps: MilestoneSectionProps['steps']; footer?: React.ReactNode; /** Brief one-liner shown when the milestone is in the past-strip. */ pastSummary: React.ReactNode; }> = [ { key: 'berth_interest', phase: berthInterestPhase, title: 'Berth Interest', icon: Anchor, // No status badge — the count IS the status. Showing "0 berths" // would just duplicate the empty-state copy below. status: hasLinkedBerth ? `${interest.linkedBerthCount} berth${(interest.linkedBerthCount ?? 0) === 1 ? '' : 's'}` : null, // No advanceStage step — the milestone tracks a state (berths // linked) rather than a stage transition. Hide the row chrome by // passing an empty steps array; the footer renders the action. steps: [], footer: berthInterestPhase === 'current' ? (
Add a berth from the Recommendations tab or the client's active interest panel to mark this milestone complete.
) : null, pastSummary: hasLinkedBerth ? `${interest.linkedBerthCount} berth${(interest.linkedBerthCount ?? 0) === 1 ? '' : 's'} linked` : 'Skipped', }, { key: 'eoi', phase: eoiPhase, title: 'EOI', icon: Send, status: interest.eoiDocStatus ?? interest.eoiStatus, steps: [ { label: 'EOI sent', date: interest.dateEoiSent, advanceStage: 'eoi', // 99% of the time the EOI is sent through Documenso and this // stamps automatically via the webhook. Label as "manually" so // reps reach for it only when Documenso fails to deliver or the // EOI was sent outside the integrated flow. actionLabel: 'Mark EOI as sent manually', }, { label: 'EOI signed', date: interest.dateEoiSigned, // Stage stays at 'eoi'; the sub-status badge flips via a separate // PATCH (see MilestoneAdvanceButton.onConfirm fallback below). advanceStage: 'eoi', actionLabel: 'Mark EOI as signed manually', }, ], // When the EOI milestone is the active next step but nothing's been // sent yet, surface the actual generation entry points instead of // making the rep navigate to the EOI tab first. Mirrors the EOI // tab's Generate flow exactly — same dialog component, same // confirmation step — so behaviour stays consistent. footer: eoiPhase === 'current' && !interest.dateEoiSent ? (
) : null, pastSummary: interest.dateEoiSigned ? `Signed ${formatDate(interest.dateEoiSigned)}` : 'Completed', }, { key: 'reservation', phase: reservationPhase, title: 'Reservation', icon: FileSignature, status: interest.reservationDocStatus ?? null, steps: [ { label: 'Reservation agreement signed', date: interest.dateReservationSigned ?? null, advanceStage: 'reservation', actionLabel: 'Mark reservation as signed', }, ], pastSummary: interest.dateReservationSigned ? `Signed ${formatDate(interest.dateReservationSigned)}` : 'Completed', }, { key: 'deposit', phase: depositPhase, title: 'Deposit', icon: Wallet, status: interest.depositStatus, steps: [ { label: 'Deposit received', date: interest.dateDepositReceived, advanceStage: 'deposit_paid', hideAutoButton: true, }, ], footer: depositPhase === 'current' && !interest.dateDepositReceived ? (
advance('deposit_paid', date)} /> Or record a payment in the Payments section.
) : null, pastSummary: interest.dateDepositReceived ? `Received ${formatDate(interest.dateDepositReceived)}` : 'Recorded', }, { key: 'contract', phase: contractPhase, title: 'Contract', icon: FileSignature, status: interest.contractDocStatus ?? interest.contractStatus, steps: [ { label: 'Contract sent', date: interest.dateContractSent, advanceStage: 'contract', actionLabel: 'Mark contract as sent', }, { label: 'Contract signed', date: interest.dateContractSigned, advanceStage: 'contract', actionLabel: 'Mark contract as signed', }, ], pastSummary: interest.dateContractSigned ? `Signed ${formatDate(interest.dateContractSigned)}` : 'Completed', }, ]; const pastMilestones = milestones.filter((m) => m.phase === 'past'); const currentMilestones = milestones.filter((m) => m.phase === 'current'); const futureMilestones = milestones.filter((m) => m.phase === 'future'); return (
{/* Skip-ahead nudge — informational only; fires when the deal jumped past a milestone without stamping the matching date. */} {/* Conflict callout — fires when a linked berth is sold or already under offer to another active deal. Doesn't block the rep; just surfaces the situation so they treat the deal as a backup. */} {/* Qualification checklist — surfaces the port's per-port criteria so the rep can mark each one confirmed before the deal advances out of 'enquiry'. Hidden when the port has no enabled criteria. */} {/* Payments — bank-issued invoices live elsewhere; this is the internal audit record of money received against the deal. The running deposit total here drives the auto-advance into the deposit_paid stage server-side. Hidden before the reservation stage: no deposit is expected yet, so the empty card is just noise — the next-milestone card carries the actionable copy instead. */} {showPaymentsSection ? ( ) : null} {/* Pre-reservation: the dedicated "Next step" guidance card was removed in favour of a brighter NEXT STEP pill on the active MilestoneSection below (it already owns the workflow actions — two surfaces was redundant). Nurturing keeps a slim helper since no milestone is naturally "current" while a deal is paused. */} {interest.pipelineStage === 'nurturing' ? (

Deal is on nurture

Schedule a follow-up reminder or log a contact when the prospect re-engages, then move them back to Qualified.

) : null} {/* Sales-process milestones — phase-aware so the user only sees what's actionable now. Past milestones collapse into a tight history strip; the current milestone gets the full card; future milestones are hidden behind a toggle so reps can still skip-ahead when reality calls for it (an override-confirm gates the actual stage move). */} {pastMilestones.length > 0 && (
Past {pastMilestones.map((m) => ( {m.title} · {m.pastSummary} ))}
)} {currentMilestones.length > 0 && (
{currentMilestones.map((m) => ( ))}
)} {futureMilestones.length > 0 && ( )}
{/* Lead & Source (editable) */}

Lead

({ value: s.value, label: s.label }))} value={interest.source} onSave={save('source')} />
{/* Contact — client's primary email + phone (from the linked client record) AND the first/last-contact activity dates from the contact log. Phone is rendered via libphonenumber-js's international formatter so `+33633219796` reads as `+33 6 33 21 97 96` (matches the canonical client-page display). Both email + phone are click-to-edit: the PATCH flows to the underlying client_contacts row (resolved via the `*ContactId` fields surfaced by the interest read). */}

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.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.dateFirstContact || interest.dateLastContact ? ( <> ) : (

No contact activity logged yet — log a call, email, or meeting from the Contact log tab to start tracking.

)} {interest.reservationStatus ? ( ) : null}
{/* Berth requirements — desired length / width / draft. Editable inline so reps can capture or correct a buyer's needs without leaving the Overview tab. These values drive the auto-tick on the "Dimensions confirmed" qualification row + the BerthRecommenderPanel rankings below. */}

Berth requirements

{/* Reminder */} {interest.reminderEnabled && (

Reminder

)} {/* Most-recent threaded note teaser. Saves a click into the Notes tab when the rep just wants to peek at "what was discussed last." Always rendered now that the redundant `interests.notes` blob is gone — falls back to an empty-state prompt so reps still have an obvious entry point to the Notes tab from Overview. */}

Latest note

{interest.recentNote ? `View all${interest.notesCount && interest.notesCount > 1 ? ` ${interest.notesCount}` : ''}` : 'Add note'}
{interest.recentNote ? (

{interest.recentNote.content}

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

) : (
No notes yet.
)}
{/* Linked berths (plan §5.5) - shown ABOVE the recommender so reps see what's already linked before browsing more options. Each row exposes per-berth role-flag toggles and the EOI bypass control (only visible once the parent interest's primary EOI is signed). */} {/* Won-status wrap-up checklist — only renders when this interest's outcome is `won`. Surfaces upload slots for the manual paperwork that didn't flow through the EOI->Contract chain automatically. */} {/* Pre-EOI supplemental info request. Sends the client a one-time public form pre-filled with what's on file so they can confirm / correct details before the EOI is drafted. Hides itself once the EOI is signed. */} {/* Berth recommender (plan §5.3) - always-mounted card driven by the interest's desired dimensions. Renders an inline guidance message when dimensions aren't set yet. */} {confirmDialog} {/* Mounted at the Overview level so the EOI milestone's "Generate EOI" footer button can launch the dialog without leaving the tab. Same dialog component the dedicated EOI tab uses — single source of truth for the editing/confirmation flow. */}
); } export function getInterestTabs({ interestId, currentUserId, clientId = null, interest, }: InterestTabsOptions): DetailTab[] { // The EOI / Contract / Reservation tabs are stage-conditional — // each appears only at the stages where the rep is likely to act // on it. Hides clutter from later-stage deals where earlier docs // are ancient history. Each tab still queries for its own past // documents; if a deal regresses the past docs remain accessible // via the generic Documents tab. const stageIdx = PIPELINE_STAGES.indexOf(interest.pipelineStage as PipelineStage); const qualifiedIdx = PIPELINE_STAGES.indexOf('qualified'); const reservationIdx = PIPELINE_STAGES.indexOf('reservation'); const depositIdx = PIPELINE_STAGES.indexOf('deposit_paid'); const contractIdx = PIPELINE_STAGES.indexOf('contract'); // EOI: from qualified through contract (the deal's whole life past lead-only). const showEoiTab = stageIdx >= qualifiedIdx; // Reservation: once the EOI is signed onward — the reservation agreement // is the v1 step between EOI and deposit. Stays visible through contract // so the rep can re-open the signed reservation later. const showReservationTab = stageIdx >= reservationIdx; // Contract: from deposit_paid onward (deal is committed and the contract // becomes the next active document). const showContractTab = stageIdx >= depositIdx && stageIdx <= contractIdx; const tabs: DetailTab[] = [ { id: 'overview', label: 'Overview', content: , }, { id: 'contact-log', label: 'Contact log', content: , }, { id: 'notes', label: 'Notes', content: ( ), }, ]; if (showEoiTab) { tabs.push({ id: 'eoi', label: 'EOI', content: , }); } if (showContractTab) { tabs.push({ id: 'contract', label: 'Contract', content: , }); } if (showReservationTab) { tabs.push({ id: 'reservation', label: 'Reservation', content: , }); } tabs.push( { id: 'documents', label: 'Documents', content: , }, { id: 'recommendations', label: 'Berth Recommendations', content: ( ), }, { id: 'activity', label: 'Activity', content: , }, ); return tabs; }