fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish

Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing-
progress redesign + env-to-admin migration + dev-mode banner) with the
2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW).

CRITICAL (3):
 - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths
   no longer silently drop interest links
 - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed
 - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage —
   callers must go through /stage with the override-guard chain

HIGH (14/15):
 - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across
   interests/documents/reservations/reminders/invoices (migration 0070)
 - H-02 login page reads ?redirect= param with same-origin guard
 - H-03 CRM invite token moves to URL fragment so it never lands in
   nginx access logs / Referer headers
 - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4)
 - H-05 toggleAccount writes an audit row
 - H-06 upsertSetting masks any value whose key ends with _encrypted
 - H-07 archiveClient cascade fires per-interest audit rows
 - H-08 createSalesTransporter applies SMTP_TIMEOUTS
 - H-09 AppShell stable children — viewport flip across breakpoint no
   longer destroys in-progress form drafts
 - H-10 portal documents page swaps Unicode glyph status icons for
   Lucide CheckCircle2/XCircle/Circle + aria-labels
 - H-12 list components swap alert(...) for toast.warning(...)
 - H-13 5 icon-only buttons gain aria-label
 - H-14 parseBody treats empty bodies as {}
 - H-15 admin layout renders a 403 panel instead of silent bounce
 - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet

MEDIUM (28+):
 - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE
   WHEREs across custom-fields, notes (all 6 entity types x update +
   delete), client-contacts, yacht ownerClient lookup, webhook reads
 - M-D01 documents-hub realtime event-name typo (file:created -> uploaded)
 - M-EM01 portal-auth emails thread through portId
 - M-EM02 sendEmail accepts cc/bcc params
 - M-EM04 notification_digest catalog key
 - M-IN01 portal presigned download URLs use 4h TTL
 - M-IN02 OpenAI client lazy-instantiated
 - M-IN04 stale pdfme refs updated to pdf-lib AcroForm
 - M-IN05 umami.testConnection returns tagged union
 - M-L01 reservations tenure_type unified with berths
 - M-L02 report-generators canonicalize stage values
 - M-AU01 audit log placeholder copy fixed
 - M-AU04 outcome_set / outcome_cleared distinct audit verbs
 - M-NEW-2 activity feed entity name+type separator
 - M-R01 portal allowlist narrowed + portal_session backstop in proxy
 - M-SC02 companies archived partial index
 - M-SC04 audit_logs.searchText documented as DB-managed
 - M-S01 storage_s3_access_key_encrypted admin field
 - M-U01 audit log empty state uses <EmptyState>
 - M-U09 invoice delete dialog -> <AlertDialog>
 - M-U10 toast.success on ClientForm + InterestForm create/edit
 - M-U11 settings-form-card logo preview alt text
 - M-U14 mobile topbar title on clients/yachts/interests/berths
 - M-U15 Invoices in mobile More-sheet

LOW (6/8):
 - L-AU01 severity defaults for security-relevant verbs
 - L-AU02 +13 missing actions in admin audit filter
 - L-AU03 +7 missing entity types in admin audit filter
 - L-AU04 dead listAuditLogs stubbed
 - L-D02 CLAUDE.md Owner-wins chain tightened

