feat(interests): EOI/contract/reservation tabs + contact log + berth interest milestone + interest list overhaul

Major interest workflow expansion driven by the rapid-fire UX session.

EOI / Contract / Reservation tabs replace the generic Documents tab when
the deal is at the relevant stage — workspace pattern with active-doc
hero, signing progress, paper-signed upload, and history strip. Stage-
conditional visibility wired through interest-tabs.tsx so the tab set
shrinks/expands as the deal moves through the pipeline.

Contact log: per-interaction structured log (channel/direction/summary/
optional follow-up reminder). New `interest_contact_log` table + service
+ tab UI (timeline with channel-coded icons + compose dialog).
auto-creates a reminder when followUpAt is set.

Berth Interest milestone: first milestone in the OverviewTab's pipeline
strip, completes the moment any berth is linked via the junction. Drives
the "have we captured what they want?" sanity check for general_interest
leads before they move to EOI.

Stage-conditional milestones: past phases collapse into a one-liner
strip, current phase expands, future phases hide behind a "Show
upcoming" toggle. Inline stage picker now defers reason capture to an
override-confirm view (only required for illegal transitions, not the
default flow).

Notes blob → threaded: dropped `interests.notes` column entirely; the
threaded `interest_notes` table is the single source of truth. Latest-
note teaser on Overview links into the dedicated Notes tab. Polymorphic
notes service gains aggregated client view (unions client + interest +
yacht notes with source chips and group-by-source toggle).

Berth interest list overhaul:
  - Configurable columns via ColumnPicker (18 toggleable, 5 default-on)
  - Natural-sort SQL ORDER BY on mooring number (A1, A2, A10 not A10, A2)
  - Per-letter row tinting via colored left-border accent + dot in cell
  - Documents tab merged Files (single attachments section)

Topbar improvements:
  - Always-visible back arrow on detail pages (path depth > 2)
  - Breadcrumb-hint store + useBreadcrumbHint hook so detail pages can
    push their entity hierarchy (Clients › Mary Smith › Interest › B17)
  - Tighter spacing, softer separators, 160px crumb truncation

DataTable upgrades:
  - Page-size selector with All option (validator cap raised to 1000)
  - getRowClassName slot for per-row styling (used by berth tinting)
  - Fixed Radix SelectItem crash on empty-string values via __any__
    sentinel (was crashing every list page that opened a select filter)

Interest list:
  - Configurable columns picker
  - Stage cell clickable into detail
  - TagPicker + SavedViewsDropdown sized h-8 to match adjacent buttons
  - Save view moved into ColumnPicker menu; Views button hidden when
    no views are saved
  - Pipeline kanban board endpoint at /api/v1/interests/board with
    minimal projection, 5000-row cap + truncated banner, filter
    pass-through

Mobile chrome + sidebar collapse removed (always-expanded design choice).

User management lists super-admins (was inner-joined on user_port_roles
which excluded global super-admins).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 20:59:28 +02:00
parent 267c2b6d1f
commit 3e4d9d6310
87 changed files with 5593 additions and 902 deletions

View File

