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

@@ -26,7 +26,11 @@ export const POST = withAuth(
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
{ dimensionUnit: body.dimensionUnit, overrides: body.overrides },
{
dimensionUnit: body.dimensionUnit,
overrides: body.overrides,
includeYachtDetails: body.includeYachtDetails,
},
);
return NextResponse.json({ data: result }, { status: 201 });
} catch (error) {

View File

@@ -191,6 +191,13 @@ export function EoiGenerateDialog({
const [addressOverride, setAddressOverride] = useState<AddressOverrideState | null>(null);
// Phase 3c - yacht spawn flow.
const [yachtSpawnOpen, setYachtSpawnOpen] = useState(false);
// Section 3 (yacht details) inclusion toggle. Defaults to ON; the toggle
// only renders when a yacht is actually linked (no toggle = always ON,
// which matches today's behaviour). Set OFF to blank yacht.* + owner.*
// merge fields on the generated PDF even though the yacht record exists
// (early-stage clients, multi-berth deals where yacht-dims don't apply,
// explicit client request to keep Section 3 off the document).
const [includeYachtDetails, setIncludeYachtDetails] = useState(true);
// Resolved EOI context - the actual values the document will be
// auto-filled with. Loaded only while the dialog is open so we don't
@@ -564,6 +571,9 @@ export function EoiGenerateDialog({
// the yacht's own `lengthUnit` column when unspecified.
dimensionUnit: effectiveDimensionUnit,
...(hasAnyOverride ? { overrides } : {}),
// Only send the flag when the rep explicitly opted out; default
// (toggle ON) keeps existing behaviour without bloating the body.
...(ctx?.yacht && !includeYachtDetails ? { includeYachtDetails: false } : {}),
},
});
// Bounce every cache that surfaces the interest's EOI state so the
@@ -679,41 +689,60 @@ export function EoiGenerateDialog({
</dl>
</div>
<div className="space-y-1 border-t pt-2">
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-2">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Optional (Section 3 - left blank if absent)
Section 3 - yacht details
</p>
{ctx.yacht ? (
<div className="inline-flex rounded-md border bg-muted/30 p-0.5 text-[11px]">
<button
type="button"
onClick={() => setDimensionUnit('ft')}
className={
'rounded px-2 py-0.5 transition-colors ' +
(effectiveDimensionUnit === 'ft'
? 'bg-background font-medium shadow-sm'
: 'text-muted-foreground hover:text-foreground')
}
aria-pressed={effectiveDimensionUnit === 'ft'}
>
ft
</button>
<button
type="button"
onClick={() => setDimensionUnit('m')}
className={
'rounded px-2 py-0.5 transition-colors ' +
(effectiveDimensionUnit === 'm'
? 'bg-background font-medium shadow-sm'
: 'text-muted-foreground hover:text-foreground')
}
aria-pressed={effectiveDimensionUnit === 'm'}
>
m
</button>
</div>
) : null}
<div className="flex items-center gap-2">
{ctx.yacht ? (
<label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-muted-foreground">
<input
type="checkbox"
checked={includeYachtDetails}
onChange={(e) => setIncludeYachtDetails(e.target.checked)}
className="h-3.5 w-3.5 cursor-pointer accent-primary"
aria-label="Include yacht details on the EOI"
/>
<span>Include on EOI</span>
</label>
) : null}
{ctx.yacht && includeYachtDetails ? (
<div className="inline-flex rounded-md border bg-muted/30 p-0.5 text-[11px]">
<button
type="button"
onClick={() => setDimensionUnit('ft')}
className={
'rounded px-2 py-0.5 transition-colors ' +
(effectiveDimensionUnit === 'ft'
? 'bg-background font-medium shadow-sm'
: 'text-muted-foreground hover:text-foreground')
}
aria-pressed={effectiveDimensionUnit === 'ft'}
>
ft
</button>
<button
type="button"
onClick={() => setDimensionUnit('m')}
className={
'rounded px-2 py-0.5 transition-colors ' +
(effectiveDimensionUnit === 'm'
? 'bg-background font-medium shadow-sm'
: 'text-muted-foreground hover:text-foreground')
}
aria-pressed={effectiveDimensionUnit === 'm'}
>
m
</button>
</div>
) : null}
</div>
</div>
{ctx.yacht && !includeYachtDetails ? (
<p className="text-[11px] text-muted-foreground">
Yacht details will be omitted from the EOI even though a yacht is linked.
</p>
) : null}
<dl className="space-y-1.5">
<div className="flex items-start gap-2">
<div className="flex-1">

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