feat(audit-session): legacy-stage canonicalization + multi-berth label sweep + PDF/UI polish

Critical data-correctness fixes
- external-eoi.service: stage-advance list rewritten against canonical
  7-stage vocab (enquiry/qualified/nurturing → eoi). Was hardcoded to
  legacy 9-stage names (open/details_sent/in_communication/eoi_sent), so
  EOI uploads from 'qualified' silently skipped the stage flip. Now also
  writes eoiDocStatus='signed' alongside eoiStatus='signed'.
- public-interest.service + api/public/interests/route: pipelineStage
  'open' → 'enquiry' for new public interests.
- interests.service: legacy 'open' gate → 'enquiry'; inline-stage-picker
  comments updated.
- Display fallbacks canonicalized: dashboard.service, dashboard-report-data,
  pdf/templates/{interest,client}-summary, interest-picker, timeline route
  all route through canonicalizeStage / stageLabelFor.

Multi-berth interest label sweep
- New helper src/lib/templates/interest-berth-label.ts with 9 unit tests
  (deriveInterestBerthLabel reuses formatBerthRange + caps at 5 segments,
  falls back to 'first + N more').
- New batched aggregator getAllBerthMooringsForInterests on the
  interest-berths service.
- BoardInterestRow + listInterests + getInterest extended with
  berthMoorings: string[].
- Swept render sites: interest-detail-header, pipeline-card +
  pipeline-column (kanban), interest-columns (list), interest-card,
  interest-detail (breadcrumb), client-pipeline-summary +
  client-interests-tab, yacht-tabs, shared interest-picker.
- PDF report "New interests (in period)" Source column → Berth column.

Dashboard PDF report fixes
- Hardcoded EUR → reads ports.default_currency once at the top of
  resolveDashboardReportData. Falls back to USD.
- 'maintenance' berth-status bucket removed everywhere (wasn't in
  canonical BERTH_STATUSES); cleaned from dashboard.service,
  dashboard-report-data, occupancy-report, berth-status-chart, fixture.
- Berth demand ranking: dropped placeholder Tier column (resolver
  hardcoded 'A' — heat-tier never plumbed through).
- Deal pulse distribution: tier values capitalized (hot → Hot etc.).
- Validator widgetIds.max 20 → 40 (catalog has 25 entries; was throwing
  "Validation failed" when all sections checked).
- Export dialog: badges tightened (text-[8px] py-px whitespace-nowrap, no
  more 2-line wraps on "needs date range"); accepts initialRange?:
  DateRange so the dashboard's active range pre-fills dateFrom/dateTo via
  rangeToBounds.

Interest banner overcounts fix
- interest-berth-status-banner: filters out self-caused under-offer
  berths (where the only active deal touching the berth IS this same
  interest). Waits for all competing-queries before committing the
  count. Was showing "3 berths unavailable" when only 1 actually had a
  competitor.

Sessions list ordering
- sessions-list: client-side sort by lastAt desc + displays lastAt
  instead of firstAt so visible timestamp matches the sort key.

Audit log polish
- Details button: side Sheet → Popover anchored to the button (in-place
  inline dropdown). Works with the virtualized table.
- From/To date pickers: width w-44 → w-52, wrapper gap-3 → gap-x-4 gap-y-3.

EntityFolderView (Documents Hub entity view)
- Per-row Download button (hover-reveal icon).
- File-type icon prefix + tighter row layout.
- Per-row interest-berth badge: files.ts attaches interestBerthLabel via
  one batched getAllBerthMooringsForInterests call across all groups.
  AggregatedFile type + EntityFolderView render the badge linking back
  to the parent interest.

External EOI upload dialog
- Title input pre-fills from the derived default via controlled
  displayTitle = title || defaultTitle (no setState-in-effect).

EOI Generate dialog
- Success toast on mutation success.
- Primary berth's "Include in EOI" checkbox is now forced-on + disabled
  with tooltip: the primary IS the canonical "berth for this deal",
  excluding it is semantically nonsense.

Primary berth must always be in EOI bundle (service + backfill)
- interest-berths.service: insert path forces is_in_eoi_bundle=true
  whenever is_primary=true; update path coerces back to true when the
  caller tries to set false on a primary. Backfilled 7 existing rows.

Documenso redirect URL fallback
- port-config getPortDocumensoConfig: resolution chain extended to
  documenso_redirect_url → public_site_url → null. Operators with
  public_site_url configured (most ports) now get sensible signer
  landing without setting two settings.

World-map click → navigate
- website-analytics-shell: country click navigates to the nationality-
  filtered Clients page via router.push instead of copying a URL to
  clipboard.

Documents Hub: subfolder grid in main panel
- Subfolder cards rendered above the documents list when the current
  folder has children. Lets reps drill into subfolders from the main
  content area, not only via the sidebar tree.

Interest list initial sort
- usePaginatedQuery gains initialSort option (used when URL has no sort
  param). Interest list passes updatedAt desc so the table header
  surfaces the active sort visibly + most-recently-added/edited bubble
  to the top.

Interest auto-assign on create
- interests.service createInterest: three-tier owner resolution chain
  — explicit input → port's default_new_interest_owner setting →
  creator (when not super-admin). Super-admins skipped since they often
  create on behalf of other reps.

Backfills
- 12 interests with eoi_status='signed' + missing eoi_doc_status='signed'
  aligned.
- 7 interest_berths rows with is_primary=true but is_in_eoi_bundle=false
  flipped to true.

Verified
- pnpm tsc --noEmit: clean
- pnpm exec vitest run: 1463 / 1463 passed

Captured 25+ additional UAT findings to docs/superpowers/audits/alpha-uat-master.md
across all 4 buckets, including two OPEN QUESTIONS (Reservations module
re-imagine, Reports dedicated page promotion).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 20:41:27 +02:00
parent 70d1e7e9b2
commit 41737fa950
47 changed files with 905 additions and 269 deletions

View File