@@ -2,9 +2,10 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Check, ChevronDown, Loader2 } from 'lucide-react';
import { AlertTriangle, Check, ChevronDown, ChevronLeft, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Textarea } from '@/components/ui/textarea';
import { apiFetch } from '@/lib/api/client';
@@ -47,37 +48,40 @@ export function InlineStagePicker({
}: InlineStagePickerProps) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [reason, setReason] = useState('');
const [pendingStage, setPendingStage] = useState<string | null>(null);
// When a user picks a stage that isn't a legal next step (and has the
// override permission), the popover transitions into a confirm view
// that asks for a reason before committing. Reasons are not exposed
// for legal transitions — they're stored as audit-log notes on the
// interest's history, accessible via the activity timeline.
const [overrideTarget, setOverrideTarget] = useState<PipelineStage | null>(null);
const [overrideReason, setOverrideReason] = useState('');
const { can } = usePermissions();
const canOverride = can('interests', 'override_stage');
const stage = safeStage(currentStage);
const mutation = useMutation({
mutationFn: async (next: PipelineStage) => {
// Auto-set override:true when the picked stage isn't a legal
// transition AND the user has override_stage. Without this, the
// permission was unreachable from the inline picker (audit R2-M7)
// and users had to fall back to the modal InterestStagePicker.
mutationFn: async ({ next, reason }: { next: PipelineStage; reason: string | null }) => {
const needsOverride = !canTransitionStage(stage, next);
const useOverride = needsOverride && canOverride;
return apiFetch(`/api/v1/interests/${interestId}/stage`, {
method: 'PATCH',
body: {
pipelineStage: next,
reason: reason.trim() || (useOverride ? 'Manual override (inline)' : undefined),
reason: reason ?? (useOverride ? 'Manual override (inline)' : undefined),
override: useOverride || undefined,
},
});
},
onSuccess: (_data, next) => {
onSuccess: (_data, vars) => {
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
setOpen(false);
setReason('');
setOverrideTarget(null);
setOverrideReason('');
setPendingStage(null);
toast.success(`Stage moved to ${STAGE_LABELS[next]}`);
toast.success(`Stage moved to ${STAGE_LABELS[vars.next]}`);
},
onError: (err) => {
setPendingStage(null);
@@ -90,15 +94,40 @@ export function InlineStagePicker({
setOpen(false);
return;
}
const isOverride = !canTransitionStage(stage, next);
if (isOverride && canOverride) {
// Switch into the confirm view rather than firing the mutation
// immediately — overrides bypass the transition guard so a reason
// is genuinely useful for the audit trail.
setOverrideTarget(next);
setOverrideReason('');
return;
}
setPendingStage(next);
mutation.mutate(next);
mutation.mutate({ next, reason: null });
}
function commitOverride() {
if (!overrideTarget) return;
setPendingStage(overrideTarget);
mutation.mutate({
next: overrideTarget,
reason: overrideReason.trim() || 'Manual override (inline)',
});
}
function cancelOverride() {
setOverrideTarget(null);
setOverrideReason('');
}
return (
<Popover
open={open}
onOpenChange={(o) => {
if (!mutation.isPending) setOpen(o);
if (mutation.isPending) return;
setOpen(o);
if (!o) cancelOverride();
}}
>
<PopoverTrigger asChild>
@@ -125,54 +154,122 @@ export function InlineStagePicker({
</PopoverTrigger>
<PopoverContent
align="start"
className="w-64 p-0"
className="w-72 p-0"
onClick={(e) => stopPropagation && e.stopPropagation()}
>
<div className="border-b px-2 py-1">
<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Reason (optional)…"
rows={1}
className="min-h-0 resize-none border-none bg-transparent px-0 py-0.5 text-xs leading-tight shadow-none focus-visible:ring-0"
disabled={mutation.isPending}
/>
</div>
<ul role="listbox" aria-label="Pipeline stages" className="py-1">
{PIPELINE_STAGES.map((s) => {
const isCurrent = s === stage;
const isPending = pendingStage === s && mutation.isPending;
return (
<li key={s}>
<button
type="button"
role="option"
aria-selected={isCurrent}
disabled={mutation.isPending}
onClick={() => pick(s)}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm',
'transition-colors hover:bg-muted/60 disabled:opacity-60',
isCurrent && 'font-medium',
)}
>
{/* Colored chip (mirrors the inline stage badge) - turns
the picker into a visual scan rather than just a list. */}
<span
className={cn('inline-flex h-5 w-3 shrink-0 rounded-sm', STAGE_DOT[s])}
aria-hidden
/>
<span className="flex-1">{STAGE_LABELS[s]}</span>
{isPending ? (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
) : isCurrent ? (
<Check className="size-3.5 text-muted-foreground" />
) : null}
</button>
</li>
);
})}
</ul>
{overrideTarget ? (
// Confirm-override view: only reached when the user picked a
// stage that isn't a legal next step. Reason is optional but
// strongly nudged for the audit log.
<div className="p-3 space-y-3">
<div className="flex items-start gap-2">
<AlertTriangle className="size-4 shrink-0 text-amber-600 mt-0.5" />
<div className="text-sm">
<p className="font-medium text-foreground">Override transition</p>
<p className="text-xs text-muted-foreground">
{STAGE_LABELS[stage]} {STAGE_LABELS[overrideTarget]} isn&apos;t a standard next
step. The change will be flagged in the audit log.
</p>
</div>
</div>
<div>
<label
htmlFor="stage-override-reason"
className="text-xs font-medium text-muted-foreground"
>
Reason (optional but recommended)
</label>
<Textarea
id="stage-override-reason"
value={overrideReason}
onChange={(e) => setOverrideReason(e.target.value)}
placeholder="e.g. Skipping EOI, client signed contract directly"
rows={2}
className="mt-1 text-sm"
disabled={mutation.isPending}
autoFocus
/>
</div>
<div className="flex items-center justify-between gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={cancelOverride}
disabled={mutation.isPending}
className="gap-1"
>
<ChevronLeft className="size-3.5" />
Back
</Button>
<Button
type="button"
size="sm"
onClick={commitOverride}
disabled={mutation.isPending}
>
{mutation.isPending && <Loader2 className="size-3.5 animate-spin mr-1" />}
Confirm override
</Button>
</div>
</div>
) : (
// Default view: just the stage list. No upfront textarea —
// earlier UX put a "Reason (optional)…" field at the top
// which read as visually noisy for the >90% of changes that
// are normal transitions and never get a reason attached.
<ul role="listbox" aria-label="Pipeline stages" className="py-1">
{PIPELINE_STAGES.map((s) => {
const isCurrent = s === stage;
const isPending = pendingStage === s && mutation.isPending;
const isOverride = s !== stage && !canTransitionStage(stage, s);
const blockedByPermission = isOverride && !canOverride;
return (
<li key={s}>
<button
type="button"
role="option"
aria-selected={isCurrent}
disabled={mutation.isPending || blockedByPermission}
onClick={() => pick(s)}
title={
blockedByPermission
? `Override required (you don't have permission)`
: isOverride
? 'Non-standard transition — confirm step required'
: undefined
}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm',
'transition-colors hover:bg-muted/60 disabled:opacity-50 disabled:cursor-not-allowed',
isCurrent && 'font-medium',
)}
>
{/* Colored chip (mirrors the inline stage badge) — turns
the picker into a visual scan rather than just a list. */}
<span
className={cn('inline-flex h-5 w-3 shrink-0 rounded-sm', STAGE_DOT[s])}
aria-hidden
/>
<span className="flex-1">{STAGE_LABELS[s]}</span>
{isPending ? (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
) : isCurrent ? (
<Check className="size-3.5 text-muted-foreground" />
) : isOverride && canOverride ? (
<span
className="text-[10px] uppercase tracking-wide text-amber-600"
title="Override required"
>
</span>
) : null}
</button>
</li>
);
})}
</ul>
)}
</PopoverContent>
</Popover>
);

View File

@@ -72,6 +72,29 @@ const SOURCE_LABELS: Record<string, string> = {
broker: 'Broker',
};
/**
* Toggleable columns for the InterestList ColumnPicker. `actions` and
* `clientName` are intentionally omitted from this list — actions is a
* row-control column that should never be hidden, and clientName is the
* primary entity identifier (a row with no name has no useful purpose).
*/
export const INTEREST_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [
{ id: 'yachtName', label: 'Yacht' },
{ id: 'berthMooringNumber', label: 'Berth' },
{ id: 'desiredSize', label: 'Desired size' },
{ id: 'pipelineStage', label: 'Stage' },
{ id: 'eoiStatus', label: 'EOI status' },
{ id: 'source', label: 'Source' },
{ id: 'dateLastContact', label: 'Last contact' },
];
/**
* Columns hidden by default for users who haven't customised their view.
* Keep the busy `desiredSize` and `eoiStatus` collapsed by default —
* power-users can turn them back on via the column picker.
*/
export const INTEREST_DEFAULT_HIDDEN: string[] = ['desiredSize', 'eoiStatus'];
const EOI_STATUS_LABELS: Record<string, { label: string; tone: string }> = {
waiting_for_signatures: { label: 'Waiting', tone: 'bg-amber-100 text-amber-900' },
signed: { label: 'Signed', tone: 'bg-emerald-100 text-emerald-900' },
@@ -176,7 +199,10 @@ export function getInterestColumns({
const stage = row.original.pipelineStage;
const badges = computeUrgencyBadges(row.original satisfies InterestUrgencyInput);
return (
<div className="flex flex-col gap-1 items-start">
<Link
href={`/${portSlug}/interests/${row.original.id}`}
className="flex flex-col gap-1 items-start hover:opacity-80 transition-opacity"
>
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${stageBadgeClass(stage)}`}
>
@@ -196,7 +222,7 @@ export function getInterestColumns({
))}
</div>
) : null}
</div>
</Link>
);
},
},

View File

@@ -0,0 +1,435 @@
'use client';
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
Bell,
CalendarDays,
Mail,
MessageCircle,
MoreVertical,
Phone,
Pencil,
Plus,
Trash2,
Users,
Video,
} from 'lucide-react';
import { format, formatDistanceToNowStrict } from 'date-fns';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { Textarea } from '@/components/ui/textarea';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { cn } from '@/lib/utils';
interface InterestContactLogTabProps {
interestId: string;
}
type Channel = 'email' | 'phone' | 'whatsapp' | 'in_person' | 'video' | 'other';
type Direction = 'outbound' | 'inbound';
interface ContactLogEntry {
id: string;
occurredAt: string;
channel: Channel;
direction: Direction;
summary: string;
followUpAt: string | null;
reminderId: string | null;
createdBy: string;
createdAt: string;
updatedAt: string;
}
const CHANNEL_META: Record<Channel, { label: string; icon: typeof Phone; tone: string }> = {
email: { label: 'Email', icon: Mail, tone: 'bg-sky-100 text-sky-700' },
phone: { label: 'Phone', icon: Phone, tone: 'bg-emerald-100 text-emerald-700' },
whatsapp: { label: 'WhatsApp', icon: MessageCircle, tone: 'bg-emerald-100 text-emerald-700' },
in_person: { label: 'In person', icon: Users, tone: 'bg-amber-100 text-amber-800' },
video: { label: 'Video', icon: Video, tone: 'bg-violet-100 text-violet-700' },
other: { label: 'Other', icon: CalendarDays, tone: 'bg-slate-100 text-slate-700' },
};
/**
* Per-interaction contact log. Sales reps log every email / call /
* WhatsApp / meeting touch with the client here so the team has a
* structured history of "what was the last conversation about" — not
* just the bare "last contact 8d ago" timestamp on the interest.
*
* Each entry can optionally schedule a follow-up that auto-creates a
* reminder pointing back at the interest. Editing the entry's
* follow-up date keeps the linked reminder in sync; deleting the
* entry removes the reminder.
*/
export function InterestContactLogTab({ interestId }: InterestContactLogTabProps) {
const [composeOpen, setComposeOpen] = useState(false);
const [editTarget, setEditTarget] = useState<ContactLogEntry | null>(null);
const { data: res, isLoading } = useQuery<{ data: ContactLogEntry[] }>({
queryKey: ['interests', interestId, 'contact-log'],
queryFn: () =>
apiFetch<{ data: ContactLogEntry[] }>(`/api/v1/interests/${interestId}/contact-log`),
});
const entries = res?.data ?? [];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-foreground">Contact log</h3>
<p className="text-xs text-muted-foreground">
Record each conversation. The most recent log entry sets the &ldquo;Last contact&rdquo;
chip on the interest header.
</p>
</div>
<Button size="sm" onClick={() => setComposeOpen(true)} className="gap-1.5">
<Plus className="size-4" />
Log contact
</Button>
</div>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-20 rounded-lg" />
<Skeleton className="h-20 rounded-lg" />
</div>
) : entries.length === 0 ? (
<EmptyState onAdd={() => setComposeOpen(true)} />
) : (
<ol className="space-y-2">
{entries.map((e) => (
<ContactLogRow key={e.id} entry={e} interestId={interestId} onEdit={setEditTarget} />
))}
</ol>
)}
<ComposeDialog interestId={interestId} open={composeOpen} onOpenChange={setComposeOpen} />
{editTarget && (
<ComposeDialog
interestId={interestId}
existing={editTarget}
open={!!editTarget}
onOpenChange={(o) => !o && setEditTarget(null)}
/>
)}
</div>
);
}
// ─── Row ─────────────────────────────────────────────────────────────────────
function ContactLogRow({
entry,
interestId,
onEdit,
}: {
entry: ContactLogEntry;
interestId: string;
onEdit: (e: ContactLogEntry) => void;
}) {
const queryClient = useQueryClient();
const channelMeta = CHANNEL_META[entry.channel];
const Icon = channelMeta.icon;
const deleteMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/contact-log/${entry.id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'contact-log'] });
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
toast.success('Contact log entry deleted.');
},
onError: (err) => toastError(err),
});
return (
<li className="rounded-lg border bg-background p-3">
<div className="flex items-start gap-3">
<span
aria-hidden
className={cn(
'mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full',
channelMeta.tone,
)}
>
<Icon className="size-3.5" />
</span>
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-foreground">{channelMeta.label}</span>
<Badge variant="outline" className="text-[10px] capitalize">
{entry.direction}
</Badge>
<span className="text-xs text-muted-foreground">
{format(new Date(entry.occurredAt), 'MMM d, yyyy · HH:mm')}
</span>
<span className="text-xs text-muted-foreground">
({formatDistanceToNowStrict(new Date(entry.occurredAt))} ago)
</span>
</div>
<p className="text-sm text-foreground whitespace-pre-wrap">{entry.summary}</p>
{entry.followUpAt && (
<p className="inline-flex items-center gap-1.5 rounded-md border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs text-amber-900">
<Bell className="size-3" />
Follow up {format(new Date(entry.followUpAt), 'MMM d, yyyy')} (reminder created)
</p>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7" aria-label="Row actions">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(entry)}>
<Pencil className="mr-2 size-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
disabled={deleteMutation.isPending}
onClick={() => {
if (window.confirm('Delete this contact log entry?')) {
deleteMutation.mutate();
}
}}
>
<Trash2 className="mr-2 size-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</li>
);
}
// ─── Empty state ─────────────────────────────────────────────────────────────
function EmptyState({ onAdd }: { onAdd: () => void }) {
return (
<div className="rounded-xl border border-dashed bg-muted/20 p-8 text-center">
<div className="mx-auto flex size-12 items-center justify-center rounded-full bg-background text-muted-foreground">
<Phone className="size-5" />
</div>
<h3 className="mt-3 text-sm font-medium text-foreground">No contact logged yet</h3>
<p className="mt-1 text-xs text-muted-foreground">
Record every call, email, and meeting so the team has full context the next time someone
picks up the deal.
</p>
<Button size="sm" onClick={onAdd} className="mt-4 gap-1.5">
<Plus className="size-3.5" />
Log first contact
</Button>
</div>
);
}
// ─── Compose / edit dialog ───────────────────────────────────────────────────
function ComposeDialog({
interestId,
existing,
open,
onOpenChange,
}: {
interestId: string;
existing?: ContactLogEntry;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const queryClient = useQueryClient();
const isEdit = !!existing;
const defaultOccurredAt = useMemo(() => {
if (existing) return localIsoString(existing.occurredAt);
return localIsoString(new Date().toISOString());
}, [existing]);
const [occurredAt, setOccurredAt] = useState<string>(defaultOccurredAt);
const [channel, setChannel] = useState<Channel>(existing?.channel ?? 'phone');
const [direction, setDirection] = useState<Direction>(existing?.direction ?? 'outbound');
const [summary, setSummary] = useState<string>(existing?.summary ?? '');
const [followUpAt, setFollowUpAt] = useState<string>(
existing?.followUpAt ? localIsoString(existing.followUpAt) : '',
);
// Re-sync local state when the existing entry changes (e.g. opening
// the edit dialog for a different row).
useMemo(() => {
if (open) {
setOccurredAt(
existing ? localIsoString(existing.occurredAt) : localIsoString(new Date().toISOString()),
);
setChannel(existing?.channel ?? 'phone');
setDirection(existing?.direction ?? 'outbound');
setSummary(existing?.summary ?? '');
setFollowUpAt(existing?.followUpAt ? localIsoString(existing.followUpAt) : '');
}
}, [open, existing]);
const mutation = useMutation({
mutationFn: async () => {
const body = {
occurredAt: new Date(occurredAt).toISOString(),
channel,
direction,
summary,
followUpAt: followUpAt ? new Date(followUpAt).toISOString() : null,
};
if (isEdit) {
return apiFetch(`/api/v1/contact-log/${existing!.id}`, {
method: 'PATCH',
body,
});
}
return apiFetch(`/api/v1/interests/${interestId}/contact-log`, {
method: 'POST',
body,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'contact-log'] });
// Bump the parent interest cache so the "Last contact" header chip
// updates without a refresh.
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
toast.success(isEdit ? 'Contact log entry updated.' : 'Contact logged.');
onOpenChange(false);
},
onError: (err) => toastError(err),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit contact log entry' : 'Log a contact'}</DialogTitle>
<DialogDescription>
Record the channel, the direction, and what was discussed. Optionally schedule a
follow-up a reminder will be created automatically.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-1">
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="cl-channel">Channel</Label>
<Select value={channel} onValueChange={(v) => setChannel(v as Channel)}>
<SelectTrigger id="cl-channel">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(CHANNEL_META) as Channel[]).map((c) => (
<SelectItem key={c} value={c}>
{CHANNEL_META[c].label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="cl-direction">Direction</Label>
<Select value={direction} onValueChange={(v) => setDirection(v as Direction)}>
<SelectTrigger id="cl-direction">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="outbound">Outbound (you reached out)</SelectItem>
<SelectItem value="inbound">Inbound (they reached out)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="cl-occurred">When did the conversation happen?</Label>
<Input
id="cl-occurred"
type="datetime-local"
value={occurredAt}
onChange={(e) => setOccurredAt(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="cl-summary">Summary</Label>
<Textarea
id="cl-summary"
placeholder="e.g. Confirmed yacht size, asked about tax structure, said they'll respond after their accountant reviews."
rows={4}
value={summary}
onChange={(e) => setSummary(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="cl-followup">Follow up by (optional creates a reminder)</Label>
<Input
id="cl-followup"
type="datetime-local"
value={followUpAt}
onChange={(e) => setFollowUpAt(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={mutation.isPending}
>
Cancel
</Button>
<Button
onClick={() => mutation.mutate()}
disabled={mutation.isPending || summary.trim().length === 0}
>
{mutation.isPending ? 'Saving…' : isEdit ? 'Save changes' : 'Log contact'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/**
* Convert an ISO string into the `YYYY-MM-DDTHH:mm` format that
* `<input type="datetime-local">` expects, in the user's local
* timezone. (Browsers don't accept the trailing `Z` in this input
* type and reject anything with a timezone offset.)
*/
function localIsoString(iso: string): string {
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}

View File

@@ -0,0 +1,416 @@
'use client';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
AlertTriangle,
CheckCircle2,
ExternalLink,
FileSignature,
Loader2,
RefreshCw,
Upload,
XCircle,
} from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
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 { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/ui-store';
interface InterestContractTabProps {
interestId: string;
clientId: string | null;
}
interface DocumentRow {
id: string;
documentType: string;
title: string;
status: 'draft' | 'sent' | 'partially_signed' | 'completed' | 'expired' | 'cancelled';
createdAt: string;
signers?: Array<{ status: string }>;
}
interface DocumentSigner {
id: string;
signerName: string;
signerEmail: string;
signerRole: string;
signingOrder: number;
status: string;
signedAt?: string | null;
}
const STATUS_LABELS: Record<DocumentRow['status'], string> = {
draft: 'Draft',
sent: 'Awaiting signatures',
partially_signed: 'Partially signed',
completed: 'Signed',
expired: 'Expired',
cancelled: 'Cancelled',
};
const STATUS_TONES: Record<DocumentRow['status'], string> = {
draft: 'bg-slate-100 text-slate-700',
sent: 'bg-blue-100 text-blue-700',
partially_signed: 'bg-amber-100 text-amber-800',
completed: 'bg-emerald-100 text-emerald-700',
expired: 'bg-rose-100 text-rose-700',
cancelled: 'bg-slate-200 text-slate-600',
};
const ACTIVE_STATUSES = new Set<DocumentRow['status']>(['draft', 'sent', 'partially_signed']);
/**
* Dedicated Contract workspace tab. Mirrors the EOI tab pattern but
* for sales contracts. Contracts differ from EOIs in that there's no
* standard Documenso template — each contract is drafted custom per
* deal. So the active flows are:
*
* 1. **Upload paper-signed copy** — the signed contract was handled
* outside the system; rep uploads the PDF for the record.
*
* 2. **Upload draft for Documenso signing** — rep uploads the PDF
* draft, configures signers + signing order + signature field
* placement, then sends via Documenso. (Recipient configurator
* and field-placement UI are the bigger pieces; for v1 a default
* footer-anchored signature layout is used.)
*
* The Documents tab still shows every contract document (signed or
* drafted) as a permanent history.
*/
export function InterestContractTab({ interestId, clientId: _clientId }: InterestContractTabProps) {
const portSlug = useUIStore((s) => s.currentPortSlug);
const [uploadSignedOpen, setUploadSignedOpen] = useState(false);
const [uploadForSigningOpen, setUploadForSigningOpen] = useState(false);
const { data: docsRes, isLoading: docsLoading } = useQuery<{ data: DocumentRow[] }>({
queryKey: ['documents', { interestId, documentType: 'contract' }],
queryFn: () =>
apiFetch<{ data: DocumentRow[] }>(
`/api/v1/documents?interestId=${interestId}&documentType=contract`,
),
});
const docs = docsRes?.data ?? [];
const activeDoc = useMemo(() => docs.find((d) => ACTIVE_STATUSES.has(d.status)) ?? null, [docs]);
const completedDocs = useMemo(() => docs.filter((d) => !ACTIVE_STATUSES.has(d.status)), [docs]);
return (
<div className="space-y-5">
{docsLoading ? (
<Skeleton className="h-44 w-full rounded-lg" />
) : activeDoc ? (
<ActiveContractCard
doc={activeDoc}
portSlug={portSlug ?? null}
onUploadSigned={() => setUploadSignedOpen(true)}
/>
) : (
<EmptyContractState
onUploadSigned={() => setUploadSignedOpen(true)}
onUploadForSigning={() => setUploadForSigningOpen(true)}
/>
)}
{completedDocs.length > 0 && (
<section className="rounded-lg border bg-background">
<header className="flex items-center justify-between border-b px-4 py-2.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Contract history
</h3>
<span className="text-xs text-muted-foreground">
{completedDocs.length} {completedDocs.length === 1 ? 'document' : 'documents'}
</span>
</header>
<ul className="divide-y">
{completedDocs.map((d) => (
<li key={d.id} className="flex items-center gap-3 px-4 py-2.5 text-sm">
<StatusBadge status={d.status} />
<span className="flex-1 truncate font-medium">{d.title}</span>
<span className="text-xs text-muted-foreground">
{new Date(d.createdAt).toLocaleDateString()}
</span>
{portSlug && (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/documents/${d.id}` as any}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
Open
<ExternalLink className="size-3" />
</Link>
)}
</li>
))}
</ul>
</section>
)}
{/* Reuses the external-EOI upload dialog. The endpoint
`/api/v1/interests/{id}/external-eoi` is EOI-specific today
— for contract paper-uploads we'll need the equivalent
contract endpoint (deferred to a follow-up; the dialog UI
is the pattern we'll clone). For now the flow is documented
as 'coming soon' rather than misrouting through EOI. */}
{uploadSignedOpen && (
<ExternalEoiUploadDialog
open={uploadSignedOpen}
onOpenChange={setUploadSignedOpen}
interestId={interestId}
/>
)}
{/* Upload-for-Documenso-signing dialog placeholder. The real
dialog (PDF picker + recipient configurator + send button)
is part of the larger custom-doc-upload service that's a
follow-up. For now show a friendly "coming soon" card. */}
{uploadForSigningOpen && (
<ComingSoonDialog
open={uploadForSigningOpen}
onOpenChange={setUploadForSigningOpen}
title="Send contract for signing"
body="Upload-and-send-via-Documenso for contracts is being built. For now, draft the contract externally, get it signed via paper or another tool, then upload the signed copy here."
/>
)}
</div>
);
}
// ─── Active contract hero ────────────────────────────────────────────────────
function ActiveContractCard({
doc,
portSlug,
onUploadSigned,
}: {
doc: DocumentRow;
portSlug: string | null;
onUploadSigned: () => void;
}) {
const queryClient = useQueryClient();
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,
});
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('Contract cancelled.');
},
onError: (err) => toastError(err),
});
const remindAllMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/documents/${doc.id}/remind`, { method: 'POST', body: {} }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['documents', doc.id, 'signers'] });
toast.success('Reminder sent.');
},
onError: (err) => toastError(err),
});
return (
<section className="rounded-xl border bg-gradient-brand-soft p-5 shadow-xs">
<header className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<FileSignature className="size-4 text-foreground" />
<h2 className="truncate text-base font-semibold text-foreground">{doc.title}</h2>
<StatusBadge status={doc.status} />
</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 && (
<Button asChild variant="outline" size="sm" className="gap-1.5 [&_svg]:size-3.5">
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/documents/${doc.id}` as any}
>
Open
<ExternalLink />
</Link>
</Button>
)}
{!allSigned && (
<Button
variant="outline"
size="sm"
disabled={remindAllMutation.isPending}
onClick={() => remindAllMutation.mutate()}
className="gap-1.5 [&_svg]:size-3.5"
>
{remindAllMutation.isPending ? <Loader2 className="animate-spin" /> : <RefreshCw />}
Remind all
</Button>
)}
</div>
</header>
<div className="mt-4 rounded-lg border bg-background p-4">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Signing progress
</h3>
{signersLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" /> Loading signers
</div>
) : signers.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
Documenso hasn&apos;t reported signers yet check back in a moment.
</p>
) : (
<SigningProgress documentId={doc.id} signers={signers} />
)}
</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" />
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={() => {
if (window.confirm('Cancel this contract? Signers will no longer be able to sign.')) {
cancelMutation.mutate();
}
}}
className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3"
>
<XCircle />
Cancel contract
</Button>
</div>
</footer>
</section>
);
}
// ─── Empty state ─────────────────────────────────────────────────────────────
function EmptyContractState({
onUploadSigned,
onUploadForSigning,
}: {
onUploadSigned: () => void;
onUploadForSigning: () => void;
}) {
return (
<section className="rounded-xl border border-dashed bg-muted/20 p-8 text-center">
<div className="mx-auto flex size-14 items-center justify-center rounded-full bg-background text-muted-foreground">
<FileSignature className="size-6" />
</div>
<h2 className="mt-4 text-base font-semibold text-foreground">
No contract in flight for this interest
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Sales contracts are drafted custom per deal. Either upload a paper-signed copy you handled
externally, or upload the draft PDF and send for e-signing via Documenso.
</p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
<Button onClick={onUploadForSigning} size="sm" className="gap-1.5">
<FileSignature className="size-4" />
Upload draft for signing
</Button>
<Button onClick={onUploadSigned} variant="outline" size="sm" className="gap-1.5">
<Upload className="size-4" />
Upload paper-signed copy
</Button>
</div>
</section>
);
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function StatusBadge({ status }: { status: DocumentRow['status'] }) {
return (
<Badge
variant="outline"
className={cn(
'border-transparent text-[10px] font-semibold uppercase tracking-wide',
STATUS_TONES[status],
)}
>
{status === 'completed' && <CheckCircle2 className="mr-1 size-3" />}
{STATUS_LABELS[status]}
</Badge>
);
}
/**
* Placeholder for the upload-for-Documenso-signing flow until the
* full upload + recipient + field-placement service is shipped.
* Intentional dead-end so reps know the path exists rather than
* misclicking and getting confusing behaviour.
*/
function ComingSoonDialog({
open,
onOpenChange,
title,
body,
}: {
open: boolean;
onOpenChange: (next: boolean) => void;
title: string;
body: string;
}) {
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={() => onOpenChange(false)}
>
<div
className="max-w-md rounded-lg border bg-background p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-base font-semibold text-foreground">{title}</h3>
<p className="mt-2 text-sm text-muted-foreground">{body}</p>
<div className="mt-4 flex justify-end">
<Button onClick={() => onOpenChange(false)} size="sm" variant="outline">
Got it
</Button>
</div>
</div>
</div>
);
}

