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

@@ -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">