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:
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user