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,41 +689,60 @@ 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>
{ctx.yacht ? ( <div className="flex items-center gap-2">
<div className="inline-flex rounded-md border bg-muted/30 p-0.5 text-[11px]"> {ctx.yacht ? (
<button <label className="inline-flex cursor-pointer items-center gap-1.5 text-[11px] text-muted-foreground">
type="button" <input
onClick={() => setDimensionUnit('ft')} type="checkbox"
className={ checked={includeYachtDetails}
'rounded px-2 py-0.5 transition-colors ' + onChange={(e) => setIncludeYachtDetails(e.target.checked)}
(effectiveDimensionUnit === 'ft' className="h-3.5 w-3.5 cursor-pointer accent-primary"
? 'bg-background font-medium shadow-sm' aria-label="Include yacht details on the EOI"
: 'text-muted-foreground hover:text-foreground') />
} <span>Include on EOI</span>
aria-pressed={effectiveDimensionUnit === 'ft'} </label>
> ) : null}
ft {ctx.yacht && includeYachtDetails ? (
</button> <div className="inline-flex rounded-md border bg-muted/30 p-0.5 text-[11px]">
<button <button
type="button" type="button"
onClick={() => setDimensionUnit('m')} onClick={() => setDimensionUnit('ft')}
className={ className={
'rounded px-2 py-0.5 transition-colors ' + 'rounded px-2 py-0.5 transition-colors ' +
(effectiveDimensionUnit === 'm' (effectiveDimensionUnit === 'ft'
? 'bg-background font-medium shadow-sm' ? 'bg-background font-medium shadow-sm'
: 'text-muted-foreground hover:text-foreground') : 'text-muted-foreground hover:text-foreground')
} }
aria-pressed={effectiveDimensionUnit === 'm'} aria-pressed={effectiveDimensionUnit === 'ft'}
> >
m ft
</button> </button>
</div> <button
) : null} 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> </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({