feat(eoi-generate): Include-yacht toggle to omit Section 3 when yacht is a placeholder

EoiGenerateDialog gains an inline "Include on EOI" checkbox in the
Section 3 header (renders only when ctx.yacht is set; defaults ON so
existing behaviour is unchanged). When OFF, the generate-and-sign POST
flips includeYachtDetails=false on the body; service blanks
eoiContext.yacht before either pathway runs:

- Documenso template payload: buildDocumensoPayload reads no yacht so
  yacht.* and owner.* merge fields ship empty. Existing template tolerates
  blanks per the "left blank if absent" copy.
- In-app PDF fill (pdf-lib): generateEoiPdfFromTemplate sees no yacht so
  AcroForm field writes for the yacht block are skipped.

Persists the rep's choice in the document-create audit log
(metadata.includeYachtDetails) so an audit trail records explicit opt-outs
even though documents has no JSONB metadata column today.

ft/m unit toggle in the Section 3 header now hides when Include is OFF
(unit choice is meaningless without yacht details).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 13:11:19 +02:00
parent 7bdfc340ae
commit cd6b19e173
4 changed files with 103 additions and 37 deletions

View File

@@ -490,7 +490,7 @@ async function generateEoiFromSourcePdf(
portId: string,
context: GenerateInput,
meta: AuditMeta,
options?: { dimensionUnit?: 'ft' | 'm' },
options?: { dimensionUnit?: 'ft' | 'm'; includeYachtDetails?: boolean },
applied: AppliedOverrides = { resolved: {}, documentOverrideColumns: {} },
): Promise<{ document: DbDocument; file: DbFile }> {
if (!context.interestId) {
@@ -501,6 +501,12 @@ async function generateEoiFromSourcePdf(
await buildEoiContext(context.interestId, portId),
applied,
);
// Rep opted out of Section 3 — blank the yacht slot so the AcroForm fill
// skips writing the yacht.* / owner.* fields (matching the Documenso
// pathway).
if (options?.includeYachtDetails === false) {
eoiContext.yacht = null;
}
const pdfBytes = await generateEoiPdfFromTemplate(eoiContext, {
dimensionUnit: options?.dimensionUnit ?? eoiContext.yacht?.lengthUnit ?? 'ft',
});
@@ -597,7 +603,14 @@ export async function generateAndSign(
signers: GenerateAndSignInput['signers'],
pathway: 'inapp' | 'documenso-template',
meta: AuditMeta,
options?: { dimensionUnit?: 'ft' | 'm'; overrides?: EoiOverridesInput },
options?: {
dimensionUnit?: 'ft' | 'm';
overrides?: EoiOverridesInput;
/** False = blank out Section 3 (yacht.* + owner.* merge fields) even
* when the interest carries a linked yacht. True (or unset) keeps the
* current behaviour (auto-fill from yacht record). */
includeYachtDetails?: boolean;
},
) {
// Phase 3b - apply per-field overrides BEFORE either pathway resolves the
// EOI context, so any setAsDefault contact promotion is visible to the
@@ -623,7 +636,7 @@ async function generateAndSignViaInApp(
context: GenerateInput,
signers: GenerateAndSignInput['signers'],
meta: AuditMeta,
options?: { dimensionUnit?: 'ft' | 'm' },
options?: { dimensionUnit?: 'ft' | 'm'; includeYachtDetails?: boolean },
applied: AppliedOverrides = { resolved: {}, documentOverrideColumns: {} },
) {
const template = await getTemplateById(templateId, portId);
@@ -755,7 +768,7 @@ async function generateAndSignViaDocumensoTemplate(
portId: string,
context: GenerateInput,
meta: AuditMeta,
options?: { dimensionUnit?: 'ft' | 'm' },
options?: { dimensionUnit?: 'ft' | 'm'; includeYachtDetails?: boolean },
applied: AppliedOverrides = { resolved: {}, documentOverrideColumns: {} },
) {
if (!context.interestId) {
@@ -766,6 +779,14 @@ async function generateAndSignViaDocumensoTemplate(
await buildEoiContext(context.interestId, portId),
applied,
);
// Rep opted out of Section 3 (yacht details) — blank the yacht slot so
// buildDocumensoPayload + the EOI template see "no yacht linked" and
// leave yacht.* / owner.* merge fields empty. Persisted in document
// metadata below for audit (kind: 'eoi_include_yacht_details=false').
const yachtDeclined = options?.includeYachtDetails === false;
if (yachtDeclined) {
eoiContext.yacht = null;
}
const signers = await getPortEoiSigners(portId);
// Per-port Documenso template + recipient IDs (with env fallback). Each
// tenant pointing at its own Documenso instance has different numeric
@@ -908,6 +929,10 @@ async function generateAndSignViaDocumensoTemplate(
pathway: 'documenso-template',
templateId: env.DOCUMENSO_TEMPLATE_ID_EOI,
interestId: context.interestId,
// Rep's explicit Section-3 choice. Audit-only — Docs row has no
// metadata jsonb; the blanked yacht.* / owner.* merge fields on the
// generated PDF are the user-visible evidence.
includeYachtDetails: !yachtDeclined,
},
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,

View File

@@ -109,6 +109,14 @@ export const generateAndSignSchema = generateSchema.extend({
* EOI's Length/Width/Draft formValues. The drawer's toggle drives this;
* server defaults to the yacht's `lengthUnit` column when omitted. */
dimensionUnit: z.enum(['ft', 'm']).optional(),
/** Optional Section 3 (yacht details) inclusion. Defaults to true when a
* yacht is linked. Rep can flip OFF in the dialog to blank out yacht.*
* + owner.* merge fields even though a yacht is on file — used when the
* yacht is a placeholder, multi-berth deals where yacht-specific dims
* don't apply, or the client explicitly asked for the section to stay
* off the document. Persisted in `documents.metadata.includeYachtDetails`
* for audit. */
includeYachtDetails: z.boolean().optional(),
/** Phase 3b/3-follow-up - optional per-field overrides applied at generation. */
overrides: z
.object({