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