View File

@@ -13,7 +13,6 @@ import {
MessageCircle,
Phone,
AlarmClock,
Upload,
} from 'lucide-react';
import Link from 'next/link';
@@ -24,7 +23,6 @@ import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { PermissionGate } from '@/components/shared/permission-gate';
import { InterestForm } from '@/components/interests/interest-form';
import { ExternalEoiUploadDialog } from '@/components/interests/external-eoi-upload-dialog';
import { InlineStagePicker } from '@/components/interests/inline-stage-picker';
import { InterestOutcomeDialog } from '@/components/interests/interest-outcome-dialog';
import { apiFetch } from '@/lib/api/client';
@@ -104,7 +102,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
const [editOpen, setEditOpen] = useState(false);
const [archiveOpen, setArchiveOpen] = useState(false);
const [outcomeDialog, setOutcomeDialog] = useState<null | 'won' | 'lost'>(null);
const [externalEoiOpen, setExternalEoiOpen] = useState(false);
// (Upload-paper-signed-EOI dialog moved to the EOI tab.)
const isArchived = !!interest.archivedAt;
const outcomeBadge = resolveOutcomeBadge(interest.outcome);
@@ -221,7 +219,6 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
<InlineStagePicker
interestId={interest.id}
currentStage={interest.pipelineStage}
className="-ml-2.5"
/>
</PermissionGate>
)}
@@ -379,20 +376,12 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
</>
)}
</PermissionGate>
<PermissionGate resource="documents" action="upload_signed">
<button
type="button"
onClick={() => setExternalEoiOpen(true)}
aria-label="Upload externally-signed EOI"
title="Upload externally-signed EOI (paper / outside Documenso)"
className={cn(
'rounded-md p-1.5 text-muted-foreground/70 transition-colors',
'hover:bg-foreground/5 hover:text-foreground',
)}
>
<Upload className="size-4" />
</button>
</PermissionGate>
{/* The "Upload paper-signed EOI" button used to live here.
It's now on the dedicated EOI tab (in both the active-EOI
hero and the empty-state CTA row), where it sits next to
the document it relates to. The header was a shotgun of
actions that didn't all belong; collecting them per-tab
is the cleaner UX. */}
<PermissionGate resource="interests" action="edit">
<button
type="button"
@@ -456,12 +445,6 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
}}
isLoading={archiveMutation.isPending || restoreMutation.isPending}
/>
<ExternalEoiUploadDialog
open={externalEoiOpen}
onOpenChange={setExternalEoiOpen}
interestId={interest.id}
/>
</>
);
}

View File

