feat(eoi): signed-EOI hero + send-signed-copy; fix search dropdown z-order
- 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) <noreply@anthropic.com>
This commit is contained in:
21
src/app/api/v1/documents/[id]/send-signed-copy/route.ts
Normal file
21
src/app/api/v1/documents/[id]/send-signed-copy/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -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 ? (
|
||||
<SignedEoiCard
|
||||
doc={latestSignedDoc}
|
||||
portSlug={portSlug ?? null}
|
||||
onView={(id, name) => setPreviewFile({ id, name })}
|
||||
onSendCopy={handleSendCopy}
|
||||
onGenerateNew={() => setGenerateOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<EmptyEoiState
|
||||
@@ -189,18 +241,18 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
|
||||
{/* History strip - completed + cancelled EOIs from earlier in the
|
||||
deal's life. Quiet and skimmable; the active document above
|
||||
carries the day-to-day attention. */}
|
||||
{completedDocs.length > 0 && (
|
||||
{historyDocs.length > 0 && (
|
||||
<section className="rounded-lg border bg-background">
|
||||
<header className="flex items-center justify-between border-b px-4 py-2.5">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
EOI history
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{completedDocs.length} {completedDocs.length === 1 ? 'document' : 'documents'}
|
||||
{historyDocs.length} {historyDocs.length === 1 ? 'document' : 'documents'}
|
||||
</span>
|
||||
</header>
|
||||
<ul className="divide-y">
|
||||
{completedDocs.map((d) => (
|
||||
{historyDocs.map((d) => (
|
||||
<li key={d.id} className="flex items-center gap-3 px-4 py-2.5 text-sm">
|
||||
<StatusBadge status={d.status} />
|
||||
<span className="flex-1 truncate font-medium">{d.title}</span>
|
||||
@@ -210,8 +262,11 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
|
||||
{d.signedFileId ? (
|
||||
<SignedPdfActions
|
||||
fileId={d.signedFileId}
|
||||
documentId={d.id}
|
||||
isSignedCopySendable={d.status === 'completed'}
|
||||
title={d.title}
|
||||
onView={(id, name) => setPreviewFile({ id, name })}
|
||||
onSendCopy={handleSendCopy}
|
||||
/>
|
||||
) : null}
|
||||
{portSlug && (
|
||||
@@ -272,6 +327,7 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
|
||||
fileId={previewFile?.id}
|
||||
fileName={previewFile?.name}
|
||||
/>
|
||||
{confirmDialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Signed document
|
||||
</h3>
|
||||
<SignedPdfActions fileId={doc.signedFileId} title={doc.title} onView={onView} />
|
||||
<SignedPdfActions
|
||||
fileId={doc.signedFileId}
|
||||
documentId={doc.id}
|
||||
isSignedCopySendable={doc.status === 'completed'}
|
||||
title={doc.title}
|
||||
onView={onView}
|
||||
onSendCopy={onSendCopy}
|
||||
/>
|
||||
</div>
|
||||
<SignedPdfPreview fileId={doc.signedFileId} />
|
||||
</div>
|
||||
@@ -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 (
|
||||
<section className="rounded-xl border bg-gradient-brand-soft p-5 shadow-xs">
|
||||
<header className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CheckCircle2 className="size-4 text-emerald-600" aria-hidden />
|
||||
<h2 className="truncate text-base font-semibold text-foreground">{doc.title}</h2>
|
||||
<StatusBadge status={doc.status} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Signed · {new Date(doc.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-x-3 gap-y-1">
|
||||
{doc.signedFileId ? (
|
||||
<SignedPdfActions
|
||||
fileId={doc.signedFileId}
|
||||
documentId={doc.id}
|
||||
isSignedCopySendable={doc.status === 'completed'}
|
||||
title={doc.title}
|
||||
onView={onView}
|
||||
onSendCopy={onSendCopy}
|
||||
/>
|
||||
) : null}
|
||||
{portSlug && (
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/documents/${doc.id}` as any}
|
||||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
Open in Documents
|
||||
<ExternalLink className="size-3" aria-hidden />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{doc.signedFileId ? (
|
||||
<div className="mt-4 rounded-lg border bg-background p-4">
|
||||
<SignedPdfPreview fileId={doc.signedFileId} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-4 rounded-md border border-dashed bg-background p-3 text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<footer className="mt-3 flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This deal's EOI is signed. Generate a new one only if you need to re-issue it.
|
||||
</p>
|
||||
<Button variant="ghost" size="sm" onClick={onGenerateNew} className="gap-1.5">
|
||||
<FileSignature className="size-4" aria-hidden />
|
||||
Generate new EOI
|
||||
</Button>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 className="size-3" aria-hidden /> Download
|
||||
</button>
|
||||
{onSendCopy && documentId && isSignedCopySendable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSendCopy(documentId)}
|
||||
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<Mail className="size-3" aria-hidden /> Send to client
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
<header className="relative grid h-14 grid-cols-[auto_1fr_auto] items-center border-b border-border bg-background gap-3 px-4 shrink-0">
|
||||
// `z-30` makes the header a stacking context that sits ABOVE the page
|
||||
// content in <main>. 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.
|
||||
<header className="relative z-30 grid h-14 grid-cols-[auto_1fr_auto] items-center border-b border-border bg-background gap-3 px-4 shrink-0">
|
||||
{/* 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user