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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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']],
|
||||
|
||||
179
src/components/documents/eoi-cancel-dialog.tsx
Normal file
179
src/components/documents/eoi-cancel-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'll be saved to the client'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'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'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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user