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:
2026-06-03 15:55:28 +02:00
parent 3b227fe9b2
commit d1f6d6a427
4 changed files with 251 additions and 7 deletions

View 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);
}
}),
);

View File

@@ -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 deals 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&apos;t linked to this EOI yet, so inline preview, download, and send
aren&apos;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&apos;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}
</>
);
}

View File

@@ -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

View File

@@ -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 {