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:
@@ -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'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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
435
src/components/interests/interest-contact-log-tab.tsx
Normal file
435
src/components/interests/interest-contact-log-tab.tsx
Normal 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 “Last contact”
|
||||
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())}`;
|
||||
}
|
||||
416
src/components/interests/interest-contract-tab.tsx
Normal file
416
src/components/interests/interest-contract-tab.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
362
src/components/interests/interest-eoi-tab.tsx
Normal file
362
src/components/interests/interest-eoi-tab.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,7 @@ export const interestFilterDefinitions: FilterDefinition[] = [
|
||||
{ label: 'Manual', value: 'manual' },
|
||||
{ label: 'Referral', value: 'referral' },
|
||||
{ label: 'Broker', value: 'broker' },
|
||||
{ label: 'Other', value: 'other' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
419
src/components/interests/interest-reservation-tab.tsx
Normal file
419
src/components/interests/interest-reservation-tab.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'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'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
|
||||
|
||||
Reference in New Issue
Block a user