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, ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent, 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 }); return NextResponse.json({ data: result }, { status: 201 });
} catch (error) { } catch (error) {

View File

@@ -191,6 +191,13 @@ export function EoiGenerateDialog({
const [addressOverride, setAddressOverride] = useState<AddressOverrideState | null>(null); const [addressOverride, setAddressOverride] = useState<AddressOverrideState | null>(null);
// Phase 3c - yacht spawn flow. // Phase 3c - yacht spawn flow.
const [yachtSpawnOpen, setYachtSpawnOpen] = useState(false); 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 // Resolved EOI context - the actual values the document will be
// auto-filled with. Loaded only while the dialog is open so we don't // 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. // the yacht's own `lengthUnit` column when unspecified.
dimensionUnit: effectiveDimensionUnit, dimensionUnit: effectiveDimensionUnit,
...(hasAnyOverride ? { overrides } : {}), ...(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 // Bounce every cache that surfaces the interest's EOI state so the
@@ -679,11 +689,24 @@ export function EoiGenerateDialog({
</dl> </dl>
</div> </div>
<div className="space-y-1 border-t pt-2"> <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"> <p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Optional (Section 3 - left blank if absent) Section 3 - yacht details
</p> </p>
<div className="flex items-center gap-2">
{ctx.yacht ? ( {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]"> <div className="inline-flex rounded-md border bg-muted/30 p-0.5 text-[11px]">
<button <button
type="button" type="button"
@@ -714,6 +737,12 @@ export function EoiGenerateDialog({
</div> </div>
) : null} ) : null}
</div> </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"> <dl className="space-y-1.5">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<div className="flex-1"> <div className="flex-1">

View File

@@ -490,7 +490,7 @@ async function generateEoiFromSourcePdf(
portId: string, portId: string,
context: GenerateInput, context: GenerateInput,
meta: AuditMeta, meta: AuditMeta,
options?: { dimensionUnit?: 'ft' | 'm' }, options?: { dimensionUnit?: 'ft' | 'm'; includeYachtDetails?: boolean },
applied: AppliedOverrides = { resolved: {}, documentOverrideColumns: {} }, applied: AppliedOverrides = { resolved: {}, documentOverrideColumns: {} },
): Promise<{ document: DbDocument; file: DbFile }> { ): Promise<{ document: DbDocument; file: DbFile }> {
if (!context.interestId) { if (!context.interestId) {
@@ -501,6 +501,12 @@ async function generateEoiFromSourcePdf(
await buildEoiContext(context.interestId, portId), await buildEoiContext(context.interestId, portId),
applied, 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, { const pdfBytes = await generateEoiPdfFromTemplate(eoiContext, {
dimensionUnit: options?.dimensionUnit ?? eoiContext.yacht?.lengthUnit ?? 'ft', dimensionUnit: options?.dimensionUnit ?? eoiContext.yacht?.lengthUnit ?? 'ft',
}); });
@@ -597,7 +603,14 @@ export async function generateAndSign(
signers: GenerateAndSignInput['signers'], signers: GenerateAndSignInput['signers'],
pathway: 'inapp' | 'documenso-template', pathway: 'inapp' | 'documenso-template',
meta: AuditMeta, 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 // Phase 3b - apply per-field overrides BEFORE either pathway resolves the
// EOI context, so any setAsDefault contact promotion is visible to the // EOI context, so any setAsDefault contact promotion is visible to the
@@ -623,7 +636,7 @@ async function generateAndSignViaInApp(
context: GenerateInput, context: GenerateInput,
signers: GenerateAndSignInput['signers'], signers: GenerateAndSignInput['signers'],
meta: AuditMeta, meta: AuditMeta,
options?: { dimensionUnit?: 'ft' | 'm' }, options?: { dimensionUnit?: 'ft' | 'm'; includeYachtDetails?: boolean },
applied: AppliedOverrides = { resolved: {}, documentOverrideColumns: {} }, applied: AppliedOverrides = { resolved: {}, documentOverrideColumns: {} },
) { ) {
const template = await getTemplateById(templateId, portId); const template = await getTemplateById(templateId, portId);
@@ -755,7 +768,7 @@ async function generateAndSignViaDocumensoTemplate(
portId: string, portId: string,
context: GenerateInput, context: GenerateInput,
meta: AuditMeta, meta: AuditMeta,
options?: { dimensionUnit?: 'ft' | 'm' }, options?: { dimensionUnit?: 'ft' | 'm'; includeYachtDetails?: boolean },
applied: AppliedOverrides = { resolved: {}, documentOverrideColumns: {} }, applied: AppliedOverrides = { resolved: {}, documentOverrideColumns: {} },
) { ) {
if (!context.interestId) { if (!context.interestId) {
@@ -766,6 +779,14 @@ async function generateAndSignViaDocumensoTemplate(
await buildEoiContext(context.interestId, portId), await buildEoiContext(context.interestId, portId),
applied, 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); const signers = await getPortEoiSigners(portId);
// Per-port Documenso template + recipient IDs (with env fallback). Each // Per-port Documenso template + recipient IDs (with env fallback). Each
// tenant pointing at its own Documenso instance has different numeric // tenant pointing at its own Documenso instance has different numeric
@@ -908,6 +929,10 @@ async function generateAndSignViaDocumensoTemplate(
pathway: 'documenso-template', pathway: 'documenso-template',
templateId: env.DOCUMENSO_TEMPLATE_ID_EOI, templateId: env.DOCUMENSO_TEMPLATE_ID_EOI,
interestId: context.interestId, 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, ipAddress: meta.ipAddress,
userAgent: meta.userAgent, 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; * EOI's Length/Width/Draft formValues. The drawer's toggle drives this;
* server defaults to the yacht's `lengthUnit` column when omitted. */ * server defaults to the yacht's `lengthUnit` column when omitted. */
dimensionUnit: z.enum(['ft', 'm']).optional(), 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. */ /** Phase 3b/3-follow-up - optional per-field overrides applied at generation. */
overrides: z overrides: z
.object({ .object({