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

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