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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user