@@ -9,6 +9,7 @@ import { InterestDetailHeader } from '@/components/interests/interest-detail-hea
import { getInterestTabs } from '@/components/interests/interest-tabs';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
import { apiFetch } from '@/lib/api/client';
interface InterestData {
@@ -102,7 +103,30 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp
return () => setChrome({ title: null, showBackButton: false });
}, [titleForChrome, setChrome]);
const tabs = data ? getInterestTabs({ interestId, currentUserId, interest: data }) : [];
// Topbar breadcrumb: Clients Mary Smith Interest B17.
// Parent client links straight back to the client detail; the
// current crumb is the primary berth's mooring (or "Interest" if
// no berth linked yet — same trick the page H1 uses).
useBreadcrumbHint(
data
? {
parents:
data.clientId && data.clientName
? [{ label: data.clientName, href: `/${portSlug}/clients/${data.clientId}` }]
: [],
current: data.berthMooringNumber ?? 'Interest',
}
: null,
);
const tabs = data
? getInterestTabs({
interestId,
currentUserId,
clientId: data.clientId ?? null,
interest: data,
})
: [];
return (
<DetailLayout

View File

@@ -1,12 +1,18 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { FileSignature } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { DocumentList } from '@/components/documents/document-list';
import { EoiGenerateDialog } from '@/components/documents/eoi-generate-dialog';
import { FileGrid, type FileRow } from '@/components/files/file-grid';
import { FileUploadZone } from '@/components/files/file-upload-zone';
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
import { PermissionGate } from '@/components/shared/permission-gate';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
interface InterestDocumentsTabProps {
@@ -15,70 +21,151 @@ interface InterestDocumentsTabProps {
interface InterestData {
id: string;
yachtId?: string | null;
berthId?: string | null;
clientName?: string | null;
/** Surfaced by getInterestById for the EOI prerequisites checklist. */
clientPrimaryEmail?: string | null;
clientHasAddress?: boolean;
clientId?: string | null;
}
/**
* Documents tab — legal instruments (EOI / contract / reservation) with
* full signing status, plus an Attachments section for any other file the
* rep wants on the deal. Replaces the standalone Files tab — at the
* interest level virtually everything is either a legal doc or rare
* one-off, and a separate tab was dead weight 95% of the time.
*/
export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps) {
const queryClient = useQueryClient();
const [eoiDialogOpen, setEoiDialogOpen] = useState(false);
const [previewFile, setPreviewFile] = useState<FileRow | null>(null);
// Same query key + queryFn shape as InterestDetail's parent query, so the
// cache is consistent. (Mismatched shapes on the same key clobber each other
// and the parent header degenerates to "Unknown Client".)
const { data: interest } = useQuery<InterestData>({
queryKey: ['interests', interestId],
queryFn: () =>
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then((r) => r.data),
});
const prerequisites = {
// Required (EOI Section 2 - top paragraph): name, address, email.
hasName: Boolean(interest?.clientName),
hasEmail: Boolean(interest?.clientPrimaryEmail),
hasAddress: Boolean(interest?.clientHasAddress),
// Optional (EOI Section 3): yacht + berth. Render blank when absent.
hasYacht: Boolean(interest?.yachtId),
hasBerth: Boolean(interest?.berthId),
// Files attach at the client level (the schema has no interest_id
// FK on `files`). For an interest, surface every file that belongs
// to its parent client — covers the realistic case where a rep
// uploaded a passport / scan / photo while working a deal.
// Until the interest record loads we pass a sentinel clientId so the
// server returns empty rather than the unscoped port-wide file list.
const clientId = interest?.clientId ?? '__pending__';
const filesQueryKey = ['files', { clientId }] as const;
const { data: files, isLoading: filesLoading } = usePaginatedQuery<FileRow>({
queryKey: filesQueryKey,
endpoint: `/api/v1/files?clientId=${encodeURIComponent(clientId)}`,
filterDefinitions: [],
});
useRealtimeInvalidation({
'file:uploaded': [filesQueryKey],
'file:updated': [filesQueryKey],
'file:deleted': [filesQueryKey],
});
const handleDownload = async (file: FileRow) => {
try {
const res = await apiFetch<{ data: { url: string; filename: string } }>(
`/api/v1/files/${file.id}/download`,
);
const a = document.createElement('a');
a.href = res.data.url;
a.download = res.data.filename;
a.click();
} catch {
// silent
}
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground">Documents</h3>
<Button size="sm" variant="outline" onClick={() => setEoiDialogOpen(true)}>
Generate EOI
</Button>
</div>
const handleDelete = async (file: FileRow) => {
if (!confirm(`Delete "${file.filename}"? This cannot be undone.`)) return;
try {
await apiFetch(`/api/v1/files/${file.id}`, { method: 'DELETE' });
queryClient.invalidateQueries({ queryKey: filesQueryKey });
} catch {
// silent
}
};
<DocumentList
interestId={interestId}
emptyState={
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-border bg-muted/20 px-6 py-10 text-center">
<div className="flex size-10 items-center justify-center rounded-full bg-background text-muted-foreground">
<FileSignature className="size-5" />
const hasAttachments = files.length > 0;
return (
<div className="space-y-8">
<section className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground">Legal documents</h3>
<Button size="sm" variant="outline" onClick={() => setEoiDialogOpen(true)}>
Generate EOI
</Button>
</div>
<DocumentList
interestId={interestId}
emptyState={
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-border bg-muted/20 px-6 py-10 text-center">
<div className="flex size-10 items-center justify-center rounded-full bg-background text-muted-foreground">
<FileSignature className="size-5" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">No documents yet</p>
<p className="text-xs text-muted-foreground">
Generate the EOI to send it for signing in one click.
</p>
</div>
<Button size="sm" onClick={() => setEoiDialogOpen(true)}>
Generate EOI
</Button>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">No documents yet</p>
<p className="text-xs text-muted-foreground">
Generate the EOI to send it for signing in one click.
</p>
</div>
<Button size="sm" onClick={() => setEoiDialogOpen(true)}>
Generate EOI
</Button>
</div>
}
/>
}
/>
</section>
<section className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground">Attachments</h3>
{hasAttachments ? (
<span className="text-xs text-muted-foreground">
{files.length} file{files.length === 1 ? '' : 's'}
</span>
) : null}
</div>
<PermissionGate resource="files" action="upload">
{interest?.clientId ? (
<FileUploadZone
entityType="client"
entityId={interest.clientId}
onUploadComplete={() => {
queryClient.invalidateQueries({ queryKey: filesQueryKey });
}}
/>
) : null}
</PermissionGate>
{hasAttachments ? (
<FileGrid
files={files}
onDownload={handleDownload}
onPreview={setPreviewFile}
onRename={() => {}}
onDelete={handleDelete}
isLoading={filesLoading}
/>
) : null}
</section>
<EoiGenerateDialog
interestId={interestId}
clientId={interest?.clientId ?? null}
open={eoiDialogOpen}
onOpenChange={setEoiDialogOpen}
prerequisites={prerequisites}
/>
<FilePreviewDialog
open={!!previewFile}
onOpenChange={(open) => !open && setPreviewFile(null)}
fileId={previewFile?.id}
fileName={previewFile?.filename}
mimeType={previewFile?.mimeType ?? undefined}
/>
</div>
);

View File

@@ -0,0 +1,362 @@
'use client';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
AlertTriangle,
CheckCircle2,
ExternalLink,
FileSignature,
Loader2,
RefreshCw,
Upload,
XCircle,
} from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
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 { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/ui-store';
interface InterestEoiTabProps {
interestId: string;
/** Used by the generate dialog to deep-link to the client's record. */
clientId: string | null;
}
interface DocumentRow {
id: string;
documentType: string;
title: string;
status: 'draft' | 'sent' | 'partially_signed' | 'completed' | 'expired' | 'cancelled';
createdAt: string;
signers?: Array<{ status: string }>;
}
interface DocumentSigner {
id: string;
signerName: string;
signerEmail: string;
signerRole: string;
signingOrder: number;
status: string;
signedAt?: string | null;
}
const STATUS_LABELS: Record<DocumentRow['status'], string> = {
draft: 'Draft',
sent: 'Awaiting signatures',
partially_signed: 'Partially signed',
completed: 'Signed',
expired: 'Expired',
cancelled: 'Cancelled',
};
const STATUS_TONES: Record<DocumentRow['status'], string> = {
draft: 'bg-slate-100 text-slate-700',
sent: 'bg-blue-100 text-blue-700',
partially_signed: 'bg-amber-100 text-amber-800',
completed: 'bg-emerald-100 text-emerald-700',
expired: 'bg-rose-100 text-rose-700',
cancelled: 'bg-slate-200 text-slate-600',
};
const ACTIVE_STATUSES = new Set<DocumentRow['status']>(['draft', 'sent', 'partially_signed']);
/**
* Dedicated EOI workspace tab. The user's "where do I generate / track
* the EOI for this deal" surface, separate from the generic Documents
* tab (which is the long-tail history of every document the interest
* has accumulated, including signed past EOIs).
*
* Layout:
* - In-flight EOI hero (signing progress + reminders) when an active
* EOI document exists for the interest
* - "Generate EOI" CTA when none is in flight
* - History strip of past completed/cancelled EOIs
*
* The actual generate flow opens `EoiGenerateDialog` which now shows
* the resolved EoiContext (real values that will be filled) rather
* than just a checklist of which fields exist.
*/
export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
const portSlug = useUIStore((s) => s.currentPortSlug);
const [generateOpen, setGenerateOpen] = useState(false);
const [uploadSignedOpen, setUploadSignedOpen] = useState(false);
const { data: docsRes, isLoading: docsLoading } = useQuery<{ data: DocumentRow[] }>({
queryKey: ['documents', { interestId, documentType: 'eoi' }],
queryFn: () =>
apiFetch<{ data: DocumentRow[] }>(
`/api/v1/documents?interestId=${interestId}&documentType=eoi`,
),
});
const docs = docsRes?.data ?? [];
const activeDoc = useMemo(() => docs.find((d) => ACTIVE_STATUSES.has(d.status)) ?? null, [docs]);
const completedDocs = useMemo(() => docs.filter((d) => !ACTIVE_STATUSES.has(d.status)), [docs]);
return (
<div className="space-y-5">
{docsLoading ? (
<Skeleton className="h-44 w-full rounded-lg" />
) : activeDoc ? (
<ActiveEoiCard
doc={activeDoc}
portSlug={portSlug ?? null}
onUploadSigned={() => setUploadSignedOpen(true)}
/>
) : (
<EmptyEoiState
onGenerate={() => setGenerateOpen(true)}
onUploadSigned={() => setUploadSignedOpen(true)}
/>
)}
{/* History strip — completed + cancelled EOIs from earlier in the
deal's life. Quiet and skimmable; the active document above
carries the day-to-day attention. */}
{completedDocs.length > 0 && (
<section className="rounded-lg border bg-background">
<header className="flex items-center justify-between border-b px-4 py-2.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
EOI history
</h3>
<span className="text-xs text-muted-foreground">
{completedDocs.length} {completedDocs.length === 1 ? 'document' : 'documents'}
</span>
</header>
<ul className="divide-y">
{completedDocs.map((d) => (
<li key={d.id} className="flex items-center gap-3 px-4 py-2.5 text-sm">
<StatusBadge status={d.status} />
<span className="flex-1 truncate font-medium">{d.title}</span>
<span className="text-xs text-muted-foreground">
{new Date(d.createdAt).toLocaleDateString()}
</span>
{portSlug && (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/documents/${d.id}` as any}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
Open
<ExternalLink className="size-3" />
</Link>
)}
</li>
))}
</ul>
</section>
)}
<EoiGenerateDialog
interestId={interestId}
clientId={clientId}
open={generateOpen}
onOpenChange={setGenerateOpen}
/>
<ExternalEoiUploadDialog
open={uploadSignedOpen}
onOpenChange={setUploadSignedOpen}
interestId={interestId}
/>
</div>
);
}
// ─── In-flight EOI hero ──────────────────────────────────────────────────────
function ActiveEoiCard({
doc,
portSlug,
onUploadSigned,
}: {
doc: DocumentRow;
portSlug: string | null;
onUploadSigned: () => void;
}) {
const queryClient = useQueryClient();
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,
});
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),
});
const remindAllMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/documents/${doc.id}/remind`, { method: 'POST', body: {} }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['documents', doc.id, 'signers'] });
toast.success('Reminder sent.');
},
onError: (err) => toastError(err),
});
return (
<section className="rounded-xl border bg-gradient-brand-soft p-5 shadow-xs">
<header className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<FileSignature className="size-4 text-foreground" />
<h2 className="truncate text-base font-semibold text-foreground">{doc.title}</h2>
<StatusBadge status={doc.status} />
</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 && (
<Button asChild variant="outline" size="sm" className="gap-1.5 [&_svg]:size-3.5">
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/documents/${doc.id}` as any}
>
Open
<ExternalLink />
</Link>
</Button>
)}
{!allSigned && (
<Button
variant="outline"
size="sm"
disabled={remindAllMutation.isPending}
onClick={() => remindAllMutation.mutate()}
className="gap-1.5 [&_svg]:size-3.5"
>
{remindAllMutation.isPending ? <Loader2 className="animate-spin" /> : <RefreshCw />}
Remind all
</Button>
)}
</div>
</header>
<div className="mt-4 rounded-lg border bg-background p-4">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Signing progress
</h3>
{signersLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" /> Loading signers
</div>
) : signers.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
Documenso hasn&apos;t reported signers yet check back in a moment.
</p>
) : (
<SigningProgress documentId={doc.id} signers={signers} />
)}
</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" />
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={() => {
if (window.confirm('Cancel this EOI? Signers will no longer be able to sign.')) {
cancelMutation.mutate();
}
}}
className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3"
>
<XCircle />
Cancel EOI
</Button>
</div>
</footer>
</section>
);
}
// ─── Empty state ─────────────────────────────────────────────────────────────
function EmptyEoiState({
onGenerate,
onUploadSigned,
}: {
onGenerate: () => void;
onUploadSigned: () => void;
}) {
return (
<section className="rounded-xl border border-dashed bg-muted/20 p-8 text-center">
<div className="mx-auto flex size-14 items-center justify-center rounded-full bg-background text-muted-foreground">
<FileSignature className="size-6" />
</div>
<h2 className="mt-4 text-base font-semibold text-foreground">
No EOI in flight for this interest
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Generate the EOI to send it for signing Documenso handles the signing chain. You can also
upload a paper-signed copy if it was signed outside the system.
</p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
<Button onClick={onGenerate} size="sm" className="gap-1.5">
<FileSignature className="size-4" />
Generate EOI
</Button>
<Button onClick={onUploadSigned} variant="outline" size="sm" className="gap-1.5">
<Upload className="size-4" />
Upload paper-signed copy
</Button>
</div>
</section>
);
}
function StatusBadge({ status }: { status: DocumentRow['status'] }) {
return (
<Badge
variant="outline"
className={cn(
'border-transparent text-[10px] font-semibold uppercase tracking-wide',
STATUS_TONES[status],
)}
>
{status === 'completed' && <CheckCircle2 className="mr-1 size-3" />}
{STATUS_LABELS[status]}
</Badge>
);
}

View File

@@ -1,93 +0,0 @@
'use client';
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { FileGrid } from '@/components/files/file-grid';
import { FileUploadZone } from '@/components/files/file-upload-zone';
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
import { PermissionGate } from '@/components/shared/permission-gate';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
import type { FileRow } from '@/components/files/file-grid';
interface InterestFilesTabProps {
interestId: string;
}
export function InterestFilesTab({ interestId }: InterestFilesTabProps) {
const queryClient = useQueryClient();
const [previewFile, setPreviewFile] = useState<FileRow | null>(null);
const { data, isLoading } = usePaginatedQuery<FileRow>({
queryKey: ['files', { entityType: 'interest', entityId: interestId }],
endpoint: `/api/v1/files?entityType=interest&entityId=${encodeURIComponent(interestId)}`,
filterDefinitions: [],
});
useRealtimeInvalidation({
'file:uploaded': [['files', { entityType: 'interest', entityId: interestId }]],
'file:updated': [['files', { entityType: 'interest', entityId: interestId }]],
'file:deleted': [['files', { entityType: 'interest', entityId: interestId }]],
});
const handleDownload = async (file: FileRow) => {
try {
const res = await apiFetch<{ data: { url: string; filename: string } }>(
`/api/v1/files/${file.id}/download`,
);
const a = document.createElement('a');
a.href = res.data.url;
a.download = res.data.filename;
a.click();
} catch {
// silent
}
};
const handleDelete = async (file: FileRow) => {
if (!confirm(`Delete "${file.filename}"? This cannot be undone.`)) return;
try {
await apiFetch(`/api/v1/files/${file.id}`, { method: 'DELETE' });
queryClient.invalidateQueries({
queryKey: ['files', { entityType: 'interest', entityId: interestId }],
});
} catch {
// silent
}
};
return (
<div className="space-y-4">
<PermissionGate resource="files" action="upload">
<FileUploadZone
entityType="interest"
entityId={interestId}
onUploadComplete={() => {
queryClient.invalidateQueries({
queryKey: ['files', { entityType: 'interest', entityId: interestId }],
});
}}
/>
</PermissionGate>
<FileGrid
files={data}
onDownload={handleDownload}
onPreview={setPreviewFile}
onRename={() => {}}
onDelete={handleDelete}
isLoading={isLoading}
/>
<FilePreviewDialog
open={!!previewFile}
onOpenChange={(open) => !open && setPreviewFile(null)}
fileId={previewFile?.id}
fileName={previewFile?.filename}
mimeType={previewFile?.mimeType ?? undefined}
/>
</div>
);
}

View File

@@ -41,6 +41,7 @@ export const interestFilterDefinitions: FilterDefinition[] = [
{ label: 'Manual', value: 'manual' },
{ label: 'Referral', value: 'referral' },
{ label: 'Broker', value: 'broker' },
{ label: 'Other', value: 'other' },
],
},
{

View File

@@ -10,7 +10,6 @@ import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
@@ -62,7 +61,6 @@ interface InterestFormProps {
pipelineStage: string;
leadCategory?: string | null;
source?: string | null;
notes?: string | null;
reminderEnabled?: boolean;
reminderDays?: number | null;
tags?: Array<{ id: string }>;
@@ -130,7 +128,6 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
pipelineStage: interest.pipelineStage as (typeof PIPELINE_STAGES)[number],
leadCategory: interest.leadCategory as (typeof LEAD_CATEGORIES)[number] | undefined,
source: interest.source ?? undefined,
notes: interest.notes ?? undefined,
reminderEnabled: interest.reminderEnabled ?? false,
reminderDays: interest.reminderDays ?? undefined,
tagIds: interest.tags?.map((t) => t.id) ?? [],
@@ -457,18 +454,6 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
<Separator />
{/* Notes */}
<div className="space-y-2">
<Label>Notes</Label>
<Textarea
{...register('notes')}
placeholder="Add notes about this interest..."
rows={3}
/>
</div>
<Separator />
{/* Reminder */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">

View File

@@ -25,7 +25,15 @@ import { PermissionGate } from '@/components/shared/permission-gate';
import { InterestForm } from '@/components/interests/interest-form';
import { PipelineBoard } from '@/components/interests/pipeline-board';
import { interestFilterDefinitions } from '@/components/interests/interest-filters';
import { getInterestColumns, type InterestRow } from '@/components/interests/interest-columns';
import {
getInterestColumns,
INTEREST_COLUMN_OPTIONS,
INTEREST_DEFAULT_HIDDEN,
type InterestRow,
} from '@/components/interests/interest-columns';
import { ColumnPicker } from '@/components/shared/column-picker';
import { SaveViewDialog } from '@/components/shared/save-view-dialog';
import { useTablePreferences } from '@/hooks/use-table-preferences';
import { InterestCard } from '@/components/interests/interest-card';
import { TagPicker } from '@/components/shared/tag-picker';
import {
@@ -58,6 +66,7 @@ export function InterestList() {
const [createOpen, setCreateOpen] = useState(false);
const [editInterest, setEditInterest] = useState<InterestRow | null>(null);
const [archiveInterest, setArchiveInterest] = useState<InterestRow | null>(null);
const [saveViewOpen, setSaveViewOpen] = useState(false);
// Bulk-action dialog state
const [stageDialog, setStageDialog] = useState<{ ids: string[] } | null>(null);
@@ -134,6 +143,12 @@ export function InterestList() {
onArchive: (interest) => setArchiveInterest(interest),
});
// Persisted per-user column visibility — same pattern as ClientList.
// The hidden array is the source of truth; built columns stay
// declared and we drive table visibility via columnVisibility.
const { hidden, setHidden } = useTablePreferences('interests', INTEREST_DEFAULT_HIDDEN);
const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));
return (
<div className="space-y-4">
<PageHeader
@@ -171,8 +186,19 @@ export function InterestList() {
/>
<div className="flex flex-wrap items-center gap-2">
{/* On the kanban view we strip filters that don't make sense
* there: `pipelineStage` (the columns ARE the stages) and
* `includeArchived` (the board is for active deals — the
* list view is the place to see history). The board endpoint
* rejects these via boardFiltersSchema if they're sent. */}
<FilterBar
filters={interestFilterDefinitions}
filters={
viewMode === 'board'
? interestFilterDefinitions.filter(
(f) => f.key !== 'pipelineStage' && f.key !== 'includeArchived',
)
: interestFilterDefinitions
}
values={filters}
onChange={setFilter}
onClear={clearFilters}
@@ -188,24 +214,44 @@ export function InterestList() {
placeholder="Filter by tag / event…"
/>
</div>
<SavedViewsDropdown
entityType="interests"
currentFilters={filters}
currentSort={sort}
onApplyView={(savedFilters) => {
clearFilters();
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
}}
/>
{/* 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) => {
clearFilters();
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
}}
/>
<ColumnPicker
columns={INTEREST_COLUMN_OPTIONS}
hidden={hidden}
onChange={setHidden}
onSaveView={() => setSaveViewOpen(true)}
/>
</>
) : null}
</div>
<SaveViewDialog
open={saveViewOpen}
onOpenChange={setSaveViewOpen}
entityType="interests"
currentFilters={filters}
currentSort={sort}
/>
{viewMode === 'board' ? (
<PipelineBoard />
<PipelineBoard filters={filters} />
) : isLoading ? (
<TableSkeleton />
) : (
<DataTable
columns={columns}
columnVisibility={columnVisibility}
data={data}
pagination={pagination}
onPaginationChange={(p, ps) => {

View File

@@ -0,0 +1,419 @@
'use client';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
AlertTriangle,
CheckCircle2,
ExternalLink,
FileSignature,
Loader2,
RefreshCw,
Upload,
XCircle,
} from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
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 { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/ui-store';
interface InterestReservationTabProps {
interestId: string;
clientId: string | null;
}
interface DocumentRow {
id: string;
documentType: string;
title: string;
status: 'draft' | 'sent' | 'partially_signed' | 'completed' | 'expired' | 'cancelled';
createdAt: string;
signers?: Array<{ status: string }>;
}
interface DocumentSigner {
id: string;
signerName: string;
signerEmail: string;
signerRole: string;
signingOrder: number;
status: string;
signedAt?: string | null;
}
const STATUS_LABELS: Record<DocumentRow['status'], string> = {
draft: 'Draft',
sent: 'Awaiting signatures',
partially_signed: 'Partially signed',
completed: 'Signed',
expired: 'Expired',
cancelled: 'Cancelled',
};
const STATUS_TONES: Record<DocumentRow['status'], string> = {
draft: 'bg-slate-100 text-slate-700',
sent: 'bg-blue-100 text-blue-700',
partially_signed: 'bg-amber-100 text-amber-800',
completed: 'bg-emerald-100 text-emerald-700',
expired: 'bg-rose-100 text-rose-700',
cancelled: 'bg-slate-200 text-slate-600',
};
const ACTIVE_STATUSES = new Set<DocumentRow['status']>(['draft', 'sent', 'partially_signed']);
/**
* Dedicated Reservation workspace tab. Mirrors the EOI tab pattern but
* for reservation agreements. Contracts differ from EOIs in that there's no
* standard Documenso template — each reservation is drafted custom per
* deal. So the active flows are:
*
* 1. **Upload paper-signed copy** — the signed reservation was handled
* outside the system; rep uploads the PDF for the record.
*
* 2. **Upload draft for Documenso signing** — rep uploads the PDF
* draft, configures signers + signing order + signature field
* placement, then sends via Documenso. (Recipient configurator
* and field-placement UI are the bigger pieces; for v1 a default
* footer-anchored signature layout is used.)
*
* The Documents tab still shows every reservation document (signed or
* drafted) as a permanent history.
*/
export function InterestReservationTab({
interestId,
clientId: _clientId,
}: InterestReservationTabProps) {
const portSlug = useUIStore((s) => s.currentPortSlug);
const [uploadSignedOpen, setUploadSignedOpen] = useState(false);
const [uploadForSigningOpen, setUploadForSigningOpen] = useState(false);
const { data: docsRes, isLoading: docsLoading } = useQuery<{ data: DocumentRow[] }>({
queryKey: ['documents', { interestId, documentType: 'reservation_agreement' }],
queryFn: () =>
apiFetch<{ data: DocumentRow[] }>(
`/api/v1/documents?interestId=${interestId}&documentType=reservation_agreement`,
),
});
const docs = docsRes?.data ?? [];
const activeDoc = useMemo(() => docs.find((d) => ACTIVE_STATUSES.has(d.status)) ?? null, [docs]);
const completedDocs = useMemo(() => docs.filter((d) => !ACTIVE_STATUSES.has(d.status)), [docs]);
return (
<div className="space-y-5">
{docsLoading ? (
<Skeleton className="h-44 w-full rounded-lg" />
) : activeDoc ? (
<ActiveReservationCard
doc={activeDoc}
portSlug={portSlug ?? null}
onUploadSigned={() => setUploadSignedOpen(true)}
/>
) : (
<EmptyReservationState
onUploadSigned={() => setUploadSignedOpen(true)}
onUploadForSigning={() => setUploadForSigningOpen(true)}
/>
)}
{completedDocs.length > 0 && (
<section className="rounded-lg border bg-background">
<header className="flex items-center justify-between border-b px-4 py-2.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Reservation history
</h3>
<span className="text-xs text-muted-foreground">
{completedDocs.length} {completedDocs.length === 1 ? 'document' : 'documents'}
</span>
</header>
<ul className="divide-y">
{completedDocs.map((d) => (
<li key={d.id} className="flex items-center gap-3 px-4 py-2.5 text-sm">
<StatusBadge status={d.status} />
<span className="flex-1 truncate font-medium">{d.title}</span>
<span className="text-xs text-muted-foreground">
{new Date(d.createdAt).toLocaleDateString()}
</span>
{portSlug && (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/documents/${d.id}` as any}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
Open
<ExternalLink className="size-3" />
</Link>
)}
</li>
))}
</ul>
</section>
)}
{/* Reuses the external-EOI upload dialog. The endpoint
`/api/v1/interests/{id}/external-eoi` is EOI-specific today
— for reservation paper-uploads we'll need the equivalent
reservation endpoint (deferred to a follow-up; the dialog UI
is the pattern we'll clone). For now the flow is documented
as 'coming soon' rather than misrouting through EOI. */}
{uploadSignedOpen && (
<ExternalEoiUploadDialog
open={uploadSignedOpen}
onOpenChange={setUploadSignedOpen}
interestId={interestId}
/>
)}
{/* Upload-for-Documenso-signing dialog placeholder. The real
dialog (PDF picker + recipient configurator + send button)
is part of the larger custom-doc-upload service that's a
follow-up. For now show a friendly "coming soon" card. */}
{uploadForSigningOpen && (
<ComingSoonDialog
open={uploadForSigningOpen}
onOpenChange={setUploadForSigningOpen}
title="Send reservation for signing"
body="Upload-and-send-via-Documenso for contracts is being built. For now, draft the reservation externally, get it signed via paper or another tool, then upload the signed copy here."
/>
)}
</div>
);
}
// ─── Active reservation hero ────────────────────────────────────────────────────
function ActiveReservationCard({
doc,
portSlug,
onUploadSigned,
}: {
doc: DocumentRow;
portSlug: string | null;
onUploadSigned: () => void;
}) {
const queryClient = useQueryClient();
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,
});
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('Reservation cancelled.');
},
onError: (err) => toastError(err),
});
const remindAllMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/documents/${doc.id}/remind`, { method: 'POST', body: {} }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['documents', doc.id, 'signers'] });
toast.success('Reminder sent.');
},
onError: (err) => toastError(err),
});
return (
<section className="rounded-xl border bg-gradient-brand-soft p-5 shadow-xs">
<header className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<FileSignature className="size-4 text-foreground" />
<h2 className="truncate text-base font-semibold text-foreground">{doc.title}</h2>
<StatusBadge status={doc.status} />
</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 && (
<Button asChild variant="outline" size="sm" className="gap-1.5 [&_svg]:size-3.5">
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/documents/${doc.id}` as any}
>
Open
<ExternalLink />
</Link>
</Button>
)}
{!allSigned && (
<Button
variant="outline"
size="sm"
disabled={remindAllMutation.isPending}
onClick={() => remindAllMutation.mutate()}
className="gap-1.5 [&_svg]:size-3.5"
>
{remindAllMutation.isPending ? <Loader2 className="animate-spin" /> : <RefreshCw />}
Remind all
</Button>
)}
</div>
</header>
<div className="mt-4 rounded-lg border bg-background p-4">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Signing progress
</h3>
{signersLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" /> Loading signers
</div>
) : signers.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
Documenso hasn&apos;t reported signers yet check back in a moment.
</p>
) : (
<SigningProgress documentId={doc.id} signers={signers} />
)}
</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" />
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={() => {
if (window.confirm('Cancel this contract? Signers will no longer be able to sign.')) {
cancelMutation.mutate();
}
}}
className="h-7 gap-1.5 text-xs text-destructive hover:text-destructive [&_svg]:size-3"
>
<XCircle />
Cancel contract
</Button>
</div>
</footer>
</section>
);
}
// ─── Empty state ─────────────────────────────────────────────────────────────
function EmptyReservationState({
onUploadSigned,
onUploadForSigning,
}: {
onUploadSigned: () => void;
onUploadForSigning: () => void;
}) {
return (
<section className="rounded-xl border border-dashed bg-muted/20 p-8 text-center">
<div className="mx-auto flex size-14 items-center justify-center rounded-full bg-background text-muted-foreground">
<FileSignature className="size-6" />
</div>
<h2 className="mt-4 text-base font-semibold text-foreground">
No reservation in flight for this interest
</h2>
<p className="mt-1 text-sm text-muted-foreground">
reservation agreements are drafted custom per deal. Either upload a paper-signed copy you
handled externally, or upload the draft PDF and send for e-signing via Documenso.
</p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
<Button onClick={onUploadForSigning} size="sm" className="gap-1.5">
<FileSignature className="size-4" />
Upload draft for signing
</Button>
<Button onClick={onUploadSigned} variant="outline" size="sm" className="gap-1.5">
<Upload className="size-4" />
Upload paper-signed copy
</Button>
</div>
</section>
);
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function StatusBadge({ status }: { status: DocumentRow['status'] }) {
return (
<Badge
variant="outline"
className={cn(
'border-transparent text-[10px] font-semibold uppercase tracking-wide',
STATUS_TONES[status],
)}
>
{status === 'completed' && <CheckCircle2 className="mr-1 size-3" />}
{STATUS_LABELS[status]}
</Badge>
);
}
/**
* Placeholder for the upload-for-Documenso-signing flow until the
* full upload + recipient + field-placement service is shipped.
* Intentional dead-end so reps know the path exists rather than
* misclicking and getting confusing behaviour.
*/
function ComingSoonDialog({
open,
onOpenChange,
title,
body,
}: {
open: boolean;
onOpenChange: (next: boolean) => void;
title: string;
body: string;
}) {
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={() => onOpenChange(false)}
>
<div
className="max-w-md rounded-lg border bg-background p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-base font-semibold text-foreground">{title}</h3>
<p className="mt-2 text-sm text-muted-foreground">{body}</p>
<div className="mt-4 flex justify-end">
<Button onClick={() => onOpenChange(false)} size="sm" variant="outline">
Got it
</Button>
</div>
</div>
</div>
);
}