@@ -120,6 +120,60 @@ _Copy tweaks, alignment, single-prop edits, obvious typos._
> - **SHIPPED (primitive) in 8f42940:** `src/components/ui/file-input-button.tsx` lands with the shape the queue asked for + an optional `showSelectedFilename` mode. external-eoi-upload-dialog migrated. The 5 other queued sites were re-audited — they already use the hidden-input + Button-trigger pattern (no browser-default UI visible), so no migration was needed; the primitive is in place for any new caller.
> - **EOI empty state: add "Mark as signed without file" button (parity with Reservation + Contract tabs)** — _src/components/interests/interest-eoi-tab.tsx:553-562_ (`EmptyEoiState` only renders Generate + Upload paper-signed) — `MarkExternallySignedDialog` already supports `docType: 'eoi'` (mark-externally-signed-dialog.tsx:37-41) with full copy ("Flips the EOI sub-status to 'signed' without uploading a file…"); the reservation tab uses the same dialog via a third ghost-button row (interest-reservation-tab.tsx:378-380). EOI tab's empty state just never grew the button. Add it as a third ghost-variant Button, wired to a `setMarkExternalOpen(true)` state hook + the existing dialog. ~5-10 min. Captured 2026-05-21 from UAT. **SHIPPED in 52342ee.**
> - **Activity feed: "See all" link to the full audit log** — _src/components/dashboard/activity-feed.tsx_ (ActivityFeedInner, around line 175) — the card lists the most recent audit events but has no jump-off to the full audit-log page. Add a "See all" link in the card header (or as a trailing row underneath the list). Confirm the target route (likely `/{portSlug}/admin/audit-log`) and permission-gate the link by the same `audit_log.view` perm the admin sidebar uses, so non-admin reps see the card but not the link. ~10 min. **SHIPPED in 203f543:** link points at `/<port>/admin/audit` and is gated by `admin.view_audit_log`.
> - **Interest form berth-picker: render selected berths as compact range/list instead of "A1 + N more"** — _src/components/interests/interest-form.tsx:439-447_ (the PopoverTrigger label render) + reuse _src/lib/templates/berth-range.ts:48_ (`formatBerthRange`, already tested + already used for EOIs / folder names / document-detail Interest sub-label). Today: primary mooring + `" + N more"` truncation regardless of count or layout — `A1 + 4 more` for 5 consecutive berths reads worse than `A1-A5`. Fix: build the full mooring list (primary + additional), pass through `formatBerthRange()`, render the resulting string. Then apply a truncation cap **on segment count** (post-range-collapse `.split(', ').length`): ≤5 segments → render in full (`A1-A5` or `A1, A3, B5-B7, C2, C4`); >5 nonconsecutive segments → fall back to the legacy `<first segment> + N more` form so the button doesn't overflow. Consecutive runs always collapse to a single segment, so `A1-A20` (20 berths, 1 segment) renders compact while `A1, A3, A5, A7, A9, A11` (6 segments) truncates. ~10 min. Captured 2026-05-24 from UAT.
> - **Primary berth must always be included in the EOI bundle (force checkbox on)** — _src/components/documents/eoi-generate-dialog.tsx_ (the "EOI scope" section shipped in ef37901 — currently lets the rep uncheck any berth including the primary), _src/lib/services/interest-berths.service.ts_ `addInterestBerth` + the EOI-generate handler (no service-side enforcement of "primary ⇒ in_eoi_bundle=true"). Today a rep can uncheck the primary berth's "Include in EOI" checkbox before generating, which produces a signed envelope that doesn't commit to the deal's canonical berth — semantically nonsense (the primary IS the berth the deal is about). Fix shape: (a) UI - in EoiGenerateDialog's scope picker AND the upcoming ExternalEoiUploadDialog berth-scope step, render the primary berth's "In EOI" checkbox as checked + disabled with a tooltip ("Primary berth is always included"); (b) Service - `addInterestBerth` and `upsertInterestBerth` should set `is_in_eoi_bundle=true` whenever `is_primary=true`, with a server-side guard rejecting any update that tries to set `is_in_eoi_bundle=false` while `is_primary=true`. Backfill: one-off SQL to flip `is_in_eoi_bundle=true` on any existing row where `is_primary=true AND is_in_eoi_bundle=false`. ~45 min - 1h. Captured 2026-05-24 from UAT.
> - **EOI signature progress on Overview: barebones SigningProgress + "View EOI" CTA** — _src/components/interests/interest-tabs.tsx_ (OverviewTab milestone strip) + reuse _src/components/documents/signing-progress.tsx_. Today the EOI milestone block on Overview only shows "EOI sent" / "EOI signed" sub-items (binary ticks). User wants the same per-signer progress widget that lives on the EOI tab, but barebones - signature order + who's signed at a glance + a "View EOI →" link to jump to the EOI tab for the rest of the actions (resend, cancel, etc.). Cheapest path: mount `<SigningProgress documentId={activeEoi.id} signers={signers} />` inside the milestone card when EOI is in `sent` / `partially_signed` / `signed`. Wrap in a small "Active EOI" subsection with a button at the bottom right linking to the EOI tab. ~30-45 min. Captured 2026-05-24 from UAT.
> - **Website analytics: PDF export parallel to the dashboard report** — _new_ `src/components/reports/export-website-analytics-pdf-button.tsx` + _new section catalog_ `src/lib/services/website-analytics-report-widgets.ts` + _new resolver_ `src/lib/services/website-analytics-report-data.service.ts` + reuse the existing `/api/v1/reports/generate` route with `kind: 'website-analytics'` + chart primitives in `src/lib/pdf/reports/charts.tsx`. Today only the dashboard has a PDF export; reps want the same affordance on /website-analytics so they can ship the Umami snapshot for the period. Sections to include: realtime KPIs, pageviews chart, top pages / referrers / countries (tables), weekly heatmap, world map (geo donut), sessions list (top N). Date-range default inherits the page's active range (same pattern as the dashboard fix above). ~6-8h end-to-end. Captured 2026-05-24 from UAT.
> - **🟡 OPEN QUESTION — promote Reports from a dashboard dialog to a dedicated page with proper UI. NEEDS DESIGN DISCUSSION before scoping.** — current surface area: `<ExportDashboardPdfButton>` lives in the dashboard header (`src/components/dashboard/dashboard-shell.tsx:174`), opens a dialog with sections checklist + date range + saved-templates picker + preview. As the catalog grew (5 widgets → 25 → likely 40+ once website-analytics export lands), the dialog UX is getting cramped: sections scroll inside a fixed-height popup, no grouping by domain, no per-section data-availability badges, no run-history / saved-template management surface, no schedule-recurring affordance.
> - **Discussion seeds (NOT a commitment — anchor for the design pass):**
> - **Q1.** Single Reports landing page at `/{portSlug}/reports` listing every report kind (Dashboard, Website Analytics, Client Summary, Interest Summary, Berth Spec, Occupancy, Expenses, …) with a "Generate" CTA per row?
> - **Q2.** Per-report builder screen with full-page layout: left panel = sections checklist grouped by domain (Summary / Pipeline / Berths / Lead sources / Operations) + per-section data-availability pills; right panel = live PDF preview that re-renders on toggle.
> - **Q3.** Saved-templates manager (rename, share with team, set default-for-this-port, archive). Today `<SavedTemplatesPicker>` is a popover inside the dialog with no management UI.
> - **Q4.** Run history: per-port log of every report generated (when, by whom, which sections, downloaded vs emailed). Drives reproducibility ("send me the same report Sarah ran last month") and audit.
> - **Q5.** Schedule recurring reports — pick a saved template + cadence (weekly Monday 9am, monthly first-of-month, quarterly) + recipients; the cron fires the report and emails the PDF. Massive value for stakeholders who want regular updates without nagging the operator.
> - **Q6.** Per-recipient delivery — email the PDF to designated stakeholders straight from the Generate screen (vs. download + manual email).
> - **Q7.** Permission model — `reports.export` exists today; do we need `reports.schedule` + `reports.manage_templates` carve-outs for the scheduling + sharing flows?
> - **Q8.** Integration with existing surfaces — keep the dashboard's "Export as PDF" button as a quick path that pre-selects the right report? Or remove it in favor of the dedicated page entirely?
> - **Q9.** Visual-design ambition — fleshing this out is also a chance to bring some polish (preview hover state, drag-to-reorder sections, save-as-template inline, schedule from the same screen).
> - **Q10.** Output formats beyond PDF — CSV export of the underlying data, Excel workbook with one sheet per section, PNG/JPEG snapshots of each chart, public share-link to a hosted HTML version?
> - **Q11.** Customisable report metadata — title + subtitle + cover-page copy + footer note. Today the PDF header is hardcoded "Dashboard summary · {date-range-line}" at `src/lib/pdf/reports/dashboard-report.tsx:195`; the render path already accepts a `subtitle` prop override but the dialog never exposes it. The dedicated-page builder should expose: report title, optional subtitle, optional intro paragraph, optional sign-off / footer (e.g. "Prepared for Board Meeting Q1 2026"). Saved-templates inherit these.
> - **Action:** schedule a design session covering Q1-Q10 with the operator stakeholder. Output a short design doc (`docs/reports-page-design.md`) covering routing, data shape, scheduler, permissions, then scope into discrete Bucket 3 items. Until then, keep iterating the dialog (badges, data-availability, currency etc.). Captured 2026-05-24 from UAT.
> - **Dashboard PDF export dialog: surface per-section data availability + don't render uninformative "n/a" rows** — _src/lib/services/dashboard-report-data.service.ts_ (per-widget resolvers) + _src/components/reports/export-dashboard-pdf-button.tsx_ (sections checklist) + _src/lib/pdf/reports/dashboard-report.tsx_ (render-time empty-state handling). Today on a fresh port (e.g. Port Nimara), the Average Sales Cycle section renders "Median: n/a · Mean: n/a" because there are 0 signed contracts to compute against. Same risk for: stage_conversion_rates (needs deals that have progressed AND won), berth_demand_ranking (needs interests on berths), reminders_summary (needs reminders in window), recent_activity (needs audit-log entries), new_clients_period / new_interests_period (window-dependent), etc. The "n/a" output is noisy + the rep wasn't warned that the section would be empty.
> - **Two-tier fix:**
> - **(a) Cheap baseline (~30-45 min):** server-side omit-when-empty. Each resolver returns `null` (or sets `data[widget] = undefined`) when the resulting payload has no meaningful content. The PDF render path already gates on `data.X ?` so the section disappears entirely. Concrete sections to add the gate to: avg_sales_cycle (sampleSize === 0 → omit), reminders_summary (no reminders → omit or render the empty state with copy), stage_conversion_rates (no advanced deals → omit), recent_activity (no events → omit), every period-cohort resolver (count === 0 → omit). When omitted, the section just doesn't appear in the PDF.
> - **(b) Dialog-time data availability (~2-3h):** new `GET /api/v1/reports/availability?widgetIds=...&dateFrom=...&dateTo=...` endpoint returns `{ widgetId: 'ok' | 'no_data' | 'needs_window' | 'partial' }` for each requested id (lightweight presence-check queries, no full resolution). Dialog calls it on open + on date-range change; each checkbox row shows a "No data yet" / "Needs date range" muted pill next to widgets that won't render. Rep can keep them checked (they'll be silently omitted) or uncheck for clarity. Same query powers a small "{N} sections will be empty" summary line at the top of the dialog.
> - **(c) Optional polish for non-omittable widgets** (e.g. KPIs that should always render even at zero): replace "n/a" with a helpful empty-state string ("No closed deals yet — first signed contract will populate this") so even when the section IS shown, the rep understands why the cell is blank.
> - **Recommendation:** ship (a) first (most reps just want clean reports), follow up with (b) when the catalog grows further. Captured 2026-05-24 from UAT.
> - **Dashboard PDF report: hardcoded EUR currency + stale "maintenance" berth-status bucket showing 0 / 0%** — two findings UAT 2026-05-24:
> - **(a) Hardcoded EUR**: `src/lib/services/dashboard-report-data.service.ts` Revenue forecast snapshot + Pipeline value breakdown both wrote `currency: 'EUR'` regardless of the port's `ports.default_currency`. Symptom: PDF rendered "€14,672,888" on a USD-configured port (Port Nimara). **SHIPPED this session:** service reads `ports.default_currency` once at the top of `resolveDashboardReportData` and threads `portCurrency` through both money-bearing sections. Falls back to USD when null (matches schema default).
> - **(b) "maintenance" berth-status bucket**: canonical `BERTH_STATUSES = ['available','under_offer','sold']` (3 values per `src/lib/constants.ts:175`). Stale `maintenance` references rendered a "Maintenance · 0 · 0%" row in the PDF Berth Status table + a 0-value slice in the donut. **SHIPPED this session:** removed from `dashboard.service.ts:264` (service return), `dashboard-report.tsx:25-31 + 272-275 + 332` (PDF row + donut + type shape), `berth-status-chart.tsx:16+26` (dashboard donut), `occupancy-report.tsx:23+31` (defensive label/color map), `tests/unit/pdf-report-renderer.test.ts:49-55` (fixture). 'reserved' (also legacy) still has a defensive label fallback in occupancy-report — left in place since it's data-driven, not proactively rendered.
> - **Dashboard export dialog: badges look too big, especially "needs date range" wrapping to 2 lines + dialog defaults to last-30-days instead of inheriting the dashboard's active range** — _src/components/reports/export-dashboard-pdf-button.tsx:282-290 + 65-69_. Two issues caught 2026-05-24: (1) the `CHART` and `NEEDS DATE RANGE` pills use `text-[9px]` + `py-0.5` but `NEEDS DATE RANGE` word-wraps onto a second line so the visual height balloons; (2) initial dateFrom/dateTo hardcoded to last-30 even when the rep just picked Today / 7d on the dashboard. **SHIPPED this session:** badges tightened to `text-[8px] py-px leading-none whitespace-nowrap shrink-0`; ExportDashboardPdfButton accepts `initialRange?: DateRange` and dashboard-shell passes the active range through so the export dialog opens with the picker pre-filled to whatever was already in view. Also bumped the route validator's `widgetIds.max(20)` → `.max(40)` since the catalog now has 25 widgets (was throwing "Validation failed" when all sections were checked).
> - **Analytics: click-into-country drilldown across the page (world map + Top countries list + anywhere else country-keyed) — show the timeframe-scoped sessions for that country** — _src/components/website-analytics/visitor-world-map.tsx_ (`onCountryClick` already wired but copies-to-clipboard today), _src/components/website-analytics/top-list.tsx:38_ (Top countries rows render `<span>{countryName}</span>` with no click handler), _src/components/website-analytics/sessions-list.tsx_ (sessions card — needs to honor a country filter), _src/lib/services/umami.service.ts_ `getSessions(portId, range, opts)` (extend opts with `country?: string` → passed through to Umami's `/sessions` endpoint as `country` query param; v2/v3 both honour it), _src/components/website-analytics/use-website-analytics.ts_ `useUmamiSessions` (thread the country filter through). Today: country click does nothing useful in TopList; world map copies a URL to clipboard instead of navigating. User intent: clicking a country anywhere on the analytics page should scope the sessions card (and ideally other country-aware widgets) to that country for the active timeframe.
> - **Fix shape:**
> - **(a) Page-wide filter state via URL search param `?country=<ISO2>`** so the filter is shareable + survives reload. `useSearchParams` reads it; clicks set it via `router.replace({ pathname, query: { country } })`.
> - **(b) TopList country click** — when `rowKey === 'country'` (or however TopList encodes the dimension), wrap each row in a button that sets `?country=<iso>` on click. Render a subtle "→" affordance + tooltip "View sessions from <country>".
> - **(c) World-map click** — `onCountryClick={(iso) => setCountryParam(iso)}` (replaces the current clipboard-write).
> - **(d) Sessions card scopes by country** — `useUmamiSessions(range, { page, pageSize, country })` passes through. The sessions-list header gains a removable "Filtered: <flag> United States [×]" chip when active; the × clears the param.
> - **(e) Other widgets that could honour the filter** (optional, second pass): top-pages, weekly heatmap, pageviews chart — country filter scopes their queries too. Not required for v1.
> - **Effort:** ~1.5-2h. ~30 min URL-param state + chip UI. ~30 min thread `country` through service + hook. ~30 min TopList click affordance. ~15 min world-map handler swap. ~15 min test pass. Captured 2026-05-24 from UAT. **Supersedes** the earlier "VisitorWorldMap click should navigate, not copy" entry (this is the proper version of that ask).
> - **Recent Sessions card: rows not in chronological order — sort by lastAt desc + display lastAt instead of firstAt** — _src/components/website-analytics/sessions-list.tsx_. Umami's `/sessions` page isn't reliably ordered by any timestamp; client-side sort by `lastAt` desc puts the most-recently-active session at the top, and switching the displayed time from `firstAt` to `lastAt` makes the visible timestamp match the sort key. Captured 2026-05-24 from UAT — **SHIPPED this session**.
> - **InterestDocumentsTab: remove or contextualize the Generate-EOI button** — _src/components/interests/interest-documents-tab.tsx_ — the "Generate EOI" button on the Documents tab is duplicated (already lives on Overview milestone strip + EOI tab). Either remove from Documents tab entirely (cleanest), OR make it stage-aware: pre-EOI shows "Generate EOI", at reservation stage "Generate Reservation Agreement", at contract stage "Generate Sales Contract". Each branch uses either the existing template-driven path OR upload-and-place-fields (the universal flow that shipped in 552b966). Reservations + sales contracts are likely to be custom-uploaded most of the time, so the dialog must remain capable of "upload doc → place fields → send via Documenso" for any signing-doc type beyond EOI. Cross-ref: B3 universal upload-with-fields finding (covers generic flow); this entry asks for the stage-bound contextual variant. ~30-45 min for (a) remove path; ~2-3h for (b) stage-aware variant. Captured 2026-05-24 from UAT.
> - **Interest auto-assign to creator (sales-rep roles only)** — _src/lib/services/interests.service.ts_ `createInterest` — observed UAT 2026-05-24: deal-owner chip shows "Unassigned" after a super-admin creates an interest. Super-admin behaviour is correct (often acting on behalf of others), BUT for sales-rep roles (`sales_agent`, `sales_manager`) the rep should auto-claim ownership at create time. Fix shape: createInterest reads `ctx.userId` + role; when role IN sales-rep set AND `data.assignedTo` is not explicitly provided, default to ctx.userId. Optional admin setting `auto_assign_creator_to_interest` with role-list (default: enabled for sales_agent + sales_manager, off for super_admin / director / residential_partner / viewer). ~45 min - 1h including the admin toggle + audit log entry on auto-assign. Captured 2026-05-24 from UAT.
> - **FileGrid: click-to-preview on each card** — _src/components/files/file-grid.tsx:109-123_ — re-audited 2026-05-24 in the same session: the `onClick={() => onPreview(file)}` IS wired correctly on the button AND every caller (`interest-documents-tab.tsx:180+196`, `client-files-tab.tsx:98`, `company-files-tab.tsx:98`, `yacht-files-tab.tsx`) passes `setPreviewFile` and mounts `<FilePreviewDialog>`. The original "doesn't preview" symptom is most likely the file-type-coverage gap covered by the universal-file-preview Bucket 3 finding (only PDF + images render today; everything else falls through to a blank surface). Leave as-is — the click-handler half doesn't need a fix; the type-coverage half is parked under Bucket 3.
> - **EOI generation: success toast missing (especially from Overview milestone action)** — _src/components/documents/eoi-generate-dialog.tsx_ - mutation's `onSuccess` closes the dialog + invalidates queries but doesn't fire a success toast. When the rep generates the EOI from the Overview milestone action (rather than the EOI tab), they get no visible confirmation that the envelope was created and sent. Add `toast.success` mirroring the external-EOI-upload toast: "EOI generated and sent to {N} signer{plural}" - count comes from the returned envelope's recipients. Bonus: include "View EOI" in the toast that navigates to the EOI tab. ~10-15 min. Captured 2026-05-24 from UAT.
> - **Branded post-completion email not firing when Documenso webhook is unreachable (polling fallback may not exercise the email path)** — _src/jobs/processors/documenso-poll.ts_ + _src/lib/services/documents.service.ts_ `handleDocumentCompleted` + any post-completion email-fan-out hook. Observed 2026-05-24: when webhooks were misconfigured (wrong URL), Documenso's OWN built-in confirmation email went to signers but the CRM's branded confirmation (with attached signed PDF) did not - even though `signature-poll` cron runs every 5 min and DOES call `handleDocumentCompleted`. Investigation needed:
> - **(a) Does `handleDocumentCompleted` actually queue the branded confirmation email**, or is the email path wired only at the webhook receiver layer (above `handleDocumentCompleted`)? If the latter, the polling fallback closes the doc-status state but skips the email - explains the symptom.
> - **(b) If (a) is true, hoist the email-fan-out INTO `handleDocumentCompleted`** so polling and webhook paths produce identical side-effects.
> - **(c) Idempotency on both paths.** Whatever marker prevents the email double-sending (probably the existing "is_already_completed" guard in handleDocumentCompleted) needs to also gate the email send so a webhook arriving 5 min after a poll-driven completion doesn't re-fan-out.
> - **Pairs with:** the Documenso redirect-URL default finding above (operators who fall into the misconfigured-webhook trap are the ones who would notice this — fix both together so misconfiguration degrades gracefully).
> - **Effort:** ~1.5-2h. ~45 min code-trace + verification. ~30-45 min hoisting + idempotency. ~30 min vitest with a poll-driven completion verifying the email queue receives the job. Captured 2026-05-24 from UAT.
> - **LinkedBerthRowItem dimensions: drop the "D" suffix + honor user's unit preference** — _src/components/interests/linked-berths-list.tsx:~778_ (LinkedBerthRowItem render) — today shows `206.7ft L · 46.6ft W · 14.5ft D`. The "D" (Draft) is opaque to sales reps and the unit is hardcoded to ft. Drop the draft from the inline strip (it's secondary for sales context; still visible on berth detail). Render length + width in whichever unit was actually entered for the data: yacht's `lengthUnit` column when a yacht is attached, otherwise the sales rep's most-recent typed unit, with a section-level toggle to flip. Pair with the existing dual-source dimension Bucket 3 finding which proposes the same yacht/desired toggle architecture. ~30-45 min. Captured 2026-05-24 from UAT.
> - **ExternalEoiUploadDialog: prefill title from derived default + signatories from active Documenso EOI when one exists** — _src/components/interests/external-eoi-upload-dialog.tsx:59_ (`const [title, setTitle] = useState('')` — starts empty even though `defaultTitle` at :110 already builds `"External EOI - {Client} - {berths} - {date}"`) + _:65-99_ (signatories seeding only adds one row from `interestData.clientName/primaryEmail`, ignores any existing Documenso EOI's signer list — which is right there at `useQuery(['documents', doc.id, 'signers'])` in the parent EOI tab `interest-eoi-tab.tsx:255-264`). Today's UX gaps the rep notices in the upload-signed-copy flow:
> - **(a) Title field renders empty** even though the dialog already has all the data to derive a sensible default. `defaultTitle` is computed and used as a fallback when the rep leaves the input blank on submit, but reps think "the field is empty, I need to type something" and don't realize a default is silently inserted at submit time. Fix: init the input value from `defaultTitle` once `interestData` + `berthsData` resolve (single effect that flips state from blank → derived default, only if the rep hasn't typed anything yet — gate on `title === ''` to avoid clobbering typed input). Apply **regardless of whether an active EOI exists** (user's "either way" framing).
> - **(b) Signatories seed from active EOI's signers when present**, not just the interest's client. Parent EOI tab already loads `signers` (the `useQuery(['documents', doc.id, 'signers'])` block that powers ActiveEoiCard + SigningProgress) — the cheapest path is to thread the active EOI's signer list through as a prop: `<ExternalEoiUploadDialog prefillSignatories={activeEoi ? signers.map(...) : undefined} />`. Dialog's `signatories` useMemo updates: if `prefillSignatories` is set AND `signatoriesOverride === null`, return the prefill; else fall through to the existing client-only seed. Maps each Documenso signer's `signerName/signerEmail/role` to a `SignatoryRow`, normalizing the role union (`'SIGNER'` → `'client' | 'developer' | 'rep' | 'witness' | 'cc'` based on the documenso-side role hint or position; if normalization is ambiguous, default to `'witness'` and let the rep correct).
> - **(c) Cross-ref:** pairs cleanly with the Bucket 2 "auto-cancel generated EOI when external uploaded" finding — the rep is told "this will replace the generated EOI" AND sees the existing signatories pre-filled, so they don't have to retype names/emails for 3 signers when the data is right there.
> - **Effort:** ~30-45 min total. ~5 min title pre-fill (single effect or initial-state-from-prop pattern). ~20-30 min signatory prefill prop + role normalization. ~10 min vitest covering the two prefill paths + the "rep edited then re-opens" cache behaviour. Captured 2026-05-24 from UAT.
> - **Interest form: auto-select yacht after creating one via the inline YachtForm modal** — _src/components/interests/interest-form.tsx:789-794_ — the inline yacht-create modal mounts `<YachtForm initialOwner={...} />` but doesn't pass an `onCreated` callback, even though YachtForm already supports one (`yacht-form.tsx:78` — `onCreated?: (yacht: { id; name }) => void | Promise<void>`). When the rep creates a yacht from inside the interest form, the modal closes and the YachtPicker stays empty — the rep then has to find their just-created yacht in the dropdown and select it. Trivial fix: pass `onCreated={(y) => setValue('yachtId', y.id, { shouldDirty: true })}`. Mirrors the same auto-select pattern used elsewhere for inline client-create flows. ~3 min. Captured 2026-05-24 from UAT.
1. **Dev-mode banner dismissible**_src/components/shared/dev-mode-banner.tsx:23_ — added X close button + localStorage persistence keyed by redirect address. Fixed in this session.
2. **KPI tile top padding collapsing at ≥640px**_src/components/dashboard/{pipeline-value,active-deals}-tile.tsx_ — shadcn `CardContent` default `sm:pt-0` (assumes a `CardHeader` above) was overriding the tile's `pt-5`. Added `sm:pt-5 sm:pb-5`. Fixed in this session.
@@ -518,6 +572,88 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._
> Total ~2.5-3 h end-to-end. Closes the multi-berth EOI discoverability gap (plan §1 + §4.6) and matches the documented workflow expectation that public map visibility is a _subset_ of EOI bundle coverage.
>
> **SHIPPED (a) in 05e727f:** `addInterestBerth` defaults flipped: `is_in_eoi_bundle: true`, `is_specific_interest: matches isPrimary`. (b) `linked-berths-list.tsx` rename + tooltip shipped in PR10. **(c) SHIPPED in ef37901:** EoiGenerateDialog gains an "EOI scope" section listing every linked berth with "In EOI" + "Public map" checkboxes; handleGenerate diffs vs server snapshot and PATCHes only changed rows in parallel before kicking off the envelope. Cache invalidation extended to `['interests', id, 'berths']` so LinkedBerthsList stays consistent.
>
> - **LinkedBerthsList: post-EOI UI changes — lock EOI-bundle toggle + add "In EOI" badge + let manual upload pick signed berths** — _src/components/interests/linked-berths-list.tsx:641+_ (the card body), _src/components/interests/external-eoi-upload-dialog.tsx_ (currently has no berth-scope step). Today the LinkedBerthsList always renders both toggles (Include in EOI + Public map status) regardless of whether the EOI is sent or signed — so a rep can still flip "Include in EOI" on a deal whose envelope is already mid-signing or signed, which is meaningless (signature scope is locked in the Documenso envelope's actual signed berths) and noisy.
> - **Fix shape:**
> - **(a) Hide / disable "Include in EOI" once the EOI is sent or signed.** When `eoiStatus IN ('sent', 'signed')` OR `eoiDocStatus IN ('sent', 'signed')`, replace the toggle with a static "Included in EOI ✓" / "Not in EOI -" badge (read-only). Keep the public-map toggle editable — under-offer / reserved mutates after EOI too.
> - **(b) Per-row "In EOI" badge** on every berth that was actually in the signed/generated envelope. Source of truth: the existing `is_in_eoi_bundle` snapshot at envelope-create time (we'd lock those rows on EOI generate; the post-generate UI just reads them). Subtle pill at the row's right edge so the rep can see at a glance which berths the signed document covers.
> - **(c) External EOI upload dialog: berth scope step.** Today's ExternalEoiUploadDialog doesn't ask which berths were signed - it implicitly assumes all linked interest berths. Add a step: list every linked berth as a checkbox row (defaulting to all checked), let the rep uncheck any that weren't on the signed paper, AND let the rep add berths NOT currently on the interest (with a small picker that links + flags `is_in_eoi_bundle=true` in one shot). On submit, the service writes both the document row AND the per-berth EOI-scope snapshot, mirroring the locked behaviour of the generated path.
> - **Effort:** ~4-6h end-to-end. ~1h (a) toggle visibility gate + read-only badges. ~1-1.5h (b) per-row "In EOI" badge. ~2-3h (c) ExternalEoiUploadDialog berth-scope step + service writeback + berth-add picker. ~30 min vitest covering "lock once sent". Captured 2026-05-24 from UAT.
> - **OverviewTab "Berth size desired" section: ft/m toggle + display attached yacht's dimensions inline (edit-back to yacht record)** — _src/components/interests/interest-tabs.tsx_ (OverviewTab "Berth size desired" block) + _src/components/yachts/_ (yacht-update service / inline-editable hook). The section was supposed to have a ft/m toggle defaulting to whichever unit was originally entered (per yacht's `lengthUnit` when attached, OR the rep's last typed unit). The toggle isn't present. Additionally, the linked yacht's actual dimensions aren't shown in this section — so the rep can't see "boat is 60ft × 18ft × 5ft" while typing desired-dim values for berth shopping. Both should be displayed at a glance, and yacht-dim values should be inline-editable here with the edit propagating back to the yacht record (single source of truth — yacht stays canonical).
> - **Pairs with:** the existing Bucket 3 dual-source dimension finding which covers the persisted source-of-truth picker. This entry is the OverviewTab UI half of the same architecture.
> - **Effort:** ~1.5-2h. ~30 min unit-toggle + default-from-data resolver. ~30-45 min yacht-dim row render. ~30-45 min inline-edit wired back through the yacht service. Captured 2026-05-24 from UAT.
> - **Multi-berth interest label sweep — every "Berth X" surface should render the full berth-range label (`A1-A3, B5`), not just the primary mooring** — _new helper_ `src/lib/templates/interest-berth-label.ts` (`deriveInterestBerthLabel(string[]) → string | null`, reuses `formatBerthRange`, truncates to "first + N more" when >5 segments) + _src/lib/services/interest-berths.service.ts_ (new `getAllBerthMooringsForInterests` batch aggregator) + _src/lib/services/interests.service.ts_ (extend BoardInterestRow + listInterests row shape with `berthMoorings: string[]`) + render-site sweep across every place the interest's identity is named:
> - **Sites to update (mapped from grep audit):** `src/components/interests/interest-detail-header.tsx:188-196` (the header user explicitly called out), `interest-card.tsx:55`, `pipeline-card.tsx:52-53` (kanban), `interest-columns.tsx:171-184` (list view), `interest-detail.tsx:135` (breadcrumb), `clients/client-pipeline-summary.tsx:187-188 + 301-302` (per-client deal rows), `clients/client-interests-tab.tsx:42-43 + 204-205`, `yachts/yacht-tabs.tsx:323-325` (yacht's deals tab), `search/search-result-item.tsx:68` + `search/command-search.tsx:970`, `shared/interest-picker.tsx:74`, `shared/berth-picker.tsx:126`.
> - **Strategy:** thread `berthMoorings: string[]` through every list endpoint that returns interest rows; render sites compute `deriveInterestBerthLabel(row.berthMoorings)` instead of reading bare `berthMooringNumber`. Bare primary mooring stays available for berth-FK queries that don't need the label (smart-archive, send-berth-pdf etc.). PDF templates (`client-summary.tsx`, `interest-summary.tsx`) also threaded.
> - **Effort:** ~3-4h. ~30 min helper + aggregator. ~30 min list-endpoint shape extension (BoardInterestRow + InterestRow). ~2-3h render-site sweep (~10-12 surfaces). ~30 min vitest covering helper truncation rules + service shape. Captured 2026-05-24 from UAT — IN-FLIGHT (this session).
> - **External EOI upload while a generated EOI is active: auto-cancel + replace (single source of truth for "the" EOI on an interest)** — _src/components/interests/external-eoi-upload-dialog.tsx_ (the entry surface) + _src/lib/services/documents.service.ts (markExternallySigned + cancelDocument)_ + _src/lib/services/documenso-client.ts (voidDocument)_. Today the upload-signed-copy path doesn't reconcile with a live generated EOI — the rep ends up with two EOI records on the interest (the in-flight Documenso envelope + the newly-uploaded externally-signed PDF), which compounds reporting noise, audit-log confusion, and the empty-tab dual-state problem. Pairs with the Bucket 4 bug (blank body on upload-signed-copy when active EOI exists).
> - **Fix shape:**
> - **(a) Detect-and-warn at dialog open:** when `ExternalEoiUploadDialog` mounts for an interest that already has a non-terminal generated EOI (status in `sent` / `partially_signed`), show a warning banner at the top: "An EOI generated on {date} is currently in flight. Uploading a signed copy will cancel the generated envelope and replace it with the upload." + a primary "Cancel & replace" button + a secondary "Keep both (legacy behaviour)" toggle (off by default, hidden behind an Advanced disclosure — most reps shouldn't need it).
> - **(b) Replace-mode service behaviour:** when the rep proceeds, the service runs in this order inside a transaction: (i) call `cancelDocument({ documentId: activeEoi.id, cancelMode: 'delete' })` to void the Documenso envelope + flip local row to `cancelled`; (ii) run the existing `markExternallySigned` flow with the uploaded file; (iii) emit one combined audit log entry ("EOI replaced by external upload — generated envelope {id} cancelled, external file {fileId} attached"). Idempotent: if there are multiple non-terminal generated EOIs (shouldn't happen but defensive), cancel all of them.
> - **(c) Edge cases:**
> - Generated EOI is already `partially_signed` (one or more signers signed) — warn explicitly: "1 of 3 signers has already signed the generated EOI. Cancelling will lose their digital signature record." Require a typed confirmation ("REPLACE") to proceed. The signing history stays in `audit_logs` even after cancel, but the Documenso-side proof is gone.
> - Generated EOI is already `completed` (fully signed via Documenso) — block the replace path entirely. Toast: "This EOI is already signed via Documenso. There's no need to upload an external copy." (The "Mark externally signed without file" path should also be blocked in this state — verify.)
> - Generated EOI is `cancelled` or `rejected` — no warning needed; proceed with the upload as today (empty-state path).
> - **(d) Audit-trail clarity:** the activity feed entry for the replace should link back to the cancelled-document ID so the rep can dig into why the replace happened later. Not critical, but useful for the "what happened to that EOI" question.
> - **Acceptance:**
> - Rep with an active generated EOI clicks "Upload signed copy" → sees the warning banner with the pending EOI's metadata (date + signed/total signers).
> - Confirming replace: generated envelope voided, external file uploaded + linked, doc status flips to `signed`, only ONE EOI row remains active on the interest. Activity feed shows the replace as a single event with cross-link to the cancelled envelope.
> - Already-completed EOIs block the path entirely with a clear toast.
> - Partially-signed EOIs require explicit typed confirmation.
> - **Effort:** ~2-3h. ~30 min dialog warning banner + active-EOI lookup. ~45 min service replace path (cancel + mark-externally-signed in transaction + combined audit entry). ~30 min completed/partially-signed gate copy + UX. ~30 min activity feed cross-link. ~30-45 min vitest covering the 4 states (no active EOI / pending / partially-signed / completed). Captured 2026-05-24 from UAT. **Pairs with:** Bucket 4 bug "Upload signed copy on ActiveEoiCard renders blank body" — same workflow, same surface area; ship them together so the same QA pass covers both.
> - **Documenso post-sign redirect URL: change default from blank → port's marketing site (today blank lets Documenso fall back to CRM login)** — _src/lib/settings/registry.ts:248-260_ (existing `documenso_redirect_url` registry entry — port-scoped, type `url`, NO `defaultValue`) + _src/lib/services/documenso-payload.ts:128_ (`DEFAULT_REDIRECT_URL = ''` — when the per-port setting is unset, the payload sends an empty redirect, which causes Documenso to fall back to its own configured default — for the operator that lands on the CRM login) + _src/lib/settings/registry.ts:502_ (existing `public_site_url` setting — port-scoped marketing site URL, already in the registry). Today the admin CAN set `documenso_redirect_url`, but most operators don't realize they need to, leaving it blank, which lands every signer on the CRM login post-signing. Signers are clients, not CRM users — they shouldn't be looking at our login.
> - **Fix shape:**
> - **(a) Default resolution in the payload builder.** `documenso-payload.ts:322` currently does `options.redirectUrl ?? DEFAULT_REDIRECT_URL` (= `''`). Replace with a small resolver: per-port `documenso_redirect_url` → per-port `public_site_url` → empty (Documenso's own fallback). The marketing-site default kicks in automatically for every port where the operator has set `public_site_url` (which is most of them — see registry:502 "Used by some templates and CTAs").
> - **(b) Surface the resolved value in the admin UI.** The Documenso settings card already routes through the unified registry-driven form, which surfaces env-fallback / port-override badges per field via `/api/v1/admin/settings/resolved` (per the e33313b ship). Extend the resolver chain handling for `documenso_redirect_url` so the admin sees "Using `public_site_url`: https://example.com" as the inline source-of-truth note when no explicit override is set. Eliminates the "I set the marketing site URL, why are signers still landing on the CRM login?" diagnosis loop.
> - **(c) Description copy:** existing description ("Where signers land after completing their signature. Both v1 and v2 honour it.") gets one extra clause — "When blank, falls back to the port's public marketing site (Public site URL setting); when both are blank, signers land on Documenso's own default (typically the CRM login — not recommended)."
> - **Note on "for each party":** Documenso's `meta.redirectUrl` is **document-level**, not per-recipient — all parties hit the same URL post-signing. The current ask matches that shape (one URL, applied to all signers); if per-signer-role redirects are needed later (e.g. client → marketing, developer/approver → an internal "thanks" page), that's a separate Documenso API capability worth investigating but not in scope here.
> - **Effort:** ~30-45 min. Resolver in payload builder + small admin UI source-of-truth note + a vitest covering the three resolution states (port override / public_site_url fallback / both blank). Captured 2026-05-24 from UAT.
> - **EOI Generate dialog: "Include yacht details" toggle to omit Section 3 even when a yacht is linked** — _src/components/documents/eoi-generate-dialog.tsx:667-_ (the optional Section 3 "Optional (Section 3 - left blank if absent)" block) + _src/lib/templates/merge-fields.ts:18-39_ (yacht._ + owner._ tokens) + _src/lib/services/documenso-payload.ts_ + _src/lib/pdf/fill-eoi-form.ts_ + _src/lib/services/eoi-context.ts_. Today Section 3 in the EOI is "optional" only in the sense of "left blank if no yacht is linked" — when a yacht IS linked, the section always renders with the yacht's data. Reps sometimes want to omit Section 3 even when a yacht exists: early-stage clients still yacht-shopping (yacht on file is a placeholder), multi-berth EOIs where yacht-specific dims don't apply, or the client explicitly asked to keep it off the document. No path today besides un-linking the yacht (lossy) or generating from a custom one-off template.
> - **Two-tier fix:**
> - **(a) Baseline (cheap):** add `Include yacht details` Switch/Checkbox to the Optional-section header in EoiGenerateDialog, defaulted ON, only rendered when `ctx.yacht` is set. When OFF, the dialog submits with yacht._ + owner._ merge fields blanked to empty strings (existing template tolerates blanks per the "left blank if absent" copy). In-app PDF fill pathway (`fill-eoi-form.ts`) skips the AcroForm field writes for the yacht block. Persists per-EOI as `documents.metadata.includeYachtDetails` (false) so the audit trail shows the rep's explicit choice; nothing else changes structurally. ~1.5h.
> - **(b) Optional polish:** per-port `documenso.templates.eoiWithoutYacht` template variant. When the toggle is OFF AND the port has an alt template configured, the dialog routes to that template instead — cleaner rendering with no Section 3 heading at all (the alt template is laid out without the section). When no alt template, fall back to (a)'s blank-out. ~1-1.5h additional, optional; only worth it once a port asks for the cleaner output.
> - **UI placement:** the toggle sits in the same header row as the existing ft/m unit picker (eoi-generate-dialog.tsx:672-694), next to the "Optional (Section 3...)" label — same row, right-aligned, so it's discoverable but doesn't disrupt the field grid below. Header copy updates from "Optional (Section 3 - left blank if absent)" to "Section 3 - yacht details" with the toggle nearby (its state IS the "include or not" affordance now).
> - **Out of scope:** conditional template logic (Documenso v1/v2 merge tokens are plain substitution, no `{{#if}}`); a multi-template registry beyond the single alt variant.
> - **Effort:** ~1.5-2h for (a) alone; ~3h end-to-end with (b). Captured 2026-05-24 from UAT.
> - **🟡 OPEN QUESTION — Reservations module: re-imagine end-to-end as the final A-Z piece of the CRM. NEEDS DESIGN DISCUSSION before any implementation.** — _module surface area for reference: src/lib/db/schema/reservations.ts (berth_reservations table), src/lib/services/berth-reservations.service.ts, src/components/reservations/ (BerthReservationsList, BerthReserveDialog, ReservationDetail), src/components/{clients,yachts,berths}/...-reservations-tab.tsx (read-only consumers), src/components/interests/interest-reservation-tab.tsx (the doc-signing flow, NOT the record flow), src/app/(dashboard)/[portSlug]/berth-reservations/page.tsx (top-level list, unlinked from sidebar)._
> - **Status today (discovery, 2026-05-24):** the infrastructure exists end-to-end but the workflow is half-wired and conceptually fragmented:
> - `berth_reservations` rows are the canonical "who occupies a berth right now" record (per H-01 schema comment). Status union: `pending | active | ended | cancelled`. Tenure union: `permanent | fixed_term | fee_simple | strata_lot | seasonal`. FK chain: berth + port + client + yacht (all NOT NULL with RESTRICT) + interest (nullable, SET NULL — reservation legitimately outlives the deal).
> - Creation surface: ONLY `BerthReserveDialog`, mounted on the berth detail's reservations tab. No client/yacht/interest entry point.
> - The `/[portSlug]/berth-reservations` top-level page exists but is NOT in `sidebar.tsx` — invisible navigation-wise.
> - The interest pipeline stage `reservation` + `reservationDocStatus` ('sent' / 'signed') is a **separate concept** — it tracks the legal _agreement_ (Documenso doc), not the occupancy _record_. Today a deal can pass through `reservation` stage with a signed agreement and never produce a `berth_reservations` row.
> - Client/yacht reservation tabs are read-only listings (active + lazy-loaded history). They render empty for most entities because the creation flow isn't being used.
> - **The deeper question:** what should "reservation" actually mean in this CRM? The data model carries five concepts under one umbrella that may need to be teased apart or unified deliberately:
> 1. **Occupancy facts** — who's tied up at which berth right now (current `berth_reservations`).
> 2. **Forward bookings** — future allocations (status='pending', future startDate).
> 3. **Tenure / lease records** — permanent ownership-equivalents (status='active', null endDate, tenureType='permanent' / 'fee_simple' / 'strata_lot').
> 4. **Seasonal stays** — short-term winter haul-out / summer slots (status='active', tenureType='seasonal', fixed endDate).
> 5. **The legal agreement** — the signed Documenso doc that authorises any of the above (currently lives on `interests.reservation*` fields, not on `berth_reservations`).
> - **Open questions to resolve in the design pass:**
> - **Q1.** Is "reservation" one concept or several? Today `tenure_type` plus `status` plus `endDate` carve five slices out of one table; would it be clearer as `bookings` (future-dated) + `tenancies` (active occupancy) + `agreements` (signed contracts)? Or is the unification fine and the problem is purely UX/flow?
> - **Q2.** Where does the rep _start_ a reservation? Berth-first (today's flow — pick a berth, then assign client/yacht), interest-first (move a deal through pipeline → auto-create on agreement signed), or both? Each implies different default-population + permission shapes.
> - **Q3.** Auto-create on agreement signing — yes/no/conditional? If yes, what status: `pending` (rep confirms details after) or `active` (immediate occupancy)? What happens if the signed agreement doesn't carry an explicit startDate?
> - **Q4.** Multi-berth interests (per CLAUDE.md, `interest_berths` is source of truth). When a multi-berth EOI/Reservation Agreement is signed, do we mint one reservation per in-bundle berth, or one reservation linked to the primary with the others tracked elsewhere?
> - **Q5.** Lifecycle outside the sales pipeline — renewals, transfers (Client A's reservation → Client B), seasonal returns (same client, same berth, year after year). Do these get new rows, or do we mutate? Does the agreement need to be re-signed each time?
> - **Q6.** Public map ("Under Offer" / "Sold" precedence) — should an `active` reservation flip the public berth status to something distinct from the existing interest-driven precedence ladder?
> - **Q7.** Reporting — what views does leadership actually want? Occupancy heatmap by month? Revenue forecast by tenure expiry? Renewals at risk? The data is there; the surfaces aren't.
> - **Q8.** Permissions — who can create / mutate / cancel reservations? Today gated through generic permissions; reservations probably warrant their own permission carve-outs (esp. cancellation, which has revenue implications).
> - **Q9.** Empty-state UX on client/yacht tabs — hide-when-empty vs always-show-with-helpful-empty-copy. The hide-when-empty option is cheap (data already returned in the parent loader); the "always show with creation CTA" option requires settling Q2 first (can rep create from these surfaces, or only from berth).
> - **Q10.** Integration with invoicing / expenses — does an active reservation drive recurring invoice generation? Tenure expiry → automatic renewal nudge?
> - **Sketch of plausible shape (NOT a commitment — anchor for the discussion):**
> - **Auto-create on signed `reservation_agreement`** (via existing `handleDocumentCompleted` idempotent webhook receiver). Status `pending`, rep confirms startDate + tenureType in a follow-up modal before flipping to `active`.
> - **Sidebar Reservations entry** below Berths, gated by a new `reservations.view` permission.
> - **Hide-when-empty** on Client / Yacht tabs (cheapest discoverability win). Berth tab stays always (it's the manual creation surface).
> - **Reporting:** at minimum an Occupancy widget on the dashboard (% berths under active reservation), Renewals at risk (active reservations with endDate within next 90 days), Revenue forecast by tenure (sum of berth prices × expected duration).
> - **Possible rename for clarity:** "Reservation Agreement" → "Tenancy Agreement" (signed doc); "Reservation" → "Tenancy" (occupancy record). Aligns with marina-industry vocabulary and removes the agreement/record confusion.
> - **Why this is the A-Z final piece:** the CRM today covers lead → qualification → EOI → reservation agreement → contract → handover. Reservations are the canonical record of what the sale produced — without them filled in, the system has no answer to "which berths are taken right now and by whom?" beyond ad-hoc interest-state inference. Everything downstream (renewals, occupancy reporting, transfer flows, revenue recognition timing) hangs off this. **Worth a dedicated session to design before any implementation.**
> - **Action:** schedule a design session covering Q1-Q10 with stakeholders who care about the operational side (rep workflow + ops/leadership reporting). Output should be a short design doc (`docs/reservations-design.md` or similar) covering data-model decisions (split or unify), workflow entry points, automation rules, reporting surfaces, and a phased rollout. THEN scope into discrete Bucket 3 items. Captured 2026-05-24 from UAT.
> - **Interest create: duplicate-detection warning (overlap with existing open interest for same client)** — _src/lib/services/interests.service.ts_ (new helper `findOverlappingOpenInterests(portId, clientId, berthIds, { excludeInterestId? })`) + _src/lib/services/interest-berths.service.ts_ (read-side helper) + _src/components/interests/interest-form.tsx_ (new pre-submit warning panel) + _new route_ `GET /api/v1/interests/duplicate-check?clientId=...&berthIds=...`. Today a rep can create an "A1" interest for a client and then a second "A1-A10" interest for the same client without any signal — silent data quality erosion that compounds across pipeline reports, the public map ("Under Offer" precedence), recommender tier ladder, EOI bundles. Decision: **warn, do not block** — legitimate cases exist (re-opening a lost deal, parallel scenarios, renewal alongside existing).
> - **Detection scope:** match on `client_id` + ANY berth-set intersection. "Open" = `outcome IS NULL` (or non-terminal); closed/lost/won interests are excluded from the warning. Use `interest_berths` as the source of truth (per CLAUDE.md — `interests.berth_id` does not exist post-0029); intersection is any shared `berth_id` across the candidate's berth list and any open sibling interest's berth list. Multi-tenant scope enforced via `port_id` on both sides of the join.
> - **Service shape:** `findOverlappingOpenInterests(portId, clientId, berthIds, { excludeInterestId? })` returns `Array<{ id, displayLabel, pipelineStage, currentBerths: string[], overlappingBerths: string[], updatedAt }>` — sorted most-recent first. `displayLabel` reuses the same derivation used for the document-detail Interest sub-label (berth-range via `formatBerthRange()`).
> - **UI shape (interest-form.tsx):** debounce-fire the check (300 ms) whenever both `clientId` and ≥1 berth are set; render an amber `<Alert>` (NOT `destructive`) above the submit row with copy `Possible duplicate — this client already has {N} open interest{plural} that overlap{s} with the selected berth{s}` + a list of conflicting interest chips (each linking out to `/{portSlug}/interests/{id}` in a new tab). No blocking — Submit stays enabled. On edit (existing interest), pass the interest id as `excludeInterestId` so the form doesn't flag itself. Same alert appears whenever the rep adds/removes a berth such that the overlap set changes.
> - **Edit mode behaviour:** also fires on edits — a rep extending an existing interest's berth set into another open interest's territory gets the same warning.
> - **Out of scope (intentional):** no admin-configurable strict-block toggle; no warning for closed-outcome siblings; no warning across tenants. Keep it dead-simple now and revisit if data quality issues persist.
> - **Effort:** ~1.5-2h (service helper + integration test for the overlap query + lightweight route + form-side hook + UI panel + a single Playwright smoke covering the create + edit paths). Captured 2026-05-24 from UAT. Cross-ref: pairs with existing "berth pre-flight dup check" (already shipped) — same intent (data quality at create time), different axis (this one is client-scoped, that one is berth-scoped).
1. **Berth-demand widget visual overhaul**_src/components/dashboard/berth-heat-widget.tsx_ — original "Berth heat" widget was a generic table that read as uninspired. First pass added an editorial hero + gradient — that strayed from the standard `CardHeader`/`CardContent` idiom and looked out of place next to siblings. Final version matches `hot-deals-card.tsx`'s layout exactly (icon + title + description in CardHeader, list of `-mx-2 hover:bg-accent/60` rows in CardContent); the visual upgrade is the per-row status-coloured magnitude bar. UI label renamed "Berth Heat" → "Berth Demand" in `widget-registry.tsx`. Fixed in this session.
2. **First-class "demand" sort on the berths list**_src/lib/services/berths.service.ts_, _src/components/berths/berth-columns.tsx_, _src/lib/validators_ — added `?sort=activeInterestCount` to the berths-list service via a correlated subquery in `customOrderBy`; attached `activeInterestCount` per row using the existing two-pass post-fetch pattern (alongside tags/latestInterestStage); added the "Active interests" column to `BERTH_COLUMN_OPTIONS` (default-visible, sortable). Widget's "View all by demand →" link deep-links to `/berths?sort=activeInterestCount&order=desc`. Saved views and the column picker can now use the same lens. Fixed in this session.
@@ -536,8 +672,21 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._
_New UI surfaces, new endpoints, schema migrations, multi-step flows._
> **[Umami] Larger follow-ups parked at end of 2026-05-19 build session:**
> **[Captured 2026-05-24 from UAT]**
>
> - **Interest dimensions: dual-source model (yacht dims + desired dims) with per-interest source-of-truth + recommender view-time toggle** — _src/components/interests/interest-form.tsx:670-710_ (the "Berth size desired" section, the `space-y-3` block in the React grab) + _src/lib/db/schema/interests.ts_ (new column) + _src/lib/services/berth-recommender.service.ts:379-455_ (predicate builder reads `desired*` straight off `interests` today; no yacht lookup) + _src/components/interests/berth-recommender-panel.tsx_ (new view-time picker) + _src/lib/services/qualification.service.ts_ (`computeAutoSatisfied` + `computeEvidence` — already accept either as evidence, need to clarify which is active). Today the rep manually types desired length/width/draft into the interest form. If a yacht is linked (yachts carry their own `length_ft / width_ft / draft_ft` per `src/lib/db/schema/yachts.ts:32-34`), those measurements are invisible inside the interest form — the rep has to navigate to the yacht detail page to read them, then transcribe (or estimate desired-dims around) them by hand. The two dim sets are also conceptually different (yacht = the actual boat the client owns; desired = the box the rep is shopping berths for — could legitimately differ if the rep is targeting a smaller/larger slot for upsell, or if the client is yacht-shopping in parallel). Today's recommender uses `desired*` only, ignoring yacht dims even when desired-dims are blank.
> - **Three coupled changes:**
> - **(a) Display yacht dimensions inside the "Berth size desired" section** when a yacht is linked. New read-only row above the editable Length/Width/Draft inputs showing the linked yacht's measurements as chips (e.g. `Yacht (Fiona III): 58 ft × 16 ft × 5 ft`). Renders empty / hidden when no yacht linked OR yacht has no recorded dims. Honors the existing `desiredUnit` toggle (ft ↔ m) so the chip values switch alongside the inputs.
> - **(b) Source-of-truth toggle persisted on the interest** — new column `interests.dimension_source` (`'yacht' | 'desired'`, nullable; null = auto = `'yacht' if yacht linked AND has dims else 'desired'`). UI: segmented control / radio above the dim inputs ("Use yacht dimensions" / "Use desired dimensions") shown only when a yacht is linked AND has dims (otherwise hidden; effective source is implicitly `'desired'`). Selecting "Use yacht dimensions" greys out (doesn't clear) the manual inputs — desired-dim values stay persisted but ignored by downstream consumers. Selecting "Use desired dimensions" re-enables them. Consumers (recommender, qualification evidence display, anywhere else that needs canonical dims) resolve through a new helper `resolveInterestDimensions(interest, yacht?)` that returns `{ source, lengthFt, widthFt, draftFt }` based on `dimension_source`.
> - **(c) Recommender panel: view-time toggle (independent of persisted preference)** — _src/components/interests/berth-recommender-panel.tsx_ — at top of panel, a third segmented control: `Recommend for: [Yacht] [Desired] [Both]`. Defaults to the interest's persisted `dimension_source`. Flipping it doesn't update the interest record — it's purely a query parameter for the current panel session, so the rep can explore "what if I shopped against the yacht's actual dims instead of the desired ones?" without committing. `[Both]` runs two queries side by side and shows the union with a tiny `via Yacht` / `via Desired` tag on each row's chip. Panel also surfaces a one-line note like "Recommendations using **yacht** dimensions (58 × 16 × 5 ft) - [switch]" so the rep always knows which lens is active. Recommender service accepts an explicit `inputOverride: { lengthFt, widthFt, draftFt }` param to honor the view-time selection without rereading the persisted preference.
> - **Service signature:** `getRecommendations(interestId, { dimensionsOverride?: { lengthFt, widthFt, draftFt }, ...rest })` — when `dimensionsOverride` is set, predicates 437-455 use those numbers; otherwise resolve from `dimension_source` via the new helper. Falls through to whichever is non-null when one set is missing (no yacht → desired; no desired → yacht; neither → unconstrained query, current fall-through behavior).
> - **Qualification evidence:** `computeAutoSatisfied` keeps accepting either as evidence for "Dimensions confirmed", but `computeEvidence` updates its label so the rep can see which one drove the tick: `Yacht: 58 × 16 × 5 ft (active source)` vs `Yacht: 58 × 16 × 5 ft (desired-dims active)` etc.
> - **Edge cases:**
> - Yacht linked but has zero/null dims → toggle hidden, effective source = desired, dim chip shows `Yacht (Fiona III): no recorded dimensions`.
> - Yacht unlinked after dim_source='yacht' was selected → effective source flips to 'desired' (the helper has the fall-through). DB value stays 'yacht' so re-linking the yacht restores the original intent.
> - Edit-form-only — the create-form chooses default at submit (`'yacht'` if `yachtId` set + yacht-dims-present, else `'desired'`); no need for an explicit picker in create.
> - Migration: new column is nullable + null defaults to auto-resolve, so existing rows need no backfill.
> - **Effort:** ~4-6h end-to-end. ~30 min schema + migration. ~1h `resolveInterestDimensions` helper + recommender service param. ~1.5h interest-form display + persisted toggle UI. ~1.5h recommender panel view-time toggle. ~30-45 min qualification evidence + tests. Captured 2026-05-24 from UAT.
> - **SHIPPED in a7cbee0 (O48):** New POST /api/v1/tracked-links mints redirect-link the rep can drop into outgoing email; body { targetUrl, sendId? }, returns { id, slug, targetUrl, url }, gated on `email.send`. `<TrackedLinkComposerButton>` opens a dialog: paste destination → Create → returns public /q/<slug> URL with Copy + "Insert into message" action. Wired into `<SendDocumentDialog>`'s Message body label row. **[Umami] Tracked-link composer button (Phase 4c UI)** — _src/components/email-composer/_ (find/create) + _src/lib/services/tracked-links.service.ts (already shipped)_ — backend shipped this session: `tracked_links` + `tracked_link_clicks` tables, `/q/[slug]` redirect endpoint, `createTrackedLink` + `buildTrackedUrl` helpers, Umami `link-clicked` cross-post. The missing piece is the rep-facing UI. Recommendation: a "🔗 Tracked link" button inside the sales email composer that takes the currently-selected URL (or prompts for one), calls `createTrackedLink({portId, targetUrl, sendId})`, and inserts the resulting `/q/<slug>` URL in place of the original. Show per-link click stats on the document_sends list (companion to the Bucket 2 open-rate column). Cap: ~3-4 h including the list-side rendering of click stats. Captured 2026-05-19.
> - **[Umami] Marketing-site instrumentation (Phase 4a)** — _separate marketing-site repo, NOT this one_ — adds `umami.track('cta-clicked', {…})`, `umami.track('eoi-page-reached')`, etc. calls on the marketing site so the Events tab + cross-system funnels (Phase 3 + Phase 5) light up. Also adds a `do_not_track` opt-out checkbox to the marketing-site cookie banner so visitors who decline tracking get `localStorage.setItem('umami.disabled', '1')` and skip the script entirely. Needs to be coordinated with whoever owns the marketing-site repo — capture the schema we want them to emit (event names + payload shapes) in `docs/marketing-site-event-catalogue.md` once we know which CRM funnels we actually want to drive. ~4-6 h of marketing-repo work + ~2 h of CRM-side cataloguing. Captured 2026-05-19.
> - **[Umami] Events tab (Phase 3)** — _src/components/website-analytics/events-list.tsx (new)_ + new route — Umami's `/api/websites/:id/events` is already wrapped in `umami.service.ts` (`getEvents`, `getEventsStats`, `getEventsSeries`). Surface as a new "Events" tab on the analytics page. BLOCKED on Phase 4a — the tab is empty until the marketing site fires custom events. Cap: ~3-4 h once 4a lands. Captured 2026-05-19.
@@ -698,6 +847,42 @@ _New UI surfaces, new endpoints, schema migrations, multi-step flows._
_Functional defects. Tag each with `[critical|high|medium|low]` prefix._
> - **EntityFolderView (Files section): surface per-row interest badge (berth label) + complete visual overhaul** — _src/components/documents/entity-folder-view.tsx_ + _src/hooks/use-aggregated-listing.ts_ (`AggregatedFile` type extension) + _src/lib/services/documents.service.ts_ (`listFilesAggregatedByEntity`). Today's file rows show only filename + date + optional "View signing details" link. User wants each file row to also indicate **which interest the file is attached to**, ideally as a badge containing the interest's primary berth(s) (mooring number / berth range). Cross-ref: lots of files on a multi-deal client all look identical; reps can't tell which file belongs to which deal at a glance.
> - **Fix shape:**
> - **(a) Service-side extend `listFilesAggregatedByEntity`** to also return `interestId: string | null` + `berthLabel: string | null` per file (computed via the existing `getAllBerthMooringsForInterests` aggregator + `deriveInterestBerthLabel`). The interest_id link is already snapshotted on `files`; just plumb through.
> - **(b) `AggregatedFile` type** gains the two new fields.
> - **(c) Render** a small badge next to the filename: when `berthLabel` is set, show `<Badge variant="outline">A1-A3</Badge>` linking to the interest detail. When file is attached but interest has no berths yet, show `<Badge>Interest</Badge>` instead. When file has no interest (general client/company doc), no badge.
> - **(d) Bigger visual overhaul** — same surface looks "stale": uniform monospace filenames, no file-type icons, no signed/unsigned visual hierarchy. Add: file-type icon (PDF/image/doc — match FileGrid's icon mapping), signed-state pill ("Signed" / "Sent" / "Draft" inferred from `signedFromDocumentId`), upload-by-user hint when available, tighter row spacing, hover treatment, group counts in the section header.
> - **Already SHIPPED this session (partial):** per-row Download button (icon-only, hover-reveal) + file-type icon prefix on filename + cleaner row layout (`h-8` ghost buttons, tabular-nums dates).
> - **Effort:** ~2-3h for the remaining work (~1h service+type plumbing for the interest badge, ~1-1.5h visual pass including the workflow section, ~30 min smoke tests). Captured 2026-05-24 from UAT.
> - **Sheet dialog feels cramped on wide viewports — needs more horizontal room** — UAT 2026-05-24: user flagged a Sheet (`role="dialog"` + `class="fixed top-0 right-..."`) as too narrow vs. the viewport, without specifying which one. The right-side Sheet primitive (`src/components/ui/sheet.tsx`) defaults to `w-3/4 sm:max-w-sm` per the CRM convention; many domain Sheets override to `sm:max-w-xl` / `sm:max-w-2xl` / `sm:max-w-3xl`. Likely candidates the user might have been looking at: EoiGenerateDialog (`sm:max-w-2xl`), InterestForm Sheet, ClientForm Sheet, ReminderForm Sheet, ContactLog Sheet, audit-detail Sheet (now removed in favor of Popover), the various entity-detail Sheets. **Action when user returns:** ask which Sheet specifically + ship a width bump (likely `max-w-3xl` → `max-w-5xl` + `w-[90vw]` or similar). Captured 2026-05-24 from UAT.
> - **[medium] Global-search dropdown appears translucent — table content bleeds through** — _src/components/search/command-search.tsx:321_ — the popover wrapper uses `bg-popover` which resolves to `hsl(0 0% 100%)` (opaque white per `src/app/globals.css:189`), so on paper it should be solid. But UAT 2026-05-24 shows the table behind the dropdown clearly visible through certain rows (RECENTLY VIEWED entries + the row right before RECENT SEARCHES). Possible causes to verify with DevTools: (a) a parent topbar `backdrop-filter` / `mix-blend-mode` is colour-mixing the popover's white into the page; (b) a wrapping element has `opacity` < 1; (c) `bg-popover` is being class-merged out somewhere by `cn()`. **Repro:** open the global search on the Berths page (desktop width), look at the dropdown without typing. Fix: capture the computed background of the popover wrapper in DevTools; if `rgba(.., .., .., < 1)` or `transparent`, find what's overriding `bg-popover` and replace with an explicit `bg-white dark:bg-popover` or `bg-card`. Captured 2026-05-24 from UAT.
> - **[critical] External EOI upload doesn't advance pipeline stage from `qualified` (or any new-pipeline pre-EOI stage) — stage-advance list still hard-codes legacy 9-stage names** — _src/lib/services/external-eoi.service.ts:186-190_. The advance list reads `'open' | 'details_sent' | 'in_communication' | 'eoi_sent'` — all legacy 9-stage vocabulary. The 9→7 migration replaced these with the new vocabulary (`lead`, `berth_interest`, `qualified`, `eoi`, `reservation`, `deposit`, `contract`, …). Result: when an interest is at `qualified` (the canonical pre-EOI stage in the new pipeline) and a rep uploads an externally-signed EOI, the document IS filed and `eoiStatus` flips to `signed`, but `pipelineStage` stays at `qualified`. Downstream side effects: the EOI / Reservation / Contract tabs render based on stage-reached gates, so they stay hidden; the milestone strip + activity feed report the deal as still pre-EOI even though the EOI is signed; the pipeline funnel + Pipeline Value tile undercount. Reproduced 2026-05-24 against `interests.id=a79d929e-6af7-4d54-a56e-fe3c94a5e3d8` (Matthew Ciaccio, berths A2/A3/A4) — `pipeline_stage=qualified`, `eoi_status=signed`, `date_eoi_signed=2026-05-24` confirmed via direct DB query post-upload.
> - **Fix:** rewrite the advance list against the current vocabulary in `src/lib/db/schema/interests.ts` (pipeline stage enum) — every pre-EOI stage (`lead`, `berth_interest`, `qualified`, `eoi`) should flip to `eoi_signed` (or whichever the post-EOI signed-stage name is in the current vocab); stages at or past signing (`reservation`, `deposit`, `contract`, `won`, `lost`) should stay put. Better: invert the gate — define `PRE_EOI_STAGES = [...] as const` near the pipeline enum and check membership; survives future renames.
> - **Audit cousin call sites:** every other service that gates on legacy stage names. Quick grep targets: `'open' | 'details_sent' | 'in_communication' | 'eoi_sent'`, `pipelineStage === '`, anywhere stages are checked by literal. Likely candidates: `documents.service.ts` (auto-deposit chain), `external-signing.service.ts`, the Documenso webhook handlers (`handleDocumentCompleted`, `handleRecipientSigned`), the rules engine (`berth-rules-engine.ts`), the auto-advance code, the deposit-paid handler, the contract-signed handler. **Bundle the audit + fix into one PR** so all legacy stage references die together.
> - **Backfill:** existing interests at `qualified` (or other pre-EOI stages) with `eoi_status='signed'` should be retroactively flipped to `eoi_signed`. One-off script — read all interests where `eoi_status='signed' AND pipeline_stage IN ('lead','berth_interest','qualified','eoi')`, set `pipeline_stage='eoi_signed'`, audit-log each as `kind: 'retroactive_stage_alignment'`.
> - **Effort:** ~1-1.5h for the external-eoi fix + grep audit + the 4-5 sibling fixes. ~30 min backfill script + dry-run. ~30 min vitest covering the new advance gate across every pre-EOI stage. **Severity: critical** — silent pipeline-state corruption affecting every external-EOI upload from the new-vocab pre-EOI stages. Captured 2026-05-24 from UAT.
> - **SHIPPED in this session:**
> - `external-eoi.service.ts` advance list rewritten to canonical `enquiry/qualified/nurturing` → `eoi`; target stage corrected from legacy `'eoi_signed'` to canonical `'eoi'`; now also writes `eoiDocStatus='signed'` alongside `eoiStatus='signed'`.
> - `public-interest.service.ts:233` + `api/public/interests/route.ts:60` flipped `pipelineStage:'open'` → `'enquiry'` for new public interests.
> - `interests.service.ts:1139` legacy `'open'` gate → canonical `'enquiry'`.
> - Display fallbacks canonicalized: `dashboard.service.ts:208`, `dashboard-report-data.service.ts:399`, `pdf/templates/interest-summary.tsx:77`, `pdf/templates/client-summary.tsx:134`, `components/interests/interest-picker.tsx:62`, `api/v1/interests/[id]/timeline/route.ts:225-232` — all now route through `canonicalizeStage()` / `stageLabelFor()` instead of falling back to the legacy `'open'` literal.
> - `inline-stage-picker.tsx` stale comments referencing `'open'` updated to `enquiry` to match the actual logic.
> - Backfill + tests next. NOT YET shipped: rules engine + Documenso webhook handlers' stage references (pending re-audit; if any legacy gates remain there, they'll be folded into the same PR).
> - **[medium] Clicking "Upload signed copy" on ActiveEoiCard renders a blank interest detail body + "Unknown Client" header (transient client-side cache race, NOT server-side data loss)** — _src/components/interests/interest-eoi-tab.tsx:200-204_ (`<ExternalEoiUploadDialog open={uploadSignedOpen} ... interestId={interestId} />`) + _src/components/interests/external-eoi-upload-dialog.tsx_ + _src/components/interests/active-eoi-card.tsx_ (the trigger that flips `uploadSignedOpen`). UAT 2026-05-24: on an interest that has an active generated EOI, clicking "Upload signed copy" from ActiveEoiCard caused the entire interest detail body to disappear — only the header (with "Unknown Client" instead of the actual client name) + the tab strip rendered, with no Overview / EOI / etc. content below. Screenshot captured in chat.
> - **Symptoms to triage when reproducing:**
> - Client name resolved to "Unknown Client" — suggests the interest's parent loader returned a row where `clientName` / `client.fullName` was null. May be pre-existing on this specific test interest (orphaned client FK?) OR may be a side effect of the dialog mount triggering a re-fetch with bad params.
> - Whole tab body blank below the tab strip — suggests an error boundary unmounted the tab content. Likely candidates: `ExternalEoiUploadDialog` throws during render when an active EOI already exists (the dialog may not have been built with the "EOI already exists" path in mind — it's typically used for the empty-state "Mark externally signed" entry on EmptyEoiState, not on top of an ActiveEoiCard).
> - "2 Issues" badge bottom-left = runtime error counter (Comet / React DevTools).
> - **Investigation path:**
> - Open browser console + Network tab, repro, capture the error stack and any failed `/api/v1/interests/{id}` request.
> - Diff `ExternalEoiUploadDialog`'s expected props vs what `interest-eoi-tab.tsx:200-204` passes — confirm `onSuccess` (or any required prop) isn't accidentally missing on the active-EOI-trigger path.
> - Check whether the dialog calls a service that requires an EOI doc to NOT exist (e.g. an early validation in `markExternallySigned` that throws "EOI already exists").
> - Verify whether the "Unknown Client" state pre-existed (try the same flow on a different interest with a real client) — if yes, the bug is purely in the dialog/error-boundary chain; if no, the dialog mount IS what's triggering the data loss.
> - **Workaround:** none — the upload path is blocked when an active EOI exists. Reps would have to cancel the generated EOI first (manual cancel via the EOI tab's cancel-document flow), then mark externally signed from the empty state.
> - **Severity high:** blocks a real workflow ("client signed offline; let's record it on top of the EOI we already generated"). Doesn't lose data but renders the page unusable until refresh.
> - **Pairs with:** the related Bucket 2 finding to auto-cancel the generated EOI when an external upload happens — both touch the same "two EOIs at once" workflow gap. Captured 2026-05-24 from UAT.
-1. **[high] BulkAddBerthsWizard side-pontoon dropdown uses a wrong, locally-defined enum (not the canonical / admin-editable vocabulary)** — _src/components/admin/bulk-add-berths-wizard.tsx:42_ — the wizard hard-codes `const SIDE_PONTOON_OPTIONS = ['Port', 'Starboard', 'Bow', 'Stern', '']` (nautical directions). The **actual** canonical list in _src/lib/constants.ts:187_ `BERTH_SIDE_PONTOON_OPTIONS` is: `'No', 'Quay SB', 'Quay PT', 'Quay SB, Yes PT', 'Quay PT, Yes SB', 'Yes SB', 'Yes PT', 'Yes SB, PT', 'Finger SB', 'Finger PT'` — these match the original NocoDB enum + the single-berth edit form + EOI/contract surfaces. Reps using the bulk wizard end up writing `side_pontoon='Port'` / `'Starboard'` etc. to the DB — values that no other surface in the app produces or filters on. Filtering / reporting / search across the same column gives misleading results because the data has two parallel vocabularies.
> - **Additional problem:** the codebase has a full per-port vocabularies system (_src/lib/vocabularies.ts_) where `berth_side_pontoon_options` is registered as admin-editable, with defaults sourced from `BERTH_SIDE_PONTOON_OPTIONS`. The wizard not only uses the wrong list — it bypasses the admin-editability entirely. Even after fixing the values, admins won't be able to tune the list per-port unless the wizard reads through `getVocabulary('berth_side_pontoon_options')` like other surfaces should.