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

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

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

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

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

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

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

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

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

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

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

View File

@@ -1,11 +1,11 @@
'use client';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import type { Route } from 'next';
import { useRouter } from 'next/navigation';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Bell, Download, Mail, Trash2, X } from 'lucide-react';
import { ArrowLeft, Bell, Download, Mail, Send, Trash2, UserPlus, X } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -15,6 +15,22 @@ import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useConfirmation } from '@/hooks/use-confirmation';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { cleanSignerName } from '@/components/documents/signing-progress';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
/** Capitalize the first letter; rest stays as-is. Used for normalising
* free-text enum values ('signer'/'approver'/'sent'/'pending') for
* display without resorting to full ALL-CAPS that other surfaces use. */
function capFirst(s: string | null | undefined): string {
if (!s) return '';
return s.charAt(0).toUpperCase() + s.slice(1);
}
interface DetailDoc {
id: string;
@@ -39,6 +55,9 @@ interface DetailSigner {
signerRole: string;
signingOrder: number;
status: string;
/** Null = never invited yet → "Send invitation" CTA.
* Set + status pending → "Send reminder" CTA. */
invitedAt: string | null;
signedAt: string | null;
signingUrl: string | null;
}
@@ -158,6 +177,22 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
}
};
// #67: state-aware action button. When a signer has no `invitedAt`
// they've never been mailed — fire the initial invitation (the same
// route the EOI tab uses; handles v2 distribute-or-self-heal).
const handleSendInvitation = async (signerId: string) => {
try {
await apiFetch(`/api/v1/documents/${documentId}/send-invitation`, {
method: 'POST',
body: { signerId },
});
toast.success('Invitation sent');
queryClient.invalidateQueries({ queryKey: ['document-detail', documentId] });
} catch (err) {
toastError(err);
}
};
const handleCancel = async () => {
const ok = await confirm({
title: 'Cancel document',
@@ -213,7 +248,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
kpiLine={
<>
<StatusPill status={STATUS_PILL_MAP[doc.status] ?? 'pending'} withDot>
{doc.status.replace(/_/g, ' ')}
{capFirst(doc.status.replace(/_/g, ' '))}
</StatusPill>
<span>
{signers.filter((s) => s.status === 'signed').length}/{signers.length} signed
@@ -279,28 +314,42 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="font-medium text-foreground">{signer.signerName}</div>
<div className="font-medium text-foreground">
{/* #67 cleanup: strip `(was: …)` / `(placeholder)`
email-redirect leak suffixes that the EOI tab
already scrubs on its own SigningProgress card. */}
{cleanSignerName(signer.signerName) || signer.signerEmail}
</div>
<StatusPill status={SIGNER_PILL_MAP[signer.status] ?? 'pending'}>
{signer.status}
{capFirst(signer.status)}
</StatusPill>
</div>
<div className="text-xs text-muted-foreground">
{signer.signerEmail} · {signer.signerRole}
{signer.signerEmail} · {capFirst(signer.signerRole)}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{signer.signedAt
? `Signed ${new Date(signer.signedAt).toLocaleDateString('en-GB')}`
: 'Pending'}
: signer.invitedAt
? `Invited ${new Date(signer.invitedAt).toLocaleDateString('en-GB')}`
: 'Not yet invited'}
</div>
{signer.status === 'pending' && doc.documensoId && isInFlight ? (
<div className="mt-2 flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleRemind(signer.id)}
>
<Bell className="mr-1.5 h-3 w-3" aria-hidden /> Remind
</Button>
{/* #67 state-aware CTA: invited yet? remind. else: send. */}
{signer.invitedAt ? (
<Button
size="sm"
variant="outline"
onClick={() => handleRemind(signer.id)}
>
<Bell className="mr-1.5 h-3 w-3" aria-hidden /> Send reminder
</Button>
) : (
<Button size="sm" onClick={() => handleSendInvitation(signer.id)}>
<Send className="mr-1.5 h-3 w-3" aria-hidden /> Send invitation
</Button>
)}
{signer.signingUrl ? (
<button
type="button"
@@ -339,44 +388,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
{/* Right column */}
<div className="flex flex-col gap-4">
<section className="rounded-md border bg-white p-4">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Watchers
</h2>
{watchers.length === 0 ? (
<p className="text-xs text-muted-foreground">No one is watching this document yet.</p>
) : (
<ul className="space-y-1">
{watchers.map((w) => (
<li key={w.userId} className="flex items-center justify-between text-sm">
<span className="truncate font-mono text-xs text-muted-foreground">
{w.userId.slice(0, 8)}
</span>
<button
type="button"
aria-label="Remove watcher"
onClick={async () => {
try {
await apiFetch(`/api/v1/documents/${documentId}/watchers/${w.userId}`, {
method: 'DELETE',
});
toast.success('Watcher removed');
queryClient.invalidateQueries({
queryKey: ['document-detail', documentId],
});
} catch (err) {
toastError(err);
}
}}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" aria-hidden />
</button>
</li>
))}
</ul>
)}
</section>
<WatchersCard documentId={documentId} watchers={watchers} />
<section className="rounded-md border bg-white p-4">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
@@ -405,3 +417,130 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
</div>
);
}
/**
* #67 watcher Add UI. The watchers list previously displayed only
* user-id stubs (truncated UUID) with a delete button and no way to
* add new watchers. This card resolves user IDs to display names
* via the existing `/api/v1/admin/users/picker` endpoint (already
* used by the registry-driven settings form), surfaces a "+ Add"
* select, and keeps the delete affordance unchanged.
*/
interface PickerUser {
id: string;
email: string;
name: string | null;
}
function WatchersCard({ documentId, watchers }: { documentId: string; watchers: DetailWatcher[] }) {
const queryClient = useQueryClient();
const [selected, setSelected] = useState('');
const { data: usersData } = useQuery({
queryKey: ['admin', 'users-picker'],
queryFn: () => apiFetch<{ data: PickerUser[] }>('/api/v1/admin/users/picker'),
});
const users = usersData?.data ?? [];
const userById = useMemo(() => {
const map = new Map<string, PickerUser>();
for (const u of users) map.set(u.id, u);
return map;
}, [users]);
const watcherIds = new Set(watchers.map((w) => w.userId));
const candidates = users.filter((u) => !watcherIds.has(u.id));
async function addWatcher(userId: string) {
if (!userId) return;
try {
await apiFetch(`/api/v1/documents/${documentId}/watchers`, {
method: 'POST',
body: { userId },
});
toast.success('Watcher added');
setSelected('');
queryClient.invalidateQueries({ queryKey: ['document-detail', documentId] });
} catch (err) {
toastError(err);
}
}
async function removeWatcher(userId: string) {
try {
await apiFetch(`/api/v1/documents/${documentId}/watchers/${userId}`, {
method: 'DELETE',
});
toast.success('Watcher removed');
queryClient.invalidateQueries({ queryKey: ['document-detail', documentId] });
} catch (err) {
toastError(err);
}
}
return (
<section className="rounded-md border bg-white p-4">
<h2 className="mb-1 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Watchers
</h2>
<p className="mb-3 text-xs text-muted-foreground">
Watchers receive an in-app notification on every signing event (opened, signed, declined,
completed).
</p>
{watchers.length === 0 ? (
<p className="text-xs text-muted-foreground">No one is watching this document yet.</p>
) : (
<ul className="mb-3 space-y-1">
{watchers.map((w) => {
const u = userById.get(w.userId);
return (
<li key={w.userId} className="flex items-center justify-between text-sm">
<span className="truncate">
{u?.name ?? u?.email ?? `User ${w.userId.slice(0, 8)}`}
</span>
<button
type="button"
aria-label="Remove watcher"
onClick={() => removeWatcher(w.userId)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" aria-hidden />
</button>
</li>
);
})}
</ul>
)}
<div className="flex items-center gap-2">
<Select value={selected} onValueChange={setSelected}>
<SelectTrigger className="h-9 flex-1 text-xs">
<SelectValue placeholder="Add a watcher…" />
</SelectTrigger>
<SelectContent>
{candidates.length === 0 ? (
<div className="px-2 py-3 text-xs text-muted-foreground">
All users in this port are already watching.
</div>
) : (
candidates.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name ?? u.email}
</SelectItem>
))
)}
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
disabled={!selected}
onClick={() => addWatcher(selected)}
>
<UserPlus className="mr-1.5 h-3 w-3" aria-hidden /> Add
</Button>
</div>
</section>
);
}

View File

@@ -138,7 +138,10 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
'document:cancelled': [['documents']],
'document:rejected': [['documents']],
'document:signer:signed': [['documents']],
'file:created': [['files']],
// M-D01: server emits `file:uploaded` (see src/lib/services/files.ts);
// every other consumer listens on that name. `file:created` was a
// typo here, so the hub's file list never invalidated on upload.
'file:uploaded': [['files']],
'file:updated': [['files']],
'file:deleted': [['files']],
'folder:created': [['document-folders']],

View File

@@ -0,0 +1,179 @@
'use client';
import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, Loader2, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
interface Signer {
id: string;
signerName: string;
signerEmail: string;
signerRole: string;
status: string;
}
interface EoiCancelDialogProps {
documentId: string;
signers: Signer[];
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* Cancel-with-notify modal. Two variants by signedCount:
* - 0 signed: simple confirm with optional reason. Cancel button.
* - 1+ signed: list each signer with a checkbox so the rep picks
* who to email. Pre-checks the signers who have signed (they're
* the most-affected) — rep can opt out.
*
* In both cases the reason textarea is optional and (when present)
* gets inlined into the cancellation email body + the audit log.
*
* On confirm: POST /api/v1/documents/[id]/cancel with
* { reason, notifyRecipients: [signerId, ...] }
* The server voids the envelope, marks status=cancelled, sends the
* branded cancellation email to each picked recipient.
*/
export function EoiCancelDialog({ documentId, signers, open, onOpenChange }: EoiCancelDialogProps) {
const queryClient = useQueryClient();
const [reason, setReason] = useState('');
const [notifyIds, setNotifyIds] = useState<Set<string>>(() => {
// Default: pre-check the signers who have signed — they're the
// recipients most likely to want to know. Pending signers can be
// notified too but the rep needs to opt them in.
return new Set(signers.filter((s) => s.status === 'signed').map((s) => s.id));
});
const signedCount = useMemo(() => signers.filter((s) => s.status === 'signed').length, [signers]);
const cancelMutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/documents/${documentId}/cancel`, {
method: 'POST',
body: {
reason: reason.trim() || null,
notifyRecipients: Array.from(notifyIds),
},
}),
onSuccess: () => {
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' });
toast.success(
notifyIds.size > 0
? `EOI cancelled. ${notifyIds.size} signer${notifyIds.size === 1 ? '' : 's'} notified.`
: 'EOI cancelled.',
);
onOpenChange(false);
// Reset internal state so a second open of the dialog starts clean.
setReason('');
setNotifyIds(new Set());
},
onError: (err) => toastError(err),
});
const toggle = (id: string) => {
setNotifyIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="size-4 text-amber-600" aria-hidden /> Cancel this EOI?
</DialogTitle>
<DialogDescription>
{signedCount === 0
? 'No signatures have been collected yet. The signing service will be told to void this envelope.'
: `${signedCount} signer${signedCount === 1 ? ' has' : 's have'} already signed. The envelope will be voided and pick the signers you want to notify by email below.`}
</DialogDescription>
</DialogHeader>
{signedCount > 0 && (
<div className="space-y-2 rounded-md border bg-muted/30 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Notify
</p>
<ul className="space-y-1.5">
{signers.map((s) => (
<li key={s.id} className="flex items-center gap-2 text-sm">
<Checkbox
id={`notify-${s.id}`}
checked={notifyIds.has(s.id)}
onCheckedChange={() => toggle(s.id)}
/>
<Label htmlFor={`notify-${s.id}`} className="flex-1 cursor-pointer font-normal">
<span className="font-medium">{s.signerName || s.signerEmail}</span>{' '}
<span className="text-xs text-muted-foreground">
· {s.signerRole}
{s.status === 'signed' ? ' · already signed' : ' · pending'}
</span>
</Label>
</li>
))}
</ul>
<p className="text-xs italic text-muted-foreground">
Leave all unchecked to cancel silently no emails will be sent.
</p>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="cancel-reason" className="text-xs font-semibold uppercase tracking-wide">
Reason (optional)
</Label>
<Textarea
id="cancel-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="e.g. Yacht owner changed terms; will resend a fresh EOI."
className="min-h-[80px] resize-y"
maxLength={2000}
/>
<p className="text-xs text-muted-foreground">
Appears in the cancellation email (if you notify anyone) and the audit log.
</p>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Keep EOI
</Button>
<Button
variant="destructive"
onClick={() => cancelMutation.mutate()}
disabled={cancelMutation.isPending}
className="gap-1.5 [&_svg]:size-3.5"
>
{cancelMutation.isPending ? (
<Loader2 className="animate-spin" aria-hidden />
) : (
<XCircle />
)}
Cancel EOI
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -6,13 +6,13 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, ExternalLink, FileSignature, Pencil } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import {
Select,
@@ -47,7 +47,14 @@ interface EoiContextResponse {
nationality: string | null;
primaryEmail: string | null;
primaryPhone: string | null;
address: { street: string; city: string; country: string } | null;
address: {
street: string;
city: string;
subdivision: string;
postalCode: string;
country: string;
countryIso: string;
} | null;
};
yacht: {
id: string;
@@ -55,6 +62,16 @@ interface EoiContextResponse {
lengthFt: string | null;
widthFt: string | null;
draftFt: string | null;
lengthM: string | null;
widthM: string | null;
draftM: string | null;
/** Which unit the rep originally entered the dimensions in — drives
* the toggle's default position. The trio of *Unit columns usually
* share a value in practice; we read `lengthUnit` as the
* representative. */
lengthUnit: 'ft' | 'm';
widthUnit: 'ft' | 'm';
draftUnit: 'ft' | 'm';
hullNumber: string | null;
flag: string | null;
} | null;
@@ -100,18 +117,46 @@ export function EoiGenerateDialog({
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedTemplate, setSelectedTemplate] = useState<string>(DOCUMENSO_TEMPLATE_VALUE);
// Unit picker for the Length/Width/Draft preview row + the values that
// ship to Documenso. Defaults to whichever side the rep originally typed
// (drives off the yacht's `lengthUnit` column). Stored as state so the
// rep can flip ft↔m before generating without losing the underlying data.
const [dimensionUnit, setDimensionUnit] = useState<'ft' | 'm' | null>(null);
// Resolved EOI context — the actual values the document will be
// auto-filled with. Loaded only while the dialog is open so we don't
// pay for the join tree on every interest detail page render.
const { data: ctxRes, isLoading: ctxLoading } = useQuery<EoiContextResponse>({
const {
data: ctxRes,
isLoading: ctxLoading,
error: ctxError,
} = useQuery<EoiContextResponse>({
queryKey: ['interests', interestId, 'eoi-context'],
queryFn: () => apiFetch<EoiContextResponse>(`/api/v1/interests/${interestId}/eoi-context`),
enabled: open,
staleTime: 30_000,
retry: false,
});
const ctx = ctxRes?.data;
// Server-side EOI validators throw `Cannot generate EOI - missing
// required client details: client name, client email, client address`.
// Parse that list so the dialog can render an inline fix-it form
// (no need to bounce out to the client detail page).
const ctxErrorMessage = ctxError instanceof Error && ctxError.message ? ctxError.message : null;
const missingFields = useMemo(() => {
if (!ctxErrorMessage) return new Set<'name' | 'email' | 'address'>();
const m = ctxErrorMessage.match(/missing required client details:\s*([^.]+)/i);
if (!m) return new Set<'name' | 'email' | 'address'>();
const tokens = m[1]!.split(',').map((s) => s.trim().toLowerCase());
const out = new Set<'name' | 'email' | 'address'>();
for (const t of tokens) {
if (t.includes('name')) out.add('name');
if (t.includes('email')) out.add('email');
if (t.includes('address')) out.add('address');
}
return out;
}, [ctxErrorMessage]);
const { data: templatesRes } = useQuery<{ data: InAppTemplate[] }>({
queryKey: ['document-templates', { templateType: 'eoi', isActive: true }],
@@ -123,6 +168,86 @@ export function EoiGenerateDialog({
});
const inAppTemplates = useMemo(() => templatesRes?.data ?? [], [templatesRes]);
// Only show the template picker when there's a real choice — the
// Documenso path is always present, so we show the dropdown once at
// least one in-app pdf-lib template is configured. Otherwise it's a
// 1-item select which adds noise.
const showTemplatePicker = inAppTemplates.length > 0;
// ─── Inline fix-it form for missing client fields ──────────────────────────
// Drafted as one piece of local state so a partial save (e.g. address
// succeeds but email fails) leaves the rest of the inputs untouched.
const [fixDraft, setFixDraft] = useState<{
name: string;
email: string;
street: string;
city: string;
postalCode: string;
subdivisionIso: string;
countryIso: string | null;
}>({
name: '',
email: '',
street: '',
city: '',
postalCode: '',
subdivisionIso: '',
countryIso: null,
});
const [fixSaving, setFixSaving] = useState(false);
const persistMissingFields = async (): Promise<void> => {
if (!clientId) {
toastError(new Error('Client ID missing — refresh the page.'));
return;
}
setFixSaving(true);
try {
// Issue one PATCH/POST per missing field. Sequential rather than
// parallel so a downstream failure surfaces a coherent error rather
// than partial-and-confused state.
if (missingFields.has('name')) {
if (!fixDraft.name.trim()) throw new Error('Client name is required.');
await apiFetch(`/api/v1/clients/${clientId}`, {
method: 'PATCH',
body: { fullName: fixDraft.name.trim() },
});
}
if (missingFields.has('email')) {
if (!fixDraft.email.trim()) throw new Error('Client email is required.');
await apiFetch(`/api/v1/clients/${clientId}/contacts`, {
method: 'POST',
body: { channel: 'email', value: fixDraft.email.trim(), isPrimary: true },
});
}
if (missingFields.has('address')) {
if (!fixDraft.street.trim()) throw new Error('Street address is required.');
await apiFetch(`/api/v1/clients/${clientId}/addresses`, {
method: 'POST',
body: {
streetAddress: fixDraft.street.trim(),
city: fixDraft.city.trim() || null,
postalCode: fixDraft.postalCode.trim() || null,
subdivisionIso: fixDraft.subdivisionIso.trim() || null,
countryIso: fixDraft.countryIso,
isPrimary: true,
},
});
}
// Refetch the EOI context so the dialog flips into preview-ready mode.
// Also bounce caches that downstream surfaces watch (client detail,
// interest detail) so the rep sees the edits everywhere immediately.
await queryClient.invalidateQueries({
queryKey: ['interests', interestId, 'eoi-context'],
});
await queryClient.invalidateQueries({ queryKey: ['clients', clientId] });
await queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
} catch (err) {
toastError(err);
} finally {
setFixSaving(false);
}
};
async function patchClient(body: Record<string, unknown>) {
if (!ctx) return;
@@ -149,22 +274,6 @@ export function EoiGenerateDialog({
placeholder: 'Full legal name',
},
},
{
key: 'nationality',
label: 'Nationality',
value: ctx.client.nationality,
present: !!ctx.client.nationality,
edit: {
variant: 'country' as const,
onSave: async (next: string | null) => {
// Country combobox emits the ISO code; the read-only string is the
// localised country name (resolved server-side). Coerce here so we
// store the canonical ISO.
const iso = next ? (next as string).toUpperCase() : null;
await patchClient({ nationalityIso: iso });
},
},
},
{
key: 'email',
label: 'Email address',
@@ -173,9 +282,17 @@ export function EoiGenerateDialog({
},
{
key: 'address',
// Mirrors the rendered EOI Address field exactly so the rep sees
// what's going to appear on the document.
label: 'Address',
value: ctx.client.address
? [ctx.client.address.street, ctx.client.address.city, ctx.client.address.country]
? [
ctx.client.address.street,
ctx.client.address.city,
ctx.client.address.subdivision,
ctx.client.address.postalCode,
ctx.client.address.countryIso,
]
.filter(Boolean)
.join(', ')
: null,
@@ -184,6 +301,17 @@ export function EoiGenerateDialog({
]
: [];
// Default the dimension toggle to the unit the rep originally typed in
// (yacht.lengthUnit). We fall back to 'ft' for legacy rows where the
// unit column was never set.
const defaultDimensionUnit: 'ft' | 'm' = ctx?.yacht?.lengthUnit ?? 'ft';
const effectiveDimensionUnit: 'ft' | 'm' = dimensionUnit ?? defaultDimensionUnit;
const dimensionsForRender = ctx?.yacht
? effectiveDimensionUnit === 'ft'
? [ctx.yacht.lengthFt, ctx.yacht.widthFt, ctx.yacht.draftFt]
: [ctx.yacht.lengthM, ctx.yacht.widthM, ctx.yacht.draftM]
: [];
// Optional — Section 3 of the EOI. Generation proceeds without them.
const optional = ctx
? [
@@ -200,12 +328,8 @@ export function EoiGenerateDialog({
},
{
key: 'dimensions',
label: 'Dimensions (L × W × D, ft)',
value: ctx.yacht
? [ctx.yacht.lengthFt, ctx.yacht.widthFt, ctx.yacht.draftFt]
.map((v) => v ?? '—')
.join(' × ')
: null,
label: `Dimensions (L × W × D, ${effectiveDimensionUnit})`,
value: ctx.yacht ? dimensionsForRender.map((v) => v ?? '—').join(' × ') : null,
},
{
key: 'berth',
@@ -241,11 +365,25 @@ export function EoiGenerateDialog({
pathway: isDocumenso ? 'documenso-template' : 'inapp',
// Signers derived server-side from EOI context for both pathways.
signers: [],
// Dimension unit chosen in the drawer's toggle — drives which
// side (ft|m) of the yacht's stored dimensions flows into the
// EOI's Length/Width/Draft formValues. Defaults server-side to
// the yacht's own `lengthUnit` column when unspecified.
dimensionUnit: effectiveDimensionUnit,
},
});
queryClient.invalidateQueries({
predicate: (q) => q.queryKey[0] === 'documents',
});
// Bounce every cache that surfaces the interest's EOI state so the
// Overview tab flips immediately from "Generate EOI" prompt to
// "EOI sent / awaiting signatures", the EOI tab picks up the new
// signers row, and the timeline reflects the just-stamped milestone.
await Promise.all([
queryClient.invalidateQueries({
predicate: (q) => q.queryKey[0] === 'documents',
}),
queryClient.invalidateQueries({ queryKey: ['interests', interestId] }),
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'eoi-context'] }),
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'timeline'] }),
]);
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate EOI');
@@ -255,38 +393,41 @@ export function EoiGenerateDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-xl overflow-y-auto">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<FileSignature className="size-4" aria-hidden />
Generate Expression of Interest
</DialogTitle>
<DialogDescription>
Review the values that will be auto-filled into the EOI. Anything wrong? Edit it on the
client&apos;s record before generating.
</DialogDescription>
</DialogHeader>
</SheetTitle>
<SheetDescription>
Review the values that will be auto-filled. Edit anything inline changes save back to
the client / interest record automatically. The EOI is generated once everything looks
right.
</SheetDescription>
</SheetHeader>
<div className="space-y-4 py-1">
<div className="space-y-2">
<Label htmlFor="eoi-template">Template</Label>
<Select value={selectedTemplate} onValueChange={setSelectedTemplate}>
<SelectTrigger id="eoi-template">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={DOCUMENSO_TEMPLATE_VALUE}>
Standard EOI sent for e-signature (recommended)
</SelectItem>
{inAppTemplates.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
<div className="space-y-4 py-4">
{showTemplatePicker && (
<div className="space-y-2">
<Label htmlFor="eoi-template">Template</Label>
<Select value={selectedTemplate} onValueChange={setSelectedTemplate}>
<SelectTrigger id="eoi-template">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={DOCUMENSO_TEMPLATE_VALUE}>
Standard EOI sent for e-signature (recommended)
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{inAppTemplates.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{ctxLoading ? (
<div className="space-y-2">
@@ -305,9 +446,6 @@ export function EoiGenerateDialog({
<PreviewRow
key={row.key}
label={row.label}
// Nationality stores the localised country name in the preview
// but commits the ISO. Pass the underlying ISO via a closure
// so the CountryCombobox can highlight it correctly.
value={row.value}
missing={!row.present}
edit={row.edit}
@@ -316,9 +454,41 @@ export function EoiGenerateDialog({
</dl>
</div>
<div className="space-y-1 border-t pt-2">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Optional (Section 3 left blank if absent)
</p>
<div className="flex items-center justify-between">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Optional (Section 3 left blank if absent)
</p>
{ctx.yacht ? (
<div className="inline-flex rounded-md border bg-muted/30 p-0.5 text-[11px]">
<button
type="button"
onClick={() => setDimensionUnit('ft')}
className={
'rounded px-2 py-0.5 transition-colors ' +
(effectiveDimensionUnit === 'ft'
? 'bg-background font-medium shadow-sm'
: 'text-muted-foreground hover:text-foreground')
}
aria-pressed={effectiveDimensionUnit === 'ft'}
>
ft
</button>
<button
type="button"
onClick={() => setDimensionUnit('m')}
className={
'rounded px-2 py-0.5 transition-colors ' +
(effectiveDimensionUnit === 'm'
? 'bg-background font-medium shadow-sm'
: 'text-muted-foreground hover:text-foreground')
}
aria-pressed={effectiveDimensionUnit === 'm'}
>
m
</button>
</div>
) : null}
</div>
<dl className="space-y-1.5">
{optional.map((row) => (
<PreviewRow key={row.key} label={row.label} value={row.value} edit={row.edit} />
@@ -328,9 +498,8 @@ export function EoiGenerateDialog({
{portSlug && clientId && (
<div className="border-t pt-2 space-y-1">
<p className="text-[11px] text-muted-foreground">
Editing name / nationality / yacht name above patches the underlying records
directly. For phone, address, or to manage linked berths, jump to the canonical
page:
Editing name / yacht name above patches the underlying records directly. For
phone, address, or to manage linked berths, jump to the canonical page:
</p>
<div className="flex flex-wrap gap-3">
<Link
@@ -357,10 +526,132 @@ export function EoiGenerateDialog({
</div>
)}
</div>
) : missingFields.size > 0 && clientId ? (
<div className="rounded-md border border-amber-200 bg-amber-50/60 p-3 space-y-3">
<div className="space-y-0.5">
<p className="text-xs font-medium text-amber-900">
Missing required client details
</p>
<p className="text-[11px] text-amber-800/80">
Fill the fields below they&apos;ll be saved to the client&apos;s record before
the EOI renders.
</p>
</div>
<div className="space-y-3">
{missingFields.has('name') && (
<div className="space-y-1">
<Label htmlFor="fix-name" className="text-xs">
Client full name
</Label>
<Input
id="fix-name"
value={fixDraft.name}
onChange={(e) => setFixDraft((d) => ({ ...d, name: e.target.value }))}
placeholder="Jane Smith"
/>
</div>
)}
{missingFields.has('email') && (
<div className="space-y-1">
<Label htmlFor="fix-email" className="text-xs">
Client email
</Label>
<Input
id="fix-email"
type="email"
value={fixDraft.email}
onChange={(e) => setFixDraft((d) => ({ ...d, email: e.target.value }))}
placeholder="jane@example.com"
/>
</div>
)}
{missingFields.has('address') && (
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="fix-street" className="text-xs">
Street address
</Label>
<Input
id="fix-street"
value={fixDraft.street}
onChange={(e) => setFixDraft((d) => ({ ...d, street: e.target.value }))}
placeholder="123 Marina Way"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="fix-city" className="text-xs">
City
</Label>
<Input
id="fix-city"
value={fixDraft.city}
onChange={(e) => setFixDraft((d) => ({ ...d, city: e.target.value }))}
placeholder="Athens"
/>
</div>
<div className="space-y-1">
<Label htmlFor="fix-postal" className="text-xs">
Postal code
</Label>
<Input
id="fix-postal"
value={fixDraft.postalCode}
onChange={(e) =>
setFixDraft((d) => ({ ...d, postalCode: e.target.value }))
}
placeholder="98000"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="fix-region" className="text-xs">
Region / State <span className="text-muted-foreground">(optional)</span>
</Label>
<Input
id="fix-region"
value={fixDraft.subdivisionIso}
onChange={(e) =>
setFixDraft((d) => ({ ...d, subdivisionIso: e.target.value }))
}
placeholder="ISO-3166-2 e.g. US-CA"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Country</Label>
<CountryCombobox
value={fixDraft.countryIso}
onChange={(iso) =>
setFixDraft((d) => ({ ...d, countryIso: iso ?? null }))
}
/>
</div>
</div>
</div>
)}
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
size="sm"
onClick={() => void persistMissingFields()}
disabled={fixSaving}
>
{fixSaving ? 'Saving…' : 'Save & preview EOI'}
</Button>
</div>
</div>
) : (
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
Couldn&apos;t load the EOI preview data. Try closing and reopening the dialog.
</p>
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 space-y-1">
{ctxErrorMessage ? (
<p className="font-medium">{ctxErrorMessage}</p>
) : (
<p>
Couldn&apos;t load the EOI preview data. Try closing and reopening the dialog.
</p>
)}
</div>
)}
{!ctxLoading && ctx && !requiredMet && (
@@ -374,16 +665,16 @@ export function EoiGenerateDialog({
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<SheetFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isGenerating}>
Cancel
</Button>
<Button onClick={handleGenerate} disabled={!requiredMet || isGenerating || ctxLoading}>
{isGenerating ? 'Generating…' : 'Generate EOI'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</SheetFooter>
</SheetContent>
</Sheet>
);
}

View File

@@ -2,9 +2,13 @@
import { apiFetch } from '@/lib/api/client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { Check, Clock, X, Mail, Eye, Bell, Send } from 'lucide-react';
import { toastError } from '@/lib/api/toast-error';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface Signer {
id: string;
@@ -14,7 +18,6 @@ interface Signer {
signingOrder: number;
status: string;
signedAt?: string | null;
/** Phase 1+2 lifecycle columns surfaced on the API row. */
invitedAt?: string | null;
openedAt?: string | null;
lastReminderSentAt?: string | null;
@@ -25,28 +28,78 @@ interface SigningProgressProps {
signers: Signer[];
}
const STATUS_COLORS: Record<string, string> = {
pending: 'bg-muted border-muted-foreground/30 text-muted-foreground',
signed: 'bg-green-100 border-green-500 text-green-800',
declined: 'bg-red-100 border-red-500 text-red-800',
};
const STATUS_LABELS: Record<string, string> = {
pending: 'Pending',
signed: 'Signed',
declined: 'Declined',
};
const ROLE_LABELS: Record<string, string> = {
client: 'Client',
signer: 'Signer',
developer: 'Developer',
approver: 'Sales/Approver',
approver: 'Approver',
sales: 'Sales / Approver',
cc: 'CC',
viewer: 'Viewer',
other: 'Other',
};
type Tone = 'pending' | 'opened' | 'signed' | 'declined';
const STATUS_META: Record<string, { label: string; tone: Tone; icon: typeof Check }> = {
pending: { label: 'Pending', tone: 'pending', icon: Clock },
signed: { label: 'Signed', tone: 'signed', icon: Check },
declined: { label: 'Declined', tone: 'declined', icon: X },
};
// Card styling per status — colour-tinted background + left accent stripe.
// `opened` is a runtime-derived tone (pending status + openedAt set) so a
// signer who's actually looked at the doc reads visually distinct from one
// who hasn't yet — the rep can tell at a glance who's stalling vs who
// hasn't engaged at all.
const TONE_STYLES: Record<
Tone,
{
card: string;
accentBar: string;
circle: string;
statusChipBg: string;
statusChipText: string;
iconBubble: string;
}
> = {
pending: {
card: 'bg-card hover:shadow-sm',
accentBar: 'before:bg-amber-300/70',
circle: 'bg-muted text-foreground/70 border-border',
statusChipBg: 'bg-amber-50 border-amber-200',
statusChipText: 'text-amber-800',
iconBubble: 'bg-amber-100 text-amber-700 border-card',
},
opened: {
card: 'bg-sky-50/40 hover:bg-sky-50/60',
accentBar: 'before:bg-sky-400',
circle: 'bg-sky-100 text-sky-800 border-sky-200',
statusChipBg: 'bg-sky-50 border-sky-200',
statusChipText: 'text-sky-800',
iconBubble: 'bg-sky-500 text-white border-card',
},
signed: {
card: 'bg-emerald-50/50 hover:bg-emerald-50/70',
accentBar: 'before:bg-emerald-500',
circle: 'bg-emerald-500 text-white border-emerald-500',
statusChipBg: 'bg-emerald-100 border-emerald-300',
statusChipText: 'text-emerald-800',
iconBubble: 'bg-emerald-500 text-white border-card',
},
declined: {
card: 'bg-rose-50/40 hover:bg-rose-50/60',
accentBar: 'before:bg-rose-500',
circle: 'bg-rose-500 text-white border-rose-500',
statusChipBg: 'bg-rose-100 border-rose-300',
statusChipText: 'text-rose-800',
iconBubble: 'bg-rose-500 text-white border-card',
},
};
/**
* Phase 6 polish: human-readable "X minutes/hours/days ago" for the
* activity badges (invited / opened / last reminded). Uses
* Intl.RelativeTimeFormat so it follows the user's locale.
* "X minutes/hours/days ago" using Intl.RelativeTimeFormat. Returns null
* when the input is null/invalid so callers can skip rendering.
*/
function humanRelative(isoOrNull: string | null | undefined): string | null {
if (!isoOrNull) return null;
@@ -64,14 +117,94 @@ function humanRelative(isoOrNull: string | null | undefined): string | null {
return rtf.format(-days, 'day');
}
/** Compact absolute timestamp for inline display next to relative time.
* Always renders date + time so a signer who signed weeks ago still
* reads as a real moment in the timeline (not just "Signed 12 days
* ago"). Year is omitted for the current calendar year to keep the
* string short; long-running EOIs that span year boundaries see the
* year so "Dec 3, 23:14" doesn't ambiguously mean last year or this. */
function compactAbsolute(isoOrNull: string | null | undefined): string | null {
if (!isoOrNull) return null;
const d = new Date(isoOrNull);
if (Number.isNaN(d.getTime())) return null;
const sameYear = d.getFullYear() === new Date().getFullYear();
const dateOpts: Intl.DateTimeFormatOptions = sameYear
? { month: 'short', day: 'numeric' }
: { year: 'numeric', month: 'short', day: 'numeric' };
const date = d.toLocaleDateString(undefined, dateOpts);
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
return `${date}, ${time}`;
}
/** Tick state every minute so relative-time strings ("Signed 3 min ago")
* re-render without a manual refresh. Returns a number that increments
* every 60s — components read it to invalidate memoization. */
function useMinuteTick(): number {
const [tick, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 60_000);
return () => clearInterval(id);
}, []);
return tick;
}
/**
* Initials shown in the avatar circle.
*
* Cleans the signer name before deriving initials:
* - Strips the `(was: <orig-email>)` suffix that `applyRecipientRedirect`
* bakes into Documenso recipients when EMAIL_REDIRECT_TO is on.
* - Strips Documenso template placeholder markers like `(placeholder)`.
*
* Then derives the bubble label:
* - Real CRM-source-of-truth name (e.g. "David Mizrahi") → "DM".
* - Single-word role placeholder ("Developer" / "Approver" / "Client")
* → first letter only ("D" / "A" / "C"). Reads as a typed role
* marker rather than a truncated name.
* - Empty string → "?".
*/
function getInitials(name: string): string {
const clean = name
.replace(/\s*\(was:[^)]*\)/i, '')
.replace(/\s*\(placeholder\)/i, '')
.replace(/\s*\(placeholder\b[^)]*\)/i, '')
.trim();
const parts = clean.split(/\s+/).filter(Boolean);
if (parts.length === 0) return '?';
if (parts.length === 1) {
const word = parts[0]!;
return word.slice(0, 1).toUpperCase();
}
return (parts[0]![0]! + parts[parts.length - 1]![0]!).toUpperCase();
}
/**
* Cleaned signer display name (matches the initials derivation above).
* The raw `signerName` may carry redirect/placeholder suffixes; this is
* what the card surfaces as the headline. Exported so the document
* detail page can apply the same scrub (#67).
*/
export function cleanSignerName(name: string): string {
return name
.replace(/\s*\(was:[^)]*\)/i, '')
.replace(/\s*\(placeholder\)/i, '')
.replace(/\s*\(placeholder\b[^)]*\)/i, '')
.trim();
}
export function SigningProgress({ documentId, signers }: SigningProgressProps) {
const queryClient = useQueryClient();
// Force a re-render every 60s so the "X minutes ago" labels update
// even when the user leaves the tab open without a webhook arriving.
// Reading `tick` below is enough to wire the dependency.
const tick = useMinuteTick();
void tick;
const sorted = [...signers].sort((a, b) => a.signingOrder - b.signingOrder);
// Phase 6 — surface reminder cooldown / success / error in a toast
// rather than the silent catch the old handler used. Reps need to
// know whether the manual "Resend" actually fired.
// Reminder = follow-up nudge to someone who's already been invited.
// Documenso enforces per-signer rate-limiting (default once / 7 days)
// so this only fires when the cooldown has elapsed.
const remindMutation = useMutation({
mutationFn: (signerId: string) =>
apiFetch<{ data: { sent: boolean; reason?: string } }>(
@@ -89,75 +222,201 @@ export function SigningProgress({ documentId, signers }: SigningProgressProps) {
onError: (err) => toastError(err, 'Failed to send reminder'),
});
// Initial invitation = the first branded email containing the signing
// link. In `manual` send mode (per-port admin setting) the EOI is
// generated without auto-sending, so this is the rep's first chance
// to dispatch. In `auto` mode the initial email goes out at generate
// time and the button is hidden because invitedAt is already stamped.
const inviteMutation = useMutation({
mutationFn: (signerId: string) =>
apiFetch<{ data: { recipientId: string; sent: boolean } }>(
`/api/v1/documents/${documentId}/send-invitation`,
{ method: 'POST', body: { recipientId: signerId } },
),
onSuccess: () => {
toast.success('Invitation sent.');
queryClient.invalidateQueries({ queryKey: ['documents', documentId, 'signers'] });
},
onError: (err) => toastError(err, 'Failed to send invitation'),
});
return (
<div className="flex items-start gap-2">
{sorted.map((signer, idx) => {
<div className="space-y-2.5">
{sorted.map((signer) => {
const baseStatus = STATUS_META[signer.status] ?? STATUS_META.pending!;
// Promote `pending + has been opened` to the `opened` tone so the
// card reads visually distinct from "invited but never clicked".
const tone: Tone =
baseStatus.tone === 'pending' && signer.openedAt ? 'opened' : baseStatus.tone;
const styles = TONE_STYLES[tone];
const StatusIcon =
tone === 'opened' ? Eye : tone === 'signed' ? Check : tone === 'declined' ? X : Clock;
const statusLabel =
tone === 'opened'
? 'Opened'
: tone === 'signed'
? 'Signed'
: tone === 'declined'
? 'Declined'
: 'Pending';
const invitedAgo = humanRelative(signer.invitedAt);
const openedAgo = humanRelative(signer.openedAt);
const remindedAgo = humanRelative(signer.lastReminderSentAt);
return (
<div key={signer.id} className="flex items-center gap-2">
<div className="flex flex-col items-center gap-1">
<div
className={`flex h-10 w-10 items-center justify-center rounded-full border-2 text-xs font-bold ${STATUS_COLORS[signer.status] ?? STATUS_COLORS.pending}`}
>
{signer.signingOrder}
<div key={signer.id} className="relative">
<div
className={cn(
// Left accent stripe via a `::before` so the colour reads
// immediately at the line of the card without competing
// with the avatar circle.
'relative flex items-start gap-3 rounded-lg border p-3 pl-4 transition-colors',
'before:absolute before:left-0 before:top-2 before:bottom-2 before:w-1 before:rounded-r',
styles.card,
styles.accentBar,
)}
>
{/* Avatar circle (initials) with status icon overlay so the
state reads from the avatar itself even before the
status pill is parsed. */}
<div className="relative shrink-0">
<div
className={cn(
'flex h-11 w-11 items-center justify-center rounded-full border-2 text-sm font-bold shadow-sm',
styles.circle,
)}
>
{getInitials(signer.signerName)}
</div>
<div
className={cn(
'absolute -bottom-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full border-2 shadow-sm',
styles.iconBubble,
)}
>
<StatusIcon className="size-3" aria-hidden />
</div>
</div>
<div className="max-w-28 text-center">
<p className="truncate text-xs font-medium">{signer.signerName}</p>
<p className="truncate text-xs text-muted-foreground">
{ROLE_LABELS[signer.signerRole] ?? signer.signerRole}
</p>
<p className="text-xs text-muted-foreground">
{STATUS_LABELS[signer.status] ?? signer.status}
</p>
{signer.signedAt && (
<p className="text-xs text-muted-foreground">
{new Date(signer.signedAt).toLocaleDateString('en-GB')}
</p>
)}
{/* Phase 6 polish — activity badges so reps can see at a
glance when each signer was last touched. */}
{signer.status === 'pending' && (invitedAgo || openedAgo || remindedAgo) && (
<div className="mt-1 space-y-0.5">
{invitedAgo && (
<p
className="text-[10px] text-muted-foreground"
title={signer.invitedAt ?? ''}
>
Invited {invitedAgo}
</p>
{/* Name + role + email + status pill + activity */}
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-medium text-foreground">
{cleanSignerName(signer.signerName) || signer.signerEmail}
</span>
<span
className={cn(
'inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
styles.statusChipBg,
styles.statusChipText,
)}
{openedAgo && (
<p
className="text-[10px] text-muted-foreground"
title={signer.openedAt ?? ''}
>
Opened {openedAgo}
</p>
)}
{remindedAgo && (
<p
className="text-[10px] text-muted-foreground"
title={signer.lastReminderSentAt ?? ''}
>
Reminded {remindedAgo}
</p>
)}
</div>
)}
{signer.status === 'pending' && (
<button
onClick={() => remindMutation.mutate(signer.id)}
disabled={remindMutation.isPending}
className="mt-1 text-xs text-primary underline hover:no-underline disabled:opacity-50"
>
{remindMutation.isPending ? 'Sending…' : 'Resend'}
</button>
)}
<StatusIcon className="size-2.5" aria-hidden />
{statusLabel}
</span>
<span className="text-[11px] text-muted-foreground">
· {ROLE_LABELS[signer.signerRole] ?? signer.signerRole}
{' · '}
<span className="font-medium">#{signer.signingOrder}</span>
</span>
</div>
<p className="truncate text-xs text-muted-foreground">{signer.signerEmail}</p>
{/* Activity timeline — explicit "Not yet invited" state so
reps in manual-send mode know an action is required.
Once invited, each event surfaces with a precise
timestamp tooltip (the relative-time is the headline). */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 pt-0.5 text-[11px] text-muted-foreground">
{!signer.invitedAt && signer.status === 'pending' ? (
<span className="inline-flex items-center gap-1 italic text-amber-700">
<Mail className="size-3" aria-hidden />
Not yet invited
</span>
) : null}
{invitedAgo && (
<span
className="inline-flex items-center gap-1"
title={signer.invitedAt ? new Date(signer.invitedAt).toLocaleString() : ''}
>
<Mail className="size-3" aria-hidden />
Invited {invitedAgo}
</span>
)}
{openedAgo && (
<span
className="inline-flex items-center gap-1"
title={signer.openedAt ? new Date(signer.openedAt).toLocaleString() : ''}
>
<Eye className="size-3" aria-hidden />
Opened {openedAgo}
</span>
)}
{remindedAgo && (
<span
className="inline-flex items-center gap-1"
title={
signer.lastReminderSentAt
? new Date(signer.lastReminderSentAt).toLocaleString()
: ''
}
>
<Bell className="size-3" aria-hidden />
Reminded {remindedAgo}
</span>
)}
{signer.signedAt && (
<span
className="inline-flex items-center gap-1 font-medium text-emerald-700"
title={new Date(signer.signedAt).toLocaleString()}
>
<Check className="size-3" aria-hidden />
Signed {humanRelative(signer.signedAt)}
<span className="font-normal text-emerald-700/70">
· {compactAbsolute(signer.signedAt)}
</span>
</span>
)}
</div>
</div>
{/* Per-signer action button — semantics depend on send state:
• `invitedAt === null` → "Send invitation" (the rep is the
one dispatching the first email; this fires the branded
invite + stamps invitedAt).
• `invitedAt !== null` → "Send reminder" (Documenso-side
nudge, rate-limited per cooldown).
• Signed/declined → no button. */}
{signer.status === 'pending' &&
(signer.invitedAt ? (
<Button
variant="outline"
size="sm"
className="h-7 shrink-0 gap-1.5 px-2.5 text-xs [&_svg]:size-3"
disabled={remindMutation.isPending}
onClick={() => remindMutation.mutate(signer.id)}
title="Send a follow-up reminder. Rate-limited by Documenso."
>
<Bell />
{remindMutation.isPending && remindMutation.variables === signer.id
? 'Sending…'
: 'Send reminder'}
</Button>
) : (
<Button
variant="default"
size="sm"
className="h-7 shrink-0 gap-1.5 px-2.5 text-xs [&_svg]:size-3"
disabled={inviteMutation.isPending}
onClick={() => inviteMutation.mutate(signer.id)}
title="Send the initial signing invitation to this recipient."
>
<Send />
{inviteMutation.isPending && inviteMutation.variables === signer.id
? 'Sending…'
: 'Send invitation'}
</Button>
))}
</div>
{idx < sorted.length - 1 && <div className="mb-6 h-0.5 w-8 shrink-0 bg-border" />}
</div>
);
})}