feat(eoi): seed Standard EOI in-app template per port
Adds a new per-port document_templates row of type 'eoi' containing an
HTML EOI / Letter of Intent body with {{section.field}} merge tokens
that mirror the EoiContext shape. Enables the in-app pdfme PDF path as
an alternative to the Documenso template flow.
- New getStandardEoiTemplateHtml() returns the Letter-sized HTML body
with Applicant / Yacht / Owner / Berth / Interest / Signatures blocks
- STANDARD_EOI_MERGE_FIELDS exported for resolveTemplate wiring (11.4)
- seed-data.ts inserts one document_templates row per port inside the
existing withTransaction block, between ownership transfers and
interests, using SEED_USER_ID for audit consistency
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -33,7 +33,12 @@ import {
|
|||||||
berths,
|
berths,
|
||||||
berthReservations,
|
berthReservations,
|
||||||
interests,
|
interests,
|
||||||
|
documentTemplates,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
|
import {
|
||||||
|
getStandardEoiTemplateHtml,
|
||||||
|
STANDARD_EOI_MERGE_FIELDS,
|
||||||
|
} from '@/lib/pdf/templates/eoi-standard-inapp';
|
||||||
|
|
||||||
// ─── Tunables ────────────────────────────────────────────────────────────────
|
// ─── Tunables ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -840,6 +845,21 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
|
|||||||
.where(eq(yachts.id, yachtId));
|
.where(eq(yachts.id, yachtId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 6b. Standard EOI Template (in-app PDF path) ────────────────────────
|
||||||
|
// One row per port. Used by the in-app pdfme renderer when the port opts
|
||||||
|
// for in-app PDF generation over the Documenso template flow.
|
||||||
|
await tx.insert(documentTemplates).values({
|
||||||
|
portId,
|
||||||
|
name: 'Standard EOI (in-app)',
|
||||||
|
description:
|
||||||
|
'Default Expression of Interest / Letter of Intent template, rendered in-app via pdfme. Use for ports that prefer in-app PDF generation over the Documenso template path.',
|
||||||
|
templateType: 'eoi',
|
||||||
|
bodyHtml: getStandardEoiTemplateHtml(),
|
||||||
|
mergeFields: STANDARD_EOI_MERGE_FIELDS,
|
||||||
|
isActive: true,
|
||||||
|
createdBy: SEED_USER_ID,
|
||||||
|
});
|
||||||
|
|
||||||
// ── 7. Interests (15) ──────────────────────────────────────────────────
|
// ── 7. Interests (15) ──────────────────────────────────────────────────
|
||||||
// Spread across pipeline stages.
|
// Spread across pipeline stages.
|
||||||
// Valid stages (from interests schema comment):
|
// Valid stages (from interests schema comment):
|
||||||
|
|||||||
337
src/lib/pdf/templates/eoi-standard-inapp.ts
Normal file
337
src/lib/pdf/templates/eoi-standard-inapp.ts
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
/**
|
||||||
|
* Standard in-app EOI (Letter of Intent) template.
|
||||||
|
*
|
||||||
|
* Rendered in-app via pdfme (HTML → PDF pipeline) for ports that prefer the
|
||||||
|
* in-app PDF generation path over the Documenso template flow.
|
||||||
|
*
|
||||||
|
* Merge tokens use the {{section.field}} convention and match the
|
||||||
|
* `EoiContext` shape produced by `buildEoiContext` in
|
||||||
|
* `src/lib/services/eoi-context.ts`. The tokens are resolved by
|
||||||
|
* `resolveTemplate` (Task 11.4 wires the expanded resolver).
|
||||||
|
*
|
||||||
|
* Related:
|
||||||
|
* - Field mapping: docs/eoi-documenso-field-mapping.md
|
||||||
|
* - Context builder: src/lib/services/eoi-context.ts
|
||||||
|
* - Schema: document_templates (src/lib/db/schema/documents.ts)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const STANDARD_EOI_MERGE_FIELDS: string[] = [
|
||||||
|
'date.today',
|
||||||
|
'date.year',
|
||||||
|
'port.name',
|
||||||
|
'port.defaultCurrency',
|
||||||
|
'client.fullName',
|
||||||
|
'client.nationality',
|
||||||
|
'client.primaryEmail',
|
||||||
|
'client.primaryPhone',
|
||||||
|
'client.address.street',
|
||||||
|
'client.address.city',
|
||||||
|
'client.address.country',
|
||||||
|
'yacht.name',
|
||||||
|
'yacht.hullNumber',
|
||||||
|
'yacht.flag',
|
||||||
|
'yacht.yearBuilt',
|
||||||
|
'yacht.lengthFt',
|
||||||
|
'yacht.widthFt',
|
||||||
|
'yacht.draftFt',
|
||||||
|
'yacht.lengthM',
|
||||||
|
'yacht.widthM',
|
||||||
|
'yacht.draftM',
|
||||||
|
'company.name',
|
||||||
|
'company.legalName',
|
||||||
|
'company.taxId',
|
||||||
|
'company.billingAddress',
|
||||||
|
'owner.type',
|
||||||
|
'owner.name',
|
||||||
|
'owner.legalName',
|
||||||
|
'berth.mooringNumber',
|
||||||
|
'berth.area',
|
||||||
|
'berth.lengthFt',
|
||||||
|
'berth.price',
|
||||||
|
'berth.priceCurrency',
|
||||||
|
'berth.tenureType',
|
||||||
|
'interest.stage',
|
||||||
|
'interest.leadCategory',
|
||||||
|
'interest.dateFirstContact',
|
||||||
|
'interest.notes',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getStandardEoiTemplateHtml(): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Expression of Interest — Letter of Intent</title>
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: letter;
|
||||||
|
margin: 0.9in 0.9in 1.0in 0.9in;
|
||||||
|
}
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: "Times New Roman", Georgia, serif;
|
||||||
|
font-size: 12pt;
|
||||||
|
color: #111;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
border-bottom: 2px solid #111;
|
||||||
|
padding-bottom: 8pt;
|
||||||
|
margin-bottom: 18pt;
|
||||||
|
}
|
||||||
|
.header .port-name {
|
||||||
|
font-size: 18pt;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5pt;
|
||||||
|
}
|
||||||
|
.header .doc-title {
|
||||||
|
margin-top: 4pt;
|
||||||
|
font-size: 14pt;
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 18pt;
|
||||||
|
font-size: 11pt;
|
||||||
|
}
|
||||||
|
h2.section {
|
||||||
|
font-size: 12pt;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8pt;
|
||||||
|
border-bottom: 1px solid #555;
|
||||||
|
padding-bottom: 2pt;
|
||||||
|
margin: 16pt 0 8pt 0;
|
||||||
|
}
|
||||||
|
table.fields {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 6pt;
|
||||||
|
}
|
||||||
|
table.fields td {
|
||||||
|
padding: 3pt 6pt 3pt 0;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
table.fields td.label {
|
||||||
|
width: 34%;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.addr-line {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.signatures {
|
||||||
|
margin-top: 36pt;
|
||||||
|
display: table;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.signatures .slot {
|
||||||
|
display: table-cell;
|
||||||
|
width: 50%;
|
||||||
|
padding-right: 18pt;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.signatures .slot:last-child {
|
||||||
|
padding-right: 0;
|
||||||
|
padding-left: 18pt;
|
||||||
|
}
|
||||||
|
.sig-line {
|
||||||
|
border-top: 1px solid #111;
|
||||||
|
margin-top: 42pt;
|
||||||
|
padding-top: 4pt;
|
||||||
|
font-size: 10pt;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0.4in;
|
||||||
|
left: 0.9in;
|
||||||
|
right: 0.9in;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 9pt;
|
||||||
|
color: #666;
|
||||||
|
border-top: 1px solid #ccc;
|
||||||
|
padding-top: 4pt;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<div class="port-name">{{port.name}}</div>
|
||||||
|
<div class="doc-title">Expression of Interest — Letter of Intent</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="meta">
|
||||||
|
<div><strong>Date:</strong> {{date.today}}</div>
|
||||||
|
<div><strong>Port:</strong> {{port.name}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
This Expression of Interest (the “EOI”) is entered into between
|
||||||
|
<strong>{{port.name}}</strong> and the Applicant named below, and records the
|
||||||
|
Applicant’s non-binding intent to proceed toward a berth acquisition at
|
||||||
|
the port. It is subject to subsequent definitive documentation.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="section">1. Applicant</h2>
|
||||||
|
<table class="fields">
|
||||||
|
<tr>
|
||||||
|
<td class="label">Full name</td>
|
||||||
|
<td>{{client.fullName}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Nationality</td>
|
||||||
|
<td>{{client.nationality}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Email</td>
|
||||||
|
<td>{{client.primaryEmail}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Phone</td>
|
||||||
|
<td>{{client.primaryPhone}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Address</td>
|
||||||
|
<td>
|
||||||
|
<p class="addr-line">{{client.address.street}}</p>
|
||||||
|
<p class="addr-line">{{client.address.city}}</p>
|
||||||
|
<p class="addr-line">{{client.address.country}}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 class="section">2. Yacht</h2>
|
||||||
|
<table class="fields">
|
||||||
|
<tr>
|
||||||
|
<td class="label">Name</td>
|
||||||
|
<td>{{yacht.name}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Hull number</td>
|
||||||
|
<td>{{yacht.hullNumber}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Flag</td>
|
||||||
|
<td>{{yacht.flag}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Year built</td>
|
||||||
|
<td>{{yacht.yearBuilt}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Length (ft / m)</td>
|
||||||
|
<td>{{yacht.lengthFt}} ft / {{yacht.lengthM}} m</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Beam (ft / m)</td>
|
||||||
|
<td>{{yacht.widthFt}} ft / {{yacht.widthM}} m</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Draft (ft / m)</td>
|
||||||
|
<td>{{yacht.draftFt}} ft / {{yacht.draftM}} m</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 class="section">3. Owner</h2>
|
||||||
|
<p>
|
||||||
|
Owner type: <strong>{{owner.type}}</strong><br />
|
||||||
|
Owner name: <strong>{{owner.name}}</strong>
|
||||||
|
<span class="muted"> (legal: {{owner.legalName}})</span>
|
||||||
|
</p>
|
||||||
|
<table class="fields">
|
||||||
|
<tr>
|
||||||
|
<td class="label">Company name</td>
|
||||||
|
<td>{{company.name}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Legal name</td>
|
||||||
|
<td>{{company.legalName}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Tax ID</td>
|
||||||
|
<td>{{company.taxId}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Billing address</td>
|
||||||
|
<td>{{company.billingAddress}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p class="muted" style="font-size:10pt;">
|
||||||
|
The company block is populated only where the yacht is company-owned; for
|
||||||
|
client-owned yachts these fields render empty.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 class="section">4. Berth</h2>
|
||||||
|
<table class="fields">
|
||||||
|
<tr>
|
||||||
|
<td class="label">Mooring number</td>
|
||||||
|
<td>{{berth.mooringNumber}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Area</td>
|
||||||
|
<td>{{berth.area}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Length</td>
|
||||||
|
<td>{{berth.lengthFt}} ft</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Price</td>
|
||||||
|
<td>{{berth.price}} {{berth.priceCurrency}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Tenure type</td>
|
||||||
|
<td>{{berth.tenureType}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 class="section">5. Interest Summary</h2>
|
||||||
|
<table class="fields">
|
||||||
|
<tr>
|
||||||
|
<td class="label">Pipeline stage</td>
|
||||||
|
<td>{{interest.stage}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Lead category</td>
|
||||||
|
<td>{{interest.leadCategory}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">First contact</td>
|
||||||
|
<td>{{interest.dateFirstContact}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label">Notes</td>
|
||||||
|
<td>{{interest.notes}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 class="section">6. Signatures</h2>
|
||||||
|
<div class="signatures">
|
||||||
|
<div class="slot">
|
||||||
|
<div class="sig-line">
|
||||||
|
Applicant — {{client.fullName}}
|
||||||
|
</div>
|
||||||
|
<div class="muted" style="font-size:10pt; margin-top:2pt;">Date: __________________</div>
|
||||||
|
</div>
|
||||||
|
<div class="slot">
|
||||||
|
<div class="sig-line">
|
||||||
|
For and on behalf of {{port.name}}
|
||||||
|
</div>
|
||||||
|
<div class="muted" style="font-size:10pt; margin-top:2pt;">Date: __________________</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
{{port.name}} · Expression of Interest · {{date.year}}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user