View File

@@ -4,7 +4,8 @@ import Link from 'next/link';
import { useParams } from 'next/navigation';
import { format, formatDistanceToNowStrict } from 'date-fns';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { CheckCircle2, Circle, FileSignature, Plus, Send, Wallet } from 'lucide-react';
import { useState } from 'react';
import { Anchor, CheckCircle2, Circle, FileSignature, Plus, Send, Wallet } from 'lucide-react';
import type { DetailTab } from '@/components/shared/detail-layout';
import { Button } from '@/components/ui/button';
@@ -16,12 +17,20 @@ import { BerthRecommenderPanel } from '@/components/interests/berth-recommender-
import { LinkedBerthsList } from '@/components/interests/linked-berths-list';
import { InterestTimeline } from '@/components/interests/interest-timeline';
import { InterestDocumentsTab } from '@/components/interests/interest-documents-tab';
import { InterestFilesTab } from '@/components/interests/interest-files-tab';
import { LEAD_CATEGORIES, PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
import {
LEAD_CATEGORIES,
PIPELINE_STAGES,
canTransitionStage,
type PipelineStage,
} from '@/lib/constants';
import { InterestEoiTab } from '@/components/interests/interest-eoi-tab';
import { InterestContactLogTab } from '@/components/interests/interest-contact-log-tab';
import { InterestContractTab } from '@/components/interests/interest-contract-tab';
import { InterestReservationTab } from '@/components/interests/interest-reservation-tab';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
type InterestPatchField = 'leadCategory' | 'source' | 'notes';
type InterestPatchField = 'leadCategory' | 'source';
const LEAD_CATEGORY_OPTIONS = LEAD_CATEGORIES.map((c) => ({
value: c,
@@ -37,6 +46,9 @@ function humanizeStatus(value: string | null): string | null {
interface InterestTabsOptions {
interestId: string;
currentUserId?: string;
/** Used by the dedicated EOI tab to deep-link to the client's record
* for inline edits ("wrong details? edit on the client's page"). */
clientId?: string | null;
interest: {
pipelineStage: string;
/** Drives the recommender panel mounted on the Overview tab. */
@@ -59,6 +71,9 @@ interface InterestTabsOptions {
reminderEnabled: boolean;
reminderDays: number | null;
reminderLastFired: string | null;
/** Count of berths linked via the interest_berths junction —
* drives the "Berth Interest" milestone on the Overview tab. */
linkedBerthCount?: number;
notes: string | null;
/** Surfaced by getInterestById for the Overview "most recent note"
* teaser - saves a click into the Notes tab to peek at the latest. */
@@ -87,13 +102,23 @@ function useInterestPatch(interestId: string) {
});
}
type Phase = 'past' | 'current' | 'future';
function useStageMutation(interestId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ stage, reason }: { stage: string; reason?: string }) =>
mutationFn: async ({
stage,
reason,
override,
}: {
stage: string;
reason?: string;
override?: boolean;
}) =>
apiFetch(`/api/v1/interests/${interestId}/stage`, {
method: 'PATCH',
body: { pipelineStage: stage, reason },
body: { pipelineStage: stage, reason, override },
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['interests', interestId] });
@@ -278,6 +303,73 @@ function MilestoneSection({
);
}
/**
* Collapsible wrapper for future-phase milestones. Hidden by default so
* the overview stays focused on the current stage; expanding lets reps
* record skipped milestones (the action click then routes through the
* advance() override-confirm).
*/
function FutureMilestones({
milestones,
stageMutation,
advance,
activeMilestone,
currentStage,
}: {
milestones: Array<{
key: 'berth_interest' | 'eoi' | 'deposit' | 'contract';
title: string;
icon: React.ComponentType<{ className?: string }>;
status: string | null;
steps: MilestoneSectionProps['steps'];
footer?: React.ReactNode;
}>;
stageMutation: ReturnType<typeof useStageMutation>;
advance: (stage: string) => void;
activeMilestone: 'berth_interest' | 'eoi' | 'deposit' | 'contract' | null;
currentStage: string;
}) {
const [expanded, setExpanded] = useState(false);
return (
<div className="rounded-lg border border-dashed">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex w-full items-center justify-between gap-2 px-4 py-2.5 text-sm text-muted-foreground hover:text-foreground hover:bg-muted/30 transition-colors"
>
<span>
{expanded ? 'Hide' : 'Show'} upcoming milestones
<span className="ml-2 text-xs">({milestones.map((m) => m.title).join(' · ')})</span>
</span>
<span className="text-xs">{expanded ? '▴' : '▾'}</span>
</button>
{expanded && (
<div
className={cn(
'grid grid-cols-1 gap-4 p-4 pt-0',
milestones.length === 1 ? '' : 'lg:grid-cols-2',
)}
>
{milestones.map((m) => (
<MilestoneSection
key={m.key}
title={m.title}
icon={m.icon}
status={m.status}
isPending={stageMutation.isPending}
onAdvance={advance}
currentStage={currentStage}
isActive={activeMilestone === m.key}
steps={m.steps}
footer={m.footer}
/>
))}
</div>
)}
</div>
);
}
function OverviewTab({
interestId,
interest,
@@ -292,25 +384,98 @@ function OverviewTab({
const save = (field: InterestPatchField) => async (next: string | null) => {
await mutation.mutateAsync({ [field]: next });
};
const advance = (stage: string) =>
stageMutation.mutate({ stage, reason: 'Marked from overview' });
/**
* Advance the pipeline. When the requested target isn't a legal next
* step (e.g. user clicked "Mark deposit received" while still on
* EOI Sent), prompt for confirmation and pass `override:true` so the
* backend transition guard lets the change through. Mirrors the
* skip-ahead pattern from the inline stage picker so audit trails
* stay consistent regardless of which surface the rep used.
*/
const advance = (stage: string) => {
const fromStage = interest.pipelineStage as PipelineStage;
const toStage = stage as PipelineStage;
const isOverride = fromStage !== toStage && !canTransitionStage(fromStage, toStage);
if (isOverride) {
const ok = window.confirm(
`This advances the stage from "${fromStage.replace(/_/g, ' ')}" to "${toStage.replace(
/_/g,
' ',
)}", which isn't a standard next step. Continue?\n\nThe change will be flagged in the audit log.`,
);
if (!ok) return;
}
stageMutation.mutate({
stage,
reason: isOverride ? 'Skip-ahead from overview milestones' : 'Marked from overview',
override: isOverride || undefined,
});
};
// Which milestone is the next one to act on? "EOI Signed" → Deposit is next;
// "Deposit 10%" → Contract is next; "Contract Signed" / "Completed" → none.
// Determine each milestone's phase relative to the current pipeline
// stage. The overview hides future-phase milestones by default — it
// was visually noisy to see Deposit + Contract cards on a deal still
// at the EOI stage, and the empty cards invited mis-clicks.
//
// Past milestones still render (collapsed history) so reps can see
// what's been completed. Future milestones are gated behind a "Show
// upcoming milestones" toggle so the rep CAN reach them when a deal
// genuinely skips stages — the click then routes through the same
// override-confirm flow as the inline stage picker.
const stageIdx = PIPELINE_STAGES.indexOf(interest.pipelineStage as PipelineStage);
const eoiSignedIdx = PIPELINE_STAGES.indexOf('eoi_signed');
const depositIdx = PIPELINE_STAGES.indexOf('deposit_10pct');
const contractSignedIdx = PIPELINE_STAGES.indexOf('contract_signed');
let activeMilestone: 'eoi' | 'deposit' | 'contract' | null = null;
if (stageIdx === -1 || stageIdx >= contractSignedIdx) {
activeMilestone = null;
} else if (stageIdx < eoiSignedIdx) {
activeMilestone = 'eoi';
} else if (stageIdx < depositIdx) {
activeMilestone = 'deposit';
} else {
activeMilestone = 'contract';
}
const phaseFor = (milestoneEndStageIdx: number): Phase => {
if (stageIdx === -1) return 'future';
if (stageIdx >= milestoneEndStageIdx) return 'past';
// The "current" milestone is the one whose end-stage hasn't been
// reached and whose start-stage is at-or-before the current stage.
return 'current';
};
// Berth Interest milestone — first thing the rep needs to capture
// (especially for general_interest leads). Completes the moment ANY
// berth is linked to the interest via the junction. While unset, it
// sits as the "current" milestone unless the deal has already moved
// past EOI sent (in which case the rep clearly didn't need a berth
// pinned first, so we mark it 'past' implicitly).
const hasLinkedBerth = (interest.linkedBerthCount ?? 0) > 0;
const berthInterestPhase: Phase = hasLinkedBerth
? 'past'
: stageIdx === -1 || stageIdx >= eoiSignedIdx
? 'past'
: 'current';
const eoiPhase = phaseFor(eoiSignedIdx);
// Deposit is current once the EOI is signed but before deposit is in.
const depositPhase: Phase =
stageIdx === -1
? 'future'
: stageIdx >= depositIdx
? 'past'
: stageIdx >= eoiSignedIdx
? 'current'
: 'future';
const contractPhase: Phase =
stageIdx === -1
? 'future'
: stageIdx >= contractSignedIdx
? 'past'
: stageIdx >= depositIdx
? 'current'
: 'future';
const activeMilestone: 'berth_interest' | 'eoi' | 'deposit' | 'contract' | null =
berthInterestPhase === 'current'
? 'berth_interest'
: eoiPhase === 'current'
? 'eoi'
: depositPhase === 'current'
? 'deposit'
: contractPhase === 'current'
? 'contract'
: null;
const toNum = (v: string | null | undefined): number | null => {
if (v === null || v === undefined) return null;
@@ -318,100 +483,190 @@ function OverviewTab({
return Number.isFinite(n) ? n : null;
};
const milestones: Array<{
key: 'berth_interest' | 'eoi' | 'deposit' | 'contract';
phase: Phase;
title: string;
icon: React.ComponentType<{ className?: string }>;
status: string | null;
steps: MilestoneSectionProps['steps'];
footer?: React.ReactNode;
/** Brief one-liner shown when the milestone is in the past-strip. */
pastSummary: React.ReactNode;
}> = [
{
key: 'berth_interest',
phase: berthInterestPhase,
title: 'Berth Interest',
icon: Anchor,
// No status badge — the count IS the status. Showing "0 berths"
// would just duplicate the empty-state copy below.
status: hasLinkedBerth
? `${interest.linkedBerthCount} berth${(interest.linkedBerthCount ?? 0) === 1 ? '' : 's'}`
: null,
// No advanceStage step — the milestone tracks a state (berths
// linked) rather than a stage transition. Hide the row chrome by
// passing an empty steps array; the footer renders the action.
steps: [],
footer:
berthInterestPhase === 'current' ? (
<div className="text-xs text-muted-foreground">
Add a berth from the Recommendations tab or the client&apos;s active interest panel to
mark this milestone complete.
</div>
) : null,
pastSummary: hasLinkedBerth
? `${interest.linkedBerthCount} berth${(interest.linkedBerthCount ?? 0) === 1 ? '' : 's'} linked`
: 'Skipped',
},
{
key: 'eoi',
phase: eoiPhase,
title: 'EOI',
icon: Send,
status: interest.eoiStatus,
steps: [
{
label: 'EOI sent',
date: interest.dateEoiSent,
advanceStage: 'eoi_sent',
actionLabel: 'Mark EOI as sent',
},
{
label: 'EOI signed',
date: interest.dateEoiSigned,
advanceStage: 'eoi_signed',
actionLabel: 'Mark EOI as signed',
},
],
pastSummary: interest.dateEoiSigned
? `Signed ${formatDate(interest.dateEoiSigned)}`
: 'Completed',
},
{
key: 'deposit',
phase: depositPhase,
title: 'Deposit',
icon: Wallet,
status: interest.depositStatus,
steps: [
{
label: 'Deposit received',
date: interest.dateDepositReceived,
advanceStage: 'deposit_10pct',
hideAutoButton: true,
},
],
footer:
depositPhase === 'current' && !interest.dateDepositReceived ? (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<Button asChild size="sm" className="h-7 px-2.5 text-xs">
<Link href={`/${portSlug}/invoices/new?interestId=${interestId}&kind=deposit`}>
<Plus className="size-3.5" />
Create deposit invoice
</Link>
</Button>
<button
type="button"
onClick={() => advance('deposit_10pct')}
disabled={stageMutation.isPending}
className="text-muted-foreground hover:text-foreground disabled:opacity-50"
>
Mark received manually
</button>
</div>
) : null,
pastSummary: interest.dateDepositReceived
? `Received ${formatDate(interest.dateDepositReceived)}`
: 'Recorded',
},
{
key: 'contract',
phase: contractPhase,
title: 'Contract',
icon: FileSignature,
status: interest.contractStatus,
steps: [
{
label: 'Contract sent',
date: interest.dateContractSent,
advanceStage: 'contract_sent',
actionLabel: 'Mark contract as sent',
},
{
label: 'Contract signed',
date: interest.dateContractSigned,
advanceStage: 'contract_signed',
actionLabel: 'Mark contract as signed',
},
],
pastSummary: interest.dateContractSigned
? `Signed ${formatDate(interest.dateContractSigned)}`
: 'Completed',
},
];
const pastMilestones = milestones.filter((m) => m.phase === 'past');
const currentMilestones = milestones.filter((m) => m.phase === 'current');
const futureMilestones = milestones.filter((m) => m.phase === 'future');
return (
<div className="space-y-6">
{/* Sales-process milestones - the heart of the system. Each section is a
mini lifecycle that auto-completes as actions happen on the platform
(Documenso webhook, paid deposit invoice, signed contract). Until the
automation lands, salespeople nudge stages forward via the inline
buttons here, which auto-stamp the milestone date server-side. */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<MilestoneSection
title="EOI"
icon={Send}
status={interest.eoiStatus}
isPending={stageMutation.isPending}
onAdvance={advance}
{/* Sales-process milestones — phase-aware so the user only sees
what's actionable now. Past milestones collapse into a tight
history strip; the current milestone gets the full card; future
milestones are hidden behind a toggle so reps can still
skip-ahead when reality calls for it (an override-confirm
gates the actual stage move). */}
{pastMilestones.length > 0 && (
<div className="rounded-lg border bg-muted/20 px-4 py-2.5">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
<span className="text-[10px] font-semibold uppercase tracking-wide">Past</span>
{pastMilestones.map((m) => (
<span key={m.key} className="inline-flex items-center gap-1.5">
<CheckCircle2 className="size-3 text-emerald-600" />
<span className="font-medium text-foreground">{m.title}</span>
<span>·</span>
<span>{m.pastSummary}</span>
</span>
))}
</div>
</div>
)}
{currentMilestones.length > 0 && (
<div
className={cn(
'grid grid-cols-1 gap-4',
currentMilestones.length === 1 ? '' : 'lg:grid-cols-2',
)}
>
{currentMilestones.map((m) => (
<MilestoneSection
key={m.key}
title={m.title}
icon={m.icon}
status={m.status}
isPending={stageMutation.isPending}
onAdvance={advance}
currentStage={interest.pipelineStage}
isActive={activeMilestone === m.key}
steps={m.steps}
footer={m.footer}
/>
))}
</div>
)}
{futureMilestones.length > 0 && (
<FutureMilestones
milestones={futureMilestones}
stageMutation={stageMutation}
advance={advance}
activeMilestone={activeMilestone}
currentStage={interest.pipelineStage}
isActive={activeMilestone === 'eoi'}
steps={[
{
label: 'EOI sent',
date: interest.dateEoiSent,
advanceStage: 'eoi_sent',
actionLabel: 'Mark EOI as sent',
},
{
label: 'EOI signed',
date: interest.dateEoiSigned,
advanceStage: 'eoi_signed',
actionLabel: 'Mark EOI as signed',
},
]}
/>
<MilestoneSection
title="Deposit"
icon={Wallet}
status={interest.depositStatus}
isPending={stageMutation.isPending}
onAdvance={advance}
currentStage={interest.pipelineStage}
isActive={activeMilestone === 'deposit'}
steps={[
{
label: 'Deposit received',
date: interest.dateDepositReceived,
advanceStage: 'deposit_10pct',
// The richer invoice-first CTA lives in `footer`. We still pass
// advanceStage so the milestone derives its done-state correctly.
hideAutoButton: true,
},
]}
footer={
!interest.dateDepositReceived ? (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<Button asChild size="sm" className="h-7 px-2.5 text-xs">
<Link href={`/${portSlug}/invoices/new?interestId=${interestId}&kind=deposit`}>
<Plus className="size-3.5" />
Create deposit invoice
</Link>
</Button>
<button
type="button"
onClick={() => advance('deposit_10pct')}
disabled={stageMutation.isPending}
className="text-muted-foreground hover:text-foreground disabled:opacity-50"
>
Mark received manually
</button>
</div>
) : null
}
/>
<MilestoneSection
title="Contract"
icon={FileSignature}
status={interest.contractStatus}
isPending={stageMutation.isPending}
onAdvance={advance}
currentStage={interest.pipelineStage}
isActive={activeMilestone === 'contract'}
steps={[
{
label: 'Contract sent',
date: interest.dateContractSent,
advanceStage: 'contract_sent',
actionLabel: 'Mark contract as sent',
},
{
label: 'Contract signed',
date: interest.dateContractSigned,
advanceStage: 'contract_signed',
actionLabel: 'Mark contract as signed',
},
]}
/>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Lead & Source (editable) */}
@@ -460,19 +715,22 @@ function OverviewTab({
{/* Most-recent threaded note teaser. Saves a click into the Notes
tab when the rep just wants to peek at "what was discussed last."
Hidden when there's nothing to show. */}
{interest.recentNote ? (
<div className="space-y-1 md:col-span-2">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-medium">Latest note</h3>
<Link
href={`/${portSlug}/interests/${interestId}?tab=notes`}
className="text-xs font-medium text-primary hover:underline"
>
View all
{interest.notesCount && interest.notesCount > 1 ? ` ${interest.notesCount}` : ''}
</Link>
</div>
Always rendered now that the redundant `interests.notes` blob is
gone — falls back to an empty-state prompt so reps still have an
obvious entry point to the Notes tab from Overview. */}
<div className="space-y-1 md:col-span-2">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-medium">Latest note</h3>
<Link
href={`/${portSlug}/interests/${interestId}?tab=notes`}
className="text-xs font-medium text-primary hover:underline"
>
{interest.recentNote
? `View all${interest.notesCount && interest.notesCount > 1 ? ` ${interest.notesCount}` : ''}`
: 'Add note'}
</Link>
</div>
{interest.recentNote ? (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
<p className="line-clamp-3 whitespace-pre-wrap text-foreground/90">
{interest.recentNote.content}
@@ -486,18 +744,11 @@ function OverviewTab({
: ''}
</p>
</div>
</div>
) : null}
{/* Notes (editable, multiline) */}
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Notes</h3>
<InlineEditableField
variant="textarea"
value={interest.notes}
onSave={save('notes')}
emptyText="No notes - click to add"
/>
) : (
<div className="rounded-md border border-dashed border-border bg-muted/10 px-3 py-2 text-xs text-muted-foreground">
No notes yet.
</div>
)}
</div>
{/* Tags */}
@@ -533,14 +784,39 @@ function OverviewTab({
export function getInterestTabs({
interestId,
currentUserId,
clientId = null,
interest,
}: InterestTabsOptions): DetailTab[] {
return [
// The EOI / Contract / Reservation tabs are stage-conditional —
// each appears only at the stages where the rep is likely to act
// on it. Hides clutter from later-stage deals where earlier docs
// are ancient history. Each tab still queries for its own past
// documents; if a deal regresses the past docs remain accessible
// via the generic Documents tab.
const stageIdx = PIPELINE_STAGES.indexOf(interest.pipelineStage as PipelineStage);
const detailsSentIdx = PIPELINE_STAGES.indexOf('details_sent');
const depositIdx = PIPELINE_STAGES.indexOf('deposit_10pct');
const contractSignedIdx = PIPELINE_STAGES.indexOf('contract_signed');
// EOI: from details_sent through contract_signed (the deal's whole life)
const showEoiTab = stageIdx >= detailsSentIdx && stageIdx <= contractSignedIdx;
// Contract: appears once the deposit's been paid (deal is committed)
// and stays visible until the contract is signed
const showContractTab = stageIdx >= depositIdx && stageIdx <= contractSignedIdx;
// Reservation: appears once the contract's signed and stays visible
// through completion (reservation is the post-contract milestone)
const showReservationTab = stageIdx >= contractSignedIdx;
const tabs: DetailTab[] = [
{
id: 'overview',
label: 'Overview',
content: <OverviewTab interestId={interestId} interest={interest} />,
},
{
id: 'contact-log',
label: 'Contact log',
content: <InterestContactLogTab interestId={interestId} />,
},
{
id: 'notes',
label: 'Notes',
@@ -548,16 +824,38 @@ export function getInterestTabs({
<NotesList entityType="interests" entityId={interestId} currentUserId={currentUserId} />
),
},
];
if (showEoiTab) {
tabs.push({
id: 'eoi',
label: 'EOI',
content: <InterestEoiTab interestId={interestId} clientId={clientId} />,
});
}
if (showContractTab) {
tabs.push({
id: 'contract',
label: 'Contract',
content: <InterestContractTab interestId={interestId} clientId={clientId} />,
});
}
if (showReservationTab) {
tabs.push({
id: 'reservation',
label: 'Reservation',
content: <InterestReservationTab interestId={interestId} clientId={clientId} />,
});
}
tabs.push(
{
id: 'documents',
label: 'Documents',
content: <InterestDocumentsTab interestId={interestId} />,
},
{
id: 'files',
label: 'Files',
content: <InterestFilesTab interestId={interestId} />,
},
{
id: 'recommendations',
label: 'Recommendations',
@@ -568,5 +866,7 @@ export function getInterestTabs({
label: 'Activity',
content: <InterestTimeline interestId={interestId} />,
},
];
);
return tabs;
}

View File

@@ -305,32 +305,39 @@ function LinkedBerthRowItem({ row, portSlug, eoiStatus, onUpdate, onRemove, isPe
<div className="mt-3 grid grid-cols-1 gap-3 border-t pt-3 sm:grid-cols-2">
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<Label htmlFor={`specific-${row.berthId}`} className="text-sm font-medium">
Specifically pitching
</Label>
{/* Switch sits next to its label (gap-2.5) instead of being
flexed to the far right via justify-between — when the
column is wide, justify-between created a confusing visual
gulf between the action and what it controls. */}
<div className="flex items-center gap-2.5">
<Switch
id={`specific-${row.berthId}`}
checked={row.isSpecificInterest}
disabled={isPending}
onCheckedChange={(checked) => onUpdate(row.berthId, { isSpecificInterest: checked })}
/>
<Label
htmlFor={`specific-${row.berthId}`}
className="text-sm font-medium cursor-pointer"
>
Specifically pitching
</Label>
</div>
<p className="text-xs text-muted-foreground">
{row.isSpecificInterest ? SPECIFIC_CONSEQUENCE_ON : SPECIFIC_CONSEQUENCE_OFF}
</p>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<Label htmlFor={`bundle-${row.berthId}`} className="text-sm font-medium">
Mark in EOI bundle
</Label>
<div className="flex items-center gap-2.5">
<Switch
id={`bundle-${row.berthId}`}
checked={row.isInEoiBundle}
disabled={isPending}
onCheckedChange={(checked) => onUpdate(row.berthId, { isInEoiBundle: checked })}
/>
<Label htmlFor={`bundle-${row.berthId}`} className="text-sm font-medium cursor-pointer">
Mark in EOI bundle
</Label>
</div>
<p className="text-xs text-muted-foreground">
{row.isInEoiBundle

View File

@@ -7,8 +7,8 @@ import { DndContext, closestCenter, type DragEndEvent } from '@dnd-kit/core';
import { PipelineColumn } from '@/components/interests/pipeline-column';
import { apiFetch } from '@/lib/api/client';
import { usePipelineStore } from '@/stores/pipeline-store';
import { PIPELINE_STAGES, STAGE_LABELS } from '@/lib/constants';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
interface InterestRow {
id: string;
@@ -19,28 +19,77 @@ interface InterestRow {
updatedAt: string;
}
export function PipelineBoard() {
interface BoardResponse {
data: InterestRow[];
truncated: boolean;
total: number;
}
interface PipelineBoardProps {
/** Filter values from the parent's FilterBar — passed through to the
* /api/v1/interests/board endpoint. Subset of listInterests filters
* (no pipelineStage, no includeArchived). Optional; board works
* fine without filters. */
filters?: Record<string, unknown>;
}
export function PipelineBoard({ filters }: PipelineBoardProps = {}) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const queryClient = useQueryClient();
const { boardFilters } = usePipelineStore();
const { data: allData, isLoading } = useQuery<{ data: InterestRow[] }>({
queryKey: ['interests-board', portSlug],
queryFn: () => apiFetch('/api/v1/interests?limit=500'),
// Build the board endpoint URL with the supported filter subset.
// pipelineStage + includeArchived are intentionally not threaded
// through — see boardFiltersSchema on the backend. Stable JSON-string
// form is reused as the queryKey so React Query caches per filter combo.
const queryString = useMemo(() => {
if (!filters) return '';
const params = new URLSearchParams();
const pick = (k: string) => {
const v = filters[k];
if (v === null || v === undefined || v === '' || v === false) return;
if (Array.isArray(v)) {
if (v.length === 0) return;
params.set(k, v.join(','));
} else {
params.set(k, String(v));
}
};
pick('search');
pick('leadCategory');
pick('source');
pick('eoiStatus');
pick('tagIds');
const s = params.toString();
return s ? `?${s}` : '';
}, [filters]);
const boardQueryKey = ['interests-board', portSlug, queryString] as const;
// Dedicated board endpoint — bypasses the paginated list's max(100)
// cap, projects only the 5 fields PipelineCard renders, and hard-caps
// at 5000 server-side. If `truncated: true`, surface a banner so the
// rep knows the board isn't showing every active deal.
const {
data: allData,
isLoading,
error,
} = useQuery<BoardResponse>({
queryKey: boardQueryKey,
queryFn: () => apiFetch(`/api/v1/interests/board${queryString}`),
});
const interests = useMemo(() => {
if (!allData?.data) return [];
return allData.data.filter((i) => {
if (boardFilters.leadCategory && i.leadCategory !== boardFilters.leadCategory) return false;
if (boardFilters.search) {
const q = boardFilters.search.toLowerCase();
if (!i.clientName?.toLowerCase().includes(q)) return false;
}
return true;
});
}, [allData, boardFilters]);
// Invalidate the entire ['interests-board', portSlug, *] family so
// realtime events refresh whatever filter combo is currently active.
// Using the prefix keeps stale per-filter caches from lingering after
// the underlying data changes elsewhere in the app.
useRealtimeInvalidation({
'interest:created': [['interests-board', portSlug]],
'interest:updated': [['interests-board', portSlug]],
'interest:stageChanged': [['interests-board', portSlug]],
'interest:archived': [['interests-board', portSlug]],
});
const interests = useMemo(() => allData?.data ?? [], [allData]);
const grouped = useMemo(() => {
const map: Record<string, InterestRow[]> = {};
@@ -98,8 +147,31 @@ export function PipelineBoard() {
return <div className="flex gap-3 overflow-x-auto pb-4 animate-pulse h-64" />;
}
// Surface fetch failures instead of silently rendering nine "Empty"
// columns, which is indistinguishable from "no interests yet" and was
// exactly the bug that hid this view's silent failure for so long.
if (error) {
return (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-4 py-6 text-sm text-destructive">
Couldn&apos;t load the pipeline board.{' '}
<button
className="underline underline-offset-2"
onClick={() => queryClient.invalidateQueries({ queryKey: boardQueryKey })}
>
Retry
</button>
</div>
);
}
return (
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
{allData?.truncated ? (
<div className="mb-3 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900">
Showing the {allData.total.toLocaleString()} most-recently-updated interests. Older active
deals aren&apos;t on the board archive completed work to keep the kanban readable.
</div>
) : null}
<div className="flex gap-3 overflow-x-auto pb-4">
{PIPELINE_STAGES.map((stage) => (
<PipelineColumn