Builds the foundational primitives that subsequent waves depend on. None of these introduce new deps — date-fns, react-day-picker, and shadcn Calendar were already in the tree. - `<DatePicker>` and `<DateTimePicker>` in src/components/ui — desktop popover wrapping the existing shadcn Calendar (caption-dropdown nav so reps can jump months/years for the SkipAheadBanner backfill UX), mobile native input via useIsMobile. Drop-in for `<Input type=date>` / `<Input type=datetime-local>`. - `<FileInputButton>` in src/components/ui — styled Button + hidden input, replaces browser-default file picker UI. Most queued sweep sites already used the hidden-input + Button-trigger pattern; the primitive lands for any new caller plus consistent filename display + clear button. - ColumnPicker `hideAll()` footer item — symmetric to existing `showAll()`, with the same visibility gate. Lands platform-wide via the shared component. - Migrated highest-leverage call sites to the new primitives: * MilestoneAdvanceButton (backfill UX) * Reminder form (datetime-local → DateTimePicker) * Snooze dialog (datetime-local → DateTimePicker) * External-EOI upload dialog (date + file picker) * Payments section (received-on date) - Remaining 15+ date-input call sites parked for a follow-up sweep — several use react-hook-form `register` patterns that need careful migration to the new controlled-value contract. tsc clean. 1419/1419 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1272 lines
49 KiB
TypeScript
1272 lines
49 KiB
TypeScript
'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<Record<InterestPatchField, string | null>>) =>
|
|
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 (
|
|
<div className="flex gap-2 py-1.5 border-b last:border-0 items-center">
|
|
<dt className="w-44 shrink-0 text-sm text-muted-foreground">{label}</dt>
|
|
<dd className="flex-1 min-w-0">{children}</dd>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InfoRow({ label, value }: { label: string; value?: string | null }) {
|
|
if (!value) return null;
|
|
return (
|
|
<div className="flex gap-2 py-1.5 border-b last:border-0">
|
|
<dt className="w-44 shrink-0 text-sm text-muted-foreground">{label}</dt>
|
|
<dd className="text-sm">{value}</dd>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<void>;
|
|
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<string>(() => new Date().toISOString().slice(0, 10));
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
{variant === 'ghostLink' ? (
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
className="text-muted-foreground hover:text-foreground disabled:opacity-50"
|
|
>
|
|
{label}
|
|
</button>
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
variant={variant}
|
|
size="sm"
|
|
disabled={disabled}
|
|
className="mt-2 h-7 px-2.5 text-xs"
|
|
>
|
|
{label}
|
|
</Button>
|
|
)}
|
|
</PopoverTrigger>
|
|
<PopoverContent align="start" className="w-64 space-y-2 p-3">
|
|
<div className="space-y-1">
|
|
<label className="text-xs font-medium" htmlFor="milestone-date">
|
|
Date completed
|
|
</label>
|
|
<DatePicker
|
|
id="milestone-date"
|
|
value={date}
|
|
toDate={new Date()}
|
|
onChange={setDate}
|
|
placeholder="Pick a date"
|
|
/>
|
|
<p className="text-[11px] text-muted-foreground">
|
|
Defaults to today — back-date if the event happened earlier.
|
|
</p>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<Button type="button" variant="ghost" size="sm" onClick={() => setOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
disabled={!date || disabled}
|
|
onClick={() => {
|
|
onConfirm(date);
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
Confirm
|
|
</Button>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<section
|
|
className={cn(
|
|
'rounded-xl border bg-card p-4 shadow-sm transition-colors',
|
|
isActive ? 'border-brand-300 bg-brand-50/40 ring-1 ring-brand-200' : 'border-border',
|
|
)}
|
|
>
|
|
<header className="mb-3 flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<Icon className={cn('size-4', isActive ? 'text-brand-600' : 'text-muted-foreground')} />
|
|
<h3 className="text-sm font-semibold tracking-tight text-foreground">{title}</h3>
|
|
{isActive ? (
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-brand-600 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] text-white shadow-sm">
|
|
<span className="size-1.5 rounded-full bg-white/90" aria-hidden />
|
|
Next step
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
{status ? (
|
|
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
{humanizeStatus(status)}
|
|
</span>
|
|
) : null}
|
|
</header>
|
|
|
|
<ol className="space-y-2">
|
|
{steps.map((step, i) => {
|
|
const done = doneFlags[i] ?? false;
|
|
const isNext = !done && i === firstUnsetIdx;
|
|
return (
|
|
<li key={step.label} className="flex items-start gap-2 text-sm">
|
|
{done ? (
|
|
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600" aria-hidden />
|
|
) : (
|
|
<Circle
|
|
className={cn(
|
|
'mt-0.5 size-4 shrink-0',
|
|
isNext ? 'text-foreground/60' : 'text-muted-foreground/40',
|
|
)}
|
|
/>
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
|
<span
|
|
className={cn(
|
|
'truncate',
|
|
done
|
|
? 'text-foreground'
|
|
: isNext
|
|
? 'text-foreground'
|
|
: 'text-muted-foreground',
|
|
)}
|
|
>
|
|
{step.label}
|
|
</span>
|
|
{step.date ? (
|
|
<span className="text-xs text-muted-foreground">
|
|
{formatDate(step.date)} · {relativeDate(step.date)}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
{isNext && step.advanceStage && !step.hideAutoButton ? (
|
|
<MilestoneAdvanceButton
|
|
label={step.actionLabel ?? `Mark as ${step.label.toLowerCase()}`}
|
|
variant={isActive ? 'default' : 'outline'}
|
|
disabled={isPending}
|
|
onConfirm={(date) => onAdvance(step.advanceStage!, date)}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ol>
|
|
{footer ? <div className="mt-3 border-t pt-3 text-xs">{footer}</div> : null}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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<typeof useStageMutation>;
|
|
advance: (stage: string) => void | Promise<void>;
|
|
activeMilestone: 'berth_interest' | 'eoi' | 'reservation' | 'deposit' | 'contract' | null;
|
|
currentStage: string;
|
|
}) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
return (
|
|
<div className="rounded-lg border border-dashed">
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpanded((v) => !v)}
|
|
className="flex w-full items-center justify-between gap-2 px-4 py-2.5 text-sm text-muted-foreground hover:text-foreground hover:bg-muted/30 transition-colors"
|
|
>
|
|
<span>
|
|
{expanded ? 'Hide' : 'Show'} upcoming milestones
|
|
<span className="ml-2 text-xs">({milestones.map((m) => m.title).join(' · ')})</span>
|
|
</span>
|
|
<span className="text-xs">{expanded ? '▴' : '▾'}</span>
|
|
</button>
|
|
{expanded && (
|
|
<div
|
|
className={cn(
|
|
'grid grid-cols-1 gap-4 p-4 pt-0',
|
|
milestones.length === 1 ? '' : 'lg:grid-cols-2',
|
|
)}
|
|
>
|
|
{milestones.map((m) => (
|
|
<MilestoneSection
|
|
key={m.key}
|
|
title={m.title}
|
|
icon={m.icon}
|
|
status={m.status}
|
|
isPending={stageMutation.isPending}
|
|
onAdvance={advance}
|
|
currentStage={currentStage}
|
|
isActive={activeMilestone === m.key}
|
|
steps={m.steps}
|
|
footer={m.footer}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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' ? (
|
|
<div className="text-xs text-muted-foreground">
|
|
Add a berth from the Recommendations tab or the client's active interest panel to
|
|
mark this milestone complete.
|
|
</div>
|
|
) : 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 ? (
|
|
<div className="flex flex-wrap items-center gap-2 pt-1">
|
|
<Button type="button" size="sm" onClick={() => setEoiGenerateOpen(true)}>
|
|
Generate EOI
|
|
</Button>
|
|
<Button asChild type="button" size="sm" variant="outline">
|
|
<Link
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
href={`/${portSlug}/interests/${interestId}?tab=eoi` as any}
|
|
>
|
|
Open EOI tab
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
) : 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 ? (
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
|
<MilestoneAdvanceButton
|
|
label="Mark received manually"
|
|
variant="ghostLink"
|
|
disabled={stageMutation.isPending}
|
|
onConfirm={(date) => advance('deposit_paid', date)}
|
|
/>
|
|
<span className="text-[11px] text-muted-foreground">
|
|
Or record a payment in the Payments section.
|
|
</span>
|
|
</div>
|
|
) : 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 (
|
|
<div className="space-y-6">
|
|
{/* Skip-ahead nudge — informational only; fires when the deal jumped
|
|
past a milestone without stamping the matching date. */}
|
|
<SkipAheadBanner interest={interest} />
|
|
|
|
{/* 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. */}
|
|
<InterestBerthStatusBanner
|
|
interestId={interestId}
|
|
interestPipelineStage={interest.pipelineStage}
|
|
interestOutcome={interest.outcome}
|
|
archivedAt={null}
|
|
/>
|
|
|
|
{/* 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. */}
|
|
<QualificationChecklist interestId={interestId} currentStage={interest.pipelineStage} />
|
|
|
|
{/* 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 ? (
|
|
<PaymentsSection
|
|
interestId={interestId}
|
|
depositExpectedAmount={interest.depositExpectedAmount ?? null}
|
|
depositExpectedCurrency={interest.depositExpectedCurrency ?? null}
|
|
/>
|
|
) : 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' ? (
|
|
<div className="rounded-xl border bg-card p-4 text-sm">
|
|
<p className="font-medium text-foreground">Deal is on nurture</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
Schedule a follow-up reminder or log a contact when the prospect re-engages, then move
|
|
them back to Qualified.
|
|
</p>
|
|
</div>
|
|
) : 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 && (
|
|
<div className="rounded-lg border bg-muted/20 px-4 py-2.5">
|
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
|
|
<span className="text-[10px] font-semibold uppercase tracking-wide">Past</span>
|
|
{pastMilestones.map((m) => (
|
|
<span key={m.key} className="inline-flex items-center gap-1.5">
|
|
<CheckCircle2 className="size-3 text-emerald-600" aria-hidden />
|
|
<span className="font-medium text-foreground">{m.title}</span>
|
|
<span>·</span>
|
|
<span>{m.pastSummary}</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{currentMilestones.length > 0 && (
|
|
<div
|
|
className={cn(
|
|
'grid grid-cols-1 gap-4',
|
|
currentMilestones.length === 1 ? '' : 'lg:grid-cols-2',
|
|
)}
|
|
>
|
|
{currentMilestones.map((m) => (
|
|
<MilestoneSection
|
|
key={m.key}
|
|
title={m.title}
|
|
icon={m.icon}
|
|
status={m.status}
|
|
isPending={stageMutation.isPending}
|
|
onAdvance={advance}
|
|
currentStage={interest.pipelineStage}
|
|
isActive={activeMilestone === m.key}
|
|
steps={m.steps}
|
|
footer={m.footer}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{futureMilestones.length > 0 && (
|
|
<FutureMilestones
|
|
milestones={futureMilestones}
|
|
stageMutation={stageMutation}
|
|
advance={advance}
|
|
activeMilestone={activeMilestone}
|
|
currentStage={interest.pipelineStage}
|
|
/>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{/* Lead & Source (editable) */}
|
|
<div className="space-y-1">
|
|
<h3 className="text-sm font-medium mb-2">Lead</h3>
|
|
<dl>
|
|
<EditableRow label="Lead Category">
|
|
<InlineEditableField
|
|
variant="select"
|
|
options={LEAD_CATEGORY_OPTIONS}
|
|
value={interest.leadCategory}
|
|
onSave={save('leadCategory')}
|
|
/>
|
|
</EditableRow>
|
|
<EditableRow label="Source">
|
|
<InlineEditableField
|
|
variant="select"
|
|
options={SOURCES.map((s) => ({ value: s.value, label: s.label }))}
|
|
value={interest.source}
|
|
onSave={save('source')}
|
|
/>
|
|
</EditableRow>
|
|
</dl>
|
|
</div>
|
|
|
|
{/* 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). */}
|
|
<div className="space-y-1">
|
|
<h3 className="text-sm font-medium mb-2">Contact</h3>
|
|
<dl>
|
|
<EditableRow label="Email">
|
|
{interest.clientPrimaryEmailContactId ? (
|
|
<InlineEditableField
|
|
variant="text"
|
|
value={interest.clientPrimaryEmail ?? ''}
|
|
onSave={async (next) => {
|
|
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],
|
|
});
|
|
}}
|
|
/>
|
|
) : (
|
|
<span className="text-muted-foreground">—</span>
|
|
)}
|
|
</EditableRow>
|
|
<EditableRow label="Phone">
|
|
{interest.clientPrimaryPhoneContactId ? (
|
|
<InlineEditableField
|
|
variant="text"
|
|
value={
|
|
interest.clientPrimaryPhone
|
|
? (parsePhone(interest.clientPrimaryPhone).international ??
|
|
interest.clientPrimaryPhone)
|
|
: ''
|
|
}
|
|
onSave={async (next) => {
|
|
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],
|
|
});
|
|
}}
|
|
/>
|
|
) : (
|
|
<span className="text-muted-foreground">—</span>
|
|
)}
|
|
</EditableRow>
|
|
{interest.dateFirstContact || interest.dateLastContact ? (
|
|
<>
|
|
<InfoRow label="First Contact" value={formatDate(interest.dateFirstContact)} />
|
|
<InfoRow label="Last Contact" value={formatDate(interest.dateLastContact)} />
|
|
</>
|
|
) : (
|
|
<p className="mt-1 text-xs text-muted-foreground italic">
|
|
No contact activity logged yet — log a call, email, or meeting from the Contact log
|
|
tab to start tracking.
|
|
</p>
|
|
)}
|
|
{interest.reservationStatus ? (
|
|
<InfoRow label="Reservation" value={interest.reservationStatus} />
|
|
) : null}
|
|
</dl>
|
|
</div>
|
|
|
|
{/* 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. */}
|
|
<div className="space-y-1">
|
|
<h3 className="text-sm font-medium mb-2">Berth requirements</h3>
|
|
<dl>
|
|
<EditableRow label="Desired length (ft)">
|
|
<InlineEditableField
|
|
value={interest.desiredLengthFt ?? null}
|
|
onSave={save('desiredLengthFt')}
|
|
placeholder="e.g. 60"
|
|
emptyText="—"
|
|
/>
|
|
</EditableRow>
|
|
<EditableRow label="Desired width (ft)">
|
|
<InlineEditableField
|
|
value={interest.desiredWidthFt ?? null}
|
|
onSave={save('desiredWidthFt')}
|
|
placeholder="e.g. 25"
|
|
emptyText="—"
|
|
/>
|
|
</EditableRow>
|
|
<EditableRow label="Desired draft (ft)">
|
|
<InlineEditableField
|
|
value={interest.desiredDraftFt ?? null}
|
|
onSave={save('desiredDraftFt')}
|
|
placeholder="e.g. 6"
|
|
emptyText="—"
|
|
/>
|
|
</EditableRow>
|
|
</dl>
|
|
</div>
|
|
|
|
{/* Reminder */}
|
|
{interest.reminderEnabled && (
|
|
<div className="space-y-1">
|
|
<h3 className="text-sm font-medium mb-2">Reminder</h3>
|
|
<dl>
|
|
<InfoRow
|
|
label="Reminder Days"
|
|
value={interest.reminderDays ? `${interest.reminderDays} days` : null}
|
|
/>
|
|
<InfoRow label="Last Fired" value={formatDate(interest.reminderLastFired)} />
|
|
</dl>
|
|
</div>
|
|
)}
|
|
|
|
{/* 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. */}
|
|
<div className="space-y-1 md:col-span-2">
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<h3 className="text-sm font-medium">Latest note</h3>
|
|
<Link
|
|
href={`/${portSlug}/interests/${interestId}?tab=notes`}
|
|
className="text-xs font-medium text-primary hover:underline"
|
|
>
|
|
{interest.recentNote
|
|
? `View all${interest.notesCount && interest.notesCount > 1 ? ` ${interest.notesCount}` : ''}`
|
|
: 'Add note'}
|
|
</Link>
|
|
</div>
|
|
{interest.recentNote ? (
|
|
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
|
|
<p className="line-clamp-3 whitespace-pre-wrap text-foreground/90">
|
|
{interest.recentNote.content}
|
|
</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
{formatDistanceToNowStrict(new Date(interest.recentNote.createdAt), {
|
|
addSuffix: true,
|
|
})}
|
|
{interest.recentNote.authorId
|
|
? ` · ${
|
|
interest.recentNote.authorId === 'system'
|
|
? 'system'
|
|
: (interest.recentNote.authorName ?? 'Unknown')
|
|
}`
|
|
: ''}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="rounded-md border border-dashed border-border bg-muted/10 px-3 py-2 text-xs text-muted-foreground">
|
|
No notes yet.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<InlineTagEditor
|
|
heading="Tags"
|
|
wrapperClassName="md:col-span-2"
|
|
endpoint={`/api/v1/interests/${interestId}/tags`}
|
|
currentTags={interest.tags ?? []}
|
|
invalidateKey={['interests', interestId]}
|
|
/>
|
|
|
|
<div className="md:col-span-2">
|
|
<RemindersInline interestId={interestId} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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. */}
|
|
<WonStatusPanel interestId={interestId} outcome={interest.outcome ?? null} />
|
|
|
|
{/* 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. */}
|
|
<SupplementalInfoRequestButton interestId={interestId} eoiStatus={interest.eoiStatus} />
|
|
|
|
<LinkedBerthsList interestId={interestId} />
|
|
|
|
{/* 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. */}
|
|
<BerthRecommenderPanel
|
|
interestId={interestId}
|
|
desiredLengthFt={toNum(interest.desiredLengthFt)}
|
|
desiredWidthFt={toNum(interest.desiredWidthFt)}
|
|
desiredDraftFt={toNum(interest.desiredDraftFt)}
|
|
desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'}
|
|
linkedBerthCount={interest.linkedBerthCount ?? 0}
|
|
/>
|
|
{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. */}
|
|
<EoiGenerateDialog
|
|
interestId={interestId}
|
|
clientId={clientId}
|
|
open={eoiGenerateOpen}
|
|
onOpenChange={setEoiGenerateOpen}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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: <OverviewTab interestId={interestId} interest={interest} clientId={clientId} />,
|
|
},
|
|
{
|
|
id: 'contact-log',
|
|
label: 'Contact log',
|
|
content: <InterestContactLogTab interestId={interestId} />,
|
|
},
|
|
{
|
|
id: 'notes',
|
|
label: 'Notes',
|
|
content: (
|
|
<NotesList
|
|
entityType="interests"
|
|
entityId={interestId}
|
|
currentUserId={currentUserId}
|
|
parentInvalidateKey={['interests', interestId]}
|
|
/>
|
|
),
|
|
},
|
|
];
|
|
|
|
if (showEoiTab) {
|
|
tabs.push({
|
|
id: 'eoi',
|
|
label: 'EOI',
|
|
content: <InterestEoiTab interestId={interestId} clientId={clientId} />,
|
|
});
|
|
}
|
|
|
|
if (showContractTab) {
|
|
tabs.push({
|
|
id: 'contract',
|
|
label: 'Contract',
|
|
content: <InterestContractTab interestId={interestId} clientId={clientId} />,
|
|
});
|
|
}
|
|
|
|
if (showReservationTab) {
|
|
tabs.push({
|
|
id: 'reservation',
|
|
label: 'Reservation',
|
|
content: <InterestReservationTab interestId={interestId} clientId={clientId} />,
|
|
});
|
|
}
|
|
|
|
tabs.push(
|
|
{
|
|
id: 'documents',
|
|
label: 'Documents',
|
|
content: <InterestDocumentsTab interestId={interestId} />,
|
|
},
|
|
{
|
|
id: 'recommendations',
|
|
label: 'Berth Recommendations',
|
|
content: (
|
|
<BerthRecommenderPanel
|
|
interestId={interestId}
|
|
desiredLengthFt={toNum(interest.desiredLengthFt)}
|
|
desiredWidthFt={toNum(interest.desiredWidthFt)}
|
|
desiredDraftFt={toNum(interest.desiredDraftFt)}
|
|
desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
id: 'activity',
|
|
label: 'Activity',
|
|
content: <InterestTimeline interestId={interestId} />,
|
|
},
|
|
);
|
|
|
|
return tabs;
|
|
}
|