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:
@@ -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}
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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's
|
||||
no longer in progress.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
|
||||
@@ -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()}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -39,6 +39,7 @@ interface InterestData {
|
||||
id: string;
|
||||
content: string;
|
||||
authorId: string;
|
||||
authorName: string | null;
|
||||
createdAt: string;
|
||||
} | null;
|
||||
berthId: string | null;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 "Open" 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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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's signed berth range. When on, the berth is
|
||||
covered by the same signature and shows up in the EOI's
|
||||
<strong> Berth Range</strong> form field (e.g. "A1-A3, B5-B7"). Turn off
|
||||
covered by the same signature and shows up in the EOI's{' '}
|
||||
<strong>Berth Range</strong> form field (e.g. "A1-A3, B5-B7"). 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."
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user