From d1f6d6a427ded555057d5cde15ddcbc0c1956050 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 3 Jun 2026 15:55:28 +0200 Subject: [PATCH] feat(eoi): signed-EOI hero + send-signed-copy; fix search dropdown z-order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EOI tab: when an EOI is already signed and none is in flight, lead with a SignedEoiCard (preview + download + send-to-client) instead of the big "Generate EOI" empty state; quiet "Generate new EOI" remains for re-issue - history rows + hero gain a "Send to client" action — POST /api/v1/documents/[id]/send-signed-copy emails the deal's client the finalized signed PDF (sendSignedCopyToClient reuses sendSigningCompleted), guarded by a confirm - topbar: header gets z-30 so the global search dropdown paints above page content (charts/tables were bleeding through — header + main are sibling normal-flow boxes, so the dropdown's own z-50 couldn't win cross-context). Stays below the z-50 modal tier. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../documents/[id]/send-signed-copy/route.ts | 21 +++ src/components/interests/interest-eoi-tab.tsx | 177 +++++++++++++++++- src/components/layout/topbar.tsx | 8 +- src/lib/services/documents.service.ts | 52 ++++- 4 files changed, 251 insertions(+), 7 deletions(-) create mode 100644 src/app/api/v1/documents/[id]/send-signed-copy/route.ts diff --git a/src/app/api/v1/documents/[id]/send-signed-copy/route.ts b/src/app/api/v1/documents/[id]/send-signed-copy/route.ts new file mode 100644 index 00000000..22f3758f --- /dev/null +++ b/src/app/api/v1/documents/[id]/send-signed-copy/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { errorResponse } from '@/lib/errors'; +import { sendSignedCopyToClient } from '@/lib/services/documents.service'; + +/** + * Manually (re)send the finalized signed PDF to the deal's client. Backs + * the "Send signed copy to client" affordance on the EOI tab + document + * detail. Same `documents.edit` gate as the reminder endpoint. + */ +export const POST = withAuth( + withPermission('documents', 'edit', async (_req, ctx, params) => { + try { + const result = await sendSignedCopyToClient(params.id!, ctx.portId); + return NextResponse.json({ data: result }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/components/interests/interest-eoi-tab.tsx b/src/components/interests/interest-eoi-tab.tsx index 87002a51..249308b1 100644 --- a/src/components/interests/interest-eoi-tab.tsx +++ b/src/components/interests/interest-eoi-tab.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import Link from 'next/link'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { @@ -13,6 +13,7 @@ import { FileSignature, GitBranch, Loader2, + Mail, RefreshCw, Upload, XCircle, @@ -122,6 +123,32 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) { // (which the storage backend serves with Content-Disposition=attachment, // forcing a download even when the rep just wants to inspect the PDF). const [previewFile, setPreviewFile] = useState<{ id: string; name?: string } | null>(null); + const { confirm, dialog: confirmDialog } = useConfirmation(); + + // Manually (re)send the finalized signed PDF to the deal's client. + // Lifted to the parent (like the preview dialog) so every row + the + // signed-EOI hero share one confirm + handler. Guarded by a confirm so + // a stray click can't fire a real client email. + const handleSendCopy = useCallback( + async (documentId: string) => { + const ok = await confirm({ + title: 'Send signed copy to client?', + description: 'Emails the deal’s client the finalized signed PDF as an attachment.', + confirmLabel: 'Send copy', + }); + if (!ok) return; + try { + const res = await apiFetch<{ data: { recipientEmail: string } }>( + `/api/v1/documents/${documentId}/send-signed-copy`, + { method: 'POST' }, + ); + toast.success(`Signed copy sent to ${res.data.recipientEmail}.`); + } catch (err) { + toastError(err, 'Failed to send signed copy'); + } + }, + [confirm], + ); const { data: docsRes, isLoading: docsLoading } = useQuery<{ data: DocumentRow[] }>({ queryKey: ['documents', { interestId, documentType: 'eoi' }], @@ -134,6 +161,22 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) { const docs = docsRes?.data ?? []; const activeDoc = useMemo(() => docs.find((d) => ACTIVE_STATUSES.has(d.status)) ?? null, [docs]); const completedDocs = useMemo(() => docs.filter((d) => !ACTIVE_STATUSES.has(d.status)), [docs]); + // Most-recent fully-signed EOI. When no EOI is in flight, this becomes + // the hero (instead of the generate/upload empty state) so a deal whose + // EOI is already done leads with the signed document, per UAT 2026-06-03. + const latestSignedDoc = useMemo(() => { + return ( + docs + .filter((d) => d.status === 'completed') + .sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt))[0] ?? null + ); + }, [docs]); + // History strip excludes whichever signed doc is shown as the hero so it + // isn't listed twice. + const historyDocs = useMemo( + () => completedDocs.filter((d) => d.id !== latestSignedDoc?.id), + [completedDocs, latestSignedDoc], + ); // Pulled at the parent so we can thread the active EOI's signers into the // ExternalEoiUploadDialog as a prefill seed. ActiveEoiCard hits the same @@ -176,6 +219,15 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) { portSlug={portSlug ?? null} onUploadSigned={() => setUploadSignedOpen(true)} onView={(id, name) => setPreviewFile({ id, name })} + onSendCopy={handleSendCopy} + /> + ) : latestSignedDoc ? ( + setPreviewFile({ id, name })} + onSendCopy={handleSendCopy} + onGenerateNew={() => setGenerateOpen(true)} /> ) : ( 0 && ( + {historyDocs.length > 0 && (

EOI history

- {completedDocs.length} {completedDocs.length === 1 ? 'document' : 'documents'} + {historyDocs.length} {historyDocs.length === 1 ? 'document' : 'documents'}
    - {completedDocs.map((d) => ( + {historyDocs.map((d) => (
  • {d.title} @@ -210,8 +262,11 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) { {d.signedFileId ? ( setPreviewFile({ id, name })} + onSendCopy={handleSendCopy} /> ) : null} {portSlug && ( @@ -272,6 +327,7 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) { fileId={previewFile?.id} fileName={previewFile?.name} /> + {confirmDialog} ); } @@ -283,11 +339,13 @@ function ActiveEoiCard({ portSlug, onUploadSigned, onView, + onSendCopy, }: { doc: DocumentRow; portSlug: string | null; onUploadSigned: () => void; onView: (fileId: string, fileName?: string) => void; + onSendCopy: (documentId: string) => void; }) { const queryClient = useQueryClient(); const { confirm, dialog: confirmDialog } = useConfirmation(); @@ -614,7 +672,14 @@ function ActiveEoiCard({

    Signed document

    - + @@ -711,6 +776,90 @@ function ActiveEoiCard({ ); } +// ─── Signed EOI hero (no active EOI, but one is already signed) ─────────────── + +/** + * Shown when the deal has a fully-signed EOI and nothing is in flight. Leads + * with the signed document (preview + download + send-to-client) instead of + * the generate/upload empty state — a deal whose EOI is done shouldn't open + * on a big "Generate EOI" CTA. A quiet "Generate new EOI" remains for the + * re-issue case. + */ +function SignedEoiCard({ + doc, + portSlug, + onView, + onSendCopy, + onGenerateNew, +}: { + doc: DocumentRow; + portSlug: string | null; + onView: (fileId: string, fileName?: string) => void; + onSendCopy: (documentId: string) => void; + onGenerateNew: () => void; +}) { + return ( +
    +
    +
    +
    + +

    {doc.title}

    + +
    +

    + Signed · {new Date(doc.createdAt).toLocaleDateString()} +

    +
    +
    + {doc.signedFileId ? ( + + ) : null} + {portSlug && ( + + Open in Documents + + + )} +
    +
    + + {doc.signedFileId ? ( +
    + +
    + ) : ( +

    + The signed PDF isn't linked to this EOI yet, so inline preview, download, and send + aren't available. Open it in Documents — this lights up once migrated EOIs are + reconciled to their signed files. +

    + )} + +
    +

    + This deal's EOI is signed. Generate a new one only if you need to re-issue it. +

    + +
    +
    + ); +} + /** * Inline iframe preview of a signed PDF. Fetches a short-lived presigned * URL from `/api/v1/files/[id]/download` and renders the browser's native @@ -822,12 +971,21 @@ function StatusBadge({ status }: { status: DocumentRow['status'] }) { */ function SignedPdfActions({ fileId, + documentId, + isSignedCopySendable = false, title, onView, + onSendCopy, }: { fileId: string; + /** Document id — required for the "Send to client" action (which targets + * the document, not the raw file). */ + documentId?: string; + /** Only show "Send to client" for a fully-completed document. */ + isSignedCopySendable?: boolean; title?: string; onView: (fileId: string, fileName?: string) => void; + onSendCopy?: (documentId: string) => void; }) { const handleDownload = async () => { try { @@ -855,6 +1013,15 @@ function SignedPdfActions({ > Download + {onSendCopy && documentId && isSignedCopySendable ? ( + + ) : null} ); } diff --git a/src/components/layout/topbar.tsx b/src/components/layout/topbar.tsx index aed7b23b..0c6b1b26 100644 --- a/src/components/layout/topbar.tsx +++ b/src/components/layout/topbar.tsx @@ -51,7 +51,13 @@ export function Topbar({ ports, user, leadingSlot }: TopbarProps) { // Sarah Doe"). Detail pages register their parent via // `useBreadcrumbHint` so the label is entity-aware; everything else // is URL-derived. See src/hooks/use-smart-back.ts. -
    + // `z-30` makes the header a stacking context that sits ABOVE the page + // content in
    . Without it the search dropdown (absolute, z-50 within + // the header) still paints under main's charts/tables: header + main are + // sibling normal-flow boxes, so main paints later and wins regardless of + // the dropdown's own z. Kept below the modal/overlay tier (z-50) so + // dialogs still cover the bar. +
    {/* LEFT: optional sidebar trigger (tablet) + smart back button. Hard-capped width so the column never extends into the absolutely-positioned search bar's footprint. The cap is diff --git a/src/lib/services/documents.service.ts b/src/lib/services/documents.service.ts index 07d5c492..5e5598eb 100644 --- a/src/lib/services/documents.service.ts +++ b/src/lib/services/documents.service.ts @@ -9,7 +9,7 @@ import { files, } from '@/lib/db/schema/documents'; import { interests, interestBerths } from '@/lib/db/schema/interests'; -import { clients } from '@/lib/db/schema/clients'; +import { clients, clientContacts } from '@/lib/db/schema/clients'; import { companies } from '@/lib/db/schema/companies'; import { yachts } from '@/lib/db/schema/yachts'; import { berths } from '@/lib/db/schema/berths'; @@ -1345,6 +1345,56 @@ async function sendCascadingInviteForNextSigner(doc: { } } +/** + * Manually (re)send the finalized signed PDF of a completed document to the + * deal's client. Mirrors the automatic completion fan-out (sendSigningCompleted) + * but targets just the client recipient — backs the "Send signed copy to + * client" affordance on the EOI tab / document detail. Safe to call repeatedly. + */ +export async function sendSignedCopyToClient( + documentId: string, + portId: string, +): Promise<{ recipientEmail: string }> { + const doc = await getDocumentById(documentId, portId); + if (doc.status !== 'completed' || !doc.signedFileId) { + throw new ValidationError('This document has no signed PDF to send yet.'); + } + const owner = await resolveDocumentOwner(portId, doc); + if (!owner || owner.entityType !== 'client') { + throw new ValidationError('No client is linked to this document to send the signed copy to.'); + } + const client = await db.query.clients.findFirst({ + where: eq(clients.id, owner.entityId), + columns: { fullName: true }, + }); + // Primary email via the same is_primary-desc, created_at-desc picker the + // clients list uses, scoped to the email channel. + const [emailRow] = await db + .select({ value: clientContacts.value }) + .from(clientContacts) + .where(and(eq(clientContacts.clientId, owner.entityId), eq(clientContacts.channel, 'email'))) + .orderBy(desc(clientContacts.isPrimary), desc(clientContacts.createdAt)) + .limit(1); + if (!emailRow?.value) { + throw new ValidationError('The linked client has no email address on file.'); + } + const portRow = await db.query.ports.findFirst({ + where: eq(ports.id, portId), + columns: { name: true }, + }); + await sendSigningCompleted({ + portId, + portName: portRow?.name ?? 'Port Nimara', + recipients: [{ name: client?.fullName ?? '', email: emailRow.value }], + clientName: client?.fullName ?? doc.title, + documentLabel: DOC_TYPE_LABEL[doc.documentType] ?? 'Expression of Interest', + completedAt: new Date(), + signedPdfFileId: doc.signedFileId, + signedPdfFilename: `signed-${doc.id}.pdf`, + }); + return { recipientEmail: emailRow.value }; +} + // ─── Owner-wins resolution ──────────────────────────────────────────────────── interface ResolvedOwner {