'use client'; 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, Send, Trash2, UserPlus, X } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { PageHeader } from '@/components/shared/page-header'; import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill'; 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; title: string; status: string; documentType: string; documensoId: string | null; signedFileId: string | null; reservationId: string | null; interestId: string | null; clientId: string | null; yachtId: string | null; companyId: string | null; createdAt: string; createdBy: string; } interface DetailSigner { id: string; signerName: string; signerEmail: string; 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; } interface DetailEvent { id: string; eventType: string; createdAt: string; signerId: string | null; eventData: Record | null; } interface DetailWatcher { userId: string; addedBy: string; addedAt: string; } interface DetailResponse { data: { document: DetailDoc; signers: DetailSigner[]; events: DetailEvent[]; watchers: DetailWatcher[]; }; } const STATUS_PILL_MAP: Record = { draft: 'draft', sent: 'sent', partially_signed: 'partial', completed: 'completed', signed: 'signed', expired: 'expired', cancelled: 'cancelled', rejected: 'rejected', pending: 'pending', declined: 'declined', }; const SIGNER_PILL_MAP: Record = { pending: 'pending', signed: 'signed', declined: 'declined', }; interface DocumentDetailProps { documentId: string; portSlug: string; } export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) { const router = useRouter(); const queryClient = useQueryClient(); const [isCancelling, setIsCancelling] = useState(false); const { confirm, dialog: confirmDialog } = useConfirmation(); const { data, isLoading, error } = useQuery({ queryKey: ['document-detail', documentId], queryFn: () => apiFetch(`/api/v1/documents/${documentId}?detail=true`), }); useRealtimeInvalidation({ 'document:updated': [['document-detail', documentId]], 'document:sent': [['document-detail', documentId]], 'document:completed': [['document-detail', documentId]], 'document:cancelled': [['document-detail', documentId]], 'document:rejected': [['document-detail', documentId]], 'document:expired': [['document-detail', documentId]], 'document:signer:signed': [['document-detail', documentId]], 'document:signer:opened': [['document-detail', documentId]], }); if (isLoading) { return (
); } if (error || !data) { return (
Back to documents } />
); } const { document: doc, signers, events, watchers } = data.data; const handleRemind = async (signerId: string) => { try { await apiFetch(`/api/v1/documents/${documentId}/remind`, { method: 'POST', body: { signerId }, }); toast.success('Reminder sent'); queryClient.invalidateQueries({ queryKey: ['document-detail', documentId] }); } catch (err) { toastError(err); } }; // #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', description: 'Cancel this document? This cancels the signing request and cannot be undone.', confirmLabel: 'Cancel document', }); if (!ok) return; setIsCancelling(true); try { await apiFetch(`/api/v1/documents/${documentId}/cancel`, { method: 'POST' }); toast.success('Document cancelled'); router.push(`/${portSlug}/documents`); } catch (err) { toastError(err); setIsCancelling(false); } }; const handleEmailSignedPdf = async () => { try { const draft = await apiFetch<{ data: { to: string[]; subject: string; attachments: Array<{ fileId: string }> }; }>(`/api/v1/documents/${documentId}/compose-completion-email`, { method: 'POST' }); toast.info( `Email composer prepared for ${draft.data.to.length} signer${draft.data.to.length === 1 ? '' : 's'} - opens in PR8 wizard`, ); } catch (err) { toastError(err); } }; const isInFlight = ['sent', 'partially_signed'].includes(doc.status); const isComplete = ['completed', 'signed'].includes(doc.status); const subjectLink = doc.reservationId ? { href: `/${portSlug}/berth-reservations/${doc.reservationId}`, label: 'Reservation' } : doc.interestId ? { href: `/${portSlug}/interests/${doc.interestId}`, label: 'Interest' } : doc.clientId ? { href: `/${portSlug}/clients/${doc.clientId}`, label: 'Client' } : doc.yachtId ? { href: `/${portSlug}/yachts/${doc.yachtId}`, label: 'Yacht' } : doc.companyId ? { href: `/${portSlug}/companies/${doc.companyId}`, label: 'Company' } : null; return (
{capFirst(doc.status.replace(/_/g, ' '))} {signers.filter((s) => s.status === 'signed').length}/{signers.length} signed {watchers.length > 0 ? {watchers.length} watching : null} } actions={
{isComplete && doc.signedFileId ? ( <> ) : null} {isInFlight ? ( ) : null}
} variant="gradient" />
{/* Left column */}

Signers

{signers.length === 0 ? (

No signers attached.

) : (
    {signers.map((signer, idx) => (
  • {idx + 1}
    {/* #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}
    {capFirst(signer.status)}
    {signer.signerEmail} · {capFirst(signer.signerRole)}
    {signer.signedAt ? `Signed ${new Date(signer.signedAt).toLocaleDateString('en-GB')}` : signer.invitedAt ? `Invited ${new Date(signer.invitedAt).toLocaleDateString('en-GB')}` : 'Not yet invited'}
    {signer.status === 'pending' && doc.documensoId && isInFlight ? (
    {/* #67 state-aware CTA: invited yet? remind. else: send. */} {signer.invitedAt ? ( ) : ( )} {signer.signingUrl ? ( ) : null}
    ) : null}
  • ))}
)}
{subjectLink ? (

Linked entity

{subjectLink.label} →
) : null}
{/* Right column */}

Activity

{events.length === 0 ? (

No events yet.

) : (
    {events.slice(0, 12).map((e) => (
  • {e.eventType.replace(/_/g, ' ')}
    {new Date(e.createdAt).toLocaleString('en-GB')}
  • ))}
)}
{confirmDialog}
); } /** * #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(); 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 (

Watchers

Watchers receive an in-app notification on every signing event (opened, signed, declined, completed).

{watchers.length === 0 ? (

No one is watching this document yet.

) : (
    {watchers.map((w) => { const u = userById.get(w.userId); return (
  • {u?.name ?? u?.email ?? `User ${w.userId.slice(0, 8)}…`}
  • ); })}
)}
); }