'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, Plus, Send, Wallet } from 'lucide-react'; import type { DetailTab } from '@/components/shared/detail-layout'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; 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 { RecommendationList } from '@/components/interests/recommendation-list'; import { BerthRecommenderPanel } from '@/components/interests/berth-recommender-panel'; import { LinkedBerthsList } from '@/components/interests/linked-berths-list'; import { InterestTimeline } from '@/components/interests/interest-timeline'; 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 { 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'; 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; leadCategory: string | null; source: string | null; eoiStatus: string | null; contractStatus: string | null; depositStatus: string | null; reservationStatus: string | null; dateFirstContact: string | null; dateLastContact: string | null; dateEoiSent: string | null; dateEoiSigned: 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; 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' ? ( ) : ( )}
setDate(e.target.value)} className="h-9" />

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 ) : 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' | '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' | 'deposit' | 'contract' | null; currentStage: string; }) { const [expanded, setExpanded] = useState(false); return (
{expanded && (
{milestones.map((m) => ( ))}
)}
); } function OverviewTab({ interestId, interest, }: { interestId: string; interest: InterestTabsOptions['interest']; }) { const params = useParams<{ portSlug: string }>(); const portSlug = params?.portSlug ?? ''; 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 eoiSignedIdx = PIPELINE_STAGES.indexOf('eoi_signed'); const depositIdx = PIPELINE_STAGES.indexOf('deposit_10pct'); const contractSignedIdx = PIPELINE_STAGES.indexOf('contract_signed'); const phaseFor = (milestoneEndStageIdx: number): Phase => { if (stageIdx === -1) return 'future'; if (stageIdx >= milestoneEndStageIdx) return 'past'; // The "current" milestone is the one whose end-stage hasn't been // reached and whose start-stage is at-or-before the current stage. return 'current'; }; // Berth Interest milestone — first thing the rep needs to capture // (especially for general_interest leads). Completes the moment ANY // berth is linked to the interest via the junction. While unset, it // sits as the "current" milestone unless the deal has already moved // past EOI sent (in which case the rep clearly didn't need a berth // pinned first, so we mark it 'past' implicitly). const hasLinkedBerth = (interest.linkedBerthCount ?? 0) > 0; const berthInterestPhase: Phase = hasLinkedBerth ? 'past' : stageIdx === -1 || stageIdx >= eoiSignedIdx ? 'past' : 'current'; const eoiPhase = phaseFor(eoiSignedIdx); // Deposit is current once the EOI is signed but before deposit is in. const depositPhase: Phase = stageIdx === -1 ? 'future' : stageIdx >= depositIdx ? 'past' : stageIdx >= eoiSignedIdx ? 'current' : 'future'; const contractPhase: Phase = stageIdx === -1 ? 'future' : stageIdx >= contractSignedIdx ? 'past' : stageIdx >= depositIdx ? 'current' : 'future'; const activeMilestone: 'berth_interest' | 'eoi' | 'deposit' | 'contract' | null = berthInterestPhase === 'current' ? 'berth_interest' : eoiPhase === 'current' ? 'eoi' : depositPhase === 'current' ? 'deposit' : contractPhase === 'current' ? 'contract' : null; const toNum = (v: string | null | undefined): number | null => { if (v === null || v === undefined) return null; const n = parseFloat(v); return Number.isFinite(n) ? n : null; }; const milestones: Array<{ key: 'berth_interest' | 'eoi' | '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.eoiStatus, steps: [ { label: 'EOI sent', date: interest.dateEoiSent, advanceStage: 'eoi_sent', actionLabel: 'Mark EOI as sent', }, { label: 'EOI signed', date: interest.dateEoiSigned, advanceStage: 'eoi_signed', actionLabel: 'Mark EOI as signed', }, ], pastSummary: interest.dateEoiSigned ? `Signed ${formatDate(interest.dateEoiSigned)}` : 'Completed', }, { key: 'deposit', phase: depositPhase, title: 'Deposit', icon: Wallet, status: interest.depositStatus, steps: [ { label: 'Deposit received', date: interest.dateDepositReceived, advanceStage: 'deposit_10pct', hideAutoButton: true, }, ], footer: depositPhase === 'current' && !interest.dateDepositReceived ? (
advance('deposit_10pct', date)} />
) : null, pastSummary: interest.dateDepositReceived ? `Received ${formatDate(interest.dateDepositReceived)}` : 'Recorded', }, { key: 'contract', phase: contractPhase, title: 'Contract', icon: FileSignature, status: interest.contractStatus, steps: [ { label: 'Contract sent', date: interest.dateContractSent, advanceStage: 'contract_sent', actionLabel: 'Mark contract as sent', }, { label: 'Contract signed', date: interest.dateContractSigned, advanceStage: 'contract_signed', 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 (
{/* 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 dates (read-only - kept compact next to Lead) */}

Contact

{interest.reservationStatus ? ( ) : null}
{/* 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.authorId}` : ''}

) : (
No notes yet.
)}
{/* Tags */}

Tags

{/* 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). */} {/* 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}
); } 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 detailsSentIdx = PIPELINE_STAGES.indexOf('details_sent'); const depositIdx = PIPELINE_STAGES.indexOf('deposit_10pct'); const contractSignedIdx = PIPELINE_STAGES.indexOf('contract_signed'); // EOI: from details_sent through contract_signed (the deal's whole life) const showEoiTab = stageIdx >= detailsSentIdx && stageIdx <= contractSignedIdx; // Contract: appears once the deposit's been paid (deal is committed) // and stays visible until the contract is signed const showContractTab = stageIdx >= depositIdx && stageIdx <= contractSignedIdx; // Reservation: appears once the contract's signed and stays visible // through completion (reservation is the post-contract milestone) const showReservationTab = stageIdx >= contractSignedIdx; 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: 'Recommendations', content: , }, { id: 'activity', label: 'Activity', content: , }, ); return tabs; }