Bonus — Document detail polish (#67 partial, 3/6 deliverables):
 - state-aware action button per signer
 - watcher Add UI with display-name resolution
 - cleanSignerName cleanup

Prior session work bundled in:
 - Documenso v2 webhook + envelope-ID normalization + sequential signing
 - SigningProgress UI redesign (avatars, per-signer state, timestamps)
 - env->admin settings registry + RegistryDrivenForm + encrypted creds
 - Embedded-signing card + Test connection + setup help
 - Dev-mode EMAIL_REDIRECT_TO banner
 - Pipeline rules admin page
 - Sales email config card
 - Audit log details Sheet
 - EOI tab: Finalising badge, absolute timestamps, sequential indicator
 - Notes pipeline_stage_at_creation (migration 0069)
 - Documenso numeric ID dual-key webhook (migration 0068)
 - Dimensions criterion copy (migration 0067)

Tests: 1374/1374 vitest pass. tsc clean. lint clean.

See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and
the user-input items still pending.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 13:28:50 +02:00
parent 397dbd1490
commit 4b5f85cb7d
158 changed files with 12255 additions and 1303 deletions

View File

@@ -75,6 +75,12 @@ interface BerthRecommenderPanelProps {
desiredLengthFt: number | null;
desiredWidthFt: number | null;
desiredDraftFt: number | null;
/**
* Unit the rep originally entered the dimensions in. Drives header
* display so a metric-entered deal doesn't render its dims as ft.
* Falls back to 'ft' when missing.
*/
desiredUnit?: 'ft' | 'm' | null;
}
const TIER_LABELS: Record<Tier, { label: string; tone: string }> = {
@@ -115,11 +121,23 @@ function formatDimensions(
return parts.join(' · ');
}
function formatDesired(length: number | null, width: number | null, draft: number | null): string {
function formatDesired(
length: number | null,
width: number | null,
draft: number | null,
unit: 'ft' | 'm' = 'ft',
): string {
// Storage is canonical-ft (the recommender's SQL ranks against
// berths.length_ft etc.). For display we convert back to whatever the rep
// entered. 0.3048 m/ft exactly.
const toDisplay = (ft: number): string => {
const v = unit === 'm' ? ft * 0.3048 : ft;
return v.toFixed(2).replace(/\.?0+$/, '');
};
const parts: string[] = [];
if (length !== null) parts.push(`${length}ft L`);
if (width !== null) parts.push(`${width}ft W`);
if (draft !== null) parts.push(`${draft}ft D`);
if (length !== null) parts.push(`${toDisplay(length)}${unit} L`);
if (width !== null) parts.push(`${toDisplay(width)}${unit} W`);
if (draft !== null) parts.push(`${toDisplay(draft)}${unit} D`);
return parts.length > 0 ? parts.join(' · ') : 'no dimensions set';
}
@@ -332,11 +350,14 @@ function AmenityFilterForm({ filters, onChange }: AmenityFilterFormProps) {
);
}
// destructure includes `desiredUnit` so the header formatter pivots on the
// rep's entered unit. Falls back to 'ft' (the legacy default) when missing.
export function BerthRecommenderPanel({
interestId,
desiredLengthFt,
desiredWidthFt,
desiredDraftFt,
desiredUnit,
}: BerthRecommenderPanelProps) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
@@ -364,7 +385,12 @@ export function BerthRecommenderPanel({
apiFetch<{ data: Recommendation[] }>(`/api/v1/interests/${interestId}/recommend-berths`, {
method: 'POST',
body: {
...(showAll ? { topN: 999 } : {}),
// `showAll` opens the floodgates: bumps `topN` AND raises the
// oversize-cap so berths well beyond the strict feasibility window
// surface. Without that second bump the user could end up staring
// at "no berths match" when the test data only had oversized rows
// — exactly the case in our seeded demo port.
...(showAll ? { topN: 999, maxOversizePct: 1000 } : {}),
...(Object.keys(amenityFilters).length > 0 ? { amenityFilters } : {}),
},
}).then((r) => r.data),
@@ -400,7 +426,13 @@ export function BerthRecommenderPanel({
<div className="min-w-0 space-y-1">
<CardTitle className="flex items-center gap-2 text-base">
<Sparkles className="size-4 text-brand-600" aria-hidden />
Recommendations for {formatDesired(desiredLengthFt, desiredWidthFt, desiredDraftFt)}
Recommendations for{' '}
{formatDesired(
desiredLengthFt,
desiredWidthFt,
desiredDraftFt,
desiredUnit === 'm' ? 'm' : 'ft',
)}
</CardTitle>
{!hasDimensions ? (
<p className="text-xs text-muted-foreground">
@@ -489,9 +521,18 @@ export function BerthRecommenderPanel({
))}
</div>
) : recommendations.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
No berths match the current dimensions and filters.
</p>
<div className="py-6 text-center text-sm text-muted-foreground space-y-2">
<p>
{showAll
? 'No berths in the port match these dimensions and filters.'
: 'No berths fit inside the strict oversize tolerance.'}
</p>
{!showAll && (
<Button type="button" size="sm" variant="outline" onClick={() => setShowAll(true)}>
Show oversized matches too
</Button>
)}
</div>
) : (
<div className="space-y-2">
{recommendations.map((rec) => (
@@ -507,7 +548,7 @@ export function BerthRecommenderPanel({
{hasDimensions && recommendations.length > 0 ? (
<div className="flex justify-center pt-1">
<Button type="button" size="sm" variant="ghost" onClick={() => setShowAll((v) => !v)}>
{showAll ? 'Show top recommendations' : 'Show all feasible'}
{showAll ? 'Show top in-tolerance only' : 'Show oversized matches too'}
</Button>
</div>
) : null}

View File

@@ -1,15 +1,17 @@
'use client';
import { Activity } from 'lucide-react';
import { useState } from 'react';
import { Activity, ExternalLink } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { computeDealHealth, type DealHealthInput } from '@/lib/services/deal-health';
import { cn } from '@/lib/utils';
const PULSE_TINT: Record<'cold' | 'warm' | 'hot', string> = {
hot: 'border-emerald-200 bg-emerald-50 text-emerald-800',
warm: 'border-amber-200 bg-amber-50 text-amber-800',
cold: 'border-rose-200 bg-rose-50 text-rose-800',
hot: 'border-emerald-200 bg-emerald-50 text-emerald-800 hover:bg-emerald-100',
warm: 'border-amber-200 bg-amber-50 text-amber-800 hover:bg-amber-100',
cold: 'border-rose-200 bg-rose-50 text-rose-800 hover:bg-rose-100',
};
const PULSE_LABEL: Record<'cold' | 'warm' | 'hot', string> = {
@@ -19,12 +21,17 @@ const PULSE_LABEL: Record<'cold' | 'warm' | 'hot', string> = {
};
/**
* Header chip surfacing the rule-based deal-health score. The tooltip
* exposes every signal that contributed to the score so the calculation is
* transparent — stakeholders averse to AI black boxes can read exactly
* which dates / stages drove the verdict.
* Header chip surfacing the rule-based deal-health score.
*
* Click opens a popover with the full per-signal breakdown + plain-language
* explanation of how the score is computed, plus a link to the docs page
* for users who want the deep-dive. Replaces the prior hover-tooltip so
* the content is keyboard-accessible, doesn't time out, and reads on
* touch devices.
*/
export function DealPulseChip({ interest }: { interest: DealHealthInput }) {
const [open, setOpen] = useState(false);
// Closed / archived deals don't get a pulse — UX would be confusing.
if (interest.archivedAt || interest.outcome) return null;
@@ -33,46 +40,84 @@ export function DealPulseChip({ interest }: { interest: DealHealthInput }) {
const label = PULSE_LABEL[health.pulse];
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium cursor-help',
tint,
)}
aria-label={`Deal pulse: ${label}, score ${health.score}/100`}
>
<Activity className="size-3" aria-hidden />
{label} · {health.score}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<p className="font-semibold mb-1.5">
Deal pulse {label} ({health.score}/100)
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors cursor-pointer',
tint,
)}
aria-label={`Deal pulse: ${label}, score ${health.score}/100. Click for breakdown.`}
>
<Activity className="size-3" aria-hidden />
{label} · {health.score}
</button>
</PopoverTrigger>
<PopoverContent side="bottom" align="start" className="w-80 p-4 space-y-3">
<div>
<p className="text-sm font-semibold">
Deal pulse {label} ({health.score} / 100)
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
How likely this deal is to keep moving forward, scored from 0 to 100.
</p>
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
What pushed the score
</p>
{health.signals.length === 0 ? (
<p className="text-xs">
Baseline score (50) nothing notable yet. Log contact or progress the stage to move
the dial.
<p className="mt-1 text-xs text-muted-foreground">
Nothing notable yet the score is sitting at the baseline (50). Log a contact,
progress the stage, or send a signing request and you&apos;ll see the dial move.
</p>
) : (
<ul className="space-y-1 text-xs">
<ul className="mt-1.5 space-y-1.5 text-xs">
{health.signals.map((s) => (
<li key={s.id} className="flex gap-2">
<span className={s.delta > 0 ? 'text-emerald-300' : 'text-rose-300'}>
<li key={s.id} className="flex items-start gap-2">
<span
className={cn(
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold tabular-nums',
s.delta > 0 ? 'bg-emerald-100 text-emerald-800' : 'bg-rose-100 text-rose-800',
)}
>
{s.delta > 0 ? `+${s.delta}` : s.delta}
</span>
<span>{s.detail}</span>
<span className="text-foreground/90">{s.detail}</span>
</li>
))}
</ul>
)}
<p className="mt-2 text-[10px] opacity-70">
Rule-based. Every signal traces to a date or stage you can see no AI.
</div>
<div className="rounded-md bg-muted/40 p-2.5 text-[11px] text-muted-foreground">
<p className="font-medium text-foreground/80">How this is calculated</p>
<p className="mt-0.5">
Every signal above traces to a specific date or pipeline stage on this deal. Recent
contact + recent stage movement push the score up; long silences and outdated documents
pull it down.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex items-center justify-between gap-2">
<Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
Close
</Button>
<Button asChild variant="link" size="sm" className="text-xs">
<a
href="/docs/deal-pulse"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1"
>
Full guide
<ExternalLink className="size-3" aria-hidden />
</a>
</Button>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -211,7 +211,7 @@ export function InlineStagePicker({
const isOverride = !canTransitionStage(stage, target);
mutation.mutate({
next: target,
reason: isOverride ? 'Reverted to Open and unlinked all berths' : null,
reason: isOverride ? 'Reverted to New Enquiry and unlinked all berths' : null,
});
setOpenConfirmTarget(null);
} catch (err) {
@@ -226,7 +226,7 @@ export function InlineStagePicker({
setPendingStage(target);
mutation.mutate({
next: target,
reason: isOverride ? 'Reverted to Open (kept linked berths)' : null,
reason: isOverride ? 'Reverted to New Enquiry (kept linked berths)' : null,
});
setOpenConfirmTarget(null);
}
@@ -463,12 +463,13 @@ export function InlineStagePicker({
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset this deal to Open?</AlertDialogTitle>
<AlertDialogTitle>Reset this deal to New Enquiry?</AlertDialogTitle>
<AlertDialogDescription>
This interest has {linkedBerthCount} linked{' '}
{linkedBerthCount === 1 ? 'berth' : 'berths'}. Going back to <strong>Open</strong>{' '}
usually means restarting the lead keeping the berth links would leave them showing
as under offer on the public map for a deal that&apos;s no longer in progress.
{linkedBerthCount === 1 ? 'berth' : 'berths'}. Going back to{' '}
<strong>New Enquiry</strong> usually means restarting the lead keeping the berth
links would leave them showing as under offer on the public map for a deal that&apos;s
no longer in progress.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex-col-reverse gap-2 sm:flex-row sm:justify-end">

View File

@@ -121,8 +121,13 @@ export function getInterestColumns({
const notesCount = row.original.notesCount ?? 0;
return (
<div className="flex items-center gap-1.5 min-w-0">
{/* Client cell on the Interests list links to the INTEREST detail
— not the client page. Users browsing the interest list want
the deal context, not the underlying client. The interest
detail header has its own "Client page" deep-link if the rep
actually wants the client surface. */}
<Link
href={`/${portSlug}/clients/${row.original.clientId}`}
href={`/${portSlug}/interests/${row.original.id}`}
className="truncate font-medium text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>

View File

@@ -13,6 +13,7 @@ import {
Mail,
Phone,
AlarmClock,
User,
} from 'lucide-react';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import Link from 'next/link';
@@ -316,8 +317,28 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
client without leaving the interest workspace. Resolved from
the linked client's primary contact channels (server-side
fetch in getInterestById). */}
{interest.clientPrimaryEmail || interest.clientPrimaryPhone || whatsappNumber ? (
{interest.clientPrimaryEmail ||
interest.clientPrimaryPhone ||
whatsappNumber ||
interest.clientId ? (
<div className="flex flex-wrap items-center gap-1.5 pt-1">
{interest.clientId ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/clients/${interest.clientId}` as any}
aria-label="Open client page"
>
<User />
Client page
</Link>
</Button>
) : null}
{interest.clientPrimaryEmail ? (
<Button
asChild

View File

@@ -39,6 +39,7 @@ interface InterestData {
id: string;
content: string;
authorId: string;
authorName: string | null;
createdAt: string;
} | null;
berthId: string | null;

View File

@@ -5,9 +5,13 @@ import Link from 'next/link';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
AlertTriangle,
ArrowDown,
CheckCircle2,
Download,
Eye,
ExternalLink,
FileSignature,
GitBranch,
Loader2,
RefreshCw,
Upload,
@@ -18,12 +22,14 @@ import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { EoiCancelDialog } from '@/components/documents/eoi-cancel-dialog';
import { EoiGenerateDialog } from '@/components/documents/eoi-generate-dialog';
import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upload-dialog';
import { SigningProgress } from '@/components/documents/signing-progress';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { useConfirmation } from '@/hooks/use-confirmation';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import {
DOCUMENT_STATUS_ACTIVE,
DOCUMENT_STATUS_LABELS,
@@ -45,6 +51,10 @@ interface DocumentRow {
status: DocumentStatus;
createdAt: string;
signers?: Array<{ status: string }>;
/** Null while the EOI is in flight; populated by the completion webhook
* once the fully-signed PDF has been downloaded from Documenso and
* stored in MinIO/filesystem. Drives the "Download signed PDF" CTA. */
signedFileId?: string | null;
}
interface DocumentSigner {
@@ -141,6 +151,7 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
<span className="text-xs text-muted-foreground">
{new Date(d.createdAt).toLocaleDateString()}
</span>
{d.signedFileId ? <SignedPdfActions fileId={d.signedFileId} /> : null}
{portSlug && (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -186,25 +197,56 @@ function ActiveEoiCard({
}) {
const queryClient = useQueryClient();
const { confirm, dialog: confirmDialog } = useConfirmation();
const [cancelOpen, setCancelOpen] = useState(false);
const { data: signersRes, isLoading: signersLoading } = useQuery<{ data: DocumentSigner[] }>({
queryKey: ['documents', doc.id, 'signers'],
queryFn: () => apiFetch<{ data: DocumentSigner[] }>(`/api/v1/documents/${doc.id}/signers`),
refetchInterval: 30_000,
// Polling backstop in case a webhook event misses the open browser
// (transient socket drop, user in a different tab when the event
// fires, cloudflared tunnel hiccup). Primary update path is
// socket-driven via `useRealtimeInvalidation` below — this just
// bounds the worst-case staleness to ~5s.
refetchInterval: 5_000,
});
// Surface the per-port signing-order preference (Sequential vs Concurrent
// = Parallel in Documenso parlance) so the team knows what order recipients
// will receive the signing chain in.
const { data: signingDefaultsRes } = useQuery<{
data: { signingOrder: 'PARALLEL' | 'SEQUENTIAL' };
}>({
queryKey: ['documents', 'signing-defaults'],
queryFn: () =>
apiFetch<{ data: { signingOrder: 'PARALLEL' | 'SEQUENTIAL' } }>(
'/api/v1/documents/signing-defaults',
),
staleTime: 60_000,
});
const signingOrder = signingDefaultsRes?.data?.signingOrder ?? 'PARALLEL';
const signers = signersRes?.data ?? [];
const signedCount = signers.filter((s) => s.status === 'signed').length;
const totalCount = signers.length;
const allSigned = totalCount > 0 && signedCount === totalCount;
const cancelMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/documents/${doc.id}/cancel`, { method: 'POST', body: {} }),
onSuccess: () => {
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' });
toast.success('EOI cancelled.');
},
onError: (err) => toastError(err),
// Treat "all signers complete" as the finalised UX even when the
// DOCUMENT_COMPLETED webhook hasn't landed yet — defends against the
// gap between the last per-recipient sign event and the document-level
// completion event. The badge below flips to "Finalising" so the rep
// sees the in-flight state rather than a stale PARTIALLY_SIGNED chip.
const effectivelyCompleted = doc.status === 'completed' || allSigned;
const isAwaitingFinalisation = allSigned && doc.status !== 'completed';
// Real-time push: invalidate the signers query the moment a webhook
// fires `document:signer:*` so the card flips state without waiting
// for the 30s refetch interval. Same for `document:completed` so the
// "all signed" footer chip appears as soon as the last signer finishes.
useRealtimeInvalidation({
'document:signer:signed': [['documents', doc.id, 'signers'], ['documents']],
'document:signer:opened': [['documents', doc.id, 'signers']],
'document:completed': [['documents', doc.id, 'signers'], ['documents']],
'document:signer:rejected': [['documents', doc.id, 'signers'], ['documents']],
});
const remindAllMutation = useMutation({
@@ -223,12 +265,45 @@ function ActiveEoiCard({
<div className="flex items-center gap-2 flex-wrap">
<FileSignature className="size-4 text-foreground" aria-hidden />
<h2 className="truncate text-base font-semibold text-foreground">{doc.title}</h2>
<StatusBadge status={doc.status} />
{isAwaitingFinalisation ? (
<Badge variant="outline" className="border-sky-300 bg-sky-50 text-sky-800">
<Loader2 className="mr-1 size-3 animate-spin" aria-hidden /> Finalising
</Badge>
) : (
<StatusBadge status={doc.status} />
)}
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>
Created {new Date(doc.createdAt).toLocaleDateString()} ·{' '}
{totalCount > 0 ? `${signedCount} of ${totalCount} signed` : 'No signers loaded'}
</span>
{/* Signing-order badge — tells the team whether recipients
must sign in order or can sign concurrently. Drives off
the per-port setting; for v2 templates the template's
stored order wins server-side and we still surface our
local preference here so the UI matches what was sent. */}
<span
className={cn(
'inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide',
signingOrder === 'SEQUENTIAL'
? 'border-indigo-200 bg-indigo-50 text-indigo-800'
: 'border-sky-200 bg-sky-50 text-sky-800',
)}
title={
signingOrder === 'SEQUENTIAL'
? 'Signers receive the invite chain one at a time — each must sign before the next is emailed.'
: 'All signers receive the invite at once and can sign in any order.'
}
>
{signingOrder === 'SEQUENTIAL' ? (
<ArrowDown className="size-2.5" aria-hidden />
) : (
<GitBranch className="size-2.5" aria-hidden />
)}
{signingOrder === 'SEQUENTIAL' ? 'Sequential' : 'Concurrent'}
</span>
</div>
<p className="text-xs text-muted-foreground">
Created {new Date(doc.createdAt).toLocaleDateString()} ·{' '}
{totalCount > 0 ? `${signedCount} of ${totalCount} signed` : 'No signers loaded'}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{portSlug && (
@@ -242,7 +317,8 @@ function ActiveEoiCard({
</Link>
</Button>
)}
{!allSigned && (
{/* Remind all hides once every signer is signed — no-one to nudge. */}
{!effectivelyCompleted && (
<Button
variant="outline"
size="sm"
@@ -278,47 +354,147 @@ function ActiveEoiCard({
)}
</div>
<footer className="mt-3 flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<p className="flex items-center gap-1.5">
<AlertTriangle className="size-3 text-amber-600" aria-hidden />
Reminders are rate-limited (max once per 7 days per signer).
</p>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onUploadSigned}
className="h-7 gap-1.5 text-xs [&_svg]:size-3"
>
<Upload />
Upload paper-signed copy
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={cancelMutation.isPending}
onClick={async () => {
const ok = await confirm({
title: 'Cancel EOI',
description: 'Signers will no longer be able to sign.',
confirmLabel: 'Cancel EOI',
});
if (ok) cancelMutation.mutate();
}}
className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3"
>
<XCircle />
Cancel EOI
</Button>
{/* Signed-PDF inline preview, shown once the completion webhook has
downloaded + stored the final signed file. Defends in two ways:
(a) status === 'completed' (the ideal path), (b) doc reports a
signedFileId even when status hasn't flipped yet. */}
{doc.signedFileId ? (
<div className="mt-3 rounded-lg border bg-background p-4">
<div className="mb-3 flex items-center justify-between gap-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Signed document
</h3>
<SignedPdfActions fileId={doc.signedFileId} />
</div>
<SignedPdfPreview fileId={doc.signedFileId} />
</div>
</footer>
) : null}
{/* Footer hides once every signer is signed: Cancel + Remind reminder
stop making sense, and the rep's natural next action is to view
the signed PDF (rendered above) or open the linked document
detail page. Upload-paper-signed-copy stays available — useful
for in-person sign-out workflows even after the digital flow. */}
{!effectivelyCompleted ? (
<footer className="mt-3 flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<p className="flex items-center gap-1.5">
<AlertTriangle className="size-3 text-amber-600" aria-hidden />
Reminders are rate-limited (max once per 7 days per signer).
</p>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onUploadSigned}
className="h-7 gap-1.5 text-xs [&_svg]:size-3"
>
<Upload />
Upload paper-signed copy
</Button>
{/* Regenerate is only safe when no one has signed yet — once
signatures are on the doc, the rep must go through the
cancel-with-notify path so collaborators learn about the
discard. */}
{signedCount === 0 ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={async () => {
const ok = await confirm({
title: 'Regenerate this EOI?',
description:
'The current envelope will be voided silently — no recipients will be notified — and the generate dialog will re-open so you can rebuild.',
confirmLabel: 'Regenerate',
});
if (ok) {
try {
await apiFetch(`/api/v1/documents/${doc.id}/cancel`, {
method: 'POST',
body: { reason: 'regenerated', notifyRecipients: [] },
});
queryClient.invalidateQueries({
predicate: (q) => q.queryKey[0] === 'documents',
});
toast.success('EOI voided. Regenerate now.');
} catch (err) {
toastError(err);
}
}
}}
className="h-7 gap-1.5 text-xs [&_svg]:size-3"
title="Void the current envelope (no notifications) and rebuild from scratch."
>
<RefreshCw />
Regenerate
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setCancelOpen(true)}
className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3"
>
<XCircle />
Cancel EOI
</Button>
</div>
</footer>
) : null}
{confirmDialog}
<EoiCancelDialog
documentId={doc.id}
signers={signers}
open={cancelOpen}
onOpenChange={setCancelOpen}
/>
</section>
);
}
/**
* Inline iframe preview of a signed PDF. Fetches a short-lived presigned
* URL from `/api/v1/files/[id]/download` and renders the browser's native
* PDF viewer inside the EOI card. Constrained to a fixed max-height so a
* tall multi-page document doesn't blow out the page; the rep can open
* the file in a new tab via the alongside View button for full-screen.
*/
function SignedPdfPreview({ fileId }: { fileId: string }) {
const { data, isLoading, isError } = useQuery<{ data: { url: string; filename: string } }>({
queryKey: ['files', fileId, 'download-url'],
queryFn: () =>
apiFetch<{ data: { url: string; filename: string } }>(`/api/v1/files/${fileId}/download`),
// Presigned URL TTLs vary per backend — refresh well before they
// expire so a long-open card doesn't suddenly 403. 4 minutes is
// comfortably below the 5-minute MinIO default.
staleTime: 4 * 60_000,
});
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center text-xs text-muted-foreground">
<Loader2 className="mr-2 size-3 animate-spin" aria-hidden /> Loading preview
</div>
);
}
if (isError || !data?.data.url) {
return (
<p className="text-xs italic text-muted-foreground">
Preview unavailable use the Download button to grab the signed PDF.
</p>
);
}
return (
<iframe
src={data.data.url}
title="Signed EOI preview"
className="h-[560px] w-full rounded border bg-white"
/>
);
}
// ─── Empty state ─────────────────────────────────────────────────────────────
function EmptyEoiState({
@@ -368,3 +544,47 @@ function StatusBadge({ status }: { status: DocumentRow['status'] }) {
</Badge>
);
}
/**
* View + Download buttons for a signed PDF. `/api/v1/files/[id]/download`
* returns a presigned URL in JSON (rather than streaming the file), so
* we fetch the URL via `apiFetch` and then either open it in a new tab
* (View) or trigger a programmatic download (Download).
*/
function SignedPdfActions({ fileId }: { fileId: string }) {
const open = async (mode: 'view' | 'download') => {
try {
const res = await apiFetch<{ data: { url: string; filename: string } }>(
`/api/v1/files/${fileId}/download`,
);
if (mode === 'view') {
window.open(res.data.url, '_blank', 'noopener,noreferrer');
} else {
const a = document.createElement('a');
a.href = res.data.url;
a.download = res.data.filename;
a.click();
}
} catch (err) {
toastError(err, 'Failed to fetch signed PDF');
}
};
return (
<>
<button
type="button"
onClick={() => open('view')}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
<Eye className="size-3" aria-hidden /> View
</button>
<button
type="button"
onClick={() => open('download')}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
<Download className="size-3" aria-hidden /> Download
</button>
</>
);
}

View File

@@ -5,6 +5,7 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, ChevronsUpDown, Check, Plus } from 'lucide-react';
import { toast } from 'sonner';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
@@ -120,6 +121,26 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
const [createYachtOpen, setCreateYachtOpen] = useState(false);
const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false);
// Auto-fill pipelineStage + leadCategory based on whether a berth was
// picked. Once the rep manually edits either field we stop touching it,
// so we don't fight the user. Edit mode skips the auto-fill entirely —
// changing the berth on an in-flight interest shouldn't silently demote
// it back to "enquiry".
const userTouchedStage = useRef(false);
const userTouchedCategory = useRef(false);
useEffect(() => {
if (isEdit) return;
const hasBerth = !!selectedBerthId;
if (!userTouchedStage.current) {
setValue('pipelineStage', hasBerth ? 'qualified' : 'enquiry');
}
if (!userTouchedCategory.current) {
setValue('leadCategory', hasBerth ? 'specific_qualified' : 'general_interest');
}
// setValue is stable from RHF; isEdit doesn't change after mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedBerthId]);
function requestClose() {
if (isDirty && !isSubmitting && !mutation.isPending) {
setDiscardConfirmOpen(true);
@@ -146,6 +167,39 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
]
: undefined;
// Probe whether the selected client (or their member companies) owns any
// yachts. When zero, the form swaps the picker for an "Add yacht" CTA so
// reps don't get stuck on an empty dropdown wondering what to do. We hit
// the same autocomplete endpoint the picker uses but with an empty query
// to get the full unfiltered list scoped to the owner filter.
// Tags-availability probe — drives whether the whole Tags section
// (label + picker) renders. The picker itself returns null when empty,
// but the wrapping label/separator needed the same gate.
const { data: tagsList } = useQuery<{ data: Array<{ id: string }> }>({
queryKey: ['tag-availability-for-interest-form'],
queryFn: () => apiFetch('/api/v1/tags/options'),
staleTime: 60_000,
});
const tagsAvailable = (tagsList?.data?.length ?? 0) > 0;
const { data: yachtCount } = useQuery<{ data: Array<{ id: string }> }>({
queryKey: [
'yacht-count-for-interest-form',
selectedClientId,
memberCompanyIds.sort().join(','),
],
queryFn: () => {
const params = new URLSearchParams({ q: '' });
if (selectedClientId) params.set('ownerClientId', selectedClientId);
if (memberCompanyIds.length > 0) {
params.set('ownerCompanyIds', memberCompanyIds.join(','));
}
return apiFetch(`/api/v1/yachts/autocomplete?${params.toString()}`);
},
enabled: !!selectedClientId,
});
const hasAnyYachts = (yachtCount?.data?.length ?? 0) > 0;
const {
options: clientOptions,
isLoading: clientsLoading,
@@ -230,10 +284,27 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
method: 'POST',
body: enriched,
});
// Materialise any additional berths the rep picked in the multi-
// select. The first (primary) berth is already linked via the create
// payload's berthId; everything else gets a follow-up POST to the
// junction endpoint. We fire them in parallel — failure on one is
// surfaced as a toast but doesn't roll back the interest creation.
if (additionalBerthIds.length > 0) {
await Promise.allSettled(
additionalBerthIds.map((berthId) =>
apiFetch(`/api/v1/interests/${res.data.id}/berths`, {
method: 'POST',
body: { berthId, isSpecificInterest: false },
}),
),
);
}
return { id: res.data.id, created: true };
},
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['interests'] });
// M-U10: confirm the write landed.
toast.success(result.created ? 'Interest created' : 'Interest updated');
onOpenChange(false);
// F20: navigate to the new interest's detail page so the rep can
// start the workflow immediately. Edits stay in place — no point
@@ -254,6 +325,15 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
const selectedClient = clientOptions.find((c) => c.value === selectedClientId);
const selectedBerth = berthOptions.find((b) => b.value === selectedBerthId);
// Additional berths (beyond the primary `berthId`) accumulated by the
// multi-select. On create, after the interest row exists, each id here
// gets a follow-up POST /interests/{id}/berths so they show up in the
// linked-berths list with isPrimary=false. The primary berth (the form's
// `berthId`) is materialised by the standard create path. Edit mode
// doesn't surface this — managing extra berths post-create happens on
// the interest detail page's linked-berths section.
const [additionalBerthIds, setAdditionalBerthIds] = useState<string[]>([]);
return (
<Sheet
open={open}
@@ -337,7 +417,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
</div>
<div className="space-y-1">
<Label>Berth (optional)</Label>
<Label>Berths (optional)</Label>
<Popover open={berthOpen} onOpenChange={setBerthOpen} modal>
<PopoverTrigger asChild>
<Button
@@ -346,10 +426,20 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
aria-expanded={berthOpen}
className={cn(
'w-full justify-between',
!selectedBerthId && 'text-muted-foreground',
!selectedBerthId &&
additionalBerthIds.length === 0 &&
'text-muted-foreground',
)}
>
{selectedBerth?.label ?? interest?.berthMooringNumber ?? 'Select berth...'}
<span className="truncate">
{selectedBerthId
? `${selectedBerth?.label ?? interest?.berthMooringNumber ?? selectedBerthId}${
additionalBerthIds.length > 0
? ` + ${additionalBerthIds.length} more`
: ''
}`
: 'Select berths…'}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" aria-hidden />
</Button>
</PopoverTrigger>
@@ -362,43 +452,80 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
</CommandEmpty>
<CommandGroup>
<CommandItem
value=""
value="__clear__"
onSelect={() => {
setValue('berthId', undefined);
setBerthOpen(false);
setAdditionalBerthIds([]);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
!selectedBerthId ? 'opacity-100' : 'opacity-0',
!selectedBerthId && additionalBerthIds.length === 0
? 'opacity-100'
: 'opacity-0',
)}
/>
None
</CommandItem>
{berthOptions.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={(val) => {
setValue('berthId', val);
setBerthOpen(false);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedBerthId === option.value ? 'opacity-100' : 'opacity-0',
{berthOptions.map((option) => {
const isPrimary = selectedBerthId === option.value;
const isAdditional = additionalBerthIds.includes(option.value);
const isSelected = isPrimary || isAdditional;
return (
<CommandItem
key={option.value}
value={option.value}
onSelect={(val) => {
// Multi-select toggle. First pick becomes
// the primary berthId (the one the API uses
// for templates / list views). Subsequent
// picks go into additionalBerthIds and are
// materialised via POST /berths after the
// interest is created.
if (isPrimary) {
// Demote primary; promote first additional
// (if any) to primary so the deal still
// has one primary berth.
const promote = additionalBerthIds[0];
setValue('berthId', promote ?? undefined);
setAdditionalBerthIds(additionalBerthIds.slice(1));
} else if (isAdditional) {
setAdditionalBerthIds(
additionalBerthIds.filter((id) => id !== val),
);
} else if (!selectedBerthId) {
setValue('berthId', val);
} else {
setAdditionalBerthIds([...additionalBerthIds, val]);
}
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
isSelected ? 'opacity-100' : 'opacity-0',
)}
/>
<span className="flex-1">{option.label}</span>
{isPrimary && (
<span className="ml-2 rounded bg-primary/15 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary">
primary
</span>
)}
/>
{option.label}
</CommandItem>
))}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-xs text-muted-foreground">
Pick one or more berths. The first becomes the primary berth (used in templates and
list views); the rest get linked as alternates and can be promoted later from the
interest detail page.
</p>
</div>
<div className="space-y-2">
@@ -406,7 +533,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<Label>
Yacht <span className="text-muted-foreground font-normal">(optional)</span>
</Label>
{selectedClientId && (
{selectedClientId && hasAnyYachts && (
<Button
type="button"
variant="ghost"
@@ -419,15 +546,34 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
</Button>
)}
</div>
<YachtPicker
value={selectedYachtId ?? null}
onChange={(id) => setValue('yachtId', id ?? undefined)}
ownerFilter={yachtOwnerFilter}
disabled={!selectedClientId}
placeholder={selectedClientId ? 'Select yacht...' : 'Select a client first'}
/>
{/* Hide the picker entirely when the selected client has no
yachts on file (and isn't linked to a company with yachts).
An empty dropdown is a dead-end UX — the only useful action
in that state is "create a yacht for this client". */}
{selectedClientId && !hasAnyYachts ? (
<div className="rounded-md border border-dashed bg-muted/40 p-3 text-sm">
<p className="text-muted-foreground">This client has no yachts on file yet.</p>
<Button
type="button"
size="sm"
className="mt-2"
onClick={() => setCreateYachtOpen(true)}
>
<Plus className="mr-1 h-3.5 w-3.5" aria-hidden />
Add a yacht for this client
</Button>
</div>
) : (
<YachtPicker
value={selectedYachtId ?? null}
onChange={(id) => setValue('yachtId', id ?? undefined)}
ownerFilter={yachtOwnerFilter}
disabled={!selectedClientId}
placeholder={selectedClientId ? 'Select yacht...' : 'Select a client first'}
/>
)}
<p className="text-xs text-muted-foreground">
Required before the interest can leave the &quot;Open&quot; stage.
Required before the interest can leave the New Enquiry stage.
{memberCompanyIds.length > 0 && (
<>
{' '}
@@ -450,10 +596,11 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<div className="space-y-1">
<Label>Stage</Label>
<Select
value={watch('pipelineStage') ?? 'open'}
onValueChange={(v) =>
setValue('pipelineStage', v as (typeof PIPELINE_STAGES)[number])
}
value={watch('pipelineStage') ?? 'enquiry'}
onValueChange={(v) => {
userTouchedStage.current = true;
setValue('pipelineStage', v as (typeof PIPELINE_STAGES)[number]);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select stage" />
@@ -472,12 +619,13 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<Label>Lead Category</Label>
<Select
value={watch('leadCategory') ?? ''}
onValueChange={(v) =>
onValueChange={(v) => {
userTouchedCategory.current = true;
setValue(
'leadCategory',
v ? (v as (typeof LEAD_CATEGORIES)[number]) : undefined,
)
}
);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select category" />
@@ -583,13 +731,19 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
)}
</div>
<Separator />
{/* Tags */}
<div className="space-y-2">
<Label>Tags</Label>
<TagPicker selectedIds={tagIds} onChange={(ids) => setValue('tagIds', ids)} />
</div>
{/* Tags — TagPicker itself returns null when the port has no tags
configured AND the form has nothing selected. We hide the
wrapping label + separator in that same case so an empty
"Tags" header doesn't sit in the form. */}
{(tagIds.length > 0 || tagsAvailable) && (
<>
<Separator />
<div className="space-y-2">
<Label>Tags</Label>
<TagPicker selectedIds={tagIds} onChange={(ids) => setValue('tagIds', ids)} />
</div>
</>
)}
<SheetFooter>
<Button type="button" variant="outline" onClick={requestClose}>

View File

@@ -12,6 +12,9 @@ import {
TagsIcon,
} from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/shared/data-table';
@@ -67,6 +70,13 @@ export function InterestList() {
const { confirm, dialog: confirmDialog } = useConfirmation();
const { viewMode, setViewMode } = usePipelineStore();
// M-U14: surface the page title in the mobile topbar.
const { setChrome } = useMobileChrome();
useEffect(() => {
setChrome({ title: 'Interests', showBackButton: false });
return () => setChrome({ title: null, showBackButton: false });
}, [setChrome]);
// Force the list view at mobile widths even when the user previously
// toggled the kanban from desktop — the board is desktop-only.
useEffect(() => {
@@ -143,7 +153,7 @@ export function InterestList() {
queryClient.invalidateQueries({ queryKey: ['interests'] });
const s = res.data.summary;
if (s.failed > 0) {
alert(
toast.warning(
`${s.succeeded} of ${s.total} succeeded. ${s.failed} failed — check the activity log.`,
);
}
@@ -230,26 +240,30 @@ export function InterestList() {
placeholder="Filter by tag / event…"
/>
</div>
{/* Columns + saved views are table-only concepts; the kanban
* always shows the same compact card across every stage so
* hiding both controls in board mode keeps the toolbar honest. */}
{viewMode === 'table' ? (
<>
<SavedViewsDropdown
entityType="interests"
onApplyView={(savedFilters) => {
setAllFilters(savedFilters);
}}
/>
<ColumnPicker
columns={INTEREST_COLUMN_OPTIONS}
hidden={hidden}
onChange={setHidden}
onSaveView={() => setSaveViewOpen(true)}
/>
</>
) : null}
<StageLegend />
{/* Right-aligned toolbar group: saved views + column picker + stage
legend. `ml-auto` pushes the group to the right edge so it sits
flush with where the table extends to on desktop. Wraps to a new
line on narrow viewports because the outer container is
`flex-wrap`. Kanban view hides the table-only controls. */}
<div className="ml-auto flex flex-wrap items-center gap-2">
{viewMode === 'table' ? (
<>
<SavedViewsDropdown
entityType="interests"
onApplyView={(savedFilters) => {
setAllFilters(savedFilters);
}}
/>
<ColumnPicker
columns={INTEREST_COLUMN_OPTIONS}
hidden={hidden}
onChange={setHidden}
onSaveView={() => setSaveViewOpen(true)}
/>
</>
) : null}
<StageLegend />
</div>
</div>
<SaveViewDialog

View File

@@ -7,6 +7,8 @@ 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 { Input } from '@/components/ui/input';
@@ -14,9 +16,24 @@ 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';
// 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';
@@ -65,6 +82,10 @@ interface InterestTabsOptions {
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;
@@ -83,6 +104,23 @@ interface InterestTabsOptions {
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;
@@ -105,6 +143,7 @@ interface InterestTabsOptions {
id: string;
content: string;
authorId: string;
authorName: string | null;
createdAt: string;
} | null;
tags?: Array<{ id: string; name: string; color: string }>;
@@ -476,12 +515,21 @@ function FutureMilestones({
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();
@@ -530,10 +578,8 @@ function OverviewTab({
// 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 eoiIdx = PIPELINE_STAGES.indexOf('eoi');
const reservationIdx = PIPELINE_STAGES.indexOf('reservation');
const depositIdx = PIPELINE_STAGES.indexOf('deposit_paid');
const contractIdx = PIPELINE_STAGES.indexOf('contract');
// Sub-status carries the "is this milestone's doc actually signed?" bit
// for the doc-bearing stages (eoi / reservation / contract). A milestone
@@ -543,55 +589,41 @@ function OverviewTab({
const reservationSigned = interest.reservationDocStatus === 'signed';
const contractSigned = interest.contractDocStatus === 'signed';
// 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).
// 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 berthInterestPhase: Phase = hasLinkedBerth
? 'past'
: stageIdx === -1 || stageIdx >= eoiIdx
? 'past'
: 'current';
const eoiPhase: Phase =
stageIdx === -1
? 'future'
: stageIdx > eoiIdx || (stageIdx === eoiIdx && eoiSigned)
? 'past'
: stageIdx === eoiIdx
? 'current'
: 'future';
const reservationPhase: Phase =
stageIdx === -1
? 'future'
: stageIdx > reservationIdx || (stageIdx === reservationIdx && reservationSigned)
? 'past'
: stageIdx === reservationIdx
? 'current'
: 'future';
// Deposit becomes 'current' once the reservation is signed; auto-advance
// moves it to 'past' the moment the running deposit total catches up.
const depositPhase: Phase =
stageIdx === -1
? 'future'
: stageIdx > depositIdx
? 'past'
: stageIdx === depositIdx
? 'past'
: stageIdx === reservationIdx && reservationSigned
? 'current'
: 'future';
const contractPhase: Phase =
stageIdx === -1
? 'future'
: stageIdx === contractIdx && contractSigned
? 'past'
: stageIdx === contractIdx
? 'current'
: 'future';
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'
@@ -606,11 +638,8 @@ function OverviewTab({
? '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;
};
// 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';
@@ -659,7 +688,11 @@ function OverviewTab({
label: 'EOI sent',
date: interest.dateEoiSent,
advanceStage: 'eoi',
actionLabel: 'Mark EOI as sent',
// 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',
@@ -667,9 +700,30 @@ function OverviewTab({
// 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',
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',
@@ -778,12 +832,17 @@ function OverviewTab({
{/* 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. */}
<PaymentsSection
interestId={interestId}
depositExpectedAmount={interest.depositExpectedAmount ?? null}
depositExpectedCurrency={interest.depositExpectedCurrency ?? null}
/>
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}
/>
)}
{/* Sales-process milestones — phase-aware so the user only sees
what's actionable now. Past milestones collapse into a tight
@@ -865,12 +924,73 @@ function OverviewTab({
</dl>
</div>
{/* Contact dates (read-only - kept compact next to Lead) */}
{/* 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>
<InfoRow label="First Contact" value={formatDate(interest.dateFirstContact)} />
<InfoRow label="Last Contact" value={formatDate(interest.dateLastContact)} />
<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}
@@ -918,7 +1038,11 @@ function OverviewTab({
addSuffix: true,
})}
{interest.recentNote.authorId
? ` · ${interest.recentNote.authorId === 'system' ? 'system' : interest.recentNote.authorId}`
? ` · ${
interest.recentNote.authorId === 'system'
? 'system'
: (interest.recentNote.authorName ?? 'Unknown')
}`
: ''}
</p>
</div>
@@ -963,8 +1087,19 @@ function OverviewTab({
desiredLengthFt={toNum(interest.desiredLengthFt)}
desiredWidthFt={toNum(interest.desiredWidthFt)}
desiredDraftFt={toNum(interest.desiredDraftFt)}
desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'}
/>
{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>
);
}
@@ -1000,7 +1135,7 @@ export function getInterestTabs({
{
id: 'overview',
label: 'Overview',
content: <OverviewTab interestId={interestId} interest={interest} />,
content: <OverviewTab interestId={interestId} interest={interest} clientId={clientId} />,
},
{
id: 'contact-log',
@@ -1049,7 +1184,15 @@ export function getInterestTabs({
{
id: 'recommendations',
label: 'Recommendations',
content: <RecommendationList interestId={interestId} />,
content: (
<BerthRecommenderPanel
interestId={interestId}
desiredLengthFt={toNum(interest.desiredLengthFt)}
desiredWidthFt={toNum(interest.desiredWidthFt)}
desiredDraftFt={toNum(interest.desiredDraftFt)}
desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'}
/>
),
},
{
id: 'activity',

View File

@@ -274,7 +274,9 @@ function LinkedBerthRowItem({
>
{row.mooringNumber ?? row.berthId}
</Link>
{row.area ? <span className="text-xs text-muted-foreground">{row.area}</span> : null}
{/* `row.area` is the area letter (A, B, C…) which is already the
leading character of the mooring number rendered above, so
surfacing it again is pure noise. Hidden 2026-05-15. */}
<StatusPill status={statusToPill(row.status)}>{formatStatus(row.status)}</StatusPill>
{row.isPrimary ? (
<span className="inline-flex items-center gap-1 rounded-md border border-brand-200 bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-800">
@@ -386,8 +388,8 @@ function LinkedBerthRowItem({
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-[11px] leading-snug">
Include this berth in the EOI&apos;s signed berth range. When on, the berth is
covered by the same signature and shows up in the EOI&apos;s
<strong> Berth Range</strong> form field (e.g. &quot;A1-A3, B5-B7&quot;). Turn off
covered by the same signature and shows up in the EOI&apos;s{' '}
<strong>Berth Range</strong> form field (e.g. &quot;A1-A3, B5-B7&quot;). Turn off
to keep the link without legal coverage.
</TooltipContent>
</Tooltip>
@@ -546,7 +548,7 @@ export function LinkedBerthsList({ interestId }: LinkedBerthsListProps) {
{dealBerth ? renderRow(dealBerth, { highlight: true }) : null}
</BerthSection>
{bundleRows.length > 0 || dealBerth ? (
{bundleRows.length > 0 ? (
<BerthSection
title="In EOI bundle"
hint="Additional berths covered by the same EOI signature. Won't drive templates, but the client's signature applies to all of them."

View File

@@ -30,8 +30,14 @@ export function MultiEoiChip({ interestId }: { interestId: string }) {
staleTime: 60_000,
});
// "In-flight" = the deal actually has more than one ACTIVE EOI the rep
// could be confused by. Excludes terminal statuses (cancelled / voided /
// declined / deleted / completed) and archived rows. Without this filter
// a deal with one active EOI + N cancelled / deleted ones from prior
// attempts surfaces a misleading "N EOIs" warning.
const TERMINAL_STATUSES = new Set(['cancelled', 'voided', 'declined', 'deleted', 'completed']);
const inflight = (data?.data ?? []).filter(
(d) => !d.archivedAt && d.status !== 'voided' && d.status !== 'declined',
(d) => !d.archivedAt && !TERMINAL_STATUSES.has(d.status),
);
if (inflight.length < 2) return null;

View File

@@ -20,6 +20,7 @@ interface QualificationRow {
confirmedAt: string | null;
confirmedBy: string | null;
notes: string | null;
autoSatisfied: boolean;
}
interface QualificationResponse {
@@ -109,7 +110,11 @@ export function QualificationChecklist({
<Checkbox
id={`qual-${c.key}`}
checked={c.confirmed}
disabled={toggleMutation.isPending}
// Auto-satisfied rows can't be unchecked from the UI — the
// underlying data signal would just re-tick the box on the next
// refetch. The rep clears the dimensions tick by removing the
// yacht dims or desired-berth dims from the interest.
disabled={toggleMutation.isPending || c.autoSatisfied}
onCheckedChange={(v) =>
toggleMutation.mutate({ criterionKey: c.key, confirmed: v === true })
}
@@ -118,14 +123,25 @@ export function QualificationChecklist({
<label
htmlFor={`qual-${c.key}`}
className={cn(
'flex-1 text-sm cursor-pointer',
'flex-1 text-sm',
c.autoSatisfied ? 'cursor-default' : 'cursor-pointer',
c.confirmed ? 'text-foreground' : 'text-foreground/90',
)}
>
<span
className={cn('font-medium', c.confirmed && 'line-through text-muted-foreground')}
>
{c.label}
<span className="flex flex-wrap items-center gap-1.5">
<span
className={cn('font-medium', c.confirmed && 'line-through text-muted-foreground')}
>
{c.label}
</span>
{c.autoSatisfied && (
<span
className="rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200"
title="System-derived from data on this interest"
>
Auto
</span>
)}
</span>
{c.description ? (
<p className="mt-0.5 text-xs text-muted-foreground">{c.description}</p>