> **Status:** living doc — _started 2026-05-18, evolving across many sessions_. Single source of truth for everything the manual Playwright + React-Grab UAT pass surfaces, regardless of which day it landed on.
>
> **Companion to:** [2026-05-18 Full Codebase Audit](./2026-05-18-full-codebase-audit.md)
>
> **Methodology:** Live Playwright + React Grab walkthrough of the running CRM (default viewport). Findings dropped into chat are appended here in the matching bucket with file:line evidence where available. Cross-references annotated as `see Audit X#N` (and back-referenced in the audit doc as `→ confirmed in manual #N`).
>
> **Severity legend (for bugs):**
>
> - `critical` — data loss, security breach, multi-tenant leak, or hard block on a core flow
> - `high` — broken golden path, visible-to-customer regression, or silent prod no-op
> **Bucket 2 (Wave F):** Radio field type for admin registry + adopted on `eoi_send_mode` and `documenso_signing_order`. Include-yacht toggle on EOI generate dialog (blanks Section 3 even when a yacht is linked; choice recorded in audit log). External-EOI auto-cancel: replace-or-keep radio shows when a generated EOI is active; replace path voids the upstream Documenso envelope + flips the prior doc to cancelled before the new doc lands.
> **Bucket 4:** Sheet width sweep (Sheet primitive update covers every site). External-EOI dialog cache collision: dialog was caching `{data:…}` on the same key the parent unwraps, blanking the page on open — fix unwraps to match. External-EOI advance-gate regression test (7 cases). Search popover defensive opaque background. EntityFolderView visual overhaul: shared FileIcon mapping for type-specific colours + inline "Signed" pill from `signedFromDocumentId`.
> **Audit-cleanup callouts:** "dock-letters entity" (B2 Wave G item) was already shipped in `431375d` (D25). "Email-test endpoints" was already shipped (registry + endpoint + admin card all exist). "Cancel doc delete-vs-keep" was already shipped (cancelMode plumbed through `cancelDocument` + dialog adoption complete). These three were misclassified as queued.
>
> **Deferred / queued for follow-up sessions (~110 h):**
> **[Captured 2026-05-22 — visual breakpoint audit, 5 viewports × 20 surfaces via Playwright MCP. Screenshots local at `tmp/visual-audit-2026-05-22/<surface>/<viewport>.png`. Tablet tier (768-1023) infrastructure + dashboard PageHeader stacking shipped in `6d665d0`; findings below are the residue surfaced after that ship lit up the tier.]**
> - **SHIPPED in 2f1e1b5:** Tablet topbar logo trigger doesn't render visibly — center grid column changed to `minmax(280px, 800px)` at base with `lg:` override back to 420px min; search-container `sm:-translate-x-...` gated to `lg:` so it only kicks in when sidebar is inline. Verified at 768 — hamburger affordance visible top-left.
> - **SHIPPED in 2f1e1b5:** Dashboard title strip crushed at 1024 viewport — PageHeader horizontal-stack breakpoint moved from `lg:` (1024) to `xl:` (1280) so the strip stays stacked through tablet AND narrowest desktop width. Verified at 1024 — title reads cleanly with action row stacked below.
> - **SHIPPED in 2f1e1b5 + follow-up:** `useIsMobile()` call-site audit — only 4 callers: pipeline-chart + pipeline-funnel-chart correctly want `tier !== desktop` (short-label x-axis applies on tablet too, kept as-is); date-picker + date-time-picker were strict mobile-only and now use `useViewportTier() === 'mobile'` so tablet gets the desktop Popover Calendar instead of the native input.
> - **Rename "Mark in EOI bundle" + add tooltip** — _src/components/interests/linked-berths-list.tsx (or wherever the toggle lives)_ — the toggle controls `interest_berths.is_in_eoi_bundle` (per CLAUDE.md), which decides _which_ of the deal's berths the signed EOI document actually commits to. Today the rep sees a label they can't decode. Rename to something like "Include in EOI" + add an info-tooltip popover explaining "Berths flagged here are covered by the EOI signature. A deal can flag a subset (e.g. 2 of 3 linked berths)." ~10 min. **SHIPPED in db51106:** label renamed to "Include in EOI"; existing tooltip already explained the bundle-vs-signature distinction.
> - **Lower supplemental-info-request link TTL to ~2 weeks** — _src/lib/services/_ (token model) — link currently expires ~1 month out (`Wed, 17 Jun 2026` shown for an email sent May 18 = ~30 days). User wants ~14 days. Single constant change. ~5 min. **SHIPPED in db51106:** `TOKEN_TTL_DAYS` 30 → 14 in supplemental-forms.service.
> - **Admin Documenso settings: surface env-fallback state** — _src/app/(dashboard)/[portSlug]/admin/_ (Documenso settings page) — `getPortDocumensoConfig` already does the right thing (`adminValue ?? env.DOCUMENSO_API_KEY ?? ''`), but the admin UI doesn't show which fields are filled by the admin entry vs. silently falling back to env. Caused an in-session diagnosis loop where the operator had entered creds on Port Amador but was generating EOIs on Port Nimara — Port Nimara's admin row was empty, so it fell back to a stale env key and threw 401. Recommend a small "Using fallback from env" / "Per-port override active" pill next to each Documenso settings field so the operator can see at a glance which scope is in effect. ~30 min. **SHIPPED in e33313b:** collapsed `V2_FEATURE_FIELDS` + `CONTRACT_RESERVATION_FIELDS` (legacy `SettingsFormCard`) into `RegistryDrivenForm` sections (`documenso.behavior` + the existing `documenso.templates`). Every Documenso setting now flows through the registry path that surfaces the env-fallback / port / global source badge per field via `/api/v1/admin/settings/resolved`. EOI generation card retitled to "Templates & signing pathway" since `documenso.templates` covers EOI + reservation + contract template IDs.
> - **InterestDocumentsTab label clarity** — _src/components/interests/interest-documents-tab.tsx_ — the tab has two sections: "Legal documents" (Documenso envelopes — EOI / Reservation / Contract, signature-driven) and "Attachments" (general file uploads). "Legal documents" is misleading — the section is scoped to _signature envelopes_, not any legal doc. A rep uploading externally-signed PDFs (lawyer-prepared addenda, etc.) currently goes into Attachments — fine, but the label gap suggests reps expect "Legal documents" to accept external uploads too. Two paths: (a) rename "Legal documents" → "Signature documents" (or "Contracts & EOI") to scope it correctly, OR (b) allow external uploads into that section (more disruptive — needs file-classification metadata). ~15 min for rename + tooltip; ~2 h for upload route. **SHIPPED (a) in 552b966:** section heading renamed to "Signature documents".
> - **Berth recommender: drop the "Tier X" prefix, keep plain-English label + add tooltip** — _src/components/interests/berth-recommender-panel.tsx:181_ (the pill render) and _:94-99_ (`TIER_LABELS` map) — the pill currently renders `Tier A · Open` / `Tier B · Fall-through` / `Tier C · Active interest` / `Tier D · Late stage`. The four tier letters are internal taxonomy from `berth-recommender.service.ts` (A = never had interest, B = past fall-through, C = active interest, D = active in late stage); reps don't speak in tier letters and the suffix label already carries the meaning. Fix: (1) drop the `Tier {rec.tier} · ` prefix in the rendered pill — show just `tier.label` (e.g. "Open" / "Fall-through" / "Active interest" / "Late stage") so the chip is self-explanatory. (2) Wrap the pill in a `Popover` (click) or `Tooltip` (hover) that explains the four-state ladder in plain English: "Recommender state — **Open**: never had interest. **Fall-through**: prior interest didn't close (warm). **Active interest**: another deal is in play. **Late stage**: another deal is near-sold." (3) Optional: a small `?` icon next to the chip so the tooltip is discoverable without hovering. The internal `Tier` type stays as-is in the service (it has semantic value in the SQL ladder + admin settings); only the UI label changes. ~15 min. Captured 2026-05-18 from UAT. **SHIPPED in 203f543:** pill is now a Popover trigger with the plain-English label + HelpCircle icon; popover content explains the 4-state ladder.
> - **ChartCard: center the chart vertically when grid row is taller than the chart** — _src/components/dashboard/chart-card.tsx_ — every chart widget (`pipeline-funnel`, `occupancy-timeline`, `lead-source`, `berth-status`, `source-conversion`, …) wraps a fixed-height `ResponsiveContainer` (240-280px) inside `ChartCard`. The Card is `h-full` (stretches to its grid-row height) but the inner content keeps its 240-280px and pins to the top — when a neighbour card in the same row is taller (e.g. Pipeline Value with its full per-stage breakdown), the chart card has visible empty space below the chart. Fix: convert `ChartCard` to a flex-column (`<Card className="h-full flex flex-col">`); `CardHeader` keeps natural height; `CardContent` gets `flex-1 flex items-center` so the chart's wrapping div sits vertically centered in the remaining space. ResponsiveContainer stays at its declared fixed height. Affects all chart widgets via one wrapper change — no per-chart edits. ~10 min. Captured 2026-05-18 from UAT. **SHIPPED in 203f543.**
> - **UploadForSigningDialog feels cramped — fix inner content distribution + right-size the dialog** — _src/components/documents/upload-for-signing-dialog.tsx:166_ (currently `max-w-5xl` = 1024px) + the recipient-row + form fields inside DialogBody. Visual symptom: dialog renders at full 5xl width but inner content clusters on the left ~60% with truncated email field (`email@examp...` clipped), narrow Document title input, tiny 4-row Optional message textarea, and massive whitespace to the right. Combination makes the dialog feel narrow AND empty.
> - **Fix:**
> - **(a) Right-size the dialog:** drop to `max-w-3xl` (768px) — content fills naturally instead of swimming in 5xl.
> - **(b) Recipient row flex distribution:** `Name` input → `flex-1`, `email` input → `flex-[2]` (~2x name's width — emails are longer), role select → `w-32 shrink-0`, delete icon → `shrink-0`. Today every field is at its intrinsic width with no flex hint, so the row doesn't fill horizontal space.
> - **(c) Document title + Optional message inputs:** make sure they have `w-full` on the wrapper so they span the dialog's content width.
> - **(d) Optional message textarea:** bump rows from 4 → 6 minimum (`rows={6}` or `min-h-[8rem]`) so reps writing real messages have room.
> - **(e) Audit the other steps of the wizard** (select-file, place-fields) for the same content-distribution issues since they share DialogBody.
> - **Effort:** ~20-30 min. Captured 2026-05-21 from UAT. **Pairs nicely with:** the platform-wide form-error UX work (Bucket 2) — both touch how form content is presented in dialogs.
> - **SHIPPED (width + recipient row + textarea) in 203f543:** dialog widened to `max-w-[1400px] w-[95vw]` so the place-fields step gets the room it needs; recipient row swapped from `grid-cols-12` to a flex layout (Name `flex-1`, Email `flex-[2]`, Role `w-40 shrink-0`, delete `shrink-0`); invitation-message textarea bumped from 3 → 6 rows. Step-adaptive sizing skipped — the new wider dialog works for all three steps without per-step gymnastics.
> - **ColumnPicker: add "Hide all columns" symmetric to "Show all columns"** — _src/components/shared/column-picker.tsx:58-60 (`showAll()`) + 116-123 (button render)_ — current picker has a "Show all columns" footer item that clears the hidden set. Add a parallel `hideAll()` that sets `hidden = columns.filter(c => !c.alwaysVisible).map(c => c.id)` — hides every toggleable column while preserving `alwaysVisible` ones. Render a "Hide all columns" footer item next to "Show all columns" with the same visibility gate (only shown when ≥1 toggleable column is currently visible, mirroring the `canShowAll` logic). Since column-picker is shared across every DataTable surface (berths, clients, interests, yachts, companies, reservations, invoices, audit-log, expenses), the fix lands platform-wide automatically. ~5 min. Captured 2026-05-21 from UAT. **SHIPPED in 8f42940:** `hideAll()` + symmetric `canHideAll` gate added; both items render under the same separator.
> - **SHIPPED (core) in 03a7521 (K40):** Resolved endpoint widened to accept `?keys=k1,k2,...` so checklist batch-resolves heterogenous registry keys through port → global → env → default in one round-trip. Captures dominant source per step ("env fallback", "global default", "built-in default") surfaced inline under green tick so super-admins see when a step relies on env rather than per-port override. Compound-key gates report weakest sub-key's source. Topbar banner / dashboard tile / weekly nudge / celebration sub-items remain queued. **OnboardingChecklist: auto-check uses raw setting-row presence, not resolver chain → ports using env fallback or global config never auto-tick + super_admin discoverability gap** — _src/components/admin/onboarding-checklist.tsx:32-105 (STEPS def)_ + _src/lib/services/port-config.ts_ (the resolver chain like `getPortDocumensoConfig`) + new dashboard tile + new topbar banner for the discoverability half. Two linked bugs surfaced UAT 2026-05-21.
> - **(a) [bug] Auto-check sentinels are too strict.** Examples:
> - Email step (line 46) checks `smtp_host_override` — only fires when port has its own override row. Ports using global SMTP (the common case) never auto-tick even though email works.
> - Documenso step (lines 58-63) requires ALL of 4 port-level overrides. Per CLAUDE.md, Documenso supports env fallback (`getPortDocumensoConfig` does `adminValue ?? env.DOCUMENSO_API_KEY`), so a working port using env config registers as not-onboarded forever.
> - Same pattern likely for storage, settings, etc. — any setting with a resolver chain falls into this trap.
> - **Fix:** replace each `autoCheckSettingKey` with an `autoCheckResolver` function (named import from `src/lib/services/port-config.ts` etc.) that runs the full resolver chain and returns `true` when the functional config is complete. New OnboardingStep shape: `{ id, label, description, href, autoCheckResolver?: (portId) => Promise<boolean> }`. Sentinels stay for steps where direct setting-row presence IS the truth (e.g. branding logo URL).
> - Belt-and-braces: surface what's resolving from where directly in the step row (e.g. "Email: ✓ Using global SMTP" vs "Email: ✓ Per-port override"). Closes the "why is this checked?" gap for admins later.
> - **(b) [feature] Super_admin discoverability — nudge until onboarding hits 100%.** Today the checklist only appears on the one admin onboarding page; a super_admin who skips that page never sees it. Multi-surface nudges:
> - **Topbar banner** when onboarding < 100% — slim chip showing "Setup X% complete · Continue →" (links back to /admin/onboarding). Dismissible per-session (returns next login). Only visible to super_admin.
> - **Dashboard rail tile** "Continue setup" — small card on the dashboard widget rail showing the next incomplete step + a button. Disappears entirely at 100%.
> - **In-app notification (existing notification infra)** — fires once per week per super_admin until 100%, with a deep-link back to the checklist. "Your setup is X% complete — N items remaining."
> - **Onboarding-complete celebration** — small toast + a one-time 🎉 highlight when the 100th item ticks. Acknowledges the finish-line so the nudges going silent feels intentional, not just a bug.
> - **Permission gating:** all surfaces gate on `super_admin` (or whatever role the onboarding page itself is gated on) so non-super-admins don't see noise about settings they can't change.
> - **Effort:** ~3-4h for (a) (resolver-chain audit + 6-8 step migrations + tests) + ~3-4h for (b) (topbar banner + dashboard tile + notification job + celebration). Total ~6-8h. Captured 2026-05-21 from UAT.
> - **Agent audit (a11y + i18n) — 2026-05-21 — 27 findings bundled** — read-only Opus-agent pass over login/dashboard/interest-detail/client-detail/berth-detail/public-form/portal/admin surfaces. Ship as themed sub-PRs, not one mega-PR.
> - **a11y — discrete fixes (~3-4h total):**
> - Add `aria-label="Row actions for {name}"` on icon-only kebab triggers — _interest-columns.tsx:296_, _client-columns.tsx:301_, _berth-columns.tsx:175_. ~10min.
> - Add `aria-expanded` + `aria-controls` on the "Show/Hide upcoming milestones" disclosure — _interest-tabs.tsx:484-494_. ~5min.
> - Same for recommender "Hide/Add filters" — _berth-recommender-panel.tsx:466-471_. ~3min.
> - Fix BrandedAuthShell logo `alt` default (`'Sign in'` shows on every page) — use `alt=""` when no port name OR pass per-page override — _branded-auth-shell.tsx:32,58_. ~10min.
> - Mark PDF logo crop image decorative (`alt=""`) — _pdf-logo-uploader.tsx:312-318_. ~3min.
> - Wrap "Loading…" auth fallbacks in `role="status" aria-live="polite"` — _set-password/page.tsx:107_, _portal/activate/page.tsx:9-11_, _supplemental-info/[token]/page.tsx:140-147_. ~10min. **SHIPPED in 05e727f:** all three sites wrapped; supplemental-info also gains sr-only "Loading" copy since only a spinner was visible.
> - Link set-password hint via `aria-describedby` — _set-password/page.tsx:147_. ~3min. **SHIPPED in 05e727f:** password input now `aria-describedby="password-hint"` linked to the requirements `<p>`.
> - `text-[#007bff]` 12px link below AA contrast on auth pages — darken to `#0058b3` or always-underline — _login/set-password/reset-password pages_. ~5min. **Severity: medium** (WCAG 1.4.1 violation). **SHIPPED in ae8867d:** darkened to `#0058b3` AND always-underlined (belt + braces). Button backgrounds left at `#007bff` since white-text-on-blue at button sizes passes AA.
> - `next-intl` is wired but NEVER used — zero `useTranslations()` calls in src/. Decision: commit to i18n migration OR rip out the dead infrastructure. Holding both is tech-debt. ~scope depends on commitment.
> - Naive ternary pluralization (`count === 1 ? 'X' : 'Xs'`) across 15+ surfaces — won't translate to Polish/Arabic/Russian. Route through `Intl.PluralRules` / next-intl's `t.rich`. ~1h after i18n decision lands.
> - **Zero use of CSS logical properties — 1,173 instances of `ml-/mr-/pl-/pr-/text-left/text-right` and zero `ms-/me-/ps-/pe-/text-start/text-end`.** RTL support would require global refactor. If RTL is roadmap-bound: adopt logical properties going forward + add lint rule. ~30min for the lint guard; multi-day if RTL is real. **Note only for now.**
> - **Platform patterns (Bucket 3):**
> - **Form validation never sets `aria-invalid` / `role="alert"` / `aria-live`** across every react-hook-form caller. SR users get zero feedback on validation failure. Build a shared `<FieldError>` component emitting both visible text + ARIA. Sweep all forms. ~2h. **Bundles with the Bucket 2 form-error UX finding** — same surfaces, same primitive.
> - **Icon-only buttons inconsistent — ~50% have `aria-label`, rest have nothing or only `sr-only` text.** Add `jsx-a11y/control-has-associated-label` lint rule + sweep. ~1h.
> - **Sweep: remove em-dashes from all user-facing copy (toast messages, button labels, helper text, banners, dialog descriptions, empty states)** — em-dashes (`—`) feel AI-generated and add visual noise; user reads them as "Claude wrote this." Replace with periods, commas, colons, or simple hyphens depending on context. **Scope:** _src/components_ (every UI string), _src/lib/email/templates_ (email body copy), _src/lib/templates_ (merge-field labels + EOI body), _src/app_ (page-level copy), public form copy, error messages from `src/lib/errors`. **Out of scope (keep em-dashes):** code comments, JSDoc, audit-log entries, structured logging, this UAT findings doc itself (internal docs are fine). **Method:** grep `—` across `src/`, manually triage each match (some are inside JSX, some inside string literals); replace per context. Heuristic: if a user could see the character, replace it. **Effort:** ~2-3h depending on hit count (rough estimate 200-400 instances). Captured 2026-05-21 from UAT. **Going forward:** add an ESLint rule banning `—` in JSX text + string literals inside `src/components` so new code doesn't reintroduce them.
> - **SHIPPED (lint guard only) in 52342ee:** `no-restricted-syntax` rule on `JSXText[value=/—/]` scoped to `src/components` + `src/app`, set to `warn`. 111 existing instances flagged as warnings — sweep remains parked.
> - **[Captured 2026-05-22] Dev-server unreachable from a phone on the LAN — three bundled fixes shipped in `be261f3`:** (1) `getPortBrandingConfig` normalizes localhost / private-LAN host prefixes (192.168._, 10._, 172.16-31._, 127._, 0.0.0.0) on read so an in-app `<img src>` resolves against current origin; both branding upload routes store path-only going forward; new `absolutizeBrandingUrl()` helper re-absolutizes for email shells; DB backfill stripped the 2 existing rows. (2) Socket.IO client connects via `io()` no-URL (window.location.origin) instead of `NEXT_PUBLIC_APP_URL`; server CORS uses a function that allows localhost + private-LAN in dev, locks to APP*URL in prod (mirrors better-auth trustedOrigins). (3) Portal logout route builds redirect from `req.url` instead of `env.APP_URL`. next.config `allowedDevOrigins` widened from a hardcoded IP to 192.168.* / 10.\_ / 172.16-31.\* wildcards so HMR works across networks without per-network edit (without HMR the login form's React click handler never hydrates and the form falls back to GET, leaking the password into the URL — the symptom that caught this).
> - **[Captured 2026-05-22] Dashboard build crashed with "Module not found: Can't resolve 'fs'" via dashboard-shell → export-dashboard-pdf-button → dashboard-report-data.service → dashboard.service → @/lib/db → postgres. SHIPPED in `adf4e2b`:** split pure data + types (`PDF_DASHBOARD_WIDGET_IDS`, `PDF_DASHBOARD_WIDGETS`, `PdfDashboardWidgetId`, `PdfDashboardWidgetOption`) into new `src/lib/services/dashboard-report-widgets.ts`; client button imports from there; service re-exports from new file for backwards compat. The resolver (`resolveDashboardReportData`) stays in the service module since it's server-only.
> - **Custom-field form: "Sort Order" needs an explainer tooltip — example of a broader gap** — _src/components/admin/custom-fields/custom-field-form.tsx:298-308_ — surfaces a specific instance of a platform-wide gap: see the next finding for the full sweep. **SHIPPED in 552b966:** Sort Order now uses the FieldLabel primitive (PR4.2) with explainer tooltip. First adoption of the primitive; platform-wide sweep remains parked.
> - **DocumentList DocRow kebab: add "Download" action** — _src/components/documents/document-list.tsx:86-109_ — current kebab has Send-for-Signing (draft only), Move-to-folder, Delete. No Download. Reps reviewing a signed doc from the interest's documents tab have to navigate into the document detail to download. Add a `<DropdownMenuItem>` at the top of the menu when `doc.signedFileId` is set (or `doc.fileId` for non-Documenso docs like manual uploads), wired to the same `apiFetch('/api/v1/files/[id]/download')` + anchor-click pattern used elsewhere. Permission-gate by `files.download` if that perm exists. ~10 min. Captured 2026-05-21 from UAT. **SHIPPED in 52342ee:** DocRow now renders Download at the top of the kebab when `signedFileId` is set; wired via the existing `triggerUrlDownload` helper from PR1.
> - **InterestEoiTab "Open" link too ambiguous — relabel to "Open in Documents"** — _src/components/interests/interest-eoi-tab.tsx:163_ — the link in the EOI history list goes to `/${portSlug}/documents/${d.id}` (Documents Hub doc detail) but the label just says "Open" + an external-link icon. Rep can't tell where it goes until they hover. Change to `Open in Documents` (or `View in Documents`). Apply the same idiom anywhere else a cross-section navigation link uses bare "Open" — quick grep + sweep. ~5 min. Captured 2026-05-21 from UAT. **SHIPPED in c6dcf49.**
> - **PaymentsSection: deprioritize layout — move below milestones + collapse-by-default at Reservation** — _src/components/interests/interest-tabs.tsx:633 + 846-852_ (current `showPaymentsSection = reservationStageReached` + renders at line 847, ABOVE the milestone strip at line 859+) + _src/components/interests/payments-section.tsx_ (the section component itself). Today: hidden pre-Reservation (correct ✓), shows as a full expanded card at Reservation+ sitting above the milestone strip. Section is reference/history once expected — milestone work (active step) should be the rep's primary visual focus, not deposits-tracking.
> - **Fix (three states):**
> - **Pre-Reservation:** keep hidden (no change).
> - **Reservation+ stage, no deposits recorded yet:** render as a slim collapsed bar at the bottom of OverviewTab (below the milestone strip + below the Berth requirements / Tags / Latest note grid). Bar shows `Deposits · Not received yet` + a `Track deposit →` CTA that expands the section in place. Sits last on the page so it doesn't pull eye away from the active milestone.
> - **Reservation+ stage, deposits exist:** same below-the-milestones placement, but the collapsed bar carries a summary chip: `Deposits · $10,000 received · 2 payments · Expand`. Click expands the full PaymentsSection inline. The summary chip uses the existing currency-format helper.
> - **Render order change in interest-tabs.tsx:** lift the PaymentsSection mount from its current position (line 846-852, above milestones) to AFTER the milestone strip + AFTER the OverviewTab grid (below "Latest note", Tags, Berth requirements). It becomes the last visual element on the OverviewTab.
> - **Collapse state:** persist per-interest via Zustand or react-query cache (so re-opening the same deal remembers the rep's last expand/collapse). Default collapsed unless a deposit was added in this session.
> - **WatchersCard empty state missing bottom padding** — _src/components/documents/document-detail.tsx:546_ — `<p className="text-xs text-muted-foreground">No one is watching this document yet.</p>` has no margin while the sibling populated `<ul>` at line 548 has `mb-3 space-y-1`. Empty state text sits flush against the add-watcher form below. Add `mb-3` to the empty-state `<p>` to match. ~30s. Captured 2026-05-21 from UAT. **SHIPPED in 52342ee.** Further bumped to `mb-4 pb-1` in **e33313b** after a follow-up UAT noted the lines still read tight.
> - **DocumentDetail Interest link should show berth(s), not duplicate the client name** — _src/components/documents/document-detail.tsx:96 (type) + 237-241 (linked-entity row builder)_ + the document-detail API service that hydrates `linked.interest`. Today renders `Client: Matthew Ciaccio · Interest: Matthew Ciaccio` — visually redundant, and the Interest link carries no distinct information. Should be `Client: Matthew Ciaccio · Interest: A1-A3, B5-B7` (berth range via the existing `formatBerthRange()` helper from `src/lib/templates/berth-range.ts`, same idiom as the locked folder-naming convention and the external-EOI default title).
> - **Backend:** swap the response payload's `interest: { id, clientName }` → `interest: { id, berthLabel }` where `berthLabel` is derived in the service layer from the interest's primary or in-bundle berths. Falls back to "No berths linked" when no berths are attached.
> - **Frontend:** change line 241 from `sub: linked.interest.clientName` → `sub: linked.interest.berthLabel ?? 'No berths linked'`.
> - **Effort:** ~15-20 min including type updates + a vitest covering the multi-berth + no-berths paths. Captured 2026-05-21 from UAT. Cross-ref: pairs with the shared title-derivation helper note in the external-EOI bundle (Bucket 2) — single `deriveBerthLabel(interest)` helper used everywhere.
> - **Platform-wide `<FileInputButton>` primitive — replace 7 raw `<Input type="file">` instances with native browser-default styling** — _new_ `src/components/ui/file-input-button.tsx` + sweep — `<input type="file">` rendered without a wrapper shows the browser-default "Choose File / No file chosen" UI, which looks raw and inconsistent across Chromium / Safari / Firefox / Comet. We already use the correct idiom in `expense-form-dialog.tsx:389` (Button + hidden input + filename row) and `file-upload-zone.tsx`, but 7 other call sites still use the raw pattern.
> - **Component shape:** `<FileInputButton accept={...} multiple={...} onFilesPicked={(files) => ...} label="Upload PDF" icon={<Upload />} variant="outline" size="sm" />`. Renders a styled Button (Upload icon + label) + hidden `<input type="file">` underneath. Optional: after-pick filename row with X to clear, mirroring the expense form's pattern.
> - **Sweep:** drop-in replacement at each of the 7 sites. Pair with the platform-wide file-preview work (Bucket 3) so picker-then-preview becomes consistent everywhere.
> - **Effort:** ~10 min for the primitive; ~30-45 min for the 7-site sweep. Total ~1h. Captured 2026-05-21 from UAT.
> - **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.
> - **Q7 permissions:** Two perms — `reports.export` (generate + download; everyone with current access) and `reports.admin` (manages BOTH templates AND schedules; super_admin only by default).
> - **Q8 dashboard button:** Keep the dashboard's "Export as PDF" as a quick-path that pre-selects the Dashboard report kind at /reports with the current date range pre-filled. One-click access preserved.
> - **Q10 output formats:** PDF (primary) + CSV export of underlying data + PNG/JPEG chart snapshots. **Skip** Excel workbook and public hosted-HTML share-link for v1.
> - **Q11 metadata:** Override report title + subtitle; cover-page logo / branding swap (use another port's branding on the cover). **Skip** cover-page intro paragraph and footer/sign-off note for v1.
> - **Action:** scope into Bucket 3 items. Next step: write `docs/reports-page-design.md` covering routing, table shape (`report_runs`, `report_templates_shared`), scheduler queue + cron handler, permission seed, then split into discrete PRs.
> - **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.
3.**Client create form: Source defaults to "Manual"** — _src/components/clients/client-form.tsx_ — Source select rendered with no default in create mode, so reps had to remember to pick "Manual" every time. Now defaults to `'manual'` unless `prefill.source` is set (inquiry-inbox flow overrides to `'website'`). Fixed in this session.
4.**Client create form: primary address fields** — _src/components/clients/client-form.tsx_ — drawer previously had no address inputs, so reps had to create the client then click into the Addresses tab. Added a collapsible "Primary Address" section (street, city, postal, country, region/state) shown only in create mode; on submit, after the client POST returns the new id the form chains a `POST /api/v1/clients/{id}/addresses` with `isPrimary: true`. Address errors don't unwind the client create — a toast directs the rep to the Addresses tab. Edit mode keeps using the AddressesEditor in the detail tab. Fixed in this session.
5.**SupplementalInfoRequestButton card top padding** — _src/components/interests/supplemental-info-request-button.tsx_ — same shadcn `sm:pt-0` default-overriding bug as the KPI tiles. Replaced `p-4` with `p-4 pt-4 sm:p-6 sm:pt-6` so the header has symmetric padding on both base and `sm:` breakpoints. Fixed in this session.
6.**Qualification checklist shows evidence behind auto-ticks** — _src/lib/services/qualification.service.ts_, _src/components/interests/qualification-checklist.tsx_ — the "Dimensions confirmed" row was auto-ticking based on `desiredLengthFt/widthFt/draftFt` (or a linked yacht's dims) but never showed the rep WHAT data drove the tick, so it felt mysterious. Added an `evidence: string` field to the qualification API row + a new `computeEvidence()` helper mirroring `computeAutoSatisfied()`; UI renders `"Yacht: L × W × D ft"` or `"Desired: L × W × D ft"` in emerald under the row description when auto-satisfied. Closes the "why is this checked?" UAT finding. Fixed in this session.
7.**Recommendations tab renamed to "Berth Recommendations"** — _src/components/interests/interest-tabs.tsx_ — "Recommendations" was ambiguous once a berth was already linked (am I looking for replacements? more for the bundle?). "Berth Recommendations" reads the same regardless of state — no conditional rename needed. Fixed in this session.
8.**Berth requirements editable on Interest Overview** — _src/components/interests/interest-tabs.tsx_ — added a new "Berth requirements" section to the OverviewTab grid showing desired length / width / draft as inline-editable rows (text variant of `InlineEditableField`); expanded `InterestPatchField` to include the three dim keys. Reps can now capture / correct dims without leaving Overview, and the qualification checklist's evidence string updates in lockstep. Fixed in this session.
9.**Reminder form: preset date chips** — _src/components/reminders/reminder-form.tsx_ — Due Date input was a bare `<input type="datetime-local">`; reps had to manually pick a date/time for the 80% common cases. Added a row of quick-pick chips above the input (`In 1 hour`, `In 4 hours`, `Tomorrow`, `In 3 days`, `Next week`, `In 2 weeks`) — same idiom as the existing `snooze-dialog.tsx` presets. Day-based presets honour the user's `digestTimeOfDay` preference for hour-of-day. Fixed in this session.
10.**Consolidate "Next step" guidance into milestone card** — _src/components/interests/interest-tabs.tsx_, _src/components/interests/stage-guidance-card.tsx_ — the separate `StageGuidanceCard` and the active `MilestoneSection` had overlapping intent (both said "do X next") and the guidance card's action buttons were silently never rendered (callbacks were never wired). Removed the StageGuidanceCard mount from OverviewTab; made the milestone card's existing `Next` pill more prominent — brand-600 background, white text, "NEXT STEP" copy with a leading dot. The milestone card already owns the workflow actions (Generate EOI, etc.), so the consolidation eliminates the dual surface. Nurturing keeps a slim inline helper ("Deal is on nurture — schedule a follow-up reminder or log a contact…") since no milestone is naturally "current" while a deal is paused. `stage-guidance-card.tsx` left in the tree for potential future use but no longer mounted. Fixed in this session.
11.**Interest create form: Source defaults to 'manual'** — _src/components/interests/interest-form.tsx_ — same gap as the client form (#3). Added `source: 'manual'` to the form's RHF `defaultValues` so the Select renders with "Manual" selected on create. Inquiry / website conversion flows can later override via prefill when that path lands. Fixed in this session.
12.**Qualification checklist: highlight open items** — _src/components/interests/qualification-checklist.tsx_ — confirmed and unconfirmed rows rendered with near-identical styling, making it hard for reps to scan what's outstanding. Confirmed rows now sit in muted-foreground (still readable but de-emphasized); unconfirmed rows get a subtle amber left-border accent + `bg-warning-bg/40` tint so the rep's eye jumps to what still needs attention. Auto-satisfied rows follow confirmed styling (functionally complete). Fixed in this session.
13.**BerthRecommenderPanel: collapsible on Overview when a berth is linked** — _src/components/interests/berth-recommender-panel.tsx_, _src/components/interests/interest-tabs.tsx_ — added a `linkedBerthCount` prop; when ≥ 1 the panel mounts collapsed (header-only with a "Show recommendations" toggle button), so the LinkedBerthsList card dominates the rep's attention once a berth is picked. Network call is gated on `!collapsed && hasDimensions` so the recommender doesn't fetch options the rep won't see. The dedicated Recommendations tab keeps `linkedBerthCount` unset → always expanded (the rep navigated there explicitly). Fixed in this session.
14.**Pipeline Value tile moved from rail → chart grid** — _src/components/dashboard/widget-registry.tsx:130_ — the tile shipped in the narrow rail column but its per-stage breakdown + headline numbers + info popover needed more horizontal room to read, and the rail's reserved for reminders/alerts/glance tiles. Changed `group: 'rail'` → `'chart'` so it sits alongside the funnel/timeline/lead-source tiles. Fixed in this session.
15.**Umami v3.x integration fixed end-to-end** — _src/lib/services/umami.service.ts_, _src/app/api/v1/website-analytics/route.ts_, _src/components/website-analytics/use-website-analytics.ts_, _src/components/website-analytics/website-analytics-shell.tsx_, _src/components/website-analytics/pageviews-chart.tsx_, _src/components/dashboard/website-glance-tile.tsx_, _src/components/dashboard/widget-registry.tsx_, _src/components/ui/kpi-tile.tsx_ — entire Umami integration was built against the v1 nested response shape; v2 + v3 use a flat shape with a sibling `comparison` block. Every consumer was reading `.pageviews.value` → undefined → falling back to `0`. Probed the live instance with the configured port creds and verified the real shape, then rewrote types + readers + the dashboard tile end-to-end:
- **`UmamiMetricType` enum** dropped `'url'` (returns 400 on v3) and added `'path'`; route accepts `top-url` as a back-compat alias mapping to `path` server-side.
- **`UmamiPageviewsSeries.sessions`** marked optional — Umami v3 only returns it when the request includes a `compare` directive (we don't).
- **`WebsiteGlanceTile`** now accepts a `range` prop (was hardcoded `'today'`); widget registry passes the dashboard range through. Distinguishes error from no-data — renders "Umami unavailable" with warning icon and tooltip instead of silently showing `0` when the upstream call fails.
- **`KPITile`** delta chip now includes a `TrendingUp`/`TrendingDown`/`Minus` lucide icon so the direction is visible at a glance alongside the colour.
- **Top countries** column maps ISO codes → full country names via `getCountryName()` (was rendering raw `GP`, etc.).
- **Top pages** column maps `/` → "Homepage" inline for the root-site row.
- Service docstring updated to cite the verified v3 endpoint behaviour + the flat-shape rationale so the next reader doesn't repeat the v1-nested mistake.
-`tsc --noEmit` clean. Verified live: dashboard tile + website-analytics page both render 2,081 pageviews / 726 visitors / 872 visits / 457 bounces over 30d (the real numbers from analytics.portnimara.com). Fixed in this session.
16.**Revenue Breakdown widget removed end-to-end** — _src/components/dashboard/{revenue-breakdown-chart.tsx (deleted), widget-registry.tsx, use-analytics.ts}_, _src/app/api/v1/analytics/route.ts_, _src/lib/services/analytics.service.ts_, _tests/integration/analytics-service.test.ts_ — the "Revenue Breakdown" tile (bar chart of invoice totals by status × currency) wasn't aligned with how the org uses invoicing (no client-facing invoicing through the system — only employee expense-sheet PDFs for trip reimbursement) and was redundant once the Pipeline Value tile shipped with a weighted forecast + per-stage breakdown. Removed: widget file, dynamic import, registry entry, `useRevenue` hook, `RevenueBreakdownData` type, `MetricBase` union member, `ALL_METRICS` entry, `SnapshotData` union member, `getRevenueBreakdown` + `computeRevenueBreakdown` service functions, `refreshSnapshotsForPort` revenue branch, route dictionary entry, integration test. `RevenueReportPdf` (separate code path for the reports module) intentionally kept. `tsc --noEmit` clean. Fixed in this session.
17.**Finish CountryFlag rollout — table + filter surfaces** — _src/components/shared/country-flag.tsx (shipped this session)_ + _src/components/clients/client-columns.tsx:173_ (nationality column cell — currently renders bare ISO code; should prefix with `<CountryFlag>`) + _src/components/clients/client-filters.tsx_ (nationality filter pill — render flag next to selected country name) + _src/components/yachts/yacht-form.tsx_ (flag the yacht's country if surfaced anywhere outside the CountryCombobox transitive path) + audit any remaining `flagEmoji` or `0x1f1e6` codepoint references with `rg -n "0x1f1e6\|flagEmoji"` → expected 0 hits. Shipped this session: country-combobox + inline-country-field + addresses-editor (replaced existing emoji glyphs, which never rendered on Windows) and added flags to clients-by-country-widget / client-card / client-detail-header / website-analytics realtime + sessions + session-detail. Library: `country-flag-icons` (MIT, ~1-2 KB per flag, dynamically imported on first render, cached). Effort: ~30 min for the remaining surfaces. Captured 2026-05-22 from UAT.
- **SHIPPED in this session:** client-columns nationality cell now renders flag + name. client-filters is a free-text input (no rendered chip surface to flag). No yacht country rendering exists outside CountryCombobox. Final `rg "0x1f1e6\|flagEmoji"` returns 0 hits.
- **Follow-up fix in this session:** the original `CountryFlag` used a template-string dynamic import (`import('country-flag-icons/string/3x2/${code}')`), which silently fails in Next.js's webpack because the package's `exports` field gates each subpath. Symptom: every flag rendered as the muted placeholder box. Replaced with a single lazy `import('country-flag-icons/string/3x2')` that loads the whole index once (~1.6 MB raw / ~400 KB gzip, single chunk shared across the app), caches on a module-level promise, and lookups become synchronous after first render.
> **[Captured 2026-05-22 — visual breakpoint audit findings, medium tier]**
>
> - **Documents Hub folder rail collapses to 3-char truncation at tablet (768)** — _src/app/(dashboard)/[portSlug]/documents/page.tsx_ + the folder-rail component — at 768 viewport the rail renders folder names truncated to ~3 chars + ellipsis ("Cli...", "Co...", "Smok...", "Ya..."). Two viable shapes: (a) widen the rail's min-width to ~180px so names fit, accepting that the file panel shrinks; (b) collapse the rail to a slide-over Drawer at tablet (the file panel takes full width; a small "Folders" button toggles the drawer). (b) is more deliberate but more code. Pick (a) first; revisit if file panel feels too cramped. ~1 h for (a), ~2 h for (b). Captured 2026-05-22.
> - **Website analytics KPI cards too cramped at 1024** — _src/components/website-analytics/_ — 6 KPI tiles (Active right now / Visitors / Visits / Pageviews / Bounce rate / Visit duration) render in one row at 1024 with sidebar present, leaving each card ~120px wide. "VISIT DURATION" value truncates to `2.` with " 0%" trailing. Fix: stack into a 3+3 grid at lg, fall back to 2-col at md, single column at sm. ~30 min. Captured 2026-05-22.
> - **Pipeline Value tile per-stage rows overflow right margin at mobile (375)** — _src/components/dashboard/pipeline-value-tile.tsx_ — at 375 the per-stage row layout (label + bar + value + count/probability) crushes the right column; values like "$3,528,000" mash against the right edge of the card. Either truncate value formatting on mobile (compact: `$3.5M`) or stack the value/count/probability vertically below the bar at sm-. ~45 min. Captured 2026-05-22.
> - **Berths list at 1024 only shows 5-6 of 14 columns** — _src/components/berths/berth-columns.tsx_ — Q59 (`whitespace-nowrap` + column min-widths) was the right call for densely-typed cells, but the side effect is that at 1024 with the sidebar present (768 content area), only Mooring # / Area / Status / Latest deal stage / Active interests fit before horizontal scroll. Consider an auto-hide column policy at lg-tier: hide "Pricing valid", "Side pontoon", "Special features", etc. by default at <1280 so the table reads at a glance, with the column picker letting power users re-add. ~1.5 h. Captured 2026-05-22.
> **[Umami] Follow-ups parked at end of 2026-05-19 build session:**
>
> - **[Umami] Empty-state nudges on quiet ranges** — _src/components/website-analytics/{top-list.tsx, sessions-list.tsx, weekly-heatmap.tsx, visitor-world-map.tsx}_ — every card currently renders a flat "No data in this range" string when Umami returns nothing. Replace with a guided message that nudges the operator to expand the range — e.g. "No data in the last 7 days. Try 30d or 90d." plus a one-click button that flips the active `DateRange`. The hook stack already accepts a range setter via the URL search params, so this is purely component-level copy + a Button. ~45 min across the 4 cards. Captured 2026-05-19.
> - **SHIPPED in a7cbee0 (O52):** Sends-log "Not opened" badge carries inline tooltip explaining Apple Mail's privacy protection routes opens through Apple's proxy and can suppress this signal even when the recipient read the email. **[Umami] Apple Mail privacy disclaimer copy** — _src/app/(dashboard)/[portSlug]/admin/website-analytics/page.tsx_ — the "Track email opens" toggle helper text mentions Apple Mail pre-fetch in passing. Promote it to a bullet list under the field so admins can't miss it (Apple Mail Privacy = over-count; image-blocking clients = under-count; pixel won't fire when EMAIL_REDIRECT_TO is set). ~15 min. Captured 2026-05-19.
> - **SHIPPED in a7cbee0 (O53):** SendRow type extended with `trackOpens` / `openCount` / `firstOpenedAt`; sends-log card renders "Opened × N" badge with first-open timestamp in title, or "Not opened" when tracking on but no opens yet, or no badge when tracking was disabled for that send. **[Umami] Open-rate column on the document_sends list** — _src/components/documents/_ (find the list that renders document*sends rows; might be inside the interest detail Documents tab or in a dedicated sends-list surface), \_src/lib/services/document-sends.service.ts (listSends extension)* — Phase 4b shipped the data (`open_count` + `first_opened_at` on `document_sends`); the list UI doesn't surface it. Add an "Opened" column showing either a check + relative-time ("Opened · 2h ago · 3 opens") or an em-dash. Sort affordance optional. ~1-2 h depending on how many list surfaces exist. Captured 2026-05-19.
> - **[Umami] Verify pixel + tracked-link end-to-end with a real send** — _manual_ — flip the admin toggle on (`email_open_tracking_enabled = true` for port-nimara), send a real sales email to your own address, open it in Mail.app and Gmail web, then confirm: (a) `document_send_opens` row appears, (b) `open_count` + `first_opened_at` increment on the parent row, (c) Umami records an `email-opened` event. Same drill for `/q/<slug>` once the composer button (Bucket 3) ships. Cannot be automated — needs a real inbox. Captured 2026-05-19.
> **Outstanding (gaps on shipped work + rapid UAT capture):**
> - **Platform-wide admin-settings tooltip audit — add explainers wherever a setting isn't self-explanatory to a basic admin user** — _src/components/admin/_ (every settings/form component) + _src/components/admin/shared/registry-driven-form.tsx_ (the unified driver many admin pages use) + _src/components/ui/_ (new `<FieldLabel>` primitive bundling label + optional info-icon tooltip). The custom-field form's "Sort Order" label is one example of a recurring problem across admin pages: many fields carry ambiguous labels (Weight, Priority, Display order, Threshold, Confidence, TTL, Cap, etc.) with no inline explainer. Basic admins are forced to guess, ask another team member, or read source code.
> - **Approach (single audit pass, lots of surface area):**
> - **(a) Convention via shared primitive** — new `<FieldLabel htmlFor={...} tooltip={...}>{label}</FieldLabel>` component that renders the `<Label>` + (when `tooltip` is set) a small `<Info>` icon button that opens a `<Tooltip>` (hover on desktop, tap on mobile via Radix). Drop-in replacement for every `<Label>` in admin forms. Eliminates inconsistent tooltip styling and makes future additions trivial.
> - `src/components/admin/custom-fields/` (Sort Order — confirmed)
> - `src/components/admin/settings/settings-manager.tsx` (any setting with non-obvious unit/scale)
> - `src/components/admin/shared/registry-driven-form.tsx` — when a registry entry has a `description` already defined, it should auto-flow into the tooltip; sweep the registry definitions for missing descriptions
> - Anywhere using `<Switch>` + `<Label>` together (often pure toggle with no context)
> - **(c) Tooltip-writing guidelines** (put in a brief CLAUDE-style note inline near `<FieldLabel>`):
> - 1-2 sentences max, plain English, end with a usage tip when applicable
> - State the unit explicitly when applicable ("...in days", "...in MB", "...in feet")
> - Mention default behavior when relevant ("Leave 0 to use the system default")
> - For dangerous settings, lead with the risk ("Changing this triggers a re-index of every berth — schedule for low-traffic hours")
> - Don't restate the label; explain the **why** and **how to choose a value**
> - **(d) i18n-ready** — tooltip text routes through the existing i18n catalog so future localization passes don't need a re-audit. Where i18n keys don't exist yet, create them on the fly.
> - **Acceptance criteria:** every admin form field without an obvious meaning has a tooltip. Definition of "obvious": a label like "Name" or "Email" is self-explanatory; "Sort Order" / "Weight" / "Threshold" / "Cap" / "TTL" are not.
> - **Platform-wide form-error UX: scroll-to-first-error + focus + summary banner (29 form surfaces)** — _new_ `src/hooks/use-form-scroll-to-error.ts` + _src/components/forms/_ (form-error-summary component) + audit pass over every `useForm` + `zodResolver` caller in `src/components` (29 files including expense-form-dialog, client-form, interest-form, yacht-form, company-form, reservation forms, admin forms, …). Today's pattern: a form with validation errors renders per-field messages via `{errors.X && <p className="text-xs text-destructive">{errors.X.message}</p>}` (good), but on submit-with-errors there's no scroll-to-first-error, no focus-the-failed-field, and no summary banner — so the user just gets dropped at the top of the form with no indication of what failed. Especially bad on tall drawers/dialogs where the failing field is below the fold. Surfaced via expense-form-dialog UAT 2026-05-21.
> - **Fix shape:**
> - **(a) Shared hook** `useFormScrollToError(formMethods)` — wraps `handleSubmit` to add an `onError` callback that: (i) reads `errors` from react-hook-form, (ii) finds the first errored field's DOM node by `name` attribute (or `id`), (iii) `scrollIntoView({ block: 'center', behavior: 'smooth' })`, (iv) focuses the input (`.focus()`). For drawer/dialog content, scroll inside the scrolling container rather than the page.
> - **(b) FormErrorSummary component** — renders at the top of the form when there are ≥ 2 validation errors: a small red banner listing each failed field as an anchor link ("Amount is required · Currency is required") that on click scrolls + focuses that field. For a single error, hook-only (no banner needed — scroll handles it).
> - **(c) Audit pass:** verify every zod schema has explicit error messages on required fields (`.min(1, 'Amount is required')` not bare `.string()`); fix the bare cases. The default zod "Required" message is generic and unhelpful.
> - **(d) Consistent inline error rendering:** standardize the per-field error block into a small `<FormFieldError errors={errors} name="amount" />` helper so we don't keep open-coding the `{errors.X && <p ...>{errors.X.message}</p>}` block in every form. Migrate the existing 29 surfaces opportunistically.
> - **Mobile consideration:** on tall mobile-bottom-sheet forms, scroll-to-first-error needs to scroll the sheet content, not the page (otherwise nothing visible changes). The hook detects the scrolling ancestor at runtime.
> - **Effort:** ~3-4h end-to-end (hook + summary component + 29-form audit + zod-message fixes). Captured 2026-05-21 from UAT. **SHIPPED (primitives + first adoption) in ec6f90f:** new `useFormScrollToError` hook (handles drawer/dialog scrolling-ancestor detection) + new `<FormErrorSummary>` component (top-of-form alert, renders only when ≥2 errors). Expense-form-dialog adopts both as the validation site. Remaining ~28 form surfaces parked for follow-up sweep.
> - **Berths list "Active interests" column: static count → click/hover popover with interest details + stage-colored count chip** — _src/components/berths/berth-columns.tsx:288-297_ (current static number cell) + _src/lib/services/berths.service.ts (list endpoint extension)_ + new component `<BerthInterestsPopover berthId={...} count={...} highestStage={...} />`. Today renders just `1` / `3` / `—` — unscannable when a rep wants to know WHO has interest in a berth.
> - **Design (locked recommendation, can revisit at remediation):**
> - **Cell:** count chip (`1`, `3`) with subtle outline + hover/focus indicator. Color-coded by the **highest-active-stage** interest on the berth (e.g., border-red-500 if any at Contract, border-amber-500 at Reservation, border-emerald-500 at EOI+, neutral when only at earlier stages). Encodes stage urgency without expanding.
> - **Click/hover (desktop and mobile via Radix Popover):** opens a popover listing each active interest. Each row: client name (link to client detail) · stage badge · berth label (this berth's mooring + role: primary / in EOI bundle / specific interest) · created date · "Open interest →" link to the interest detail. Sort by stage desc so the most-progressed deal sits at top.
> - **Empty state (count = 0):** column shows `—` (no popover trigger). Today's behavior, unchanged.
> - **Mobile:** tap-to-open via Radix Popover's built-in mobile UX. Width capped at `min(360px, calc(100vw - 32px))` so the popover stays usable on small screens.
> - **Service-side:** extend the berths-list response to include `topActiveInterests: Array<{interestId, clientId, clientName, pipelineStage, isPrimary, isInEoiBundle, isSpecificInterest, createdAt}>` (cap at top 5, "View all" link in the popover footer when > 5). Single query that returns this alongside the count via `array_agg` in the existing correlated subquery — no N+1.
> - **Permission gating:** the popover row's "Open interest →" link respects `interests.view`. Client name link respects `clients.view`. Hide entire popover when neither perm is held (count chip becomes static for view-only roles).
> - **SHIPPED in 292a8b5:** new `GET /api/v1/berths/[id]/active-interests` endpoint returns up to 20 non-archived non-terminal interests linked to the berth (client name, stage, primary/EOI-bundle flags), sorted most-recently-updated first. New `<ActiveInterestsPopover>` lazy-loads on click (30s stale); rows link to the interest detail with a stage-badge tint + primary-star icon. Berth-columns cell now wraps the count number in the popover trigger. Stage-by-highest-stage cell border-tint deferred; popover delivers the same info with one click instead. **Also shipped (companion task — table density):** `DataTable` gained a `density: 'comfortable' \| 'compact'` prop; berth-list toolbar exposes a Rows3/Rows4 toggle that persists per-entity to `user_profiles.preferences.tablePreferences[entityType].density`.
> - **Interest Overview Email + Phone rows: combobox picker across client's contacts + quick-add new contact** — _src/components/interests/interest-tabs.tsx:958-1000 (Email + Phone EditableRow blocks)_ + _src/components/interests/interest-tabs.tsx:122-129 (`clientPrimaryEmail/Phone[ContactId]` types)_ + _src/lib/services/interests.service.ts (getInterestById)_ + _src/lib/services/client-contacts.service.ts_ + _new_ component `<ClientContactPicker channel="email|phone" clientId={...} selectedContactId={...} onSelect={...} />`. Today's surface shows ONLY the client's primary email + primary phone via inline editor. Two real gaps surfaced UAT 2026-05-21:
> - **Gap 1 — empty state has no quick-add:** when client has no primary contact for the channel, line 976-978 renders `<span>—</span>` with no affordance. Rep has to navigate to the Client Detail's Contacts tab to add one. Should expose `+ Add email` / `+ Add phone` inline that POSTs a new `client_contacts` row + marks isPrimary=true.
> - **Gap 2 — no multi-contact picker:** clients with multiple contacts per channel (e.g. 3 emails — personal, work, assistant) get only the primary shown. Rep can't pick which one applies to THIS deal. Picker needs a dropdown listing every contact for the channel, pre-selecting the current primary, with each row showing the value + label (Personal / Work / etc.) + a `Set as primary` action + a `+ Add new email` / `+ Add new phone` row at the bottom that POSTs a new client_contacts row.
> - **Inheritance clarification — current model already does this:** there's no separate `interests.contactEmail/Phone` column today. The displayed Email/Phone ARE the client's primary contacts (resolved server-side, edited in place via PATCH to `client_contacts`). So edits at the interest level auto-update the client. The user's "vice versa" framing assumes per-interest contact overrides exist — they don't.
> - **Two design options for the picker semantics:**
> - **Design A (recommended, single source of truth):** picker just chooses which contact to set as `isPrimary=true` for this client. Affects every other surface that reads `clientPrimaryEmail`. No schema change. Simpler.
> - **Design B (per-interest contact override):** add `interests.preferred_email_contact_id` + `preferred_phone_contact_id` nullable FK to a specific `client_contacts` row. Each interest can pin a non-primary contact for itself; falls back to client's primary when null. Schema change + service-layer fallback logic + UI to mark "use this for this deal only". Useful only if a single client routinely buys multiple deals with different contact preferences per deal — uncommon for marina sales.
> - **Decision-pending:** lean Design A unless leadership confirms the multi-deal-per-client divergence case is real.
> - **Effort:** ~3-4h for Design A end-to-end (picker component + empty-state quick-add + service-side `setPrimary` action + tests + accessibility). ~5-7h for Design B with the schema + fallback logic. Captured 2026-05-21 from UAT.
> - **SHIPPED (Design A) in 7ecf4ee:** new `<ClientChannelEditor>` combobox. Primary value renders inline (free-text for email, `<InlinePhoneField>` for phone with country code split). Chevron opens a popover listing every contact in the channel — `Make primary` button per non-primary row, delete for non-primaries, inline "Add an email / phone number" with optional Set-as-primary toggle. Backed by existing `/clients/[id]/contacts` CRUD + `promote-to-primary`. Wired into the Email + Phone rows on `interest-tabs.tsx`.
> - **Inline phone editor on the Contact row** — _src/components/interests/interest-tabs.tsx:973_ — current implementation uses a plain `InlineEditableField` text variant on Phone, so reps can't pick a country code from a dropdown or get AsYouType formatting (both available via `<PhoneInput>` in `src/components/shared/phone-input.tsx`). Wrap `PhoneInput` in a display-vs-edit toggle and PATCH both `value` (national string) + `valueE164` + `valueCountry` to `/api/v1/clients/{id}/contacts/{contactId}`. ~30-60 min. **SHIPPED in 7ecf4ee:** the phone branch of `<ClientChannelEditor>` uses `<InlinePhoneField>` (existing primitive); PATCH writes `value` / `valueE164` / `valueCountry` together. `interests.service.ts` now returns `clientPrimaryPhoneCountry` so the editor can preserve the ISO-3166-1 alpha-2 round-trip.
> - **ft ↔ m unit switching on Berth Requirements** — _src/components/interests/interest-tabs.tsx_ — the three inline-editable dim rows hard-code `(ft)` in the label. The interest already carries `desiredLengthUnit` ('ft' | 'm'); other surfaces (BerthRecommenderPanel) honour it. Add a small unit toggle that flips the rendered display (and converts on save so the canonical `desired*Ft` column stays in feet). Same pattern as elsewhere in the app (per CLAUDE.md mooring/berth dims model). ~30-45 min.
> - **Client Overview should summarize current interest's requirements** — _src/components/clients/_ — one-line "current interest needs X × Y × Z" summary on the client detail Overview tab; reps currently have to drill into Interests tab to see what a client wants. ~30 min. **SHIPPED in 7ecf4ee:** `PanelVariant` of `<ClientPipelineSummary>` renders a one-line "Wants L × W × D · Source" under each interest's header when constraints / source are captured. `<ClientInterestRow>` type extended with the new fields; the existing `/api/v1/interests` query already returned them.
> - **Duplicate Reminder surfaces on Interest Overview** — _src/components/interests/interest-tabs.tsx_ — the legacy "Reminder" panel (driven by `interest.reminderEnabled / reminderDays / reminderLastFired`) and the new "REMINDERS" section (driven by the `reminders` table via the bell-in-header) both render on the same tab and tell different stories. The legacy field still drives a real backend worker (`processFollowUpReminders` in `reminders.service.ts:428` — creates auto-follow-up reminders when no activity in N days), so we can't just delete the field. Approach: hide the legacy "Reminder" panel from the OverviewTab grid; surface the recurring-follow-up config either as a slim row inside the REMINDERS section or as a setting on the interest detail header. Keep the worker untouched. ~1 h. **SHIPPED in f39f0aa:** legacy panel hidden from Overview; worker untouched. Surfacing the recurring-follow-up config on the detail header is parked.
> - **LinkedBerthsList: no "add another berth" affordance from the card** — _src/components/interests/linked-berths-list.tsx_ — multi-berth interests are first-class (`interest_berths` is the source of truth per CLAUDE.md) but the LinkedBerthsList card doesn't expose an inline "Add a berth" button. Reps have to use the BerthRecommenderPanel below — discoverability gap. Add a CTA button to the card header (gated by `berths.edit`) that opens a picker / sheet to add another `interest_berths` row. ~45 min. **SHIPPED in 3999d4b:** "Add berth" button on the card header; opens a `<Dialog>` with a Command-primitive searchable picker backed by `/api/v1/berths/options`. Berths already linked are filtered out client-side so reps can't double-add. Mutation hits `POST /api/v1/interests/[id]/berths` with `isSpecificInterest` flag; invalidates interest-berths + berth-recommendations caches so the row appears immediately and the recommender drops the just-added berth.
> - **Supplemental-info-request: link should be reusable, not single-use** — _src/lib/services/supplemental-info_ (token model) — current email says "can only be used once"; user wants it valid until expiry so a partial submission can be revisited. Drop the single-use guard, keep TTL gate. Audit the public endpoint to ensure no token-fingerprint reuse risk before lifting the limit. ~30 min. **SHIPPED in b74fc56:** `applySubmission` drops the `isNull(consumedAt)` filter; TTL is the sole validity check. Public form's "already submitted" lockout screen replaced with a soft amber banner noting that re-submission overwrites the previous data. `consumedAt` still stamped for last-submitted context.
> - **SHIPPED in 431375d (E26):** Service: `listTokensForInterest` (latest 20 issuances with expired/consumed flags) + `getTokenForResend` (snapshots token back into issue-shape for re-email). Route: GET lists; POST accepts optional `tokenId` for resend branch (forces sendEmail=true), returns `resent: true/false`. UI: three actions (Generate/Regenerate, Generate+email or "New link+email", Resend current — only with active unconsumed unexpired token) + issuance history list with Active/Submitted/Expired per row. Supplemental-info-request: distinct Regenerate vs Resend actions + issue history — _src/components/interests/supplemental-info-request-button.tsx:83_ (the current "Resend" label) + _src/lib/services/_ (the issue endpoint that today mints a new token on every POST) — once the link becomes reusable-until-expiry (per the "should be reusable, not single-use" finding above), the single "Resend" button conflates two semantically different actions: (a) mint a NEW token (invalidates the previous one — needed when the old one expired, was leaked, or the client deleted the email), and (b) re-email the EXISTING still-valid token (needed when the client just lost the email — same token, same form-state, just push through SMTP again so they can pick up where they left off). The current implementation always does (a) — the "Resend" copy is misleading. Plus once we have reusable tokens, the rep loses visibility into "what token did we send when?" — the inline `link` state only holds the last-minted one.
> - **(a) Service split:** `regenerateSupplementalLink(interestId)` mints a new token + invalidates outstanding ones for the same interest (or keeps them parallel — design call; recommendation: invalidate, so one client only has one valid link at a time and the rep doesn't have to reason about which one is which). `resendSupplementalLinkEmail(tokenId)` emails the named existing token via SMTP without mutating the token table. Two API routes: `POST /api/v1/interests/{id}/supplemental-info-request` for regenerate, `POST /api/v1/interests/{id}/supplemental-info-request/{tokenId}/resend` for resend.
> - **(b) UI:** swap the single button for a small action group that surfaces the most recent valid token's metadata (`Issued <relative time> · expires in <N days>`) with two buttons next to it — `Resend email` (primary, fires resend on the existing token) + `Regenerate link` (ghost, mints new). If no valid token exists, show only `Generate link`. Pair this with the "separate generate + send" finding below so the rep can also generate-without-sending (e.g. share through WhatsApp).
> - **(c) History:** small expandable section "View past requests" listing the last 3-5 issued tokens with timestamp + status (active / expired / submitted / revoked). Each row gets a "Revoke" action for the active ones (defensive — covers the "we sent it to the wrong email" case). Schema-wise this is just rendering existing rows in the supplemental-info-tokens table.
> - **Effort:** ~2-3h end-to-end including the service split, two API routes, UI rework, audit-log entries on each action, and a vitest covering the resend-doesn't-mutate-token guarantee. Captured 2026-05-21 from UAT. Cross-ref: ties into the "link should be reusable, not single-use" + "separate generate link and send email" findings — best done as one coherent rework.
> - **Supplemental-info-request: separate "generate link" and "send email"** — _src/components/interests/supplemental-info-request-button.tsx_ — currently one button auto-generates + sends. User wants two steps: button 1 generates + shows the link (rep can copy / share manually); button 2 sends the templated email through SMTP. Backend change: split the existing service into `generateSupplementalLink()` and `sendSupplementalLinkEmail(linkId)`. UI change: replace single-click action with two-step UI showing link state. ~1 h. **SHIPPED in a4e30ea:** API route now accepts `{ sendEmail?: boolean }` (defaults true for back-compat); UI shows two distinct buttons — "Generate link" and "Send by email" (becomes "Regenerate link" + "Generate + email" depending on state). Email body copy also drops the "can only be used once" sentence since PR15 made tokens reusable.
> - **SHIPPED in 94c24a1 (F28):** Past strip on Interest overview becomes an `<Accordion>` — each row collapses to the same one-line summary as before, expands to render the full `<MilestoneSection>` (steps list, sub-status, inline doc actions). Reuses MilestoneSection so no new per-milestone rendering needs maintaining. Past-milestones strip → expandable history with inline doc preview — _src/components/interests/interest-tabs.tsx_ (the past-milestones strip at ~line 863) — currently a one-line collapsed summary per past milestone (just title + summary). Reps want to drill into the history of a specific milestone (e.g. see which EOI round was signed, the doc contents, who signed, when). Convert the strip into an accordion: each past milestone expands to show its associated docs + sub-status timeline + inline PDF preview using the existing pdf viewer primitive. Useful for deals with multiple EOI rounds (rework after rejection, re-sent reservation agreements, etc.) where audit trail matters. ~3-4 h.
> - **InterestBerthStatusBanner: name + link the competing deal** — _src/components/interests/interest-berth-status-banner.tsx_ — the banner that surfaces when a linked berth is under offer to a different active deal currently just says "this berth is under offer elsewhere" without identifying which interest. Reps want a small inline detail: client name + deal stage + a link button to the competing interest, so they can size up the situation (e.g. "this lead won't make it, treat ours as backup"). Service-side: extend the `getInterestBerthStatus()` (or equivalent) response with a `competingInterest: { id, clientName, pipelineStage, ... } | null` field, then surface in the banner. Permission-gate the link by `interests.view`. ~1 h. **SHIPPED in 7ecf4ee:** reuses `/berths/[id]/active-interests` endpoint (already-shipped 292a8b5) instead of extending the original `getInterestBerthStatus()` call. One query per conflicting berth via `useQueries`; picks the `isPrimary` competing interest (falls back to first non-self row); renders inline `<Link>` to the competing detail page. Falls through gracefully when the endpoint hasn't resolved yet.
> - **Notes Latest-note teaser missing round / stage context pill** — _src/components/interests/interest-tabs.tsx_ (the "Latest note" block around line 1029-1064) — notes created during a specific stage / EOI round should display a small "Round 2" or stage pill next to the timestamp so reps can see at a glance which phase a note belongs to. Currently shows author + time only. Schema: notes table doesn't carry round info today — would need a derived display from the interest's stage at note creation time (cheapest) or a stamped `created_during_stage` column (more reliable). ~45 min for derived display, ~1.5 h with migration for stamped column. (Same need likely applies to all notes lists, not just the Overview teaser.) **SHIPPED (current-stage variant) in 7ecf4ee:** stage-badge chip next to the timestamp using `STAGE_BADGE` colour map. Shows the deal's CURRENT pipelineStage — historical "stage-at-note-time" lookup would need a per-render `audit_logs` read, over-engineered for a context hint. NotesList rows on other surfaces (full Notes tab) deferred until a real need surfaces.
> - **Dimensions columns: add ft↔m toggle in the column header (persisted to user prefs); skip per-row entry-unit indicator** — _src/components/berths/berth-columns.tsx:306_, _src/components/yachts/yacht-columns.tsx:102_, _src/components/clients/client-yachts-tab.tsx:63_, _src/components/companies/company-owned-yachts-tab.tsx:106_ (any current/future Dimensions column), plus _new_ `src/lib/utils/dimensions.ts` for the conversion + format helper, and _src/lib/db/schema/users.ts_ `user_profiles.preferences` for the persisted preference key — five table surfaces render "Dimensions" in feet today; reps used to metric units have to convert in their head.
> - **Recommendation on the per-row indicator question:** **column-level toggle alone is enough.** The schema already stores per-dimension entry-unit discriminators (`lengthUnit`, `widthUnit`, `draftUnit` on berths + same pattern on yachts/interests, default `'ft'`) and even keeps separate `_M` numeric columns where metric originals exist (`nominalBoatSizeM`, `waterDepthM`) — so the _data_ knows what was entered. But surfacing that on every row in the table creates visual noise (a small "m" pill next to half the rows) that doesn't help the rep complete a task. The right time to surface entry-unit fidelity is at **EOI / contract / quote generation** time — the merge field renderer should pull the unit + value as entered so the legal document matches the rep's original input verbatim. So: column toggle for UI display, entry-unit honoured in document generation (which already happens for the EOI dialog via `effectiveDimensionUnit`).
> - **Implementation:**
> - (a) Helper: `src/lib/utils/dimensions.ts` exporting `convertFt(value, to: 'ft' | 'm')`, `formatDimension(value, unit)` (with locale-aware decimals: 1.5 m vs 4.9 ft), and `formatDimensions(l, w, d, unit)` for the L × W × D triple. Tiny, deterministic, unit-tested.
> - (b) Preference: extend `user_profiles.preferences` (JSONB) with a `dimensionUnit: 'ft' | 'm'` key (default `'ft'`); already a JSON column so no migration needed beyond a TS type extension.
> - (c) Hook: `useDimensionUnit()` returning `{ unit, setUnit }` backed by React Query + a PATCH to `/api/v1/me/preferences` on change. Optimistic update.
> - (d) UI: replace the literal `"Dimensions"` header string in each column definition with a small `<DimensionUnitToggle />` component (label + segmented toggle `ft | m`). Column body cells render via the formatter. Apply to all 5 surfaces in one pass for visual consistency.
> - (e) Document-generation path: leave EOI / contract / template merge-field rendering untouched — it already pulls entry-unit values per `effectiveDimensionUnit` in the EOI dialog (per CLAUDE.md merge-field architecture).
> - **Effort:** ~1.5-2h end-to-end (helper + pref + hook + toggle component + 5 column-definition swaps + a vitest for the formatter). The toggle persists across page reloads + tabs by virtue of going through `/me/preferences`. Captured 2026-05-18 from UAT.
> - **Berth list: "Rates (USD)" + "Pricing valid" columns hidden by default (or removed) — short-term rental fields irrelevant to purchase/long-term ports** — _src/components/berths/berth-columns.tsx:391-417_ + _src/lib/db/schema/berths.ts:62-69_ + the NocoDB import that populates them. These columns surface daily/weekly slip rental rates + a `pricing_valid_until` date — relevant for marinas that lease berths by the day/week (transient marinas), irrelevant for Port Nimara's sales-only model. Visible by default in `DEFAULT_VISIBLE_COLUMNS` (line 123-124), so every Port Nimara user sees two columns of `—` cluttering the table.
> - **Path (recommended): hide by default, keep available in column picker.** Drop `'rates'` + `'pricingValidUntil'` from the default-visible array; reps at a transient-rental port can enable via the existing Columns picker. Preserves the schema + import paths for future ports without removing functionality. ~5 min.
> - **Smarter alternative (Path 3 in the chat thinking):** conditional default-visibility — only include `'rates'` + `'pricingValidUntil'` in the default-visible set if the port has at least one non-null rate value. Auto-shows for ports that use them, auto-hides for ports that don't. ~30 min including the port-level data check + cache invalidation when rates land. More polished but heavier.
> - **Aggressive alternative (Path 1):** delete the columns + the four `*_usd` schema columns + the import paths if no port ever plans to use them. Decision: defer until we know whether ANY port in the roadmap does transient rentals. For now, hide-by-default is the right call.
> - **Bundle with:** the "trim default-visible columns" recommendation in the platform-wide table-density finding below — same audit pass, same author.
> - **Effort:** ~5 min (Path: hide-by-default). Captured 2026-05-21 from UAT.
> - **SHIPPED in c14f80a (Q59):** DataTable cells default to `whitespace-nowrap` so long values don't wrap into 4-5 lines. Columns needing wrapping override via column def's `meta.wrap = true`. Min-width comes from `column.getSize?.()` when set — opt-in per column rather than sweeping width change. **Platform-wide table density: cells shrink-wrap content instead of triggering horizontal scroll — columns need min-widths + nowrap defaults** — _src/components/ui/table.tsx:7_ (wrapper is already `overflow-auto`, good ✓) + _src/components/ui/table.tsx (TableCell base — missing `whitespace-nowrap`)_ + _src/components/berths/berth-columns.tsx_ (no `size`/`minSize` on any column except line 447's `size: 48` outlier) + every other DataTable column definition in the app. Surfaced on the berths list (UAT 2026-05-21): with ~14 columns visible, every cell wraps into 3-6 lines because the table tries to fit everything in viewport. Example pain: "Bull bollard type B · 40 ton break load" wraps into 6 lines; "63m × 14.19m (draft 4.42m)" wraps into 3 lines; "Car (3t) to Vessel" wraps into 3 lines. Result: row height bloats to 200px+, the table becomes nearly unusable.
> - **(a) TableCell base default:** add `whitespace-nowrap` to the base TableCell className in `src/components/ui/table.tsx`. Single-line content stays single-line. Cells that genuinely need wrapping (long note teasers, etc.) opt-out via `className="whitespace-normal"` per-cell.
> - **(b) Per-column `min-w-[X]` token system:** define a small set of width tokens in a shared helper based on content type — `colW.short` (status badges, count chips), `colW.medium` (mooring numbers, short labels), `colW.long` (dimensions, addresses), `colW.money` (price columns). Apply via TanStack `size: ...` or via cell className `min-w-[X]`. Reuse across every DataTable.
> - **(c) Truncate-with-tooltip for verbose cells:** the Cleat / Bollard / Access columns carry strings like "Bull bollard type B · 40 ton break load" — too long for any reasonable column width. Apply `truncate max-w-[200px]` + `title={value}` so the cell shows ellipsis + full text on hover. Optionally wrap in a `<Tooltip>` for touch parity on mobile.
> - **(d) Audit visible-by-default columns:** with 14 columns showing on the berth list, even with correct widths the table is overwhelming. Trim the default-visible set to 7-8 essentials (Mooring, Area, Latest deal stage, Active interests, Dimensions, Boat size, Price, Status) and move the rest behind the existing Columns picker (already wired per CLAUDE.md). Reps who need bollard/cleat/access details can enable those columns explicitly.
> - **Apply to all DataTable surfaces:** berths list, interests list, clients list, yachts list, companies list, reservations list, invoices list, audit-log list, expenses list. Each has its own column file; single audit pass tags the min-w token per column.
> - **Effort:** ~3-4h end-to-end (TableCell base + width token helper + column-def sweep + truncate-tooltip on verbose cells + default-visible audit). Captured 2026-05-21 from UAT.
> - **Berth list "Latest deal stage" column: make sortable by pipeline-stage rank** — _src/components/berths/berth-columns.tsx:273-287_ + _src/lib/services/berths.service.ts:80-120_ — the column currently has `enableSorting: false`; sorts by status / area / active interests / etc. already work via the existing `sortColumn` switch + `customOrderBy` correlated-subquery pattern (see `activeInterestCount` at lines 107-120). latestInterestStage isn't a column on `berths` — it's the highest-ranked active interest's stage, populated in a two-pass post-fetch.
> - **Fix:** (a) drop `enableSorting: false` on the column. (b) Add a `'latestInterestStage'` case to the sortColumn switch returning `null` (handled in customOrderBy, like `activeInterestCount`). (c) Add a `stageSort` correlated subquery mirroring `demandSort`: select the rank of the highest-active-stage interest per berth via a `CASE i.pipeline_stage WHEN 'enquiry' THEN 1 WHEN 'qualified' THEN 2 ... WHEN 'contract' THEN 7 END` ladder, then `ORDER BY ... ASC/DESC` per `query.order`. Filter same as demandSort (`port_id`, `archived_at IS NULL`, `outcome IS NULL`). Berths with no active interest → NULL; use `NULLS LAST` (ascending) / flip per direction so they land at the bottom regardless.
> - **Effort:** ~45 min. Pure additive — no schema work, no API contract change. Captured 2026-05-18 from UAT. **SHIPPED in ca51000:** column toggles to `enableSorting: true`; service-side adds a `stageSort` correlated subquery via the existing `customOrderBy` pattern (ranking 1=enquiry through 7=contract; NULLS LAST regardless of direction).
> - **SHIPPED in 991e222 (C23):** New `POST /api/v1/berths/bulk` mirroring `/interests/bulk` (discriminated union: change*status / change_tenure_type / add_tag / remove_tag / archive; 500-id cap; per-row failure reporting; routes status mutations through `updateBerthStatus` so under-offer/sold transitions still trigger primary auto-link + rules engine). BerthList toolbar wires `bulkActions` on DataTable with confirm dialogs sharing one `bulkMutation` for consistent toast + invalidation. Berth list: bulk-edit affordance (parity with bulk-add) — \_src/components/berths/berth-list.tsx* + _berth-columns.tsx_ + _src/lib/services/berths.service.ts_ + _new_ `src/app/api/v1/berths/bulk/route.ts` — bulk-add for berths exists; bulk-edit doesn't, so any cross-row mutation (status flip on a row range, price re-tier on a pontoon, tag application, area rename, archive a season's worth) is a 50× one-row-at-a-time grind. **Cross-reference:** the Bucket 3 finding "Bulk-price editing UI" already shipped the price-specific backend (`POST /api/v1/berths/bulk-update-prices`); this is the broader sibling covering every other column reps want to edit in bulk. Coordinate the two as a single rollout.
> - **Scope:** (a) Row-select infra on `<DataTable />` — checkbox column, "select all on page" / "select all matching filters" header, persistent selection across pagination (~1h, mirror `InterestList`'s `bulkActions` pattern). (b) Bulk-actions bar on ≥1 row selected: change status, change area, set price / % adjust (folds in the already-built endpoint), add/remove tags, archive/restore, export selection CSV — each opens a small confirm/edit dialog (~2-3h). (c) Unified backend `POST /api/v1/berths/bulk` (mirror `/interests/bulk`) taking `{ action, ids, ...args }`, port-validates IDs, per-row transactional with per-row failure summary so the rep sees which of 50 berths failed and why; per-row audit + realtime fan-out; cap 500 IDs (~2-3h incl tests). (d) Each action gated by the appropriate berth perm (`berths.edit`, `berths.update_prices`, `berths.archive`, `tags.manage`); endpoint enforces the most-restrictive perm of the requested action (~30min).
> - **Effort:** ~5-7h end-to-end. Captured 2026-05-18 from UAT.
> - **BulkAddBerthsWizard: block proceed when any mooring already exists in the port** — _src/components/admin/bulk-add-berths-wizard.tsx_ + _src/lib/services/berths.service.ts_ (new pre-flight check) — the wizard's review/preview step should validate the to-be-added mooring numbers against existing rows for the port and block "Submit" if any duplicates are found (rather than relying on a DB-constraint error mid-insert, which today doesn't even fire because there's no partial unique index on `(port_id, mooring_number)` — see Bucket 4 #1 "Duplicate E17 row" which captured the missing constraint).
> - **Fix:** (a) on entering the preview step, fire a `GET /api/v1/berths/check-moorings?port=<id>&moorings=A1,A2,...` (cap ~500 per call) that returns `{ existing: [{ mooringNumber, id }] }`. (b) If non-empty, show an inline error panel listing the conflicts (linked to the existing berths) and disable Submit; offer a "Remove conflicts and continue" button that drops the dupes from the wizard payload before re-enabling Submit. (c) Pair this with the partial unique index fix from Bucket 4 #1 so the DB-level guard exists as a backstop — UI validation prevents the friction; DB constraint prevents the silent dup. (d) Same pre-flight should run on per-row "single add" flow for parity.
> - **Effort:** ~1.5h (endpoint + index + UI panel + tests). Captured 2026-05-18 from UAT.
> - **SHIPPED in ca172fa:** `POST /api/v1/berths/check-duplicates` accepts up to 500 mooring numbers, returns the subset that already exist as non-archived berths (canonical `^[A-Z]+\d+$` regex validation, `berths.import` perm). Wizard fires the check during the Step 1 → Step 2 transition with a "Checking…" Continue button. Step 2 banner lists first 8 duplicates + a "Remove all duplicates" action; duplicate rows render amber-tinted with a "Dup" pill. Submit disabled while any dup remains, tooltip explains. Partial unique index `(port_id, mooring_number) WHERE archived_at IS NULL` follow-up (Bucket 4 #1) parked.
> - **Yacht Overview: verify bidirectional ft↔m auto-convert is visually reflecting (logic exists; UI may not be updating)** — _src/components/yachts/yacht-tabs.tsx:99-137_ (`saveDimension`) + _src/components/yachts/yacht-tabs.tsx:68-80_ (`useYachtPatch` cache invalidation) — the bidirectional auto-conversion IS already implemented: `saveDimension()` patches both the primary field and the converted counterpart in one PATCH, and `onSuccess` invalidates `['yachts', yachtId]`. User report ("needs to autofill with auto-converted measurements") suggests the UI isn't visually updating after save — most likely the parent that passes the `yacht` prop into `OverviewTab` either (a) doesn't share the `['yachts', yachtId]` cache key (invalidation fires, no consumer refetches), (b) is hydrated via server-component `initialData` with no client refetch, or (c) the `InlineEditableField` for the counterpart memoizes its initial value and doesn't re-render when the upstream prop changes.
> - **Verify path:** (i) confirm the yacht detail page's `useQuery` cache key matches `['yachts', yachtId]` exactly — any mismatch (`['yacht']` singular, `['yacht-detail']` wrapper) makes the invalidation a no-op. (ii) Confirm `staleTime` / `refetchOnMount` allow refetch on cache bust. (iii) If the parent refetches but the field still doesn't visually update, force-re-render via `key={yacht.lengthM}` on the counterpart InlineEditableField.
> - **Apply to sibling surfaces:** the same bidirectional save belongs on **berth detail OverviewTab** — berth schema has `lengthM`/`widthM`/`draftM` + `_unit` discriminators and likely shows the same dual ft/m sections (verify); copy the `saveDimension()` pattern. Use the shared `src/lib/utils/dimensions.ts` helper from the earlier Dimensions-column toggle finding so the conversion ratio is centralized.
> - **Effort:** ~20-30 min for the yacht debug + visual-update fix, +30 min if a berth equivalent needs the same logic. Captured 2026-05-18 from UAT.
> - **SHIPPED (round-trip lossless + dedup) in 8e9efe5:** three copies of the conversion logic existed (yacht-dimensions.ts canonical, yacht-form.tsx local, yacht-tabs.tsx local) — the form-side ones used 2dp precision so `1 ft → 0.30 m → 0.98 ft` lost data on the round-trip. Consolidated both surfaces onto `feetToMeters`/`metersToFeet` from yacht-dimensions.ts and bumped precision to 4dp. New `tests/unit/yacht-dimensions.test.ts` proves round-trip lossless on the canonical 12.5 ft ↔ 3.81 m UAT case + sweeps 1/5/12.5/25/50/120/250 ft and 0.5/1/3.81/7.62/15.24/36.58 m (29/29 ✓). UI cache-key + InlineEditableField re-render path are independent debug items — flag for separate verification.
> - **SHIPPED in 94c24a1 (G30):** Users page wraps existing UserList + InvitationsManager in Tabs control (Active users / Invitations). Standalone /admin/invitations route returns a redirect to merged page for bookmark back-compat. Removed nav catalog entry + admin-sections-browser tile; extended Users catalog keywords with "invitations / pending invites / onboarding" so command-K still lands on the right surface. Merge `/admin/invitations` into `/admin/users` — single "people with access" surface — _src/app/(dashboard)/[portSlug]/admin/users/page.tsx_, _src/app/(dashboard)/[portSlug]/admin/invitations/page.tsx_ (to be removed), _src/components/admin/users/_, _src/components/admin/admin-sections-browser.tsx:90-95_ (drop the Invitations card from the Access section), _src/lib/services/_ (invitations service likely already separate — keep it) — active users and pending invitations are the same lifecycle (a person who has or should have port access). Splitting them across two admin pages forces admins to bounce between surfaces to answer "who has access here?". Merging gives them one canonical "people" page.
> - **(a) Page shape:** keep route at `/admin/users`. Add a state filter at the top: `All | Active | Invited (pending) | Disabled | Archived`. Default to `Active`. The existing Users table extends to render invitation rows alongside active users, distinguished by a "Pending" badge + last-sent timestamp + "Resend" / "Revoke" kebab actions. Active-user kebab keeps current actions (edit role, reset password, disable). One unified `+ Invite user` button in the page header opens the existing invitation form. Search across both populations (name / email / role).
> - **(b) Data shape:** the users table already returns user rows; extend the list endpoint (or add a parallel one that the page composes) to also yield pending invitations as a discriminated-union row type `{ kind: 'user' | 'invitation', ... }`. Keep the underlying tables separate (no schema change); the page just stitches both query results into one table. Filter at the API layer when `state=active` excludes invitations, etc.
> - **(c) Removal:** delete `/admin/invitations/page.tsx`, the Invitations card from the Access section, any sidebar/search-catalog entries pointing at the old route. Add a `redirect()` from the old route to `/admin/users?state=invited` so any bookmark / external link lands in the right place.
> - **(d) Roles & Permissions stays separate** — different concept (template vs individual), low edit frequency, would bury both if merged. Cross-link: each user row's role chip → opens role edit page; role detail page → "N users with this role" with a link back.
> - **Permission gating:** confirm the unified page enforces the OR of permissions for both surfaces (`users.view` for the user rows, `invitations.manage` for sending/revoking). The "Invite" button gates on `invitations.manage`; the kebab actions per-row gate appropriately.
> - **Effort:** ~3-4h end-to-end — table extension + state filter + invitation rows + the API stitch + redirect + sidebar/catalog cleanup + tests. Captured 2026-05-18 from UAT.
> - **Consolidate every AI-feature admin control onto `/admin/ai` — REMOVE from current scattered locations (reinforced UAT 2026-05-21)** — _src/app/(dashboard)/[portSlug]/admin/ai/page.tsx_ + _src/components/admin/_ (new per-feature embedded forms) + _src/lib/db/schema/ai-usage.ts_ (existing ai*usage table for spend rollup) + \_src/components/admin/ocr-settings-form.tsx* (pattern to mirror) — the AI admin page already has master controls (`ai.master`), provider credentials (`ai.providers`), and the Receipt OCR settings embedded inline. The "Per-feature settings" card I just removed pointed at two dead routes (`../berth-pdf-parser`, `../recommender`) — surfacing the gap that AI feature-tuning isn't consistently centralized. User wants every AI-using feature's admin knobs reachable from one page.
> - **Scope (only include features that actually call an LLM today; don't include aspirational ones):**
> - **Berth PDF parser AI fallback** — 3-tier parse per CLAUDE.md (AcroForm → OCR → optional AI on low confidence). Knobs to expose: provider override (per-feature override of the global `ai.providers` choice), confidence threshold below which the AI tier fires, per-call budget cap, prompt template (advanced/optional). New embedded form `<BerthPdfParserAiSettingsForm embedded />` reading registry section `ai.berth_pdf_parser`.
> - **Receipt OCR** — already there ✓
> - **Future-feature placeholders explicitly NOT included until they ship:** berth recommender (currently "Pure SQL (no AI)" per CLAUDE.md — surfacing it as an AI setting today would mislead admins into thinking they're tuning an LLM); AI-assisted contact-log action extraction (Bucket 3 #7 future feature); AI inquiry intake parsing if/when it ships. Add each to `/admin/ai` only when the underlying feature lands.
> - **AI spend dashboard at the bottom of the page** — new card showing: current month spend total (across all AI features), top 3 features by spend, recent expensive calls (model, feature, cost, timestamp). Reads from `ai_usage` table. Helps admins debug cost spikes without leaving the AI page. Optional but high-leverage for an admin who just saw a budget alert.
> - **Cross-linking principle:** each per-feature AI section on `/admin/ai` shows a small "Non-AI settings for this feature live at →" link to the corresponding admin page (e.g. for berth PDF parser, link to wherever the OCR confidence + AcroForm overrides live). Vice-versa: each feature page gets a "AI fallback settings live at /admin/ai →" link in the relevant section. Keeps the split-brain risk in check — admins always have a one-click path between the two.
> - **Effort:** ~30 min for the berth PDF parser embedded section + registry definition, ~1.5h for the AI spend dashboard, ~30 min for the cross-link sweep, ~30-45 min for the explicit removal-from-other-surfaces audit (grep every admin page for AI toggle / API-key field / model-selector / temperature-slider and migrate to /admin/ai). Total ~3h. Captured 2026-05-18 from UAT, reinforced 2026-05-21 (user spotted yet another scattered AI setting in `src/components/admin/settings/settings-manager.tsx:241` — confirms the consolidation work needs explicit "delete from old location" alongside "add to new location" to avoid drift). Captured 2026-05-21 reinforcement.
> - **Explicit removal scope** — audit and remove (not just add to /admin/ai):
> - Any AI-related setting inside `settings-manager.tsx` SettingsManager cards
> - Any AI-related env-resolver fields exposed via RegistryDrivenForm on non-AI admin pages
> - Cross-link replaced original location with a small banner: "AI settings for this feature live at `/admin/ai` →" (per the cross-linking principle already in the entry).
> - **Password-reveal eye toggle silently no-ops when value resolves from env (or anywhere outside port/global)** — _src/components/admin/shared/registry-driven-form.tsx:440-463_ (eye-toggle click handler) + _src/app/api/v1/admin/settings/[key]/reveal/route.ts_ (server endpoint that intentionally refuses to leak env-resolved secrets per its docstring) — user clicks the eye on a sensitive field and the dots stay, no toast, no error. Root cause: the click handler only fires `reveal.mutate()` when `resolved?.isSet && resolved.source ∈ {'port', 'global'}`. When the value is resolved from `env` (legacy `.env` fallback) or `default`, the handler skips the reveal call and just sets `setShowSecret(true)`. The Input then flips `type` from `password` to `text` — but the draft is still empty, so the placeholder `'••••••••'` (set unconditionally for `sensitive` fields at line 555) keeps rendering. Net effect: indistinguishable from "the toggle is broken."
> - **Fix options:**
> - **(a) Best UX:** show a clear inline message + tooltip on the eye button when `resolved.source === 'env'` (or `'default'`): "Value comes from the environment — cannot reveal in-app. Configure in admin to view." Disable the button or change its tooltip so the user knows why nothing happens. ~15 min.
> - **(b) Optional:** allow env-reveal under a stricter permission (e.g. `admin.reveal_env_secrets`) — defaults off, super-admin only. The server endpoint's "refuses to reveal env" guard would honour the permission as an override. Riskier; only do this if there's an operational need. Capture as Bucket 3 if pursued.
> - **(c) Diagnose path:** add a console.warn / dev-mode toast when the click is swallowed silently so the next person debugging this can see what's happening.
> - **Sibling check:** the server-side route comment at lines 21-22 says it "refuses to reveal values resolved from env or default," but the implementation at lines 39-52 just calls `getSetting()` and returns whatever it gets — there's no actual refusal check in the route handler. If `getSetting()` reaches into the env fallback the endpoint would leak env values. Verify the refusal is enforced upstream in `getSetting()` (or in the registry resolver) — if not, that's a separate finding (low/medium severity bug: env secrets leakable via API to anyone with `admin.manage_settings`). Worth running through to confirm.
> - **Effort:** ~15 min for (a) UI message + tooltip; ~30 min if the route's env-refusal check needs to be added too. Captured 2026-05-18 from UAT. **SHIPPED (a) in ca51000:** eye toggle now `disabled` + `title` tooltip when value resolved from env/default. Sibling check on the route's env-refusal guard deferred to a security-side follow-up.
> - **SHIPPED in 94c24a1 (H32):** Email settings page gains an explainer panel above the SMTP cards spelling out why noreply + sales have separate credentials and which workflows ship from each. Existing field titles gained "(noreply)" suffix so the model maps cleanly. Email settings page: add explainer copy clarifying why sales send-from and noreply have separate credentials — _src/app/(dashboard)/[portSlug]/admin/email/page.tsx_ (the page) + _src/components/admin/sales-email-config-card.tsx_ (the sales card) + the existing noreply transport card — the admin page renders two cards with overlapping field names (SMTP host/port/user/pass on both, plus IMAP on the sales card) and zero context for why both exist. Operators reasonably ask "why am I configuring this twice?" The two streams are intentionally separated (per CLAUDE.md "Send-from accounts (sales send-outs)"): sales = human-initiated rep emails with IMAP-bounce-poll monitoring; noreply = fire-and-forget automation (portal invites, password resets, signing reminders, inquiry confirmations). Reasons to keep them separate include sender reputation (mixing transactional volume with human sends hurts deliverability), reply handling (reps need replies in a monitored mailbox; automation shouldn't generate reply threads), and the practical pattern of using a transactional provider (Postmark/SendGrid) for noreply + Google Workspace / Outlook for the sales mailbox.
> - **Fix:** add an explanatory header block at the top of the email-settings page (above the two cards) summarizing the split in plain language: 2-3 sentences max + a small table (sales vs noreply, what each sends, why split). Each card's CardDescription gets a one-liner anchoring to its role ("Used for rep-initiated emails (berth PDFs, brochures, manual follow-ups). Replies land in this mailbox and are bounce-monitored via IMAP." / "Used for automated emails (portal invites, password resets, signing reminders). Replies bounce."). Optional: a "Quick setup" toggle/button — "Use one mailbox for both streams" — that auto-mirrors SMTP creds from sales → noreply (or vice versa) for ports that don't need the split. Default state stays split (preserves the design intent for ports that have grown into it).
> - **Effort:** ~30 min for the explainer copy + per-card descriptions; +1h for the "Quick setup" mirror affordance if pursued. Captured 2026-05-18 from UAT.
> - **Email / SMTP admin: add a "Send test email" affordance** — _src/components/admin/shared/registry-driven-form.tsx_ (or a dedicated email-settings card adjacent to the RegistryDrivenForm) + _src/lib/email/_ (transport) + _new endpoint_ `POST /api/v1/admin/email/test-send` — once an admin configures SMTP creds + From address on the Email Settings page, they have no in-app way to confirm "did I actually wire this up correctly?" without finding a workflow that triggers a real transactional email. Add a "Send test email" button on the email settings card that pops a small dialog: input for destination address (defaults to the operator's own email), optional message body, submit fires the test via the configured transport. Server endpoint returns success / SMTP-error-with-detail so the admin sees exactly why it failed (auth fail, TLS handshake, sender-rejected) without digging into server logs.
> - **Implementation:** (a) UI: small "Send test email" button in the card actions, opens a Dialog with a single email-validated input + "Send" button. (b) Endpoint: `POST /api/v1/admin/email/test-send` with `{ to: string, subject?: string }`, gated by `admin.manage_settings`. Body: brief branded test ("This is a test from <Port Name> admin — if you got this, SMTP is working."). (c) On the server: pull the live transport config via the resolver chain (port-override → env), construct via nodemailer, send, return `{ success: true, messageId }` or `{ success: false, error: ... }` with the raw SMTP error reason. (d) Audit log a `test_email_sent` row so operators can see who tested and when.
> - **Honour the dev `EMAIL_REDIRECT_TO`** — same as production transactional emails: if set, prefix subject and reroute so QA doesn't spam users.
> - **Cross-ref:** related to the Documenso-config diagnosis loop (Bucket 3 #8 platform-wide error message audit) — same pattern of "configure-then-verify-without-real-workflow." Apply the same idiom to other integrations: Documenso test-send, S3 ping, Redis ping, IMAP test-connect.
> - **Effort:** ~1.5h for email (UI + endpoint + audit + dev-redirect honour). +1-2h each for the sibling integration test-ping buttons if pursued in the same pass. Captured 2026-05-18 from UAT.
> - **SHIPPED in 7881da6:** `POST /api/v1/admin/email/test-send` route gated on `admin.manage_settings`; reuses `sendEmail(..., ctx.portId)` so per-port SMTP overrides + `EMAIL_REDIRECT_TO` honour are exercised the same way a real notification would. Sends a minimal plaintext-only message (no logo, no branded shell) so the failure mode is pure transport — distinct from the branding-page "Send a test" which exercises the rendering pipeline. New `<SmtpTestSendCard>` on `/admin/email` between the SMTP overrides form and the SalesEmailConfigCard. Errors surface inline below the input (auth failure, ENOTFOUND, connection refused, etc.) rather than as a passing toast — whole point is to read them. Sibling test-ping buttons for Documenso / S3 / Redis / IMAP parked.
> - **YachtPicker: opening returns no yachts (empty `q` → empty list); should return a default list** — _src/app/api/v1/yachts/autocomplete/handlers.ts:10-12_ + _src/components/yachts/yacht-picker.tsx:56-60_ — the autocomplete handler short-circuits with `{ data: [] }` when `q` is empty: `if (!q) { return NextResponse.json({ data: [] }); }`. The picker fires the query the moment it opens with `debounced=''` → user opens, sees empty state, has to start typing before any options appear. Dead-end UX.
> - **Fix:** (a) handler: when `q` is empty, return the top 20-30 yachts for the port (most-recently-updated default; if `ownerType`/`ownerId` query params are provided, filter server-side to that owner). Trivial — just drop the early-return and pass `q` as optional to the `autocomplete()` service, which defaults to an empty search term meaning "no name filter". (b) Picker: extend the query string to include the owner filter so server-side filtering works (currently the picker filters client-side post-fetch, which means a yacht owned by someone other than the current `ownerFilter` may not even reach the client if it's outside the default-20). (c) UX nicety: the picker's `placeholder` could include "or search…" so the user knows typing also works.
> - **Effort:** ~30 min. Captured 2026-05-18 from UAT. **SHIPPED (a) in 2bcf544:** autocomplete handler drops the early-return; service returns top 20 most-recently-updated yachts when `q` is empty. (b) owner-side server filtering remains client-side as before; (c) deferred.
> - **YachtPicker: selected yacht renders as `Yacht <uuid-prefix>` when not in the autocomplete results** — _src/components/yachts/yacht-picker.tsx:75-79_ — the trigger button label is `match?.name ?? `Yacht ${value.slice(0, 8)}``— the fallback fires whenever the currently-selected yacht isn't in`rawOptions`(e.g. picker was opened with a pre-set value from a URL param / parent default and the autocomplete results don't include it, OR the user typed a search that filtered it out). Result: reps see`"Yacht 3bd83076"` instead of the yacht's name.
> - **Fix:** add a second `useQuery` keyed on `['yacht-detail-label', value]` that fetches `/api/v1/yachts/{value}?fields=name` when `value` is set AND not present in `rawOptions`. Use its result as the fallback label in priority order: `match?.name ?? fallbackQuery.data?.name ?? `Yacht ${value.slice(0, 8)}``. Cache hit on repeat opens; tiny request. (b) Also pre-select the currently-managed yacht as the default `value`for any picker rendered in a context where "the current yacht" makes sense — that's a parent-prop concern; this picker handles whatever`value` it's given. (c) Sweep for the same pattern in other pickers (ClientPicker, CompanyPicker, BerthPicker if they exist) — same root cause + same fix shape.
> - **Effort:** ~20 min per picker; ~1h with the sweep. Captured 2026-05-18 from UAT. **SHIPPED (YachtPicker) in 2bcf544:** fallback `useQuery(['yacht-detail-label', value])` against `/api/v1/yachts/{value}` enabled only when value isn't in `rawOptions`. ClientPicker/CompanyPicker sweep deferred until UAT confirms the same pattern needs fixing there.
> - **CommandList (cmdk) inside a Popover: scroll caps short of the bottom — applies to ALL dropdowns using the Command primitive** — _src/components/ui/command.tsx:57-75_ — `CommandList` has `max-h-[300px] overflow-y-auto overscroll-contain` plus a custom wheel handler (lines 68-72) that re-implements scrolling because "native wheel scrolling is intercepted by the focus-scope and never reaches the cmdk list" (per the inline comment). User reports they can scroll a short distance, then the list stops responding before reaching the bottom — and notes this is the case for **every dropdown on the drawer they're looking at**, so it's the shared primitive, not a per-picker bug. **SHIPPED in e33313b:** `max-h-[300px]` now `max-h-[min(300px,var(--radix-popover-content-available-height,300px))]` so the cmdk list never extends past the host Popover's available area. Non-Popover hosts fall through to the 300px static cap. The list now consistently reaches the bottom of the visible popover regardless of where the trigger sits in the viewport.
> - **(i) cmdk auto-scroll-to-highlighted-item** fights the manual scroll: when the user wheels past the currently-highlighted item, cmdk's internal handler snaps the scroll back so the highlighted item stays visible. Net effect: user can scroll up to a few items past the highlight, then it bounces back. **Fix attempt:** on wheel/scroll, clear the cmdk highlight (or set it to a non-highlighted state) so cmdk doesn't re-snap. cmdk exposes a `value` prop on `Command` for controlled-highlight; set it to `undefined` on scroll, restore on hover/keyboard nav.
> - **(ii) Manual wheel handler ignores trackpad-momentum + keyboard:** `event.currentTarget.scrollTop += event.deltaY` only handles wheel events. Trackpad-flick momentum continues firing wheel events with diminishing `deltaY`, but if cmdk traps the events the user's input bounces. Touch / keyboard arrow keys may have similar interception issues. **Fix attempt:** prefer letting cmdk handle scroll natively (newer cmdk versions fixed the popover-focus-scope issue) and remove the manual handler. Check `package.json` for `cmdk` version; if < 1.0.0, upgrade.
> - **(iii) The `max-h-[300px]` hard cap** clips longer lists. While the cap exists, scrolling SHOULD still reach the end — but combined with (i)/(ii) it caps the effective scroll distance. **Fix attempt:** use a height-aware token: `max-h-[min(400px,var(--radix-popper-available-height,400px))]` so the list grows when the popover has room and caps at 400px otherwise.
> - **Investigation order:** (1) check cmdk version + upgrade if old → may auto-fix the focus-scope issue and let us remove the manual wheel handler. (2) Test with manual handler removed. (3) If still buggy, add the controlled-highlight reset on scroll. (4) Bump the max-h as the easy win.
> - **Effort:** ~30-60 min including upgrade + testing across the YachtPicker, ClientPicker, CompanyPicker, command-search topbar, and any other Command consumers. Captured 2026-05-18 from UAT — affects every Command-based dropdown app-wide; high-leverage single-component fix.
> - **DECIDED 2026-05-21 (do not adopt Documenso embed editor):** evaluated `@documenso/embed-react`'s `EmbedCreateEnvelope` / `EmbedUpdateEnvelope` as a replacement for our custom field-placement UI. Per Documenso V2 editor docs (callout block): _"Embedded editor is included with Enterprise plans. It is also available as a paid add-on for the Platform Plan. Contact sales for access."_ Enterprise licensing is a hard no for us. Custom rebuild is the path. We're already ~70% there with `upload-for-signing-dialog.tsx`; remaining scope is the 4-item bundle below (~12-16h total). Full V2-editor parity (multi-file envelopes, Assistant/Viewer roles, dictate-next-signer, all envelope settings) would be ~30-40h but is not justified by our actual marina-CRM flows. Skip multi-file/assistant role; defer per-document envelope settings (expiration / redirect / custom reply-to) until a rep actually asks for them.
> - **Smart search: fuzzy-match pipeline stage names, surface inline mini-list of interests at that stage** — _src/components/search/command-search.tsx_ + _src/lib/services/search.service.ts_ + _src/lib/constants.ts:31 (`STAGE_LABELS`)_ + _src/hooks/use-search.ts_. Today's command-K search includes each interest's stage in result rows (rendered via `STAGE_LABELS`) but doesn't search BY stage — typing "Reservation" only matches interests with that text in their fields. User wants: type "reservation" → see a dedicated "STAGE: Reservation (N deals)" section at the top of the dropdown listing the top 5-10 interests at that stage, with each row showing client + berth label so the rep can click directly. Bottom of section: "View all N in Reservation →" link to the filtered interests list.
> - **Design (locked 2026-05-21):** inline mini-list in the search dropdown (Option A from the design clarification). Top 5-10 interests per matched stage; "View all" link jumps to `/interests?stage=<canonical>`.
> - **Backend:** new search section `'stage-matches'` returning `{ stage: PipelineStage, label: string, totalCount: number, sampleInterests: Array<{id, clientName, berthLabel, ...}> }[]`. Fuzzy match the query against `STAGE_LABELS` values + common aliases ("res" → reservation, "eoi" → eoi_signed/eoi_sent, "dep" → deposit_paid, "qual" → qualified, "won"/"contract" → contract_signed, etc.). Use `fuse.js` or a tiny custom ranker on the labels — there are only ~9 stages so even O(n) scan is fine.
> - **Frontend:** new section in the command-search dropdown rendered above the text-match "Interests" section. Borrow the existing `SectionHeading` + result-row idioms. Use the existing `berthLabel` helper (the same one used in the DocumentDetail Interest link fix and the external-EOI title default) so naming is consistent across surfaces.
> - **Alias catalog (lives next to `STAGE_LABELS`):** add a small `STAGE_SEARCH_ALIASES: Record<PipelineStage, string[]>` map for non-obvious matches ("hot lead" → qualified? probably not, leave it conservative). Aliases stay short and unambiguous — prefer false negatives over false positives.
> - **Effort:** ~2-3h end-to-end (backend section + fuzzy ranker + frontend render + alias catalog + a vitest covering "res" matching reservation but not "reservation_signed" if that's a thing). Captured 2026-05-21 from UAT.
> - **SHIPPED in d912f02:** new `searchStages(portId, query, limit)` in search.service.ts. Three match flavours (case-insensitive): (1) modern label token-prefix on `STAGE_LABELS`, (2) substring on the raw enum slug, (3) legacy-alias prefix via `LEGACY_STAGE_REMAP` so "eoi_sent" / "deposit_10pct" / "contract_signed" still land on the modern 7-stage equivalent. Each result carries a live `COUNT(*)` of non-archived interests in that stage (single grouped query). New `StageSuggestionResult` bucket added to `SearchResults` + `BucketType` union; gated on `interests.view`. Command-search dropdown renders matched rows under a "Stages" header pointing at `/interests?pipelineStage=<stage>`. Mobile overlay reuses `buildFlatRows` so the same surface appears on mobile. Sample-interests-per-stage variant from the original design was deferred — the count + filter-link does the same job with one click vs. inline preview.
> - **SHIPPED in 94c24a1 (F29):** Unified create-document wizard gets a Watchers section with multi-select checkbox list backed by `/api/v1/admin/users/picker`. Selected user ids sent in `watchers` array on POST (replaces prior hardcoded `[]`). UI matches post-creation WatchersCard so reps see same identity rows regardless of entry point. Watchers configurable at document creation time (currently post-creation only) — _src/components/documents/eoi-generate-dialog.tsx_, _src/components/documents/upload-for-signing-dialog.tsx_, _src/components/interests/external-eoi-upload-dialog.tsx_, _src/components/documents/create-document-wizard.tsx:157_ (hardcoded `watchers: []`), _src/lib/services/documents.service.ts (create paths)_, _src/lib/services/document-watchers.service.ts_ (or wherever the watcher CRUD lives). Today watchers can only be added AFTER creation via WatchersCard on the document detail. Reps usually know upfront who needs visibility (manager, developer, legal) and shouldn't have to navigate to the doc after creating it.
> - **Server-side defaults (fires on every create path):**
> - **Creating user** — always auto-added. The person who just created the doc almost certainly wants notifications about events on it.
> - **Interest's `assignedTo`** — if different from creator, auto-add. The deal owner gets visibility on doc events even if a different rep generated/uploaded.
> - **Per-port admin setting `default_document_watcher_user_ids: string[]`** — admin configures org-wide watchers (sales manager, legal, etc.). Apply on every create. Configurable in `/admin/settings` under a new "Document defaults" section.
> - **UI in each creation dialog:** small "Watchers" section (collapsed by default — "X watchers · Edit"), opens to show:
> - Each auto-added user with an `(auto)` badge so the rep can see who's already included without redundant clicks.
> - A user-picker to add additional watchers from the port's user roster.
> - An "X" to remove an auto-added watcher for this specific doc (e.g. rep wants this confidential, removes the default sales-manager). Doesn't affect the global default.
> - **Apply to ALL creation dialogs uniformly:** Documenso EOI generate, external EOI upload, Documenso upload-for-signing (reservation/contract), generic create-document wizard. Build the section as a shared `<DocumentWatchersField>` primitive so each dialog mounts it the same way.
> - **Service:** extend the document-create endpoints to accept `watcherUserIds: string[]` (replacing the current `[]` hardcode). On create, server: (i) inserts the explicit user IDs from the request, (ii) inserts the per-port default IDs, (iii) inserts creator + assignedTo if not already in the union. Dedupe by user_id + document_id (existing unique index, presumably).
> - **Effort:** ~3-4h end-to-end (admin setting + UI section + 4 dialog wirings + service-side defaults + tests). Captured 2026-05-21 from UAT. **Cross-ref:** ties into the external-EOI bundle below — the watchers section sits naturally next to the signatories editor in the same dialog. Build them in one pass.
> - **External-EOI upload: per-signatory role tagging + email auto-fill + "Email copy" distribution** — _src/components/interests/external-eoi-upload-dialog.tsx_ (current free-text `signerNames` field; needs structured rows) + _src/components/documents/document-detail.tsx:208-214 + 297-299_ (current "Email signatories" placeholder stub) + _src/lib/services/system-settings/_ (new `default_developer_email` + `default_developer_name` per port) + _new_ `src/components/documents/email-copy-dialog.tsx` + _src/lib/services/document-sends.service.ts_ (already exists per CLAUDE.md, extend for the new dispatch path). Three linked feature gaps surfaced during UAT 2026-05-21 on the external-EOI flow:
> - **(a) Per-signatory role tagging at upload time** — today's dialog has a free-text "Signer names" CSV input only. No structured concept of WHO each person is (Client vs. Developer vs. Rep vs. Witness vs. CC), so the system can't auto-fill emails downstream or build a proper recipient list for the email-copy flow. **Fix:** replace the freetext field with a recipient list editor (same idiom as the Documenso upload-for-signing-dialog's recipients step — name + email + role per row + add/remove buttons). Add a `signatories: Array<{ name, email, role }>` field to the service's input shape; persist on the document row (existing `documents.metadata` JSONB or a dedicated `document_signatories` table — TBD by scope, JSONB is cheaper for v1). Role enum: `'client' | 'developer' | 'rep' | 'witness' | 'cc'`.
> - **(b) Smart email + name auto-fill based on role** — when a rep adds a row and selects a role, the dialog pre-populates name + email from the right source. Rep can still edit. Sources:
> - **Client** → `interests.clientId` → `clients.contacts` where `channel='email' AND isPrimary=true`, fallback to first email. Name from `clients.fullName`.
> - **Developer** → new per-port system settings `default_developer_name` + `default_developer_email` (admin-editable in `/admin/email` or a new "Default signatories" section). Surfaces consistently across EOI / Reservation / Contract upload flows.
> - **Witness / CC** → no auto-fill, manual entry. Rep optionally types or picks from a contacts autocomplete.
> - **UI:** when role is selected and a row's email/name is empty, fire `setValue` with the resolved default. If the resolved data is missing (e.g. no clientId on the interest, no developer configured), show a small "No default available — enter manually" hint inline.
> - **(c) "Email signatories" → "Email copy" with multi-select + actual send** — _document-detail.tsx:208-214_ — current button is a placeholder. Build the real flow:
> - **Rename** "Email signatories" → "Email copy" (clearer intent: "send a copy of the signed document").
> - **Dialog UX:** click opens a dialog listing every signatory on the document (from (a)'s structured list) as a checklist. **All checked by default.** Optional "Add other recipient" row at the bottom for emailing someone not on the original signing list (lawyer, accountant, etc.). Optional message field (plain text, like the existing send-out compose UI).
> - **Send pipeline:** uses the existing sales send-out infrastructure (per CLAUDE.md "Send-from accounts" section): nodemailer transport with per-port `default_developer_email` → no wait, that's the sales send-from. Send-from is the configured `sales_send_from` mailbox. Body is rendered via `renderEmailBody()` (per CLAUDE.md "Audit → document_sends" section). Each send creates a `document_sends` row keyed to the document + recipient, supporting bounce tracking + reply monitoring.
> - **Attachment:** PDF threshold check (per the existing `email_attach_threshold_mb` setting) — under threshold → attached inline; over → 24h signed-URL link (escapes filename per the existing XSS protection).
> - **Audit trail:** each recipient gets a `document_sends` row. Existing "Recent sends" / activity surfaces light up automatically.
> - **Rate limit:** existing 50-sends-per-user-per-hour cap applies.
> - **(d-prereq) Create `document_signers` rows on external upload so "X / Y signed" badge works** — _src/components/documents/document-detail.tsx:278_ reads `signers.filter(s => s.status === 'signed').length / signers.length` from the `DetailSigner[]` array. For manually-uploaded external EOIs the array is empty (the upload writes only freetext `signerNames` metadata) → badge renders `0 / 0 signed` even with 3 signers entered in the dialog. Fix is downstream of (a): when migrating from freetext to the structured `signatories: Array<{name, email, role}>` shape, the service should also insert `document_signers` rows (one per signatory), all pre-stamped `status='signed'`, `signedAt=input.signedAt`, `signingOrder=index+1`, `invitedAt=null` (no invitation was sent — this is a backfill of an external signing event). Counter then renders `3/3 signed` correctly. ~15 min on top of (a)'s service work. Captured 2026-05-21 from UAT.
> - **(d) Default document title should reference client + berth(s), not just date** — _external-eoi-upload-dialog.tsx:103_ (current placeholder `'External EOI - <date>'`) — when the rep accepts the default, the document lands as `External EOI — 2026-05-21`, which is unscannable in any document list when a port has multiple deals closing on the same day. **Fix:** derive the default at dialog open time using the same `formatBerthRange()` helper that powers the locked folder-naming convention (Bucket 4 #5). Format: `External EOI — <Client name> — <berth range> — <YYYY-MM-DD>` (e.g. `External EOI — Matthew Ciaccio — A1-A3, B5-B7 — 2026-05-21`). When no client or berths are linked, gracefully fall back to the current minimal form. Apply the same idiom to the Reservation + Contract external-upload dialogs for consistency. ~15 min.
> - **Effort:** ~5-7h end-to-end. ~1.5h for (a) — structured recipient editor + service shape change + migration if a dedicated table is preferred. ~1h for (b) — auto-fill resolver + admin setting for developer defaults + UI wiring. ~3-4h for (c) — dialog + send service + branded email template + audit + attachment-vs-link logic. ~15min for (d). Captured 2026-05-21 from UAT. **Cross-ref:** the broader UploadForSigningDialog rework (item below) needs the same role-tagging UI — build the recipient-list editor once and reuse on both dialogs. The default-title derivation in (d) also belongs as a shared helper since Reservation/Contract uploads should match.
> - (a) Structured `signatories: Array<{name, email, role}>` lands on the service input, the API multipart payload, and the dialog UI. Role enum: `client/developer/rep/witness/cc`. Auto-seeds the client row from `interestData.clientName + clientPrimaryEmail` via a signatoriesOverride/null pattern (React-Compiler safe).
> - (d-prereq) `document_signers` rows inserted inside the transaction for every non-CC signatory, pre-stamped `status='signed'`, `signedAt=input.signedAt`. The document-detail "X / Y signed" badge now renders the right count.
> - **Remaining (b) + (c) + (d) deferred:** developer-default settings, "Email copy" multi-recipient dialog, send pipeline + branded template, role-based email auto-fill beyond the client row — bundles with the broader Documenso send-flow work in Wave 4.
> - **SHIPPED (3 of 4 sub-tasks) in 65ff596 (L41):** Dialog width was fixed earlier. Draft persistence: localStorage scoped per interest+documentType (`pn-crm.upload-for-signing.draft.v1:<id>:<type>`), 500ms debounce, PDF blob excluded (rep re-picks on reopen), "Draft saved" + Discard button surface, clears on submit. PDF preview error handling + zoom: onLoadError replaces spinner with failure block; toolbar gains 50–200% zoom in 25% steps (field coords stay in % of page dimensions). Field-placement keyboard shortcuts: Delete/Backspace removes selected field, arrow keys nudge 0.5% (Shift = 5%), ignored when focus is in real input/textarea/contenteditable. **UploadForSigningDialog comprehensive rework — 4 linked issues** — _src/components/documents/upload-for-signing-dialog.tsx_ — surfaced together during UAT 2026-05-21 of the Reservation Agreement send flow. All four touch the same dialog and should ship as one coherent pass.
> - **(a) [bug] "Failed to load PDF file" on the place-fields step** — the place-fields step uses `URL.createObjectURL(file)` (line 265) as `fileUrl` and passes it to react-pdf inside `FieldPlacementStep`. `pdf-viewer.tsx:149` `onLoadError` fires when react-pdf can't parse the blob. Likely causes to check: (i) the uploaded file isn't a PDF (PNG, DOCX, etc. — select-file step likely doesn't enforce `application/pdf` mime check); (ii) PDF.js worker URL misconfigured (every PDF fails the same way); (iii) blob revoked too early (`useEffect` cleanup at line 266-270 — though the deps look right); (iv) react-pdf version-incompatible with the worker bundle. **First debug step:** check browser devtools console for the actual error message — currently it's collapsed into a generic "Failed to load PDF file." string. Surface the underlying error to the UI ("Couldn't parse PDF — check that you uploaded a `.pdf` file, not an image or Word doc.") so the rep can self-diagnose.
> - **(b) [ux] Dialog way too small for the place-fields step** — dialog is `max-w-5xl` (1024px, line 166) which is fine for the recipients step, but the place-fields step has a 176px-wide field palette + 200px-wide recipients list on the left and only ~650px for the PDF preview on the right. A US Letter page at fit-width in 650px is barely legible, and field placement requires precision. **Fix:** make the dialog adaptive per-step: `max-w-3xl` for select-file + configure-recipients steps (768px is plenty for forms), but expand to `max-w-[1400px]` or `max-w-[95vw]` on the place-fields step where horizontal PDF space matters most. Alternative: full-screen modal pattern for the place-fields step only (escape exits, top bar shows step indicator + Back/Send). Also shrink the field palette from `w-44` (176px) to `w-32` (128px) by using icon-only buttons with tooltips — recovers ~50px of PDF width.
> - **(c) [feature gap] PlacedField shape missing `defaultValue` + `fieldMeta` (no UI to configure dropdown options, pre-fills, field labels, validation)** — _line 85-96, PlacedField interface_ — the current shape carries position + type + recipientIndex only. Documenso v2 `field/create-many` accepts per-field metadata that today's UI can't set:
> - **Dropdown:** options array. Today: rep places a Dropdown field → recipient sees an empty dropdown at signing time → blocked.
> - **Radio:** group label + option array. Same issue.
> - **Pre-filled defaults:** e.g., place a Name field assigned to "Matt Ciaccio" recipient + auto-populate with `interest.client.fullName` so the rep doesn't have to retype. Maps to Documenso's `defaultValue` per field.
> - **Text validation:** regex, minLength, maxLength — for fields like "passport number" or "phone".
> - **Field label:** custom label shown above the field at signing time (today defaults to the type name).
> - **Fix:** extend `PlacedField` with `defaultValue?: string`, `fieldMeta?: { options?: string[]; label?: string; required?: boolean; validation?: { regex?: string; minLength?: number; maxLength?: number } }`. Right-side properties panel on field selection (the selected-field UI already exists per the FieldPlacementStep code) gets new inputs per type:
> - Dropdown / Radio: textarea for "Options (one per line)".
> - Text / Name / Email / Number: input for "Default value" + optional "Pre-fill from" picker (Client name / Client email / Berth mooring / Interest date / …).
> - All types: "Required" checkbox + custom Label override.
> - The "Pre-fill from" picker is essentially a **per-field merge token** — borrowed from the EOI template merge-field catalog (`src/lib/templates/merge-fields.ts`). Reuse that token list + resolver so the same {{tokens}} that work in EOI templates work as field defaults here. Stitches the two flows conceptually: signer fields can be pre-filled from the same data sources EOI merge fields use.
> - **Backend wiring:** extend the v2 `field/create-many` payload in `documenso-client.ts` to pass `defaultValue` + `fieldMeta` (Documenso v2 supports these per their field API).
> - **(d) [behavior] Reservation flow should save as draft, not auto-distribute — match EOI pattern** — _line 361 + 477_ (the dialog reads `defaults?.data?.sendMode === 'auto'` system setting + changes the Send button label). User wants reservation agreement to ALWAYS save as Documenso draft so the rep can review in Documenso (preview email copy, double-check field placement, etc.) before manually triggering send. Per CLAUDE.md doc audit, EOI already uses this pattern (v2 `/template/use without distribute` → DRAFT envelope → rep distributes separately). Reservation should mirror.
> - **Fix:** option A (per-document choice, recommended) — add a small radio above the Send button in the dialog footer: `⦿ Save as draft (review in Documenso, send later) · ◯ Send immediately`. Default to "Save as draft" for reservation agreements (and contracts, by parity), since these high-stakes documents merit a review step. EOIs already follow the draft pattern; this just brings reservation/contract in line.
> - Option B (force manual) — hardcode `sendMode='manual'` for reservation / contract document types regardless of system setting. Less flexible but simpler.
> - **Lift the system `sendMode` setting to a per-document-type setting** so admins can independently configure auto-send for EOI / reservation / contract.
> - **Effort:** ~6-9h end-to-end for the full bundle (a + b + c + d + sweep of EoiGenerateDialog for parity on items b/c if applicable + tests). The dialog-width fix alone is ~30min; the rest of the work is the field-metadata schema + UI extension which is the heaviest piece. Captured 2026-05-21 from UAT.
> - **Skip-ahead backfill flow: surface real backfill controls below the banner (date pickers + signed-doc upload per gap)** — _src/components/interests/skip-ahead-banner.tsx:71_ (banner copy says "Backfill ... below" but nothing renders below), _src/components/interests/interest-tabs.tsx_ (the MilestoneSection past-phase render), _src/lib/services/interests.service.ts_ (PATCH path for date*eoi_sent / date_eoi_signed / date_reservation_signed / date_deposit_received), \_src/components/interests/interest-documents-tab.tsx* (existing upload flow we can lift from) — when a rep manually jumps a deal forward (e.g. Qualified → Reservation via the stage dropdown), the SkipAheadBanner fires and tells them to backfill, but the milestone card immediately below shows checkmarks with no controls to actually (a) set the historical date or (b) upload the signed PDF as evidence. The current `MilestoneAdvanceButton` has the date popover affordance, but it's only rendered for the NEXT unchecked step — past-but-undated steps render as a static checkmark + "—" with no edit affordance.
> - **Fix:**
> - (a) When a milestone is in the past phase AND its date column is null, render an inline "Set date" button next to the checkmark that opens the same Popover used by `MilestoneAdvanceButton` (date input defaulting to today, accepts any past date). On confirm, PATCH the relevant `date_*` column. No stage transition fires — just a date stamp.
> - (b) When a milestone is in the past phase AND its doc-status is not `'signed'` (or there's no associated `files.id` for the signed PDF), render an "Upload signed PDF" button next to "Set date" that opens a file picker, posts to the existing storage path, and flips the matching `*DocStatus` column to `'signed'` (mirrors what the Documenso webhook does on completion). For EOI specifically, the upload should link to the `documents` row representing the EOI so the file lands in the Documents hub via the same auto-deposit flow.
> - (c) Banner copy: convert the gap names from passive text into clickable jump-targets that scroll-into-view the corresponding past milestone card (e.g. "EOI sent date · EOI signed date" become anchor links). Reduces the "where is 'below'?" friction.
> - **Effort:** ~3-4h. Captured 2026-05-21 from UAT. (Bundles findings #1, #2, #3 below into one coherent backfill UX.)
> - **SHIPPED (date backfill control) in d8da1f6:** new `<MilestoneBackfillButton>` lands in the past-milestones strip whenever a date column is null (eoi/reservation/deposit/contract). Opens a DatePicker popover and PATCHes the relevant date\_\* column without firing a stage transition. **Signed-PDF upload per gap + clickable banner-gap anchor links remain parked** for the larger Documents-hub bundle.
> - **Current-stage milestone hidden under "Upcoming milestones" when its sub-steps are already checked off (active phase mislabelled)** — _src/components/interests/interest-tabs.tsx:611-624_ (`milestoneCompletion` map + `firstIncompleteKey` derivation) — the phase classifier marks a milestone as `'past'` whenever ALL its sub-steps are complete, so when the interest is at Reservation stage with reservation-agreement-signed already ticked (via the manual stage-jump), the Reservation milestone is `past` and EOI (which still has gaps because the rep hasn't backfilled) becomes the `firstIncompleteKey` → flagged as "NEXT STEP". Net effect (image 23): EOI shows as "NEXT STEP" + Reservation gets buried in the "Upcoming milestones" accordion even though it's the actual current stage.
> - **Fix:** introduce a third concept besides `past | current | future` — the milestone that owns the CURRENT pipeline stage (regardless of completion) should always be `current` and never be collapsed into the past-strip nor the upcoming-accordion. Compute the rep's "true current" milestone by mapping `interest.pipelineStage` → milestone key (eoi/eoi_sent/eoi_signed → 'eoi'; reservation → 'reservation'; deposit_paid → 'deposit'; contract_sent/contract_signed → 'contract'). The `firstIncompleteKey` rule still works for nurturing / qualified stages where no milestone naturally owns the stage. Past-but-fully-done milestones BEFORE the current stage go in the past-strip; future milestones go in the upcoming-accordion. Pair with the backfill-controls fix above so a "current" milestone with missing dates still has the affordances to fill them.
> - **Effort:** ~30-45 min. Captured 2026-05-21 from UAT.
> - **SHIPPED in d8da1f6:** introduced a `STAGE_TO_MILESTONE` map. When a stage owns a milestone (eoi/reservation/deposit_paid/contract), that milestone is forced to `'current'` regardless of sub-status completion; earlier-than-stage milestones bucket to `'past'` (so backfill controls render); later slots stay `'future'`. The legacy firstIncompleteKey rule still applies in stages without an owning milestone (enquiry/qualified/nurturing).
> - **Qualification auto-confirm "intent confirmed" once stage ≥ EOI (extend `computeAutoSatisfied`)** — _src/lib/services/qualification.service.ts:342-360_ (`computeAutoSatisfied` only branches on `'dimensions'` — `'intent_confirmed'` falls through to `false`) + the call-site context build at lines 296-316 (needs `pipelineStage` added) — when a rep manually advances the deal past Qualified to EOI/Reservation/Deposit/Contract, "Intent confirmed" still requires an explicit tick. The act of signing an EOI is itself the strongest signal of intent — leaving the row unchecked makes the checklist feel like noise. Extend the auto-satisfaction context with `pipelineStage`, add an `if (key === 'intent_confirmed') return stageIdx > qualifiedIdx;` branch, and `computeEvidence` returns "Stage advanced past Qualified" when triggered. Rep can still untick to overrule. **SHIPPED in 51ca875.**
> - **Effort:** ~30 min including the evidence string + an integration test. Captured 2026-05-21 from UAT.
> - **Qualification: stale explicit-tick survives removal of underlying auto-evidence (esp. dimensions)** — _src/lib/services/qualification.service.ts:296-334_ (`confirmed: explicit || autoSatisfied`) — `autoSatisfied` is recomputed at fetch time, but `explicit` persists in `interestQualifications.confirmed` once a rep has manually ticked the row. Result: if dims were present at one point, the rep clicked the box (or the auto-tick happened alongside an explicit click somewhere in the flow), then dims are later removed, the row STAYS ticked because `explicit=true` covers for `autoSatisfied=false`. The `AUTO` badge disappears so it now looks like a manual confirmation — but the rep may have no memory of making it. Footgun: checklist claims "Dimensions confirmed" with no underlying data.
> - **Fix (recommended — strict for derived-only criteria):** for keys where there's no rep judgement involved (`dimensions` today; future similar "does X data exist" checks), make the row purely derived — ignore `explicit`, return `confirmed: autoSatisfied`. Removing dims always unticks. Keep `explicit || autoSatisfied` for judgement-based keys like `intent_confirmed`. Implement by marking each criterion with a `derivedOnly: boolean` flag (lives next to the auto-rule) and branching in the merge.
> - **Alt (lenient with warning):** keep the OR but surface an `inconsistent` flag (`explicit && !autoSatisfied`) — UI renders the row with an amber "Evidence missing — re-verify" annotation, lets the rep re-confirm or untick.
> - **Effort:** ~45 min for strict (incl. integration test covering the remove-dims-after-tick flow); ~1h for lenient (annotation + amber styling). Captured 2026-05-21 from UAT. **SHIPPED (strict variant) in 51ca875:** `DERIVED_ONLY_KEYS` Set sentinel; merge branches on `isDerivedOnly(key)` to ignore explicit ticks for `dimensions`.
> - **Qualification checklist: collapse to one-line summary once "All confirmed"** — _src/components/interests/qualification-checklist.tsx_ — once every row is confirmed (explicit + auto combined), the full card stops being a gate and just occupies prime Overview real estate. Replace the expanded card with a single-row summary: `✓ Qualification — all confirmed (dimensions · intent)` + a chevron to expand on demand. Audit trail stays one click away. While expanded the rep can still untick or add notes; on next page load the card re-collapses if fully confirmed. Pairs naturally with the auto-confirm-on-stage-advance change above — deals at Reservation+ stage land with a collapsed Qualification block instead of a full card. Don't redesign the checklist content per stage (cognitive load); just change the visual weight once it's no longer informationally hot.
> - **Effort:** ~30 min. Captured 2026-05-21 from UAT. **SHIPPED in 51ca875:** card header is now a button-style toggle; aria-expanded; when fully confirmed it collapses to "✓ All confirmed (label · label)" + chevron; rep clicks header to inspect/untick.
> - **Yacht Ownership History tab: flesh out the controls; don't remove (carries real semantic load)** — _src/components/yachts/yacht-ownership-history.tsx_ + _src/components/yachts/yacht-tabs.tsx:333_ + _src/components/yachts/yacht-form.tsx:337-345_ (existing Transfer affordance) + _src/lib/services/yachts.service.ts:215_ (`transferOwnership` service) + _src/lib/db/schema/yachts.ts:72-96_ (`yachtOwnershipHistory` table with partial unique index `(yacht_id) WHERE end_date IS NULL`).
> - **Why keep:** the table isn't decorative — (i) partial unique index enforces one active owner at a time; (ii) berth reservation logic (`berth-reservations.service.ts`) gates "active company_membership on the owning company", so the yacht's ownership chain materially affects berth standing; (iii) the data is **already auto-populated** by `createYacht`, `transferOwnership`, and `public-interest.service.ts` — no rep effort required to maintain; (iv) audit trail value for disputed deals, EOIs generated under prior ownership, etc. Removing the tab AND/OR the table would lose audit fidelity and force reservation logic to derive ownership some other way. The "no way to enter/change" perception is a UI gap, not a missing concept.
> - **Flesh-out scope (recommended):**
> - (a) **Surface the existing Transfer flow on this tab** — the yacht form has a Transfer button (comment at line 345 confirms); add the same button to the Ownership History tab header (e.g. `"Transfer ownership →"`). Permission-gated by whatever the existing Transfer flow uses.
> - (b) **Empty-state CTA** — current empty state reads `"No ownership history"`. Replace with copy + a Transfer button so the tab is actionable on first visit, not dead-end.
> - (c) **Backfill / "Add historical entry"** — admin-only button that opens a small form (owner type/id, start date, end date, reason, notes) and inserts a row directly. Useful for backfilling pre-CRM ownership history for yachts brought over from NocoDB or legacy records. Permission: `yachts.edit_history` (new perm).
> - (d) **Edit controls on existing rows** — admin-only edit for `transferReason`, `transferNotes`, and `startDate`/`endDate` (with a strong confirm + audit log entry — these dates feed downstream logic). Don't allow editing `ownerType`/`ownerId` post-insert (use a Transfer/correction flow instead).
> - (e) **Link each row to the involved entity** — each row's `ownerType: 'client' | 'company'` + `ownerId` should render as a click-through link to the entity detail page. Right now likely a raw ID or just a label.
> - (f) **"Why was this entered?" trailing note on each row** — pull from `transferReason` (already in schema) + display `createdBy` (link to user) and `createdAt` (relative time). Tells the rep both what happened and who recorded it.
> - **Out-of-scope alternative:** if leadership concludes the audit value doesn't justify the UI cost, hide the tab from the rep-facing UI but **keep the table** + auto-populate hooks + admin-only access via `/admin/yachts/[id]/ownership-history` for the dispute case. Tab disappears from yacht detail; reservation logic continues to work. **User noted (2026-05-18):** if the tab is removed, the Transfer modal would also need to be removed — confirming that removing the tab is a coupled change with broader UI impact. Reinforces the recommendation to keep + flesh-out rather than remove.
> - **Recommendation:** ship (a) + (b) + (e) as the minimum-viable polish (~1.5h) — makes the tab feel intentional. (c) + (d) become admin-side work when there's actual demand for backfill or historical correction (~3-4h). Skip the "hide it" path unless explicit leadership ask.
> - **Effort:** ~1.5h for the minimum polish, ~5h for the full flesh-out. Captured 2026-05-18 from UAT (user weighed in towards "remove altogether"; the queue entry argues against because of the reservation-logic coupling + auto-population — final call still with the user). **SHIPPED (a) + (b) + (e) in 552b966:** "Transfer ownership" button on the tab header (perm-gated by `yachts.edit`); EmptyState action wired through to the dialog; existing OwnerLink rendering verified as link-through (e). Backfill / edit-controls (c)+(d)+(f) parked.
> - **Yacht Overview: replace single-textarea notes with the threaded `<NotesList>` (parity with clients / interests)** — _src/components/yachts/yacht-tabs.tsx:227-236_ (the legacy single-text-field at the bottom of OverviewTab) + _src/components/yachts/yacht-tabs.tsx:351_ (the full `<NotesList entityType="yachts" />` already rendered in the dedicated Notes tab) + _src/components/shared/notes-list.tsx_ — Overview today shows `<InlineEditableField variant="textarea" value={yacht.notes} ... />` — a single `yachts.notes` string column, last-edit-wins. The dedicated Notes tab has the full threaded `<NotesList>` (one entry per note, author + timestamp + edit/delete + aggregate). Clients and interests already surface threaded notes without leaving Overview.
> - **Fix:** replace the OverviewTab notes block (lines 227-236) with `<NotesList entityType="yachts" entityId={yachtId} currentUserId={currentUserId} />`. The `yachtNotes` table already exists (per CLAUDE.md polymorphic notes architecture: `notes.service.ts` dispatches across `clientNotes`/`interestNotes`/`yachtNotes`/`companyNotes`) so no backend work.
> - **Legacy `yachts.notes` column:** verify (a) anything else writes it (other than this textarea); (b) anything reads it (EOI / contract / template merge fields). If unused elsewhere, deprecate the column and stop surfacing it on Overview; the threaded NotesList becomes the canonical write path. If still in use, leave the column but stop surfacing on Overview.
> - **Companion decision:** with NotesList on Overview, the dedicated Notes tab may become redundant — same tradeoff applies to clients/interests today. Defer that decision; ship the inline NotesList first.
> - **Effort:** ~30 min for the swap + verify `currentUserId` is plumbed through to OverviewTab. Captured 2026-05-18 from UAT. **SHIPPED in c6dcf49:** OverviewTab now renders `<NotesList entityType="yachts" parentInvalidateKey={['yachts', yachtId]}>`; `currentUserId` plumbed through. Legacy `yacht.notes` column retained for EOI/contract merge-field path; decision on the dedicated Notes tab deferred.
> - **`/invoices/upload-receipts` guide: copy rewrite — terse, professional, in the luxury-CRM voice** — _src/components/invoices/upload-receipts-guide.tsx_ (the whole page; ~190 lines, ~75% of which is body copy) — current copy reads like a friendly onboarding tutorial: hand-holding ("Snap a photo of the receipt with your phone"), explanatory tangents ("The behind-the-scenes part is called OCR..."), throwaway pleasantries ("Most of the time everything is correct", "No typing. No spreadsheets. No chasing finance for the form."), and parenthetical asides ("the square with the arrow pointing up"). Tone is out of step with the rest of the CRM — the platform's brand voice is precise, restrained, declarative; this page reads warm-blog. Rewrite passes:
> - **PageHeader description** (line 31) — currently `"When you spend your own money on a business expense for the marina, use this to log it. Snap a photo of the receipt with your phone, the system reads it for you, and finance approves it on the parent company's side."` → suggested: `"Capture out-of-pocket expenses for reimbursement. The system extracts vendor, date, total, and currency from each receipt and routes the claim to finance."`
> - **"What does it actually do?" section** (lines 51-65) — replace title with `"Overview"`. Replace the two paragraphs with one line: `"Submit a photographed receipt; the system populates the expense form via OCR with AI-assisted field extraction, then forwards the claim for parent-company approval."` Drop the OCR explanation entirely — the audience is internal staff, not customers.
> - **Step 1 ("Add the scanner to your phone")** — retitle `"Install the scanner"`. Description → `"One-time setup. The scanner launches from the home screen thereafter."` Per-platform steps: drop parentheticals ("the square with the arrow pointing up"), drop the "Confirm the name 'Scanner'..." cruft, drop the trailing "Done." block in `PlatformBlock`.
> - **Step 2 ("Snap a photo of a receipt")** — retitle `"Capture a receipt"`. Description → `"Open the scanner from the home screen."` Each list item to one short sentence: `"Tap the camera tile and frame the receipt."` / `"The system extracts vendor, date, total, and currency."` / `"Review the populated fields; tap to amend."` / `"Tap Save to submit for approval."`
> - **"Tips for the best results"** — retitle `"Tips"`. Drop conversational asides; cap to 3-4 bullets, each one sentence.
> - **Target length:** ~60-70% reduction. Reads in 30 seconds instead of 3 minutes; the rep gets the workflow, not a friendly essay.
> - **Companion audit:** flag for review across other guide / help / empty-state copy that may have drifted into the same warm-blog voice (consumers of `src/components/shared/empty-state.tsx`, any `*-guide.tsx` pages, onboarding flows, longer Toast copy). One pass for tone consistency platform-wide — captured as a deferred follow-up; this page is the most visible offender.
> - **Effort:** ~45 min for this page; ~3-4h for the platform-wide tone audit if pursued. Captured 2026-05-18 from UAT.
> - **SHIPPED in e33313b:** page rewritten to terse luxury-CRM voice. PageHeader description, "What it does" section, Step 1 ("Install the scanner"), Step 2 ("Capture a receipt"), best-practices list, and PlatformBlock trailing line all tightened. Dropped the OCR mini-essay, "fancy phone camera" / "No typing. No spreadsheets" pleasantries, and parenthetical asides. ~50% size reduction in body copy. Platform-wide tone audit still parked.
> - **Expenses page header copy: drop "port" from the description** — _src/app/(dashboard)/[portSlug]/expenses/page.tsx:33_ → PageHeader at _src/components/shared/page-header.tsx:38_ — description currently reads `"Track and manage port expenses"`; user wants the word `"port"` removed. Suggested copy: `"Track and manage expenses."` (or, if the team wants to keep the "manage" verb spelled out, `"Track and manage business expenses."`). Trivially small. ~30 sec. Captured 2026-05-18 from UAT — likely indicative of a broader "remove the word 'port' from user-facing copy where it's redundant" pass; the portSlug already scopes everything, so user-facing strings shouldn't restate it. Worth a quick grep for `port expenses`, `port clients`, `port settings`, etc. in component strings. **SHIPPED in c6dcf49:** "port" dropped. Platform-wide grep sweep deferred to follow-up.
> - **Topbar search: widen + center against the _viewport_ (including sidebar space), not the topbar's middle grid slot** — _src/components/layout/topbar.tsx:57_ (grid template) + _src/components/layout/topbar.tsx:77-84_ (search container) + _src/components/search/command-search.tsx:103_ (the input itself) + _src/app/globals.css:114_ (`--width-sidebar: 256px` token already available) — current behaviour: the topbar uses `grid grid-cols-[minmax(0,1fr)_minmax(360px,640px)_minmax(0,1fr)]` inside the AppShell's main area (right of the sidebar), so the search bar centers within _the topbar_ — visually it sits offset to the right of the screen by half the sidebar width because the topbar itself starts after the sidebar. User wants the search visually centered against the full viewport (sidebar inclusive) and wider.
> - **Two coordinated changes:**
> - **(a) Wider:** bump the search container's `max-w-md` (448px) at line 81 to `max-w-2xl` (672px) or `max-w-3xl` (768px), and bump the topbar grid's middle slot from `minmax(360px,640px)` to `minmax(420px,800px)`. Cap to whatever still leaves room for the left breadcrumbs + right action row on common laptop widths (1280px - 256px sidebar = 1024px main area minus padding). 672-720px is a comfortable upper bound.
> - **(b) Viewport-centered:** the surgical trick uses the existing CSS variable. Apply a `translate-x` on the search wrapper that shifts it left by half the sidebar width: `style={{ transform: 'translateX(calc(var(--width-sidebar) / -2))' }}` (or a Tailwind arbitrary class `-translate-x-[calc(var(--width-sidebar)/2)]`). With the sidebar at 256px, the search shifts 128px left, landing its centre at viewport-50%. Works because the topbar's grid + `mx-auto` already centers the search within the post-sidebar area; subtracting half the sidebar width re-centers against the full viewport.
> - **Edge cases to handle:**
> - **Sidebar collapsed (64px):** wire the transform to use the collapsed-aware width. Cleanest: expose a single `--current-sidebar-width` CSS variable on the sidebar root that flips between `var(--width-sidebar)` and `var(--width-sidebar-collapsed)` based on collapse state. Topbar's search wrapper reads `--current-sidebar-width` so the shift adjusts automatically with no React state plumbing. ~10 min to add the variable + ~5 min to wire the transform.
> - **Mobile (< sm):** the sidebar is hidden and the layout is different (`MobileLayoutProvider` with bottom-tabs); the transform should only apply on `sm:` and up. Use `sm:-translate-x-[calc(var(--current-sidebar-width)/2)]`.
> - **Left column doesn't get visually overlapped:** since the search shifts via transform (paint-only, doesn't affect layout flow), the breadcrumbs in the left grid slot retain their declared width — but the search will visually overlap them. Solution: reduce the breadcrumbs slot's effective width (e.g. `minmax(0,0.6fr)` instead of `1fr`) OR add `pointer-events: none` to the breadcrumbs when the search is focused. Easier: hide breadcrumbs on narrower laptop widths and rely on the back-chevron + page-h1 for context (also addresses the breadcrumb-wrap finding above).
> - **Effort:** ~30-45 min total — the `--current-sidebar-width` variable + the transform + the grid bump + verifying behaviour at collapsed/expanded/mobile. Captured 2026-05-18 from UAT. **SHIPPED in 8fcbe45:** grid middle slot bumped from `minmax(360,640)` → `minmax(420,800)`; search wrapper `max-w-md` → `max-w-2xl`; `sm:-translate-x-[calc(var(--width-sidebar)/2)]` centers against the full viewport. Collapsed-sidebar-aware `--current-sidebar-width` variable parked.
> - **Pageviews chart: X-axis date ticks too cramped — drop the time component** — _src/components/website-analytics/pageviews-chart.tsx_ (recharts `XAxis`) — current bucket labels render in `YYYY-MM-DD HH:MM:SS` format from Umami's `x` field, which the chart's X-axis prints verbatim. On a 30-day range the labels overlap into an unreadable strip. Fix: pass a `tickFormatter` to `XAxis` that parses `row.x` and renders just the date portion (`MMM d` or `M/d`), keeping the timestamp available via Tooltip's full-precision render. ~10 min. Captured 2026-05-18 from UAT. **SHIPPED (formatter) earlier (already in place as "MM-DD"); thinning in e33313b:** added `interval="preserveStartEnd"` + `minTickGap={52}` to `XAxis` so multi-week ranges anchor first/last ticks and Recharts thins out the middle automatically instead of overlapping.
> - **Pageviews chart: inline note explaining Pageviews vs Sessions** — _src/components/website-analytics/pageviews-chart.tsx_ + the Card's CardHeader subtitle slot — add a small `?` info popover (matching the pattern on the Pipeline Value tile) next to the chart title that explains: "Pageviews = total page hits including refreshes. Sessions = distinct visitor sessions (a single visitor browsing multiple pages = 1 session, many pageviews)." Helpful because the chart shows both series and the distinction is non-obvious. ~10 min. Captured 2026-05-18 from UAT.
> - **Inbox page: swap section order — Reminders above Alerts** — _src/components/inbox/inbox-page-shell.tsx:84-111_ — current order is `Alerts` (line 84) then `Reminders` (line 99). User wants the order reversed so Reminders is the top section. Swap the two `<section>` blocks; ids (`inbox-section-alerts`, `inbox-section-reminders`), URL-hash deep-link logic, and the localStorage open-state keys all remain untouched (they're keyed on section id, not order). PageHeader copy "Alerts & Reminders" should also flip to "Reminders & Alerts" to mirror the new visual order. ~3 min. Captured 2026-05-18 from UAT. **SHIPPED in 203f543.**
> - **Inbox → Reminders: move filter row inline with the "New Reminder" button (embedded mode)** — _src/components/reminders/reminder-list.tsx:298-315_ — in embedded mode (used by Inbox), the "New Reminder" button renders on its own line at line 298-311 (`<div className="mb-3 flex justify-end">`), and the filters row (My/All tabs + status filter + priority filter) renders separately below at line 315. The two should share one row: filters left, button right. Fix: merge the two into a single `<div className="mb-4 flex flex-wrap items-center gap-3 sm:gap-4">`, keep the filter controls in their current order at the start, and append the "New Reminder" button with `className="ml-auto"` (or wrap the filters in a container + put the button as a sibling and use `justify-between`). Non-embedded mode (PageHeader path at lines 282-297) is unaffected. ~10 min. Captured 2026-05-18 from UAT. **SHIPPED in 203f543.**
> - **Breadcrumb wrap looks broken: orphaned separator + back-chevron misaligned** — _src/components/ui/breadcrumb.tsx:15-27_ + _src/components/layout/topbar.tsx:55-75_ — when the breadcrumb wraps (e.g. `Administration › Berths › Bulk Add` in the narrow left topbar slot), three visual issues stack: (1) trailing `›` separator after "Berths" hangs at the end of line 1 with nothing after it (orphaned, because separators are siblings of items in the `<ol>` so the flex-wrap break can land between an item and its separator); (2) "Bulk Add" wraps to line 2 indented; (3) the back-chevron `<` sits left of the wrapped line and is taller than the wrapped line, throwing off vertical alignment. Together it reads as a layout bug, not a wrap.
> - **Three coordinated fixes — ship (a) at minimum, do (b) for the real polish:**
> - **(a) Quick: make separator inline with the preceding item so wrap can't strand one** — restructure so each `<li>` contains both the label AND its trailing separator (single inline-flex unit), except the last crumb which has no separator. Drop the standalone `<BreadcrumbSeparator>` `<li>` from `Breadcrumbs` consumer. The primitive's `BreadcrumbSeparator` stays exported for backcompat. Wrap then breaks between full crumbs cleanly. ~15 min.
> - **(b) Better: ellipsis-collapse middle crumbs on overflow** — industry-standard pattern. When crumb count > 3 OR available width can't fit all crumbs single-line (detect via `ResizeObserver` on the `<nav>` or a CSS `:has(+ overflow)` trick), collapse middle crumbs to a `<BreadcrumbEllipsis>` button that opens a dropdown listing the hidden crumbs. First (root) + last (current page) always visible. Primitive already exports `BreadcrumbEllipsis` — just wire it. ~45 min. Result: breadcrumb stays single-line at every width, no wrap at all.
> - **(c) Layout polish: top-align the back-chevron** — _topbar.tsx:59_ — change the wrapping `<div className="min-w-0 flex items-center gap-1.5">` to `items-start` so even if the breadcrumb does wrap, the back-button stays top-aligned with the first crumb line instead of vertical-centering across the wrapped block. Also worth considering: hide the back-button when meaningful breadcrumbs are visible (the breadcrumb's parent link already does "go back"; two affordances is one too many). ~10 min.
> - **Topbar grid sizing observation:** topbar columns are `[minmax(0,1fr)_minmax(360px,640px)_minmax(0,1fr)]` — left slot competes for space with the centered search bar's `minmax(360px,640px)`. When search hits its max width, left slot is squeezed → breadcrumb wraps sooner. Consider bumping to `minmax(0,1.5fr)` OR letting the search shrink below 360px when needed. Optional, evaluate after (a)+(b) land.
> - **Effort:** ~15 min for (a), ~45 min for (b), ~10 min for (c). Bundle ~1h. Captured 2026-05-18 from UAT. **SHIPPED (a) in 8fcbe45:** each crumb + its trailing ChevronRight now share a single `<BreadcrumbItem>`; flex-wrap can no longer strand a separator. Ellipsis-collapse (b) + back-chevron alignment (c) parked.
> - **BulkAddBerthsWizard: currency field should use `<CurrencySelect>` (already exists, used elsewhere)** — _src/components/admin/bulk-add-berths-wizard.tsx_ (the `priceCurrency` `<Input>` in the apply-to-all row at ~lines 282-290, and the per-row instance below it) — currently a free-text `<Input>` that uppercases on blur, defaulting to `USD`. Reps can type any string (including invalid codes); no auto-complete; no consistency with other forms. The `<CurrencySelect>` component already exists at _src/components/shared/currency-select.tsx_, backed by the curated `SUPPORTED_CURRENCIES` list in _src/lib/utils/currency.ts_, and is used by the single-berth edit form (_berth-form.tsx:414_) + the expense form dialog (_expense-form-dialog.tsx:238_). Quick fix: import `CurrencySelect`, replace both the apply-to-all and per-row currency inputs with the dropdown bound to the same handlers (`applyToAll('priceCurrency', v)` / `setRowField(idx, 'priceCurrency', v)`). ~10 min. Captured 2026-05-18 from UAT. **SHIPPED in 2bcf544.**
> - **SHIPPED (wizard) in 431375d (D24):** Step 2 header gets a small monospaced ft/m button that flips dimension entry unit wizard-wide; cell values stay as-typed, single `inputToFt(v)` helper converts m→ft (3.28084) before posting canonical feet payload; column headers reflect active unit. **SHIPPED (Berth Requirements row on Interest) in 991e222 (C22):** interest-tabs Berth-requirements section honours `interest.desiredLengthUnit`; labels flip to "(m)" when set; on save PATCHes both chosen-unit and canonical counterpart so downstream surfaces (recommender, EOI merge fields) stay in lockstep. **SHIPPED (berth-list column display ft/m persisted to user prefs) in 991e222 (C21):** TablePreferences.dimensionUnit added to user-profiles JSONB; useTablePreferences returns dimensionUnit + setDimensionUnit; new getBerthColumns(unit) factory rewrites dimensions/nominalBoatSize/waterDepth cells (waterDepth converts on-the-fly from canonical meters at 3.2808 ft/m); toolbar gains ft/m toggle. Single-berth editor sweep still parked. BulkAddBerthsWizard + single-berth editor: toggleable input units (ft/m) for dimension fields — _src/components/admin/bulk-add-berths-wizard.tsx_ (the "Width (ft)" / "Length (ft)" / "Draft (ft)" inline-table headers + input parsing), _src/components/berths/berth-form.tsx_ (or equivalent single-edit) — the wizard's column headers and input parsing are hard-coded to feet. The schema supports per-dimension entry-unit discriminators (`lengthUnit`, `widthUnit`, `draftUnit` on `berths`, all defaulting to `'ft'`) plus separate `_M` numeric columns where metres-original values live — but neither the bulk wizard nor the single editor lets the rep pick which unit they're typing in. Reps who think in metres convert manually and the entry-unit discriminator never gets set.
> - **Fix:** (a) add a small `ft | m` toggle in the wizard header (and on the single-berth edit form) that flips the column header labels (e.g. "Width (ft)" → "Width (m)") and the parser. The toggle should default to whichever unit the user's `dimensionUnit` preference is set to (see the Dimensions-column-toggle finding earlier — same preference). (b) On submit, if entered unit is `'m'`, convert to ft for the stored numeric (`berths.lengthM` is the canonical metres column; `lengths.lengthFt` would be the feet column — verify the actual column names) AND set `lengthUnit='m'` so downstream document generation honours the rep's original input. Same for width / draft / nominalBoatSize / waterDepth. (c) Reuse the `src/lib/utils/dimensions.ts` helper from the Dimensions-column finding so conversion is centralized.
> - **Why this matters beyond UX:** document-generation merge fields (EOI / contract) already pull entry-unit values per `effectiveDimensionUnit` so the legal doc matches the rep's intent. Hard-coding ft on input silently coerces metric reps' values through a mental conversion, then renders the resulting ft figure on documents — losing fidelity for European customers.
> - **Effort:** ~1.5-2h end-to-end (wizard toggle + single-form toggle + parser + tests). Coordinate with the Dimensions-display toggle finding so both UI surfaces use the same preference key + helper. Captured 2026-05-18 from UAT.
> - **SHIPPED in 431375d (D25):** Select-of-A–E replaced with a chip group (A-E quick-pick) + free-text "Other…" input for any uppercase letter sequence (AA, BB, F, …). handleGenerate validates against empty/non-uppercase with toast; custom-input path uppercases + strips non-letters as typed so canonical `^[A-Z]+\d+$` regex always matches. BulkAddBerthsWizard: allow defining new dock/pontoon letters in-flow (or surface the admin path) — _src/components/admin/bulk-add-berths-wizard.tsx:78_ + _the dock/area model_ — current wizard appears to assume the dock letter already exists (per CLAUDE.md the mooring format is `[A-Z]+\d+` like `A1`, `B12` — the letter prefix is a dock/pontoon identifier). When a rep is adding berths for a _new_ dock, there's no inline way to introduce the new letter; they have to abandon the wizard, create the dock elsewhere, then come back. Two possible models — confirm which one applies in this codebase before building:
> - **(a) Dock letters are free-form / inferred from `berths.mooring_number`** (no separate `docks` table): then the wizard just needs to allow a new letter prefix in its input. UI fix: replace the letter input (or dropdown) with a combobox-style "pick existing or type a new letter" control — same idiom as Tag picker. Backend: nothing — first insert with the new prefix establishes the dock. ~30 min.
> - **(b) Docks are a first-class entity** (separate `docks` table with `port_id` + `letter` + metadata like `position`, `pontoon_type`, `power_capacity`): then the wizard needs a "+ New dock" affordance opening a small dock-create dialog (letter + name + optional metadata), then returning to the wizard with the new dock pre-selected. Permission: `berths.manage_docks` (or whichever owns dock metadata). The user's question — "_or is this an admin setting?_" — suggests they're not sure either; if it IS an admin-only concern (docks are infrastructure not data the rep should touch), then keep it admin-side and just surface a contextual link in the wizard ("New dock? Add it in Admin → Docks first → [link]"). ~1-2h depending on the model.
> - **Action item:** check whether `docks` / `pontoons` / `marina_sections` table exists in the schema (`grep -r "docks\|pontoons" src/lib/db/schema/`); shape the fix accordingly. If no dedicated table, the wizard fix is trivial; if there is one, decide admin-only vs in-wizard-create with the team. Captured 2026-05-18 from UAT.
> - **DropdownMenu content stretches to fill viewport — cap it** — _src/components/ui/dropdown-menu.tsx:66_ — the shadcn `DropdownMenuContent` primitive uses `max-h-(--radix-dropdown-menu-content-available-height)` (Radix's CSS variable that exposes the room between the trigger and the viewport edge). On long lists the menu visually stretches all the way to the viewport bottom even though the items don't need that height; reads as a wall of options. Internal `overflow-y-auto` is already on so scrolling works. Fix: replace the Radix `max-h-(...)` token with a fixed `max-h-96` (384px) or `max-h-[28rem]` (448px) so the menu caps at a comfortable height regardless of available space, scrolling internally for longer lists. Global change in the base primitive — affects every dropdown in the app, which is the right call (no consumer currently relies on the "fill the viewport" behaviour). ~2 min. If a specific dropdown needs the old behaviour, it can pass `className="max-h-[var(--radix-dropdown-menu-content-available-height)]"` to opt back in. Captured 2026-05-18 from UAT. **SHIPPED in c6dcf49.** Subsequent tightening in **e33313b:** `max-h-96` now `max-h-[min(24rem,var(--radix-dropdown-menu-content-available-height,24rem))]` so on small viewports the menu caps at the available space (not just the static 24rem) before falling back to internal scroll.
> - **DocumentsHub aside column: flush-left with the app sidebar (kill the AppShell padding for this page)** — _src/components/documents/documents-hub.tsx:246_ + _src/components/layout/app-shell.tsx:113-121_ — the desktop `<main>` wrapper applies `px-6 pt-3 pb-6` to all dashboard pages, so the DocumentsHub two-pane (`ResizablePanelGroup` with the `<aside>` folder column on the left) gets 24px of whitespace between the global app sidebar and its own border. The folder column should sit flush against the app sidebar — it reads as "an extension of the navigation," not "a card inside the page." Fix (surgical): change DocumentsHub's root `<div className="h-full">` at line 246 to `<div className="h-full -mx-6 -mt-3 -mb-6">` (mirror the AppShell desktop padding so the hub renders full-bleed inside the main viewport). Add a comment explaining the intentional escape. The right-pane content keeps its own internal `p-4` so it doesn't run flush with the viewport edge. **Alternative (cleaner long-term):** make the AppShell padding route-aware via a prop on `<main>` (or a layout-level opt-out for hub-style pages); but (a) is the right call until a second page needs the same treatment. ~5 min for the negative-margin fix. Captured 2026-05-18 from UAT. **SHIPPED in 8fcbe45:** `sm:-mx-6 sm:-mt-3 sm:-mb-6` on the wrapper (mobile layout unchanged).
> - **DocumentsHub: hide breadcrumb on root "All documents" view, move PageHeader up to fill the space** — _src/components/documents/documents-hub.tsx:196-209_ — the top row currently always renders the `FolderBreadcrumb` (and conditionally the `NewDocumentMenu` when a folder is selected); on the root view (`selectedFolderId === undefined`) the breadcrumb shows only a "Home / All documents" label with no useful navigation, eating vertical space above the `PageHeader` that already says "Documents" + description. Fix: wrap the entire breadcrumb row at line 196-209 in `{selectedFolderId !== undefined && ( … )}` so the row is gone on the root; the PageHeader becomes the top element. When the rep navigates into a folder, the row reappears with both breadcrumb + NewDocumentMenu (the existing folder views don't render PageHeader, so the breadcrumb is the wayfinding cue). ~5 min. Captured 2026-05-18 from UAT. **SHIPPED in 2bcf544.**
> - **Residential InterestsTab: whole row should navigate to the interest, not just the "View" link** — _src/components/residential/residential-client-tabs.tsx:273-289_ — current `<li>` lays out `[stage chip] [preferences/notes truncated text] [View → link]` and only the "View" text on the right is clickable. The whole row should be a target, matching the idiom used in the main client's `InterestRowItem` (`src/components/clients/client-interests-tab.tsx:53`) — the entire card is a `<button>`/`<Link>` so reps can tap anywhere. Fix: wrap the `<li>`'s flex container in `<Link href={…}>` (`className="block w-full"` to preserve layout), drop the trailing "View" link, add `hover:bg-muted/50` to make the affordance discoverable. ~10 min. Captured 2026-05-18 from UAT. **SHIPPED (client-detail tab) in c6dcf49.** **SHIPPED (standalone list page) in e33313b:** `<tr onClick={router.push(...)}>` on `residential-interests-list.tsx`; first-cell `<Link>` stops propagation so middle-click / Cmd-click still opens in a new tab.
> - **Residential namespace breadcrumb link is 404** — _src/components/layout/breadcrumbs.tsx_ (the breadcrumb generator splits the URL and makes every segment a link) + missing _src/app/(dashboard)/[portSlug]/residential/page.tsx_ — on any `/{portSlug}/residential/clients` or `/{portSlug}/residential/interests` page, the breadcrumb renders "Residential" as a link to `/{portSlug}/residential` but no `page.tsx` exists at that path (only `clients/` and `interests/` subdirectories). Clicking the breadcrumb yields a 404. Two reasonable fixes:
> - **(a) Quickest:** create `src/app/(dashboard)/[portSlug]/residential/page.tsx` as a server component that calls `redirect(`/${portSlug}/residential/clients`)`. Single file, ~5 min, breadcrumb works immediately. Same pattern works for any other namespace-only segment that lacks a real landing page.
> - **(b) Cleaner long-term:** add a "namespace" concept to the breadcrumb generator — segments that exist only as URL parents (residential, admin if applicable, …) render as plain text (`<BreadcrumbPage>`) rather than `<BreadcrumbLink>`. Centralized in `breadcrumbs.tsx`'s `SEGMENT_LABELS` map by extending the value to `{ label, namespace?: boolean }`. ~30 min, fixes the class of problem instead of one instance.
> - **Recommendation:** ship (a) now, queue (b) if/when a second namespace-only segment hits the same issue.
> - Captured 2026-05-18 from UAT. **SHIPPED (a) in c6dcf49:** new `src/app/(dashboard)/[portSlug]/residential/page.tsx` server-redirects to `/${portSlug}/residential/clients`. (b) namespace concept queued for the second-instance case.
> - **SHIPPED in 989cc4d (I34):** Email / Call / WhatsApp action buttons mirror the main ClientDetailHeader. WhatsApp number resolves from phoneE164 (preferred) or strips free-text phone to digits. Header surfaces "Linked to main client" chip when auto-link matcher (I37) finds a counterpart. Residential client detail header: match the main ClientDetailHeader layout — _src/components/residential/residential-client-detail-header.tsx_ vs _src/components/clients/client-detail-header.tsx_ — the main client header is rich (`Email` / `Call` / `WhatsApp` deeplink button row using primary contact channels, `PortalInviteButton`, `GdprExportButton`, tag chips, top-right action menu with Bell/reminder + Archive/Restore state-aware + perm-gated hard-delete, archived badge with conditional dialog routing). The residential header (33 lines vs 244) shows only an eyebrow, an inline-editable name, a status badge, and place-of-residence — visually orphaned next to the main client experience.
> - **Data-model gap to bridge:** residential clients store contacts inline (`email`, `phone`, `phoneE164`, `phoneCountry` columns on `residentialClients`) rather than via the polymorphic `clientContacts` table the main model uses. Action buttons can still be wired by synthesizing a `[{ channel: 'email', value, isPrimary: true }, { channel: 'phone', value: phone, valueE164, isPrimary: true }]` shape from the inline columns. Other features need verification per residential: tags table exists? portal invite (`residential_clients` has no `clientPortalEnabled` flag → likely N/A); GDPR export (yes — applies to any natural person in EU residence; need a `residential-gdpr-export` route if not already there); archive/restore (residential uses its own service; verify the dialog component expects a `residentialClientId` or needs a separate `ResidentialSmartArchiveDialog`).
> - **Approach options:**
> - **(a) Copy-and-adapt the JSX shape, residential-specific dialogs** — fastest path. Rebuild `residential-client-detail-header.tsx` with the same layout: title row (truncated name + archived badge), meta line (country · added date), action button row (Email / Call / WhatsApp synthesized from inline columns + optional GDPR export), tag chips (if/when residential gets tags), top-right Bell + Archive/Restore + perm-gated hard-delete. Skip features that don't apply to residential (PortalInviteButton). Parallel residential-specific dialogs where the existing client dialogs don't accept a residential type. ~1.5h.
> - **(b) Extract a shared `EntityDetailHeader` primitive** — better long-term. Refactor the main `ClientDetailHeader` to consume a generic `EntityDetailHeader` that takes `{ title, eyebrow?, meta[], contacts[], tags[], topRightActions[], archived }` and renders the layout. Both client headers become thin wrappers that map their entity to the shape. ~3-4h, eliminates the divergence that just got reported, and future entity headers (companies, yachts) can adopt it too — the visual idiom would propagate for free.
> - **Recommendation:** ship (a) now for fast visual parity; queue (b) as a separate Bucket 3 refactor when there's appetite for cross-cutting work. Captured 2026-05-18 from UAT.
> - **StageStepper: surface stage names visibly on reached slices** — _src/components/clients/client-pipeline-summary.tsx:43-82_ (the shared `StageStepper`, used on every client → Interest row card via `InterestRowItem` at `src/components/clients/client-interests-tab.tsx:87`, in the hero/panel variants of `ClientPipelineSummary` — including the per-interest links rendered by `PanelVariant` — and any other caller; fix-once-in-the-shared-component means every surface benefits) — the bar today is a 6px segmented track where each of the 7 pipeline stages is an equal-width slice (filled = reached, hollow = pending). Stage names live only in the `title=` attribute (hover tooltip), so reps have to mouse over to know which slices are filled. User wants the names visible — at least for stages the interest has reached or is currently in.
> - **Recommended approach (concise):** Keep the segmented bar exactly as-is (preserves the visual rhythm + works in narrow cards). Render an inline breadcrumb row underneath with one chip per _reached_ stage — chronological left-to-right, last chip = current stage (filled-emphasis using the stage's `STAGE_BADGE` colour), prior chips in the muted variant of the same colour family with a connecting `→`. Pending stages are not labelled (the bar carries that info). Reads as: `Enquiry → Qualified → EOI` for a deal currently in EOI. ~45min.
> - **Alternative (verbose):** Convert `StageStepper` to a true horizontal stepper layout — text label above each tick, current stage bolded, past stages muted, pending stages greyed. More familiar pattern but takes more vertical space and wraps awkwardly on narrow containers (a client card with 4-5 active interests stacks them all). ~1.5h, including a `compact` prop so the hero variant can keep the dense form.
> - **Recommendation:** ship the inline breadcrumb (concise) — solves the "I can't tell what stage this is at without hovering" complaint with minimum visual footprint, and the existing `STAGE_BADGE` colour map provides the per-stage tint for free. Add a `showLabels?: boolean` prop to `StageStepper` so the dense rail-tile variants (`size="xs"`) can opt out. Captured 2026-05-18 from UAT.
> - **SHIPPED in e33313b:** chose the "verbose" stepper variant — stage-name row underneath the bar showing every stage's `STAGE_SHORT_LABELS`. Reached stages render in foreground text; future stages in muted/60 so the rep still sees the ladder ahead. `size="xs"` keeps the dense rail-tile variant intact (no labels row). `STAGE_SHORT_LABELS` re-exported through `pipeline-constants.ts`. Inline-breadcrumb chips variant deferred — the verbose row reads better at the typical container widths we see in practice.
> - **SHIPPED (sentence rendering) in 03a7521 (J38):** Was `"<actor> updated the X"`; now `"<actor> set X to <value>"` when audit row carries `newValue`. Field-level diff line underneath keeps showing old → new strikethrough for context. Truncates inline value at 60 chars to keep long notes from blowing out the row. Other sub-items (action templates, collapsed-session preview, metadata-based create rows) still parked. EntityActivityFeed: rewrite per-row rendering to surface _what_ changed — _src/components/shared/entity-activity-feed.tsx_ (the shared per-entity timeline used on Clients / Yachts / Berths / Residential / Interest detail pages) — each row currently reads `"<actor> updated the <field>"` with the old→new values dropped on a second line, often null or rendered as a truncated JSON dump. Reps can see _something_ changed but not _what_. Several coordinated fixes — pick the subset that's worth doing in one pass:
> - **(a) Bake the value into the sentence line.** Replace `sentence()` (lines 70-77) so when both `fieldChanged` and `newValue` are present the row reads `"<actor> set <field> to <new>"` (with `(was <old>)` appended in muted text on the same line if `oldValue` exists). Eliminates the separate strikethrough line in 80% of cases and reads like a sentence, not a diff. Keeps the separate diff line only for long-form changes (notes body, descriptions) where truncation matters.
> - **(b) Type-aware value formatting beyond the four enums already handled.** `formatValueForField()` (lines 48-66) special-cases `pipelineStage`, `source`, `leadCategory`, `outcome`. Extend with: user-FK fields (`assignedTo`, `ownerId`, `createdBy`) resolved to display names via the same bulk-resolution pattern queued in the actor/diff UUID finding above; berth-FK fields (`berthId`, `primaryBerthId`) resolved to mooring number; yacht-FK / company-FK fields resolved to entity name; date columns (`outcomeAt`, `dueDate`, `startDate`) formatted as `MMM d, yyyy`; currency columns (`price`, `total`) formatted via `formatCurrency` with the row's currency code from `metadata`; boolean toggles rendered "enabled" / "disabled" instead of "true" / "false"; JSON / object values get a one-line summary (e.g. address → `"Address updated: 123 Main St → 456 Elm St"` rather than the JSON dump).
> - **(c) Compound-action verbs.** The seven `ACTION_VERBS` (lines 26-34) cover only the generic CRUD set. Many real audit-log entries use compound actions (`linked`, `unlinked`, `signed`, `sent`, `viewed`, `archived`, `set_primary`, `merged_into`, `reassigned`, …) that fall back to printing the raw action verb. Audit `audit_logs.action` distinct values for the active port and add a verb + sentence template per case, e.g. `linked` → `"<actor> linked <related-entity-label>"` (reads metadata for the related entity's id + type and renders a clickable link). Templates per action keep the sentence rendering type-safe instead of a giant switch in `sentence()`.
> - **(d) Use `metadata` for create rows.** `create` rows currently say `"<actor> created this record"`. Pull the entity's name/mooring/identifier out of `metadata` (or a small lookup if metadata's empty) so it reads `"<actor> created client <Name>"` / `"<actor> created berth <A12>"`.
> - **(e) Collapsed-session preview text.** The `SessionGroupItem` collapse (lines 245-260) currently reads `"<actor> made N changes in this session"`. Show a one-line preview of _which_ fields were touched (e.g. `"Matt changed pipeline stage, owner, and 2 more fields"`) so reps can see if the session is worth expanding without clicking.
> - Effort: ~2h for (a)+(b)+(d) (the most user-visible wins, all in this one file plus a thin bulk-resolution helper in the activity-feed service). ~1h for (c) (registry of action templates). ~30min for (e). Total ~3.5h for the full bundle, or pick (a)+(b)+(d) as the high-value MVP at ~2h. Captured 2026-05-18 from UAT — same surface as the activity-feed UUID resolution finding above (the bulk-resolution helper introduced for that finding is the prereq for (b)'s user-FK resolution; do these in one pass).
> - **SHIPPED in 03a7521 (J39):** Empty state gains "Link to a company" action; populated state grows top-right "Link to company" button. New `<LinkCompanyDialog>` wraps existing `<CompanyPicker>` + membership-role select + isPrimary checkbox, POSTs to /api/v1/companies/[id]/members. Empty-state copy dropped "Add a membership from a company's detail page". **Client → Companies tab: add CTA to link or create a company membership** — _src/components/clients/client-companies-tab.tsx_ (the tab, including the EmptyState at lines 44-51 and the table-populated branch at 53-101) — the tab currently shows a list of company memberships pulled from `company_memberships`; the EmptyState literally tells the rep "Add a membership from a company's detail page" — a backwards workflow that forces them to leave the client they're working on, navigate to a company, then come back. The populated view also has no "Add another" affordance.
> - **Backend ready:** `POST /api/v1/companies/[id]/members` already exists (with corresponding `PATCH` and `DELETE` on `/members/[mid]`, plus `POST /members/[mid]/set-primary`) and accepts a `clientId` in the body. No new schema work needed.
> - **UI work:** (1) Add a primary "Link or add company" button in the tab header (next to the `Company affiliations` heading), gated by `memberships.manage`. (2) Sheet with two modes — **(a) Link existing**: combobox-search across companies (use existing `/api/v1/companies/autocomplete`) + role select + isPrimary toggle + optional startDate; on submit calls `POST /api/v1/companies/{selectedCompanyId}/members` with this client's `clientId`. **(b) Create new + link**: opens `CompanyForm` in create mode (drawer-in-drawer or step 2 of the sheet); on successful create, chains the same membership POST. Toast on completion, invalidate `['client', clientId]` so the tab refreshes. (3) Replace the EmptyState's copy with one matching the new CTA ("No company memberships yet — link this client to a company below.") and surface the same button there too. (4) Each row in the populated table gets a kebab menu: "Set as primary" (POST set-primary), "Edit role / dates" (PATCH), "Remove" (DELETE with confirm).
> - **Symmetry note:** The "Companies → Members" tab already has the inverse flow (add a client to a company) — same UI primitives should be reusable; consider lifting the membership form into a shared `MembershipForm` if the divergence is small. ~1.5-2 h end-to-end. Captured 2026-05-18 from UAT.
> - **Activity feed: resolve actor + diff UUIDs to display names** — _src/components/dashboard/activity-feed.tsx (ActivityFeedInner ~line 175)_, plus the activity-feed service that loads `audit_logs` rows, plus the diff-rendering helper that produces the `"old → new"` strings — two related findings from UAT, both UUIDs leaking into the rendered card:
> - Diff entries with FK columns (e.g. `assignedTo: "—" → "mEcsLxo5kyFMyhbOSehxJjYSSD7CiLvv"`) print the raw user UUID instead of the user's display name. Root cause: `audit_logs.fieldChanged='assignedTo'` rows store the new column value as a raw string; the formatter has no type info that tells it to resolve as a user FK.
> - Actor / subject identifiers in the row meta (e.g. `"d62aadbf"` — short UUID prefix) also render raw. Same root cause: the renderer falls back to a UUID slice when the row's `actorName`/`subjectLabel` is empty.
> - Fix shape: (1) extend the audit-logs schema (or the activity-feed service) with a typed-field registry — `{ field: 'assignedTo', kind: 'user_fk' }`, `{ field: 'ownerId', kind: 'user_fk' }`, `{ field: 'reassignedTo', kind: 'user_fk' }` etc. (2) When the service hydrates rows for the feed, bulk-fetch every referenced user (`SELECT id, firstName, lastName, email FROM users WHERE id IN (…)`) and replace the raw UUID strings with display names in both the diff `old`/`new` AND the `actorName`/`subjectLabel` columns. (3) Render fallback: if the user no longer exists (deleted/never-existed), show `"Unknown user (#<short-uuid>)"` so the feed remains useful for forensics. (4) Same treatment for any _other_ FK fields that may have slipped in (yacht IDs, berth IDs, etc. — audit at finding time).
> - ~1.5-2 h end-to-end (schema-light approach via a per-field registry in code, no migration). If we ever expand to non-user FKs, generalize the registry to dispatch by entity type. Captured 2026-05-18 from UAT. **SHIPPED in 2cb0b99:** `getRecentActivity` now collects all userIds from `auditLogs.userId` + user-FK `oldValue`/`newValue` (assignedTo, ownerId, reassignedTo, createdBy, addedBy, changedBy, transferredBy), bulk-fetches `user_profiles`, and returns rows with display-name replacements + an `actorName` field. Unknown / deleted users fall back to `Unknown user (#short-uuid)`. ActivityItem client type extended.
> - **EOI bundle UX rework (multi-berth interests)** — _src/lib/services/interest-berths.service.ts_, _src/components/interests/linked-berths-list.tsx_, _src/components/documents/eoi-generate-dialog.tsx_ — **DESIGN CONFIRMED 2026-05-18.** Workflow assumption: half+ of interests are multi-berth; typically one signed EOI covers many berths (e.g. A1-A10) but only the website-entry / "main" berth (e.g. A2) should show "Under Offer" on the public map. The current schema defaults (`is_specific_interest=true`, `is_in_eoi_bundle=false`) invert this — every linked berth shows publicly + nothing is bundled until ticked. Three coordinated changes:
> - **(a) Smarter insert-time defaults** in `addInterestBerth()`:
> - `is_in_eoi_bundle` → default **`true`** (any linked berth is presumed covered by the signed EOI; rep unticks for the rare carve-out case).
> - `is_specific_interest` → default **`false`** for non-primary rows; **`true`** only when the row is primary (matches "only the main berth gets publicly marked Under Offer").
> - ~30 min including unit-test coverage for the new defaults and a clarifying comment.
> - **(b) Rename + tooltip on LinkedBerthsList toggle** — "Mark in EOI bundle" → "Include in EOI" + an info popover explaining the bundle-vs-public distinction (matters more now that the two flags routinely diverge). ~15 min.
> - **(c) "EOI berth scope" picker inside the EOI Generate dialog** — at the moment of EOI generation, surface every linked berth as a row with **two** checkboxes: "In EOI bundle" and "Show on public map". Pre-fill from current flag state (which, post-(a), is mostly already correct). The picker forces the rep to consciously confirm signature scope + public visibility at the moment that question is live in their head, instead of relying on them having visited the LinkedBerthsList toggles upstream. Saving the dialog updates all `interest_berths` rows in one call before kicking off the Documenso envelope. ~1.5-2 h.
>
> 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.
> - **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`).
> 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.
> - **Vocabulary split (key decision):** The pipeline-stage "reservation" + signed "Reservation Agreement" KEEP their names (they describe the right being reserved, not the occupancy). The occupancy record (the `berth_reservations` table + sidebar + client/yacht entity tabs + top-level page) is renamed **"Tenancy."** A Reservation Agreement gets signed → results in a Tenancy.
> - **Q1 data model:** Unify — keep `berth_reservations` (to be renamed `tenancies`) as one table with `tenure_type` + `status` as discriminators. The problem isn't the model — it's that flows / nav / reporting aren't wired. Schema rename pass + the workflow fixes below.
> - **Q2 entry points:** All four creation surfaces — berth detail (existing), interest detail (new, at reservation stage+), top-level `/tenancies` page (new sidebar entry), client detail (new). Each pre-fills from its parent context.
> - **Q3 auto-create:** Auto-create as `pending` on signed reservation_agreement via the existing idempotent `handleDocumentCompleted` webhook. Rep confirms `startDate` + `tenureType` in a follow-up modal before `pending → active`. Default `startDate` = signed date if not on the doc.
> - **Q4 multi-berth:** One tenancy per in-bundle berth (loop `interest_berths WHERE is_in_eoi_bundle=true`). Each gets its own lifecycle (renewals, ends independently).
> - **Q5 renewals:** Configurable per tenure type — `permanent` / `fee_simple` / `strata_lot` → mutate the existing row (one record forever). `seasonal` / `fixed_term` → new row each cycle, linked via `previous_tenancy_id` self-FK.
> - **Q5 transfers:** End old tenancy (`status='ended'`, `endDate=transfer date`) + mint new tenancy for new client with `transferred_from_tenancy_id` FK back to the old one. Preserves history.
> - **Q6 public map:** Active tenancy auto-flips `berths.status='sold'` ONLY when `tenure_type IN ('permanent', 'fee_simple', 'strata_lot')`. `seasonal` / `fixed_term` don't (they're temporary). Reversed when tenancy ends + no replacement is active.
> - **Q7 reporting (locked):** All four widgets ship in v1 — Occupancy heatmap by month, Renewals at risk (next 90 days), Revenue forecast by tenure expiry, Tenancy by tenure type breakdown. All four are gated by the platform-wide module-enabled rule below (don't render when the Tenancies module is dormant).
> - **Q8 permissions:** Three perms — `tenancies.view` (read), `tenancies.manage` (create + mutate + transfer; default super_admin + sales_manager + sales_agent), `tenancies.cancel` (cancel only; default super_admin + sales_manager). Cancel gets its own perm because of revenue implications.
> - **Q9 empty-state UX:** Always show the tab on Client / Yacht detail **when the Tenancies module is enabled** (see platform-wide rule below). When empty, render a friendly empty-state (icon + "No tenancies yet" + a "Create tenancy" button if user has `tenancies.manage`). Discoverable + drives the creation flow.
> - **Q10 invoicing:** v1 ships READ-ONLY — no auto-invoice generation on tenancy lifecycle. Decouple invoicing; revisit once we see how ports actually use the tenancy data.
> - **Platform-wide module-enabled rule (locked 2026-05-25):** the entire Tenancies module surface area is hidden by default. **A sold berth stays sold without any tenancy data** — the platform does not assume tenancies exist for sold berths. The Tenancies module only surfaces when EITHER (a) at least one `tenancies` row exists for the port (lazy auto-enable on first creation, including auto-create from a signed reservation_agreement), OR (b) an admin has explicitly enabled it via a new `system_settings.tenancies_module_enabled` boolean (default `false`). When disabled: hide sidebar entry, hide Client/Yacht/Berth `Tenancies` tab, hide all four reporting widgets from dashboard registry, hide top-level `/{portSlug}/tenancies` page (404), skip auto-create branch in `handleDocumentCompleted` (signed reservation_agreement still progresses the interest stage and flips `reservationDocStatus`, but does NOT mint a `tenancies` row). Admin Settings → Operations gets a "Tenancies module" toggle with helper copy explaining what enabling/disabling does + a warning when disabling with existing rows ("This will hide N existing tenancies — data is preserved but invisible until re-enabled"). Module auto-flips to enabled on first row insert; never auto-disables.
> - **Action:** scope into Bucket 3 items. Next steps: write `docs/tenancies-design.md` covering (1) table rename migration (`berth_reservations` → `tenancies` + `previous_tenancy_id` + `transferred_from_tenancy_id` self-FKs), (2) webhook auto-create branch (gated on module-enabled), (3) status-flip rules for public map, (4) sidebar entry + new permissions + module-enabled gating, (5) reporting widgets (all four, module-gated), (6) entity-tab empty-state CTAs (module-gated), (7) admin Operations toggle + auto-enable-on-first-insert behavior. Then split into PRs.
> - **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.
3.**Pipeline Value tile expanded with per-stage breakdown** — _src/components/dashboard/pipeline-value-tile.tsx_, _src/lib/services/dashboard.service.ts_ — replaced the single-number KPI with a richer card: gross headline + weighted forecast on top, per-stage rows below (label · mini bar · gross value · count + close-probability), and a footnote when default stage weights are in use. Service `getRevenueForecast` extended to return `grossValue`, `weight`, `totalGrossValue`, and `dealsMissingPrice` alongside the existing weighted shape; the tile pulls from `/kpis` (for gross + currency + activeInterests) and `/forecast` (for breakdown). Per-stage warning chip surfaces when berths are missing a `price` so a silently undercounted gross is visible (full coverage → "berth price missing", partial → "N of M missing price"). Leadership can now see how much of the headline is near-close vs speculative. Fixed in this session.
4.**"How weighted forecast works" info popover on the Pipeline Value tile** — _src/components/dashboard/pipeline-value-tile.tsx_ — added an `Info` icon next to the description that opens a `Popover` (click or hover) explaining the close-probability model + showing the per-stage weight table (live from `/forecast`, fallback to `STAGE_WEIGHTS` constant) + a note about whether default or per-port weights are in use. Fixed in this session.
5.**Bulk + inline berth price editing — backend complete** — _src/lib/db/schema/users.ts_, _src/lib/db/seed-permissions.ts_, _src/components/admin/roles/role-form.tsx_, _src/components/admin/users/user-permission-matrix.tsx_, _src/app/api/v1/admin/users/[id]/permission-overrides/route.ts_, _src/lib/validators/berths.ts_, _src/lib/services/berths.service.ts_, _src/app/api/v1/berths/[id]/price/route.ts_, _src/app/api/v1/berths/bulk-update-prices/route.ts_, _tests/helpers/factories.ts_ — new `berths.update_prices` permission carved out from generic `berths.edit` so sales reps can update prices without exposing the full edit surface. Permission seeded on for super_admin/director/sales_manager/sales_agent, off for viewer/residential_partner. New validators (`updateBerthPriceSchema`, `bulkUpdateBerthPricesSchema` capped at 500/batch), services (`updateBerthPrice`, `bulkUpdateBerthPrices`, both transactional + per-row audited with `fieldChanged='price'` + realtime `berth:updated` + webhook fan-out), and routes (`PATCH /api/v1/berths/[id]/price`, `POST /api/v1/berths/bulk-update-prices`). UI shipping in a follow-up — see Features bucket #1. Fixed in this session.
6.**Cancel-document: choose delete-from-Documenso vs keep-for-audit** — _src/lib/services/documents.service.ts (cancelDocument)_ + _src/lib/services/documenso-client.ts (voidDocument)_ + every cancel-document UI surface (interest reservation tab, contract tab, EOI cancel dialog, send-document dialog admin actions, etc.) — today cancel always fires `DELETE /api/v2/envelope/{id}` (or v1 equivalent), which unclogs the Documenso instance but loses the upstream audit trail. UX ask: present the rep with an explicit choice on cancel: (a) **Delete upstream** (current behaviour — frees the Documenso slot, history rendered from CRM `documents` row only) or (b) **Keep for audit** (local row → `status='cancelled'`, no DELETE call; rep can later reopen on Documenso for forensics). Default to (a). Plumb a `cancelMode: 'delete' | 'keep_remote'` param through `cancelDocument` + the route handler; only call `documensoVoid` when mode === 'delete'. ~1-1.5h: service param + UI radio in the existing confirm-cancel dialog + audit-doc-status reflection in the cancelled-doc badge ("Cancelled, kept on Documenso" when keep_remote). Captured 2026-05-22.
7.**Document signature reminders: drop rate-limit when automatic** — _src/components/interests/interest-reservation-tab.tsx:740_ ("Reminders are rate-limited (max once per 7 days per signer)") + the underlying remind-signer service. Today both manual and scheduled-auto reminders share the same 7-days-per-signer throttle. The cap is right for manual clicks (avoids harassment) but breaks the auto-cadence cron: if the rules engine wants to nudge a stale signer every 3 days, it gets swallowed. Plumb a `triggeredBy: 'manual' | 'auto'` flag from the caller and skip the rate-limit when `auto` (the cron's own cadence is the throttle). Manual UI keeps the 7-day cap. ~30-45 min: service param + cron caller + UI copy update ("Reminders are rate-limited for manual sends — automatic follow-ups run on the configured cadence"). Captured 2026-05-22.
8.**EOI tab: add upload-draft-then-place-fields option (parity with Contract / Reservation)** — _src/components/interests/interest-eoi-tab.tsx_ (no `UploadForSigningDialog` mount yet) + _src/app/api/v1/interests/[id]/upload-for-signing/route.ts_ (`documentTypeSchema` is locked to `'contract' | 'reservation_agreement'`) + _src/lib/services/custom-document-upload.service.ts_ (`CustomDocumentType` union, `targetStage` switch, `dateContractSent` / `reservationDocStatus` branch). EOI currently has two paths — template-generated (`EoiGenerateDialog`, `/template/{id}/generate-document`) and external paper-signed upload (`ExternalEoiUploadDialog`, `markExternallySigned`) — but no upload-draft-then-drag-fields flow like Contract/Reservation. Reps with a bespoke EOI PDF have to either generate from template (loses custom layout) or mark-as-external (no signing). Fix shape: extend the union to `'eoi'`, add an EOI branch to the stage-advance + doc-status switch (`pipelineStage='eoi_sent'`, `eoiDocStatus='sent'`, `dateEoiSent`), wire `<UploadForSigningDialog documentType="eoi">` into the EOI tab next to the existing "Generate EOI" CTA. Effort: ~2-3h including the route validator bump, service branch, UI mount, and a smoke playwright run. Captured 2026-05-22.
9.**Surface per-signer copyable signing URLs on every Documenso-driven doc** — _src/components/interests/interest-eoi-tab.tsx_, _src/components/interests/interest-reservation-tab.tsx_, _src/components/interests/interest-contract-tab.tsx_, _src/components/documents/signing-details-dialog.tsx_, _src/components/shared/send-document-dialog.tsx_ — once a document has been created in Documenso, each signer's `signingUrl` is already stored on the `document_signers` row (returned by `/api/v1/documents/{id}/signers`). Today the rep sees Pending / Invited badges but no way to grab a specific signer's signing URL for QA or manual delivery. Add a "Copy signing link" button next to each signer row across every signing-doc tab (EOI / Reservation / Contract / SigningDetails / SendDocument admin panel). Behaviour: button is disabled when `signingUrl` is null (Documenso hadn't returned a URL yet — e.g. send-mode failure); on click, copy to clipboard via `navigator.clipboard.writeText`, toast "Signing link copied" + the truncated URL. Useful for: smoke-testing the signing flow without spamming the rep's inbox, manually pasting a link into a custom email or Slack DM when the auto-send mode failed, and for sales reps who want to QA the look of the page before the customer touches it. ~30-45 min, all UI surface work — backend already exposes the data. Captured 2026-05-22.
10.**Per-template "Send a test" tester for every transactional email the system emits** — _src/components/admin/branding/email-preview-card.tsx_ (current sample-only tester), _src/lib/email/templates/_ (all template files), _src/app/api/v1/admin/branding/email-preview/route.ts_ (current endpoint), new endpoint `/api/v1/admin/email/test-template`. Today the admin can send ONE generic branded shell from Branding, plus an SMTP-connectivity ping from Email — but no way to fire a specific template (password reset, EOI invitation, signing reminder, GDPR export ready, portal activation, reminder digest, bounce-back notice, …) to a designated address. Add a per-template tester card: dropdown of every registered template (read from a central template registry exposing `id, label, sampleProps`), recipient email input, "Send test" button. Backend route renders the selected template with realistic sample props (port branding, fake but plausible client/yacht/EOI), pipes through the same sender helper as the real flow, returns delivery status. Goes on the Email admin page next to the existing SMTP test card. Effort: ~2-3h (registry + endpoint + card + sample-prop fixtures for each template). Captured 2026-05-22.
> - **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.
> - **[Umami] Funnels + Journeys (Phase 5)** — _src/components/website-analytics/funnel-builder.tsx (new)_ + _src/components/website-analytics/journey-flow.tsx (new)_ — Umami's `/api/websites/:id/reports/funnel` and `/journey` endpoints are wrapped (`runFunnelReport`, `runJourneyReport`). Funnel builder = pick N steps (URL or event), see per-step conversion. Journey flow = sankey-style visualisation of where visitors go after a chosen entry page. BLOCKED on Phase 4a for the event-driven half. Cap: ~6-8 h. Captured 2026-05-19; deferred to end per earlier scoping.
> - **SHIPPED (partial) in a7cbee0 (O54):** `VisitorWorldMap` already supported `onCountryClick`; wired through to copy the `/<portSlug>/clients?nationality=<ISO>` deep-link to clipboard with toast on click. Inline filtering of the analytics view itself stays parked alongside Phase 5 — the useUmami* hooks don't yet accept a country filter. **[Umami] Click-to-filter the page from the world map** — *src/components/website-analytics/visitor-world-map.tsx* + new `country` filter store + thread through every `useUmamiTop*`hook —`VisitorWorldMap`already accepts an`onCountryClick(iso2)`prop that's unused. Wire it to a page-wide country filter (Zustand store or URL search param`country=US`) that scopes every card on the page to that country's data. Mirrors Umami's own click-through behaviour. Cap: ~2-3 h. Captured 2026-05-19.
> - **[Umami] Per-rep `identify()` calls for attribution** — _src/components/auth/use-session.tsx (or wherever the session is hydrated)_ + _src/lib/services/umami.service.ts (new `identifyRep` wrapper)_ — call `umami.identify({sessionId, role: 'rep', repId: user.id})` on every authenticated CRM session so Umami's Sessions list can show "this lead came in while Matt was working hours". Privacy-gated: only fires for super-admin / sales-manager / sales-agent roles, never for residential-partner, never for portal-side users. Captured 2026-05-19; deferred as the privacy/value trade-off needs a product call before building.
0.**SHIPPED in 91be0f9:** Form-template fields bind to Interest/Client data — autofill, override-preservation history, dual-surface audit trail. Catalog (`bindable-fields.ts`) + `formFieldSchema.bindTo` allow-list + admin "Bind to" picker; `applySubmission` writes phone/yacht/insert-path diffs that were previously silent; clients endpoint mirrors the existing interest one; `<FieldHistoryProvider>` + inline clock icon next to each editable field on Client + Interest Overview tabs and ContactsEditor (per UX spec d-i). Original spec follows for reference; UI uses a clock icon rather than "i" but the popover content matches.
> - **(a) Template-builder: bind each field to an Interest/Client data point via dropdown.** Today's Field row asks for a freetext `key` + `label` + `type`. Replace `key` with a dropdown listing every bindable data point keyed by a stable token, e.g.:
> - Client-contact-scoped (per channel): `client.contacts.primaryEmail`, `client.contacts.primaryPhone` (resolved server-side to the `client_contacts` row with `isPrimary=true`).
> - Yacht-scoped (when interest has a linked yacht): `yacht.name`, `yacht.lengthFt`, `yacht.makeAndModel`, ...
> - Custom (no binding): freetext `key` for fields that don't map to any record column. Submission stored as-is in `form_submissions.data` JSONB, surfaced for rep review but not written back to any record.
> - **Field shape extension:** `{ key, label, type, required, bindingPath?: string }` where `bindingPath` is the dotted-token from the bindable catalog. `key` stays as the JSONB submission key (so existing templates keep working — `bindingPath` is purely additive).
> - **Catalog source:** define once in `src/lib/services/form-bindings-catalog.ts` exporting `BINDABLE_FIELDS: Array<{ path, label, entity, resolveCurrentValue, writeBack }>` — each entry knows where the value lives, how to read it, and how to write it back. Reuses the existing merge-fields infra (per CLAUDE.md `src/lib/templates/merge-fields.ts`) so the same vocabulary powers EOI templates AND supplemental forms.
> - **(b) Public form autofill.** When the client opens the supplemental URL, server-side resolver:
> - Loads the interest + client + linked yacht for the token.
> - For each field with a `bindingPath`, calls `resolveCurrentValue()` to get the current stored value.
> - Returns each field with a `currentValue` so the public form mounts pre-filled. Client reviews → edits if needed → submits.
> - Fields without a binding stay empty (client-provided input).
> - **(c) Submit handler: diff + override-preservation history.** On submit, for each bound field:
> - Compare submitted value against current value (case-sensitive for free-text; deep-equal for arrays/objects).
> - **Changed** → (i) call `writeBack(submittedValue)` to update the underlying interest/client/contact column. (ii) Append a history row: `{ portId, interestId, clientId, fieldPath, oldValue, newValue, source: 'supplemental_form', submissionId, providedAt, providedBy: 'client' }`. (iii) Audit log entry for the same change (existing audit infra) so org-wide audit reports see it.
> - **New schema:** `interest_field_history` table — `id, port_id, interest_id, client_id (nullable, denormalized for client-detail queries), field_path text, old_value jsonb, new_value jsonb, source text ('supplemental_form' | 'rep_edit' | 'system_inferred'), submission_id (FK to form_submissions, nullable), created_at, created_by` + indexes on `(port_id, interest_id, created_at desc)` and `(port_id, client_id, created_at desc)` for the dual-surface lookups. Alternative: stuff in `audit_logs` with `source='supplemental_form'` and reuse the existing diff schema — cheaper but harder to query for the "show me the override history for this field" UX.
> - **(d) UI surfacing on both record views.**
> - **Interest detail:** small "i" icon next to each field that has history. Hover/click opens a popover: `Previous value: <X> · Updated by client via supplemental form on <date>`. Stacks multiple history rows in chronological order.
> - **Client detail:** same UX, with an additional context line: `Updated via supplemental form for interest <berth label> on <date> → [Open interest]`. Cross-link goes to the source interest. Reuses the same `berthLabel` helper from the document-detail Interest link fix.
> - Bonus: a dedicated "Field override history" section on the interest detail's Activity tab listing every override sourced from supplemental forms (or rep edits) for that interest — gives compliance + dispute resolution a single audit surface.
> - **(e) Edge cases to think through:**
> - **Required fields that resolve to existing values** — should they bypass `required` validation since they're pre-filled? Yes; required = "must have a value at submit time", not "must be re-entered by client".
> - **Multi-value paths** (e.g. `client.contacts.primaryEmail` — what if client has none?) — `resolveCurrentValue` returns null, field renders empty, client provides one, submit writes a new client_contacts row marked isPrimary=true.
> - **Type coercion mismatches** — bind path returns a number (`desiredLengthFt`), form field type is `text`. Catalog defines the canonical type per path; template builder validates compatibility at save time.
> - **Sensitive fields** (passport, DOB) — `BINDABLE_FIELDS` entries flag `sensitivity: 'pii' | 'public' | 'internal'`; the supplemental form template builder warns / blocks selecting PII fields without explicit admin override (avoids accidental public-form data leak).
> - **Effort:** ~12-16h end-to-end. ~2-3h for the catalog + resolver/writer infra. ~2h for the template-builder dropdown UI. ~2-3h for the autofill resolver in the public form service. ~3-4h for the submit diff + history table + audit + writeback. ~2h for the dual-surface UI (interest + client detail history popover). ~1h for sensitive-field gating + edge cases. Captured 2026-05-21 from UAT. **Cross-ref:** ties into the existing supplemental-info-request findings in Bucket 2 (reusable-not-single-use, generate+send split, regenerate+resend) — ship the binding/autofill/history work AFTER those land so the supplemental form is mature enough to carry the additional complexity.
1.**SHIPPED in 0ddaf46 (M42):** FilePreviewDialog now handles seven preview kinds via a single `previewKindFor()` router. Text (.txt/.md/.csv/.tsv/.json/.xml/.log/.yaml/.ini/.html) via new `<TextPreview>` (fetches presigned URL, caps body at 1 MB with banner). Audio/video via native HTML5 with preload="metadata". Office docs (.docx/.xlsx/.pptx/.odt/.ods/.odp) embed via Microsoft hosted Office viewer (presigned URLs carry the token). Unknown mime types render a friendly "preview not supported" block with Download CTA. **Universal in-system preview for every file type (extend FilePreviewDialog beyond PDF + images)** — _src/components/files/file-preview-dialog.tsx:60-120_ — today only `mimeType?.startsWith('image/')` and `mimeType === 'application/pdf'` render; everything else falls through to a blank preview surface (no message, no fallback). User wants every document previewable in-system without forcing a download. Today's gaps: Office documents (.docx / .xlsx / .pptx), plain text (.txt / .csv / .md), email exports (.eml / .msg), video / audio, archives (.zip — see-into).
- **Tier 1 (cheap, native-browser):** plain text (`text/plain`), CSV, Markdown → fetch + render in a styled `<pre>` or via a small markdown renderer (`react-markdown` already a likely dep — verify); video (`video/*`) → `<video controls src=…>`; audio (`audio/*`) → `<audio controls src=…>`. ~1-2h for all four.
- **Tier 2 (lib-based, no server work):** DOCX → `mammoth.js` (~25KB gzipped) renders to HTML in-browser, good fidelity for text/headings/tables, loses complex formatting; XLSX → `sheetjs` (`xlsx` package) renders to an HTML table; PPTX → tricky, browser-side support is poor (recommend skip → fall back to "Download to view"). ~3-4h.
- **Tier 3 (server-side conversion):** for fidelity on complex Office docs, route through a headless LibreOffice or `gotenberg` service to convert to PDF, then preview with the existing PdfViewer. Adds infra cost (Docker container for the converter). ~6-10h including ops setup. **Recommendation:** defer Tier 3 to a follow-up; ship Tier 1 + 2 first and accept the fidelity loss for Office docs.
- **Fallback UX:** when the mime type isn't in any tier, render an empty-state card: file icon + filename + size + "Preview not supported for this file type. [Download to view]" button. Today's silent-blank surface is the bug.
- **Recent Files preview-click fix** (Bucket 4 #7) folds into this: as we audit every preview surface, wire click handlers consistently on FileGrid / RecentFilesList / DocumentList rows. Don't ship preview support without making sure every list surface is actually clickable.
0.**Platform-wide date picker primitive (desktop popover + mobile native) — replace 22 `<input type="date|datetime-local">` sites** — _new_`src/components/ui/date-picker.tsx` + `src/components/ui/date-time-picker.tsx`, then sweep 22 call sites (see list below). Native browser date/datetime inputs render with inconsistent, ugly UI on desktop (varies by Chromium/Safari/Firefox; Comet shows the worst variant). Mobile system pickers are the opposite — touch-friendly wheel/spinner UX that we want to keep. Build a wrapper that switches based on viewport.
> - **Design (no new deps needed):** we already have `react-day-picker@10`, `date-fns@4`, and `src/components/ui/calendar.tsx`. Follow the canonical shadcn pattern (verified via Context7 against current shadcn docs):
> - `<DatePicker>` — desktop: trigger Button shows formatted date + chevron, opens Popover containing `<Calendar mode="single" captionLayout="dropdown" />` (the dropdown caption gives month/year nav for fast jumping to historical dates — critical for the backfill UX). Mobile: native `<input type="date">` for the system picker.
> - `<DateTimePicker>` — desktop: same Popover with Calendar plus a native `<input type="time" step="60">` in the popover footer (shadcn-canonical approach — hides webkit-picker-indicator via `[&::-webkit-calendar-picker-indicator]:hidden` and surfaces a `Clock` icon). Mobile: native `<input type="datetime-local">`.
> - **Mobile detection:** use existing `useIsMobile` hook (if absent, add one via `window.matchMedia('(max-width: 640px)')` + `useSyncExternalStore` so SSR works). CSS-only show/hide is an alternative but DOM duplication wastes a tiny amount; hook-based is cleaner.
> - **Same prop shape as today's `<Input type="date">`** so call-site migration is `<Input type="date" value=… onChange=… />` → `<DatePicker value=… onChange=… />` — minimal surface area change.
> - **Optional polish (defer to v2):** add a `naturalLanguage` flag using `chrono-node` (~2KB) so users can type "next Tuesday" / "in 3 days" — particularly nice on the reminder form's due-date field. Skip for v1 to keep scope tight.
> - **Call sites to migrate (22 files found via `grep "datetime-local|type=\"date\""`):** `src/app/(dashboard)/[portSlug]/invoices/new/page.tsx`, `src/app/(dashboard)/[portSlug]/expenses/scan/page.tsx`, `src/components/berths/berth-form.tsx`, `src/components/invoices/invoice-detail.tsx`, `src/components/yachts/yacht-transfer-dialog.tsx`, `src/components/reservations/reservation-detail.tsx`, `src/components/reservations/berth-reserve-dialog.tsx`, `src/components/expenses/expense-form-dialog.tsx`, `src/components/admin/audit/audit-log-list.tsx`, `src/components/shared/inline-editable-field.tsx`, `src/components/shared/filter-bar.tsx`, `src/components/scan/scan-shell.tsx`, `src/components/dashboard/date-range-picker.tsx`, `src/components/interests/payments-section.tsx`, `src/components/interests/interest-tabs.tsx` (incl. the `MilestoneAdvanceButton` popover at line 318), `src/components/interests/interest-contact-log-tab.tsx`, `src/components/interests/external-eoi-upload-dialog.tsx`, `src/components/reminders/snooze-dialog.tsx`, `src/components/companies/add-membership-dialog.tsx`, `src/components/reminders/reminder-form.tsx`, `src/components/companies/company-form.tsx`, `src/components/reports/generate-report-form.tsx`. Several callers (e.g. `filter-bar.tsx`, `inline-editable-field.tsx`, `date-range-picker.tsx`) wrap the input and need slightly more care — small refactor of the wrapper, not a 1-line swap.
> - **Effort:** ~45 min to build the two wrappers + `useIsMobile` (if needed); ~2-3h to sweep all 22 call sites + visual verification in browser. Total ~3-4h. Captured 2026-05-21 from UAT.
1.**Platform-wide chart library migration: recharts → ECharts** — _src/components/dashboard/_ + _src/components/website-analytics/_ + _src/components/berths/_ — we now run two chart libraries side-by-side: ECharts (just adopted for the world choropleth + tree-shaken, canvas renderer, d3-geo projection) and recharts (everything else: berth-status donut, occupancy-timeline line, pipeline-funnel bar, lead-source pie, source-conversion bar, berth-heat-widget bars, pageviews-vs-sessions area, pipeline-value-tile mini-bars — ~8+ components). **Trade-off analysis (done 2026-05-19 during analytics build):** ECharts wins on visual polish (better default styling, smoother animations, native legend/tooltip behaviour), comprehensive chart types (sunbursts, sankeys, parallel coords, heatmaps, geo all out of the box), and canvas-renderer performance on dense series; recharts wins on React-idiom (declarative `<Area>` / `<Bar>` children vs imperative option objects) and bundle size for the very simplest charts. **Migration cost:** ~6–10 h to port the existing 8 components; each is a 50–150 LOC swap from `<ResponsiveContainer><AreaChart>…` to an `<ReactEChartsCore option={…} />` with tree-shaken module imports. **Pre-reqs already in place:**`transpilePackages: ['echarts', 'zrender', 'echarts-for-react']` added to `next.config.ts`, `d3-geo` installed, dynamic-import + canvas-renderer pattern proven on the world map. **Recommendation:** do as a single coordinated pass (consistency wins over piecemeal), gated on a free afternoon — none of the existing recharts components are buggy, this is purely about platform-wide visual + capability parity with the new analytics surfaces. Captured 2026-05-19 during the Umami flesh-out work.
2.**Bulk-price editing UI** — _src/components/berths/_, _src/components/berths/berth-columns.tsx_ — backend shipped this session (new `berths.update_prices` permission across schema + 6 role maps + admin UI + factories; validators `updateBerthPriceSchema` + `bulkUpdateBerthPricesSchema`; services `updateBerthPrice` + `bulkUpdateBerthPrices` — both per-row audited with `fieldChanged='price'`; routes `PATCH /api/v1/berths/[id]/price` + `POST /api/v1/berths/bulk-update-prices`, ≤500 berths per batch). UI work pending: (a) wire `InlineEditableField` into the price cell of `berth-columns.tsx` (click → input → PATCH) gated by `can('berths', 'update_prices')`; (b) add `bulk-price-edit-sheet.tsx` (right-side Sheet, per-row inputs, "Set all to" + "Apply % adjust" shortcuts) wired to `bulkActions` on the `<DataTable />` in `berth-list.tsx`. ~2–3 h to ship the UI.
3.**SHIPPED in a147cbc (N44):** Tile accepts optional `range` prop and threads it through /api/v1/dashboard/kpis?range=<slug> + /forecast?range=<slug>. Service functions accept optional {from,to} bounds and scope the pipeline-value SQL to interests created within the window. New `parseRangeSlug` helper inverts `rangeToSlug`. Widget registry forwards the active dashboard range to the tile. **Pipeline Value tile should respect dashboard timeframe** — _src/components/dashboard/pipeline-value-tile.tsx_, _src/lib/services/dashboard.service.ts_ — the dashboard has a Today / 7d / 30d / 90d / Custom filter at the top (`Last 30 days` shown beside the greeting) but the Pipeline Value tile shows an absolute snapshot regardless. Should be constrained to the active timeframe: e.g. "Pipeline as of end of range" + "Revenue actually realized in range" (closed-won × berth price for interests whose `outcome_at` falls in the window). Needs: dashboard-wide timeframe context (Zustand store or React Query keyed by range), forecast/KPI service variants that accept a `range`, and a "realized vs forecast" line in the tile. ~3–4 h.
3a. **Remove `/admin/reports` entirely (redundant with configurable Dashboard) + integrate PDF-report exporter into the Dashboard header** — _src/app/(dashboard)/[portSlug]/admin/reports/page.tsx + src/components/admin/reports-dashboard.tsx_ (DELETE) + _src/components/dashboard/dashboard-shell.tsx (or wherever the dashboard header lives)_ (ADD "Export to PDF" button) + the PDF exporter dialog (next entry). Today's `/admin/reports` page renders Pipeline funnel + Berth occupancy + activity feed — every card is also a Dashboard widget, and the Dashboard is configurable while this page is fixed. Surfaced UAT 2026-05-21 as "feels useless since we have the dashboard" + user follow-up 2026-05-21: "the pdf report exporter we will need to integrate into the dashboard — or make a dedicated reports page with even more charts/stats (though i think this may be redundant)." > - **Decision (locked 2026-05-21):** integrate PDF exporter into the Dashboard, remove `/admin/reports`. Path: (a) delete `src/app/(dashboard)/[portSlug]/admin/reports/page.tsx` + `src/components/admin/reports-dashboard.tsx`; (b) drop the "Reports" link from admin nav + search-nav-catalog (cross-ref the duplicate-key dedupe finding in Bucket 4 — same catalog file); (c) add a redirect from `/admin/reports` → `/dashboard` so any bookmark/external link lands sensibly; (d) add "Export to PDF" button in the Dashboard header (right-hand controls cluster, next to the date-range picker). > - **Why not a dedicated more-charts reports page:** a separate Reports page with "even more charts" inevitably duplicates Dashboard data. Either the Dashboard lags behind, or the Reports page becomes a copy. Better to invest that effort in adding more widgets directly to the Dashboard (which is configurable, so reps who don't want the extra cards can hide them). > - **What if leadership later wants a fixed read-only exec view?** revisit then — by that point we'll know whether reps actually use it or just print the Dashboard. YAGNI for now. > - **Effort:** ~30 min for the route removal + redirect + nav cleanup. PDF exporter itself is feature #3 below — that's where the substantive work is. Captured 2026-05-21 from UAT.
4.**Stylized branded PDF report exporter — Dashboard-integrated (locked 2026-05-21)** — _src/components/dashboard/_ (new `<ExportReportDialog>` + Dashboard header trigger) + _src/lib/services/dashboard-report.service.ts (new)_ + the existing `pdfme` (templates) and `pdf-lib` (filling) infra plus per-port branding from `system_settings`. **Location decision locked:** lives on the Dashboard, NOT on a separate `/admin/reports` page (which is being removed — see 3a above).
> - **UX flow:**
> - Trigger: "Export to PDF" button in the Dashboard header (right-hand cluster, next to the date-range picker).
> - Modal: widget toggle list pre-populated with every widget the user has currently visible on their Dashboard + the option to add hidden ones for this export. Each toggle row shows a thumbnail/preview of the widget for visual confirmation.
> - Range: defaults to the Dashboard's current date range; can be overridden in the modal.
> - Optional fields: report title, subtitle, custom subheader (e.g. "Q1 2026 board review"), optional commentary text block at the top.
> - Branding: auto-pulls port logo + primary colour + header/footer from `system_settings` (per CLAUDE.md branding section). No per-export branding override (matches the locked "don't duplicate branding everywhere" principle).
> - **Available widgets at export time** (any widget visible to the user on their Dashboard, gated by their permissions):
> - **Server-side rendering approach:** lean toward **`pdfme` templated rendering** (already used per CLAUDE.md, no headless-Chromium ops cost). Each widget gets a `WidgetExportTemplate` definition mapping its data to a pdfme schema fragment. Composed at export time based on which widgets the user toggled on. Falls back to a simple text-table rendering for widgets without a dedicated template (gives partial coverage on day 1, fancy charts shipped iteratively).
> - **Charts as PNG fallback** — pdfme can't render Recharts/ECharts components natively. Server-side: render each chosen widget to a PNG via a headless renderer (puppeteer or playwright running against the same chart components), then embed the PNG in the pdfme template. Pre-cache PNGs per widget per range to avoid regenerating on every export.
> - **Export-history table** (`exported_reports`): id, port_id, user_id, file_id, widgets_included, date_range_from, date_range_to, title, created_at. Reps can re-download past exports without regenerating.
> - **Effort:** ~10-14h end-to-end. ~3h for the dialog + widget toggles + modal. ~3-4h for the server-side composition + pdfme template fragments per widget. ~2-3h for chart-to-PNG rendering pipeline. ~1-2h for the export-history table + list UI. ~1-2h for the per-widget template definitions. Captured 2026-05-21 from UAT. **Cross-ref:** 3a (location decision); existing branding infra (per CLAUDE.md); chart-library migration to ECharts (Bucket 3 #00) — if that lands first, the PNG-rendering pipeline gets simpler (ECharts has a native server-side PNG export via canvas).
> - **Stack decision:** dropped pdfme in favor of `@react-pdf/renderer` (already a dep). Rationale: React component model matches the codebase, server-side `renderToBuffer` slots into the existing `/api/v1/*` route pattern, no Chromium dep (smaller image, lower memory), built-in `<PDFViewer>` for the preview modal. Charts render as data tables here — printed reports prioritize the actual numbers over chart shapes, accessible to screen readers, holds up if OCR-scanned. Recharts-as-SVG embedding can be added later if needed; Puppeteer hybrid stays available as a fallback.
> - **Phase A (3b199c2):** foundation. `BrandedReportDocument` page wrapper (logo + title + footer with port name + page numbers), `makeReportStyles(branding)` keyed off the port's primary color with a luminance check for the accent foreground (AA contrast on dark brands). `DashboardReport` with KPI grid + per-widget tables (KPI overview / pipeline funnel / berth status / source conversion / hot deals). Server-side data fetchers via the existing `dashboard.service.ts` — only the selected widget IDs trigger their fetch. `POST /api/v1/reports/generate` with zod-validated discriminated-union schema, `reports.export` perm gate, audit log on success, RFC 5987 Content-Disposition for unicode filenames. UI: `<ExportDashboardPdfButton>` on the Dashboard header. 3 unit tests prove the renderer emits `%PDF-` bytes.
> - **Phase B (47c2ba9):** clients / berths / interests list reports. Shared `<ReportTable>` zebra-striped primitive with no-break rows. Three data resolvers (`resolveClientReportData`/`resolveBerthReportData`/`resolveInterestReportData`) in `src/lib/services/list-report-data.service.ts` with primary-email/phone subqueries for clients, primary-berth left-join for interests, all capped at 1000 rows with "Showing top N of <total>" notice. Route schema widened to 4-arm discriminated union with exhaustiveness `_exhaustive: never` check. `<ExportListPdfButton>` reusable component wired into ClientList / BerthList / InterestList toolbars. 3 more render tests.
> - **Phase C (1cdc2fd):** saved templates. Migration `0079_report_templates.sql` + drizzle schema with sibling-name uniqueness scoped `(port_id, kind, LOWER(name))`. CRUD service + REST routes (`GET/POST /api/v1/reports/templates`, `GET/PATCH/DELETE /api/v1/reports/templates/[id]`) with `reports.export` perm + audit. `<SavedTemplatesPicker>` reusable component wired into both export dialogs — apply a template hydrates the form (widget selection / filters / title); save-as-template inline expands to a name input.
> - **Phase D (5a9b5f6):** preview modal. `<PdfPreviewModal>` POSTs the current form payload, renders the returned Blob in a sandboxed iframe via `URL.createObjectURL`, caches the Blob so the Download button doesn't re-fetch. Re-fetches when the rep tweaks config and re-opens preview. Object URL revoked on close + unmount. Eye button between Cancel and Download on both dialogs. Memoised previewPayload prevents unrelated re-renders from refiring the fetch.
> - **Final shape:** 4 report kinds, per-port logo + primary-color branding, customizable widget picker (dashboard) + include-archived toggle (lists), custom title, save-as-template, apply saved template, preview modal with cached Blob for download, 1000-row export cap, `reports.export` perm, audit-logged, RFC 5987 unicode filenames. **1454/1454 vitest pass; 6 PDF render tests included.** Manual end-to-end (open dashboard → preview → download) is the next gate.
5.**Web analytics integration (companion to #3)** — _new feature_ — per-port web analytics provider config in admin (GA4 / Plausible / Umami / Cloudflare), surfaced as widgets on the dashboard and ingestable into the branded PDF report. Needs: settings UI, provider adapter layer (`src/lib/integrations/analytics/`), dashboard widgets, and inclusion in the report exporter. ~8–12 h.
6.**SHIPPED in 94c24a1 (H33):** Supplemental-info-request email rebuilt to use the shared branded shell (logo + blurred overhead background + max-width 600 table layout) instead of prior plain-HTML page. Per-port branding (logo / primary color / background / header / footer) flows from `getPortBrandingConfig`; CTA button picks up port's primary color. Supplemental-info-request email: branded HTML styling — _src/lib/email/templates/_ — the email is plain HTML (logo missing, no header card, no blurred background), inconsistent with the other branded transactional emails (portal activation / reset / login wrap content in a `BrandedAuthShell`-equivalent HTML layout per CLAUDE.md). Rebuild the template to match the table-based, max-width 600, logo + blurred overhead background look, pulling port branding from `system_settings`. ~1-2 h.
7.**SHIPPED in 989cc4d (I35):** New ResidentialInterestCard + getResidentialInterestColumns + residentialInterestFilterDefinitions; list page drives DataTable + FilterBar + ColumnPicker + SavedViewsDropdown + bulkActions. List endpoint validator widened (pipelineStage as string OR string[], added source filter). Service post-fetches client names via single IN-list lookup so column 1 renders fullName without N+1. New /api/v1/residential/interests/bulk supports change*stage + archive (100-id cap). Kanban view deferred. Residential interests list: visual + functional parity with the main InterestList — \_src/components/residential/residential-interests-list.tsx* vs _src/components/interests/interest-list.tsx_ + _interest-card.tsx_ + _interest-columns.tsx_ + _interest-filters.tsx_ — the residential interests page today is a slim search + stage-filter list (~200 lines). The main InterestList (~700 lines + supporting files) carries the bulk of the product idiom: card / table / kanban view modes (kanban is desktop-only), `usePaginatedQuery` with sort + saved views, full `FilterBar` (search, stage, tags, owner, source, date ranges), `ColumnPicker` for table mode, bulk actions wired to `/interests/bulk` (archive, change stage, add/remove tag), realtime invalidation across multiple event names, per-row archive flow, kebab actions, `InterestCard` rich row component. Reps switching between berth interests and residential interests today get two visually-divergent experiences for what is effectively the same conceptual surface.
> - **(a) Card view + visual parity (highest leverage)** — replace the table-style `<li>`-per-row layout with a `ResidentialInterestCard` mirroring `InterestCard` (header with client name + stage chip + last-activity, body with preferences/notes preview, footer with quick actions). Reuse the existing `<DataTable />` primitive for the table mode so column picker + sort + bulk-select come for free. ~3-4h.
> - **(b) Export to PDF + CSV** — match the export affordance the main page has (or, if the main page lacks it, add it to both surfaces in the same pass — captured here so it lands on both). PDF: render rows + summary header via `pdfme` / `pdf-lib` (existing infra per CLAUDE.md), branded with port logo. CSV: server-side endpoint `/api/v1/residential/interests/export?format=csv|pdf` (or client-side generation if the dataset is bounded — residential volumes are typically small). Trigger from a kebab menu on the page header. ~2h.
> - **(c) Filter / sort / pagination parity** — extend the residential interests endpoint to accept the same `FilterDefinition` shape (stage, source, assignee, date range, tags) and wire `usePaginatedQuery` + `FilterBar` on the page. ~2-3h.
> - **(d) Bulk actions + saved views** — only if residential workflows actually use them (verify with the external partner team first — residential volumes may be low enough that bulk-mutate is unused). ~2h if needed, skip if not.
> - **Refactor opportunity:** much of the InterestList scaffolding is generic — there's a latent opportunity to extract an `EntityList<T>` primitive that takes `{ endpoint, columns, cardComponent, filterDefinitions, bulkActions }` and renders the whole shell. Both surfaces become thin configs. ~6-8h for the extraction + porting both lists, but pays off the next time a similar list ships (companies, yachts already have parallel lists that could adopt it). Out-of-scope for this finding; capture as a follow-up if appetite exists.
> - **Recommendation:** ship (a) + (b) in one ~5-6h pass for the high-visibility wins (cards + export). Defer (c) until the residential team complains about filter gaps. Skip (d) unless verified-needed.
> - **Companion fix:** see Bucket 1 finding "Residential namespace breadcrumb link is 404" — if the parity work lands a `/residential` landing page, that breadcrumb finding folds into this.
8.**SHIPPED in 989cc4d (I36):** New registry entry `residential_partner_recipients` (comma-separated) under section `residential.partner`. `createResidentialInterest` fires `forwardResidentialInquiryToPartner` after the row lands. Helper uses the same branded shell other transactional emails use. Failures log + never block create. /admin/residential-stages page picks up a registry-driven card so admins manage recipients alongside stages. Residential inquiry → auto-forward to external partner email(s) — _src/lib/services/residential.service.ts_ (`createResidentialInterest`), _src/app/api/public/residential-inquiries/route.ts:97_ (public intake), _src/lib/services/settings.service.ts_ + admin settings UI, _src/lib/email/templates/_ (new template), BullMQ enqueue — residential clients are managed by an external partner; every new residential inquiry needs to be forwarded automatically to one or more configured email addresses so the partner can act on it.
> - **Settings model:** new per-port `system_settings` keys: `residential_forward_enabled` (bool, default false), `residential_forward_recipients` (JSON array of email addresses — `to`), `residential_forward_cc` (JSON array, optional), `residential_forward_filter` (optional discriminator — e.g. only forward inquiries with certain `source` values or above a price/size threshold; v1 ships without this and forwards everything).
> - **Admin UI:** new section in `src/app/(dashboard)/[portSlug]/admin/settings/` ("Residential routing") with: enable toggle, recipient list editor (add/remove emails, drag-reorder, per-row "primary" flag for the To field vs CC), template preview ("Send sample to me"), and a small "Last forwarded N inquiries in the past 7 days" stat for confidence. Permission-gated by `admin.manage_settings`.
> - **Email template:** new branded HTML template `residential-inquiry-forwarded.tsx` in `src/lib/email/templates/` matching the existing branded-shell idiom (port logo + table-based layout per CLAUDE.md) — body includes inquiry fields (client name, contact channel, preferences, notes, source, submission timestamp, link to the residential interest in the CRM if the partner has portal access; otherwise a "view in CRM" stub).
> - **Send pipeline:** enqueue a BullMQ job in `createResidentialInterest` (don't send inline — keeps public intake fast + retries handle SMTP flakes). Job: render template with port branding + inquiry payload, send via existing nodemailer transport, audit a `document_sends` row per recipient for forensics. Honour the dev-only `EMAIL_REDIRECT_TO` envar (per CLAUDE.md) so QA doesn't spam the real partners.
> - **Edge cases:** retry on SMTP failure (BullMQ default retry policy); de-dup if the same inquiry triggers create twice within the dedup window (already a residential-intake concern — verify); skip forwarding when forwarding is disabled mid-flight (settings read at job time, not enqueue time, so toggle takes effect immediately).
> - **Effort:** ~3-4h for settings + template + service hook + BullMQ wiring; +1h for admin UI + sample-send button. Captured 2026-05-18 from UAT.
>
> - **Related:** see Feature 6 below — auto-link residential to existing main-client records, which fires at the same moment in the create pipeline; build (5) and (6) in one pass so the forwarded email can carry the "matched to existing CRM client X" context if a link was found.
9.**SHIPPED in 989cc4d (I37):** Migration 0080 adds `residential_clients.linked_client_id` (nullable FK, SET NULL on cascade) + partial index. New `findAndLinkMatchingMainClient` service matches by email first (case-insensitive client*contacts lookup) then by E.164 phone; first exact match wins. Fires fire-and-forget from `createResidentialClient`. Header surfaces link via "Linked to main client" chip. Backfill script + reverse-direction link from main ClientDetailHeader stay as follow-ups. Auto-link residential interests to existing main-client records (same person) — \_src/lib/services/residential.service.ts* (`createResidentialClient` + `createResidentialInterest`), _src/app/api/public/residential-inquiries/route.ts_, new schema migration adding _src/lib/db/schema/residential.ts_ join table, _src/components/residential/residential-client-detail-header.tsx_ + _src/components/clients/client-detail-header.tsx_ (surface the link on both sides), new admin/dev script for backfill — when the same person who exists in the main berth client list registers a residential interest (or vice-versa), the two records should auto-link so reps can see the full relationship at a glance.
> - **Why a link, not a merge:** the two pipelines are operationally distinct (different team handles residential, different lifecycle stages, different downstream services). A hard merge would conflate records that should remain queryable separately. A symbolic link preserves both records while making the relationship discoverable.
> - **Schema:** new join table `residential_client_links (id, port_id, residential_client_id, client_id, linked_at, linked_by_user_id, link_method enum('auto_email_match' | 'auto_phone_match' | 'manual'), confidence numeric(3,2), notes text)` — composite unique on `(port_id, residential_client_id, client_id)` so the same pair can't be linked twice. Both FKs ON DELETE CASCADE so dropping either side cleans the link automatically.
> - **Match logic** (at residential client/interest create time): normalize the residential `email` to lowercase and check against `client_contacts.value` WHERE `channel='email'`; normalize `phoneE164` and check against `client_contacts.valueE164` WHERE `channel='phone'`. Email match → confidence 0.95 (auto-link, log audit); phone match → confidence 0.80 (auto-link with a "candidate match" badge so the rep can confirm); both match → confidence 0.99. If multiple candidate main-clients match (shared email — family/spouse case), DO NOT auto-link; instead surface all candidates in a UI banner for the rep to pick. Same logic runs in reverse when a new main-client is created (look for matching residential client).
> - **UI surface:** on residential client detail header — small "Linked to <Main client name>" pill below the name, click-through to the main client; if a candidate match was surfaced but not auto-linked, a banner: "Possible match: <Name> (same email/phone). [Link] [Dismiss]". Mirror on the main client header. Add a "Link to existing residential client" / "Link to existing main client" button on each side for manual link creation (combobox-search across the other side). Add an "Unlink" affordance with confirm — useful when an auto-match was wrong (e.g. shared family email).
> - **Audit + telemetry:** every auto-link writes an `audit_logs` row with `action='auto_linked'`, `metadata={method, confidence}` so the org can audit auto-link accuracy. Optional admin dashboard tile showing "N residential links auto-created / manually overridden this week" for ongoing confidence in the match logic.
> - **Backfill script:** `pnpm tsx scripts/backfill-residential-links.ts` — one-pass scan of existing residential_clients vs clients for matching email/phoneE164; idempotent (skips pairs already linked); dry-run by default, `--apply` to commit. Required because the join table is new and existing records won't be auto-linked retroactively.
> - **Effort:** ~4-6h end-to-end (migration + service hook with match logic + UI on both header sides + backfill script + tests + audit). Significant scope but high-leverage: gives reps a single mental model of "this person across our two product lines" instead of two parallel records. Captured 2026-05-18 from UAT.
- **World-map heatmap of Umami visitor origins** — _new file_`src/components/website-analytics/visitor-world-map.tsx` (heatmap card) + extend _src/lib/services/umami.service.ts_ (already returns `top-country` data via `getMetric(type: 'country')`) + viz lib choice (e.g. `react-simple-maps` + Natural Earth TopoJSON, or `@visx/geo`, or a simple SVG world from D3) — render a world choropleth colour-scaled by visitor count per country, surfaced on the Website Analytics page (and optionally on the dashboard as a separate rail widget). Hover any country to see the visitor count tooltip; click to filter the page's other widgets to that country (uses Umami `filters` query param if we extend the route to support it). Implementation notes: ISO 3166-1 alpha-2 codes map cleanly to country features in Natural Earth; cache the topojson in `public/` to avoid per-load fetch. Bundle weight ~50-80KB gzipped depending on lib choice; dynamic-import to keep it off the dashboard bundle when the widget is collapsed. ~4-6h end-to-end. Companion / overlap candidate: the "Clients by country" widget below — a single map could surface both data sources via a toggle (Umami visitors vs CRM clients/prospects) instead of two separate widgets. Captured 2026-05-18 from UAT.
7.**SHIPPED in a147cbc (N45):** New GET /api/v1/dashboard/clients-by-country groups non-archived clients by nationality*iso. `<ClientsByCountryWidget>` renders compact ranked list with mini-bars; rows link to /clients?nationality=<ISO>. Registered as default-visible rail. **"Clients by country" dashboard widget** — \_src/components/dashboard/* (new file `clients-by-country-widget.tsx`), _src/components/dashboard/widget-registry.tsx_, _src/lib/services/dashboard.service.ts_ (or `analytics.service.ts` if it should live in the snapshot-cached family), _new endpoint or extension to `/api/v1/dashboard/...`_ — surface a per-country breakdown of clients (and optionally prospects — interests with `outcome` still open) so leadership can see geographic distribution at a glance. Data shape: aggregate `client_addresses` (or `clients.country` if that column exists) by `country_code` for clients that are non-archived and (for the prospect overlay) join through interests-with-open-outcome. UI options to pick from at build time: (a) compact ranked list with mini bars per row (matches `BerthHeatWidget` / `HotDealsCard` idiom — fits the rail), or (b) a choropleth/world-map (heavier; needs a viz lib like `react-simple-maps` + a topojson; better fit for the chart grid). Pick (a) by default — same footprint as existing rail tiles, no new bundle weight, and clicking a country could deep-link `/clients?country=DE`. Permissioning: gate on `clients.view`. Registry: defaultVisible: true. Effort: ~2-3 h for variant (a) + endpoint + tests; ~6-8 h for variant (b) with a real map. Captured 2026-05-18 from UAT (user request: "add a widget that breaks down prospects/clients by country as a card on the dashboard").
8.**SHIPPED in a147cbc (N46):** New `preferences.dashboardWidgetOrder?: string[]` on user*profiles; `useDashboardWidgets` sorts visibleWidgets by order (unlisted ids fall through to registry order) and exposes `setOrder(nextOrder)` that PATCHes optimistically. DashboardShell wires @dnd-kit/core + sortable: Rearrange toggle turns on per-widget grip handles + sortable-context wraps each group (charts / rails / feed) so drops stay in-group. PointerSensor 8px activation distance, KeyboardSensor for a11y. **Drag-and-drop rearrangable dashboard widgets** — \_src/components/dashboard/dashboard-shell.tsx*, _src/components/dashboard/widget-registry.tsx_, _src/hooks/use-dashboard-widgets.ts_ (assumed name), _src/lib/db/schema/users.ts_ (`user_profiles.preferences`), _src/app/api/v1/me/preferences/route.ts_ — today widget order is hard-coded by registry array order, and visibility is the only user-controllable axis (persisted in `user_profiles.preferences.dashboardWidgets` as a `{ [id]: boolean }` map). Reps want to choose **which analytics show where** on their dashboard (e.g. push Pipeline Funnel to the top, demote Berth Status, swap rail order). Approach: (a) introduce a parallel `dashboardWidgetOrder: string[]` preference (ordered list of widget IDs; missing IDs render after the list in registry order so newly-added widgets always surface); (b) extend `useDashboardWidgets` to return `visibleWidgets` already sorted by this order; (c) keep the three-group layout (`chart` / `rail` / `feed`) — drag-reorder is scoped _within_ a group so the rail's narrower min-col doesn't get a chart-sized tile dropped into it (and vice versa) — moving a widget between groups stays a registry-level concern (the move-out-of-rail request that triggered this entry is an example); (d) add `@dnd-kit/core` + `@dnd-kit/sortable` (lightweight, RSC-safe, already shadcn-adjacent); (e) wrap each group's grid in a `SortableContext`, render a small grip handle on each card header that's only visible in "rearrange mode" (toggle in the existing Customize dropdown — keeps casual users from accidentally grabbing tiles); (f) on drop, optimistic-update the preference and PATCH `/api/v1/me/preferences` with the new order array; (g) realtime: not needed (per-user state). Tests: vitest for the order-merge helper, Playwright smoke for drag-drop + persistence across reload. ~4-6 h end-to-end. Captured 2026-05-18 from UAT after moving the Pipeline Value tile from rail → chart group exposed that re-shuffling widgets is currently a code change, not a user action.
9.**AI-assisted action extraction from contact-log entries** — _src/components/interests/interest-contact-log-tab.tsx_, new LLM service — current dialog already has quick-template buttons that seed `"Called the client. Discussed:\n\n• \n\nNext step: "` (and similar for in-person / email) into the summary textarea — soft structure without enforcement. Adding rigid form fields ("Topic", "Next step", "Outcome") risks killing rep adoption (sales reps notoriously avoid form-y CRMs). Better path: keep the freeform textarea + templates exactly as-is, add an **"Extract action items"** button beside Save that LLM-parses the body and returns proposed follow-ups — `create reminder for {datetime}`, `update desiredLengthFt to {n}`, `suggest stage advance to deposit_paid`, etc. Each proposal lands as a confirm-each list; rep approves individually. **AI assists, rep approves** — never silently mutates the record. Scope: ~6-10 h end-to-end (prompt engineering + LLM client + extraction schema + per-action confirm UI + audit logging of accepted/rejected proposals). Privacy considerations: contact-log entries can contain PII / financial details — route through an in-region LLM provider per the existing email/storage approach. Defer until a user is genuinely asking for it; the current template-seed pattern is fine for now.
10.**SHIPPED (list endpoint + admin route) in aa1f5d2 (R62):** New `listTemplates(portId)` in documenso-client paginates every visible template on configured instance (5-page cap at 100/page = 500 templates). Handles v1 + v2 endpoint shapes; normalises to `{ id, name }`. New GET /api/v1/admin/documenso/templates route exposes list (gated on `admin.manage_settings`). Field-mapping editor + sync-now button + per-template badges stay as picker-UI follow-up. **Documenso-first templates: pull templates from Documenso instead of uploading through CRM (admin UI gap)** — _src/components/admin/document-templates/template-form.tsx_ (template create/edit UI, currently uploads source PDF/HTML), _src/lib/db/schema/documents.ts:254_ (`documensoTemplateId` column already exists), _src/lib/services/document-templates.ts:611_ (`pathway: 'documenso-template'` already routes through Documenso), _src/lib/services/documenso-template-sync.service.ts_ (existing per-port EOI sync; needs generalization), _src/lib/services/documenso-client.ts_ (need a `listTemplates()` wrapper) — the schema and signing pathway support Documenso-hosted templates (the CRM stores only the Documenso template ID, Documenso owns rendering), but the admin UI today assumes the source PDF/HTML lives in the CRM. Reps who maintain their templates in Documenso can wire ONE per port (the EOI, via the existing per-port sync) but can't add other types (welcome letter, handover checklist, correspondence) as Documenso-hosted entries without DB-level intervention. Real product gap — closes the "is Documenso the source of truth, or is the CRM?" question for ports that prefer to author in Documenso.
> - **(a) Template-source toggle** in `template-form.tsx`: radio between "Upload to CRM" (current behaviour) and "Pull from Documenso". Selecting the latter changes the form below.
> - **(b) Documenso template picker** — new combobox that calls a new `GET /api/v1/admin/documenso/templates` endpoint backed by `listTemplates()` (new wrapper in `documenso-client.ts` — v1: `GET /api/v1/templates`; v2: `GET /api/v2/envelope/template`). Lists Documenso-side templates by name + id; selecting one populates `documensoTemplateId` and `templateFormat='documenso_render'`. Cache the list for ~5 minutes per port.
> - **(c) Per-template field-mapping editor** — once a Documenso template is picked, show its field labels (pulled via `getTemplate(id)` — already exists in the sync service) alongside a select-from-merge-tokens dropdown per row. Save the mapping into the `fieldMapping` JSONB column (currently used for AcroForm; reuse the shape: `{ documensoFieldLabel: mergeToken }`). Validate against `VALID_MERGE_TOKENS` on save so the field map can't reference a non-existent CRM token.
> - **(d) "Sync now" button** — re-fetch the Documenso template, diff field labels against the saved `fieldMapping`, surface added / renamed / removed fields so the admin can update the mapping when the Documenso template changes. Generalizes the existing per-port EOI sync (`documenso-template-sync.service.ts`) to per-template.
> - **(e) Template-list page treatment** — each template row in the list shows a small badge "Hosted in Documenso" vs "CRM-managed source" so admins can tell at a glance which is which.
> - **(f) `generateAndSign` already handles this** — `pathway: 'documenso-template'` skips CRM PDF generation and calls Documenso's template-generate endpoint. No service-layer work needed beyond the new admin UI plumbing.
> - **Migration consideration:** the existing per-port EOI sync (single Documenso template ID stored in port settings) becomes redundant once per-template mapping ships — migrate the per-port pointer into a row in `document_templates` with `templateFormat='documenso_render'` + the existing `templateType='eoi'`. Then deprecate the port-setting key. Single-port-EOI flow continues to work via the same templateType lookup; admins gain the ability to add additional Documenso-hosted templates (welcome letter, etc.) using the same UI.
> - **Webhook + auto-file integration:** untouched — signing webhooks (`DOCUMENT_SIGNED`, `DOCUMENT_COMPLETED`, etc.) key on document/envelope ID, not template source, so Documenso-first templates inherit the same signing-status tracking + auto-deposit into the entity folder.
> - **Effort:** ~5-7h end-to-end (toggle + picker + listTemplates wrapper + field-mapping UI + sync button + list-row badge + migration of the per-port EOI pointer + tests). Smaller (~3-4h) if (d) sync button is deferred. Captured 2026-05-18 from UAT in answer to "what happens if we upload templates straight to Documenso? Can we pull the template through?" — answer: yes, but only the EOI flows through today; this finding closes the UI gap for the other template types.
- **[Deferred — blocked on embeddings-based recommender] Berth recommender AI admin section on `/admin/ai`** — _src/app/(dashboard)/[portSlug]/admin/ai/page.tsx_ + _src/lib/services/berth-recommender.service.ts_ — the berth recommender is currently pure SQL (per CLAUDE.md: "Rule-based today; future versions will optionally use embeddings for soft preference matching"). When/if the embeddings-based version ships, surface its admin controls on `/admin/ai` alongside the other AI-feature sections: provider override, embedding model, similarity threshold, per-call budget cap. Until then, the recommender does not call an LLM — including it under `/admin/ai` today would mislead admins into thinking they're tuning an LLM. **Action: revisit when an AI/embeddings tier is added to the recommender.** Cross-ref: parent finding "Consolidate every AI-feature admin control onto /admin/ai" in Bucket 2. Captured 2026-05-18 from UAT.
- **[Deferred — depends on Bucket 3 #7 contact-log action extraction] Contact-log AI admin section on `/admin/ai`** — when "AI-assisted action extraction from contact-log entries" (Bucket 3 #7) ships, add its admin controls to `/admin/ai`: provider override, prompt-template editor, per-call budget cap, accepted/rejected proposal stats. Cross-ref: parent finding "Consolidate every AI-feature admin control onto /admin/ai" in Bucket 2 + Bucket 3 #7 "AI-assisted action extraction from contact-log entries". Captured 2026-05-18 from UAT.
- **[Deferred — no design exists] AI inquiry-intake parsing admin section on `/admin/ai`** — if/when AI-assisted inquiry intake parsing is built (e.g. LLM normalizes inbound web-form / email inquiries into structured fields before the rep sees them), surface its admin controls on `/admin/ai`: provider override, confidence threshold for auto-accept vs human-review, fallback behaviour when the AI tier fails, per-call budget cap. No design or scope exists for this feature today — captured as a placeholder so the thought isn't lost when the AI-feature page expands. Cross-ref: parent finding "Consolidate every AI-feature admin control onto /admin/ai" in Bucket 2. Captured 2026-05-18 from UAT.
8.**SHIPPED (Documenso 401/403 path — foundation) in c14f80a (Q61):**`<PortDocumensoConfig>` gains `apiKeySource` + `apiUrlSource` ('port' | 'global' | 'env' | 'default' | 'none'). `getPortDocumensoConfig` populates them based on which layer of the resolver chain produced the value. documenso-client's `<ResolvedCreds>` exposes the source flags; the 401/403 branch surfaces them in `DOCUMENSO_AUTH_FAILURE` internalMessage so operators see "api key source: env, port: <id>" instead of generic `path → 401` body. Same pattern can extend to S3 / Redis / IMAP in follow-ups. **Platform-wide error message audit for prod debuggability** — _cross-cutting_ — triggered by the Documenso-config diagnosis loop: the user got a generic 502 + "Invalid token" upstream message when the real cause was "no Documenso creds entered for this port (silently fell back to a stale env value)." Operators in prod can't see logs the way we can in dev; the error surface should self-describe. Two layers of work:
- **(a) Pre-flight config-shape errors at known integration boundaries** — _src/lib/services/documenso-client.ts_, _src/lib/services/storage/\*_, _src/lib/email/_, _src/lib/services/imap-bounce-poller.ts_, IMAP, SMS providers, payment gateways, etc. — when a call would fail because admin/env config is empty or unparseable, raise a typed `CodedError`_before_ the network call with an operator-facing message like `"Documenso is not configured for {portName}. Open Admin → Documenso settings to enter the API key, or set DOCUMENSO_API_KEY in env."` Include the offending setting key + port name. The `documenso-client``resolveCreds()` is the canonical example to template from — others (IMAP, S3, SMTP, Stripe etc.) should follow the same pattern.
- **(b) User-facing error-message audit** — _src/lib/errors.ts_, all `try/catch` blocks in `src/app/api/*`, all `toastError` consumers in `src/components/*` — scan for `errorResponse(err)` paths that return generic "Something went wrong" / status codes only, and enrich with: (i) the operation that failed ("EOI generation", "Send invoice", "Upload file"), (ii) the likely cause (config missing, permission denied, conflict, etc.), (iii) the next step (where to fix it). Especially important for setting-driven features (email send accounts, storage backends, Documenso config, webhook secrets) where the real cause is one config field off-screen. The error catalog in `src/lib/errors.ts` already supports `CodedError` with operator-friendly `userMessage` — most call sites just need to populate it.
- Total scope: probably a 1-2 day audit + remediation pass. Out-of-scope items to consider during the pass: a per-port "Integrations health" admin page that probes each external integration and shows green/red with the same diagnostic copy.
9.**Universal "upload file → optionally place signing fields"** — _src/components/documents/upload-for-signing-dialog.tsx_ (the existing place-fields step) + _src/components/documents/new-document-menu.tsx_ (Documents Hub upload), _src/components/documents/documents-hub.tsx_ (root + folder upload), _src/components/files/file-upload-zone.tsx_ (the shared dropzone), _src/components/clients/_ + _yachts/_ + _companies/_ document-tab upload surfaces — every modal where a PDF can land should expose an optional "Send for signature?" toggle that swaps the regular file-upload for the field-placement wizard. Avoids the re-upload friction the user currently hits when an arbitrary doc needs signatures. Shape: extract a `<DocumensoFieldPlacementStep>` from `UploadForSigningDialog`, mount it conditionally after the dropzone in every upload modal, and route through `/upload-for-signing` when fields are placed (skip it when only a plain file is uploaded). Backend: extend `CustomDocumentType` to accept `'generic'` (no pipeline-stage advance, no doc-status flip — just files + documents row in `sent` status). Effort: ~8-12h. Captured 2026-05-22.
10.**Comprehensive admin-settings IA audit + regroup** — _src/app/(dashboard)/[portSlug]/admin/_ — 41 admin pages today, organically grown: `ai`, `audit`, `backup`, `berths/bulk-add`, `berths/reconcile`, `branding`, `brochures`, `custom-fields`, `documenso`, `duplicates`, `email-templates`, `email`, `errors`, `forms`, `import`, `inquiries`, `invitations`, `monitoring`, `ocr`, `onboarding`, `pipeline-rules`, `ports`, `pulse`, `qualification-criteria`, `reminders`, `reports`, `residential-stages`, `roles`, `sends`, `settings`, `storage`, `tags`, `templates`, `users`, `vocabularies`, `webhooks`, `website-analytics`. Settings are scattered — e.g. test-email lives on Branding, SMTP test on Email, password-reset copy probably in `email-templates`, but the rep has to guess. Audit each page for: (a) what settings live there now, (b) which settings logically belong elsewhere ("right home" test — Documenso send mode currently lives on Documenso, makes sense; per-port email signature would make more sense under Branding than Email), (c) duplicates (vocabularies vs custom-fields vs qualification-criteria overlap on enum tuning). Then propose a regrouped IA — likely fewer top-level pages with clear domain headers (Configuration → Branding, Email, Documenso, Storage, Webhooks; Workflows → Pipeline rules, Reminders, Auto-stage advancement; Catalog → Vocabularies, Tags, Custom fields, Qualification criteria; Operations → Monitoring, Pulse, Audit log, Errors, Backup; Data → Import, Duplicates, Bulk berth tools; Identity → Users, Roles, Invitations, Onboarding). Pair with a new admin index page that groups by domain instead of a flat alphabetical list. Effort: ~1.5-2 days — audit pass + IA proposal review + actual file moves + nav updates + redirect shims for old URLs. Captured 2026-05-22.
- **SHIPPED in this session (Phase 1 + Phase 2):** Full audit + proposal at `docs/admin-ia-proposal.md`. Final IA = 7 domains, 38 pages (down from 41 via three deletes). `admin-sections-browser.tsx` rewritten to the new domain shape (Brand & Communication, Sales workflow, Catalog, Identity & access, Inbox & data quality, Integrations, System & observability). Deleted with redirects: `/admin/ocr` → `/admin/ai`, `/admin/reports` → `/[portSlug]/dashboard`, `/admin/invitations` → `/admin/users` (this last one was already a redirect). Renamed: "Documenso & EOI" → "Signing service (Documenso)". New: `/admin/berths` index page surfacing bulk-add + reconcile sub-tools (which were previously discoverable only via deep links). `<EmailPreviewCard>` on Branding cross-links to `/admin/email` per-template tester. Search-nav-catalog updated (ocr entry removed, berths entry added). tsc clean.
11.**B3 #9 follow-up — UI wiring for universal upload-with-fields** — _src/components/documents/upload-for-signing-dialog.tsx_ (`<FieldPlacementStep>` lives inside this monolith — needs extraction into a standalone component the other upload modals can mount conditionally), _src/components/documents/new-document-menu.tsx_ + _src/components/documents/documents-hub.tsx_ + _src/components/files/file-upload-zone.tsx_ + entity-tab upload sites (client/yacht/company doc tabs). **Backend foundations SHIPPED 2026-05-22**: `CustomDocumentType` union now includes `'generic'`; `uploadDocumentForSigning` skips pipeline-stage advance + doc-status flip when generic; route validator accepts the new value; storage path category routes to `signed-source/`. **UI half deferred** to a paired session — needs careful surgery to each upload modal to add the "Send for signature?" toggle + mount the extracted field-placement step. Effort for UI wiring: ~5-7h. Captured 2026-05-22.
- **SHIPPED in this session:** `UploadForSigningDialog` now accepts `interestId: string | null`, `entity?: { type, id }`, `folderId?` and `onCreated?` callback. When `interestId` is null + `documentType='generic'`, the dialog POSTs to a new generic endpoint `/api/v1/upload-for-signing` instead of the interest-scoped one. The service was refactored to accept `interestId: string | null` and an optional `entity` arg, skips the pipeline-stage advance + doc-status flip + interest lookup on the generic path, and routes the file row's FK + auto-filed folder via either the interest's client or the caller-supplied entity. New menu item in `NewDocumentMenu` ("Upload & send for signature") appears on both Documents Hub root + folder views; new buttons under `FileUploadZone` on `ClientFilesTab` + `CompanyFilesTab`. Permission gated by `documents.send_for_signing`. Service-level validation enforces the invariant that generic-type uploads MUST come without interestId and vice-versa.
12.**Time-period PDF report + chart rendering + deeper data** — _src/lib/pdf/reports/dashboard-report.tsx_, _src/lib/services/dashboard-report-data.service.ts_, _src/lib/pdf/reports/types.ts_, new _src/lib/pdf/reports/charts.tsx_, _src/components/reports/export-dashboard-pdf-button.tsx_ (date-range picker). Today's PDF report ignores dateFrom/dateTo for most sections and renders every chart-style widget as a table. User wants: (a) **time-range filter** that scopes EVERY section to a chosen window — new clients in the window, new interests in the window, active interests touching the window, in-progress berths (sold/under-offer transitions in the window), pipeline counts at the start vs end of window, etc.; (b) **chart rendering** — react-pdf supports SVG, so build small SVG generators (`<PipelineFunnel data>`, `<OccupancyTimeline data>`, `<SourceMixDonut data>`) inline OR pre-render via vega-lite/d3-node to PNG and embed; (c) **deeper data per section** — add berths-in-flight (status changes within window), client+interest cohort tables, contact-cadence histogram, document-signing throughput. Shape: extend `DashboardReportData` with `window: {from, to}` and new sub-sections; extend the export-PDF dialog to take a date-range; route handler propagates the window to every per-section resolver. Effort: ~8-12h depending on chart-rendering approach (inline SVG is ~6h, vega-lite pre-render is ~10h with a worker round-trip). Captured 2026-05-22.
- **SHIPPED in this session:** Catalog expanded from 5 ids to 25 — chart variants (pipeline funnel bar, berth status donut, source conversion bar, lead source donut, occupancy timeline line) + period cohorts (new clients/interests, berths sold, deposits received, documents/contracts signed) + value views (pipeline value breakdown, revenue forecast, avg sales cycle, berth demand, country distribution, deal pulse distribution, recent activity). Hand-rolled SVG chart primitives in `src/lib/pdf/reports/charts.tsx` (HorizontalBarChart, DonutChart, LineChart) using @react-pdf/renderer's native Svg/Path/Rect support. Export-dialog grew a date-range picker with Last-30/90-days quick presets, defaults to last 30 days. Route + service plumbing carries dateFrom/dateTo. 11 of 16 pending resolvers landed (new_clients_period, new_interests_period, berths_sold_period via audit log, deposits_received_period, signed_documents_period, contracts_signed_period, berth_demand_ranking, lead_source_donut, client_country_distribution, recent_activity, pipeline_value_breakdown, revenue_forecast, avg_sales_cycle). Still pending (in this session's PENDING_RESOLVER_IDS set): stage_conversion_rates, occupancy_timeline_chart (needs daily buckets), inquiry_inbox_summary, reminders_summary, deal_pulse_distribution (requires the pulse service's dynamic computation, not a simple column query — left as follow-up). Also shipped: PDF logo absolutize for server-side fetch (was empty because @react-pdf/renderer can't fetch path-only URLs server-side), "Dashboard report" → "Report" default name, section-orphan fix (`wrap={false}` + `minPresenceAhead`).
> - **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.
> - **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.
> - 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).
> - 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.
> - **Fix:** (a) delete `SIDE_PONTOON_OPTIONS` at line 42. (b) Replace the two `SIDE_PONTOON_OPTIONS.filter(Boolean).map(...)` blocks (lines 264 + 334) with a call to the vocabulary hook — confirm the pattern used by `BerthForm` / single-berth edit (likely `useVocabulary('berth_side_pontoon_options')` or a server-component read). (c) Audit every other dropdown in the wizard for the same pattern: `BERTH_MOORING_TYPES`, `BERTH_CLEAT_TYPES`, `BERTH_BOLLARD_TYPES`, `BERTH_ACCESS_OPTIONS` are all registered as admin-editable vocabularies — verify the wizard reads through `getVocabulary` for all of them, not a local constant. (d) **Data backfill:** the four wrong values (`Port` / `Starboard` / `Bow` / `Stern`) may already be in production rows added via this wizard — write a one-off script to either remap them (`Port → Quay PT` or similar based on the port team's intent) or null them out + flag for manual review. Coordinate with the port team before running.
> - **Effort:** ~30min for the wizard fix + dropdown audit, ~30min for the backfill script + dry-run. Total ~1h plus a stakeholder check on the remap mapping. **Severity high** because (i) silently writing out-of-vocabulary data is a long-tail data-integrity problem and (ii) it shadows the existing admin-editability infra (operators may not realize the vocab is overridable for this field because the wizard ignores it). Captured 2026-05-18 from UAT.
> - **SHIPPED in 2d57417:** wizard now reads `useVocabulary('berth_side_pontoon_options')` instead of the wrong hard-coded enum; admin-editable per-port overrides honoured automatically. Data-backfill script + cross-vocab audit (mooring/cleat/bollard/access — none currently surfaced in the wizard but registered as editable) parked as follow-up.
> 0. **[high] All file downloads land with a blob-UUID filename + no extension** — _src/components/dashboard/chart-card.tsx:34_ (PNG/CSV exports), _src/app/(dashboard)/[portSlug]/expenses/page.tsx:95_ (CSV/XLSX export), _src/components/clients/client-files-tab.tsx:42_, _src/components/companies/company-files-tab.tsx:42_, _src/components/interests/interest-documents-tab.tsx:72_, _src/components/interests/interest-eoi-tab.tsx:597_, _src/components/admin/backup-admin-panel.tsx:90_ — 7 separate download sites share a near-identical anchor-click pattern that creates `<a download="<name>">`, calls `.click()`, and revokes the URL — but **the anchor is never appended to the document**, so Chromium-based browsers (Comet/Arc/Chrome) silently ignore the `download` attribute and fall back to using the blob URL's UUID for the filename (no extension). Captured UAT screenshot: dashboard chart "Download PNG" lands as `939c78df-48cc-466c-a22e-53e9dea69294` 35.5 KB instead of `<chart-name>.png`. Fix: extract a single `triggerBlobDownload(blob, filename)` helper into `src/lib/utils/download.ts` that (1) `document.body.appendChild(a)`, (2) `a.click()`, (3) `a.remove()`, (4) `URL.revokeObjectURL(url)` on a microtask/next-tick so Chrome has time to read the URL. Refactor all 7 call sites to import it; delete the local copies (and the chart-card-local `triggerBlobDownload` declared at chart-card.tsx:34). ~20-30 min including manual verification of each download surface. **Affects every file-export flow** — bumping severity to high. Captured 2026-05-18 from UAT. **SHIPPED in 2d57417:** added `src/lib/utils/download.ts` with `triggerBlobDownload(blob, filename)` (DOM-attached anchor + deferred URL revoke) + sibling `triggerUrlDownload(url, filename)` for presigned-URL paths; refactored all 7 call sites, dropped the chart-card-local copy.
1.**SHIPPED in aa1f5d2 (T64):** Migration 0082 deduplicates any existing (port_id, mooring_number) collisions by archiving all but the canonical row (prefers price-bearing rows, then earliest-created; archived rows carry explicit `archive_reason`). Adds partial unique index `uniq_berths_port_mooring_active` on (port_id, mooring_number) WHERE archived_at IS NULL so archived moorings can be reissued but live duplicates can't be created in the first place. **[high] Duplicate row for berth E17 in port-nimara** — DB: two `berths` rows with `mooring_number='E17'`, both with `price=NULL`. The canonical mooring format is meant to be unique per port (see CLAUDE.md "Mooring number canonical format"). Surfaced by the dashboard tile via the new "berth price missing" chip but the root cause is missing/leaked unique constraint. Recommend: dedupe + add partial unique index on `(port_id, mooring_number) WHERE archived_at IS NULL`. Deferred per session call (warning-only UI ships now).
2.**SHIPPED in aa1f5d2 (T65):**`changeInterestStage` now blocks any non-override transition into eoi / reservation / deposit_paid / contract when primary berth has no price (NULL or 0). Override path (sales-manager fix) stays open and records reason in audit log per existing override-reason gate. **[medium] Stage advance allowed without berth price** — Service-level: `changeInterestStage` lets an interest reach EOI/Reservation/Deposit Paid/Contract on a primary berth whose `price` is NULL. EOI doc generation downstream presumably renders blank/$0 for the quote field. Cross-port impact unknown. Recommend: add a `ValidationError("Berth price must be set before advancing past Qualified")` gate in `changeInterestStage` for stages eoi+. Deferred per session call.
3.**[medium] Smart search renders duplicate React keys for `/admin/templates` — console warning + potential render glitch** — _src/lib/services/search-nav-catalog.ts:89-94 + 275-280_ + _src/components/search/command-search.tsx:587_. Two entries in the nav catalog both point at `/:portSlug/admin/templates` ("settings" category at line 89 with EOI/Documenso keywords, "admin" category at line 275 with PDF/email-template keywords). The search renderer keys rows by `navigation:${href}` → React fires the "Encountered two children with the same key" warning. Visible in console as `navigation:/port-nimara/admin/templates`. Behavior is unsupported — could cause omitted/duplicated rows.
- **Fix (layered):**
- **(a) Catalog dedupe:** merge the two entries — keep the "settings" one (line 89, matches surrounding /admin/branding + /admin/storage cluster), absorb the admin-version's keywords (`'pdf templates'`, `'email templates'`, `'merge fields'`, `'eoi template'`), delete the duplicate at line 275.
- **(b) Defensive render-side key:** even after dedupe, change command-search.tsx:587 to compose keys as `navigation:${href}:${category}` (or filter duplicates by href at catalog-load time). Protects against the same bug recurring when new nav entries land.
- **Audit:** grep the catalog for any other href that appears twice — likely candidates around /admin/email, /admin/users, /admin/settings if similar consolidations happened. Single dedupe sweep at the top of the catalog file.
- **Effort:** ~15 min. Captured 2026-05-21 from UAT console.
- **SHIPPED in 2d57417:** dedupe lives at the catalog-search layer (`searchNavCatalog` keeps the highest-scoring entry per href via a Map) so any future intentional cross-category re-entries are safe; the two `/admin/templates` rows were also merged into a single richer-keyword entry.
4.**[medium] Overview "Latest note" teaser is stale after creating a note in the Notes tab (no cross-query invalidation)** — _src/components/shared/notes-list.tsx:164-184_ (create/update/delete mutations) + _src/components/interests/interest-tabs.tsx:1083-1104_ (teaser reads `interest.recentNote` + `interest.notesCount` from the parent interest detail object). The notes-list mutations invalidate `[entityType, entityId, 'notes', 'own' | 'aggregated']` but not the parent `['interests', interestId]` query that hydrates `recentNote` / `notesCount`. Net effect: rep adds a note in the Notes tab → switches to Overview → teaser still shows the previous note + the old count until a hard refresh. Same gap presumably affects Client / Company / Yacht detail Overviews if they have similar embedded latest-note teasers.
- **Fix:** add an optional `parentInvalidateKey?: QueryKey` prop to `NotesList`; on each mutation's `onSuccess`, invalidate it alongside the notes query key. The interest tab passes `['interests', interestId]`; the client/company/yacht tabs pass their equivalent. Belt-and-braces: also invalidate inside the parent entity's note-related mutations if any exist directly.
- **Effort:** ~20-30 min (prop + 4 call sites + a vitest covering the invalidation chain). Captured 2026-05-21 from UAT.
- **SHIPPED in 2d57417:** `NotesList` now takes `parentInvalidateKey?: QueryKey`; wired through 5 callers (interests, clients, yachts, companies, residential_clients, residential_interests). Create / update / delete mutations invalidate the parent detail query alongside the notes query key.
5.**[high] InterestDocumentsTab uploads land with `client_id=NULL` — invisible in Attachments + no client subfolder auto-created** — _src/components/interests/interest-documents-tab.tsx:141-147_ (caller passes `entityType="client"` + `entityId={clientId}` but NOT `clientId` separately) + _src/components/files/file-upload-zone.tsx:63_ (only appends `clientId` to the form body when given as a prop) + _src/lib/services/files.ts:85-101_ (`uploadFile` reads `data.clientId ?? null` literally — does not derive it from `entityType==='client' + entityId`). Net effect: upload POST hits `/api/v1/files/upload` with `entityType=client&entityId=<UUID>` but no `clientId` form field, so the `files` row lands with `client_id = NULL`. Cascading bugs: (a) the Documents tab's "Attachments" list (`GET /api/v1/files?clientId=<UUID>`, filters on `eq(files.clientId, clientId)`) returns empty — file vanishes from the interest's Documents tab; (b) Documents Hub auto-deposit can't `ensureEntityFolder` for the client (it walks `files.clientId`), so the `Clients/<client-name>/` subfolder under the system root is never created — file lives at root in "All documents" but isn't filed by entity. The file IS reachable via the port-wide "All documents" view because that query has no clientId filter.
- **Fix (recommended at service layer — durable):** in `src/lib/services/files.ts:uploadFile`, when `data.entityType==='client'` AND `data.clientId` is not set, default `data.clientId = data.entityId`. Same for `entityType==='company'` → `companyId`, `entityType==='yacht'` → `yachtId`. Catches any other caller making the same mistake. Plus `ensureEntityFolder` should fire on every upload that lands with an entity FK, not only when explicit clientId was provided.
- **Caller fix (belt + braces):** pass `clientId={interest.clientId}` alongside `entityType` + `entityId` in interest-documents-tab.tsx:141-147. Audit other FileUploadZone call-sites for the same pattern (client-files-tab, yacht-files-tab, company-files-tab).
- **Backfill needed:** existing rows uploaded via this path have `client_id=NULL` despite having `entity_type='client'` + `entity_id=<UUID>`. One-off script to backfill `client_id` from `entity_id` where entity_type='client' AND client_id IS NULL; same for company/yacht. Then re-run `ensureEntityFolder` for affected rows so the Documents Hub tree catches up.
- **Effort:** ~30 min for service-layer fix + caller audit + backfill script. **High severity** — affects every interest-tab upload on the platform, breaks the Documents Hub filing model for those files. Captured 2026-05-21 from UAT.
- **SHIPPED (service layer + caller) in 2d57417:** `uploadFile` in `src/lib/services/files.ts` now derives `clientId/companyId/yachtId` from `(entityType, entityId)` when the explicit FK isn't passed. Interest-documents-tab also passes `clientId={interest.clientId}` belt-and-braces. **Backfill script + nested-folder migration remain outstanding** — those bundle with the larger Bucket 4 #6 "nested document subfolders" feature in PR Batch 4.
6.**[medium] External EOI upload — 3 linked bugs: lying toast + broken View button + UUID-named download** — _src/components/interests/external-eoi-upload-dialog.tsx:60_ + _src/components/interests/interest-eoi-tab.tsx:589-605 (`SignedPdfActions`)_ + _src/app/api/v1/files/[id]/download/route.ts_ + _src/lib/services/files.ts (`getDownloadUrl`)_. Surfaced together during UAT 2026-05-21 of the backfill flow.
- **(a) Toast lies about stage advance** — server (`external-eoi.service.ts:142-160`) only advances stage when current is `open|details_sent|in_communication|eoi_sent`; at Reservation+ it correctly leaves the stage alone. But the client toast hardcodes `"External EOI uploaded — interest advanced to EOI Signed"` regardless of what the server did. **Fix:** have `uploadExternallySignedEoi` return `{ stageChanged: boolean, newStage?: PipelineStage }`; client toasts conditionally: stageChanged → "External EOI uploaded — stage advanced to EOI Signed"; else → "External EOI uploaded — filed against this deal (stage unchanged)". ~20 min.
- **(b) "View" button downloads instead of previewing in-app** — `SignedPdfActions.open('view')` opens the presigned URL via `window.open`. Browser behavior depends on `Content-Disposition` header from MinIO/S3 — defaulting to `attachment` triggers download every time. **Fix:** swap `window.open` for the existing `FilePreviewDialog` component (already supports PDFs + images per `file-preview-dialog.tsx:60-61`). Lift a `[previewFile, setPreviewFile]` state to the parent EOI tab and render `<FilePreviewDialog open={!!previewFile} fileId={previewFile?.id} ... />` once. SignedPdfActions's View button just sets the preview state. Pairs with the platform-wide "preview-everything" Bucket 3 feature so the same inline-preview surface gets full file-type coverage. ~30 min.
- **(c) Download filename is the storage-key UUID** — same root cause: `Content-Disposition` doesn't include a filename, so the browser uses the URL's last path segment (the UUID per `generateStorageKey`). **Fix:** generate the presign in `getDownloadUrl` with `response-content-disposition: attachment; filename="<files.filename>"` (S3/MinIO presign param). Honors the original filename stored in `files.filename`. ~15 min including a sweep of other download call sites — `client-files-tab.tsx`, `company-files-tab.tsx`, `interest-documents-tab.tsx`, `interest-eoi-tab.tsx` all hit the same endpoint. Consider also adding a sibling `response-content-disposition: inline` mode (e.g. `GET /api/v1/files/[id]/download?disposition=inline`) for the cases where we DO want native browser preview as a fallback to FilePreviewDialog.
- **(d) [high] Server discards `dateEoiSigned` + `eoiStatus` when stage is past EOI — skip-ahead banner falsely persists** — _src/lib/services/external-eoi.service.ts:142-160_ — when current stage is past `eoi_sent` (e.g. `reservation`, `deposit_paid`, `contract_*`), the `else` branch (lines 157-160) only updates `updatedAt`, ignoring the `signedAt` from the form. So even though the user uploaded an externally-signed EOI with a valid date, `interests.dateEoiSigned` stays NULL → the SkipAheadBanner keeps demanding the rep backfill the EOI signed date with no way to satisfy it.
- **Fix:** split the two concerns. Document metadata (dateEoiSigned + eoiStatus='signed') should ALWAYS be written from the upload — only the pipelineStage advance is gated:
- **Also audit:** `interest-rules-engine` / `evaluateRule('eoi_signed', ...)` should fire on this path too (a manually-uploaded external EOI is still an EOI-signed event for the rules engine — berth-rules like "auto-mark berth Under Offer" depend on it).
- ~30-45 min including the audit + integration test.
- **(e) [medium] No edit affordance for uploaded-EOI metadata post-upload (signedAt / signerNames / notes / title locked)** — the EOI tab's history list at `interest-eoi-tab.tsx` shows uploaded documents but exposes no edit button. Once a rep uploads with a typo in signerNames or the wrong signedAt date, they can't correct it — they'd have to delete and re-upload (losing the audit log link).
- **Fix:** add an "Edit metadata" icon-button next to View / Download on each external-EOI row in the EOI tab. Opens a small dialog with the same fields the upload dialog has (signedAt, signerNames, notes, title), pre-filled. Submit PATCHes the document's metadata JSONB + the interest's `dateEoiSigned` (when changed) in one transaction. Audit-log the change with old→new diff.
- Permission gate: `documents.edit_metadata` or reuse `documents.upload_signed` (the same permission that allowed the upload).
- Side concern: the same edit affordance probably belongs on signed Reservations and signed Contracts too — but those are typically Documenso-bound (signedAt is webhook-attested), so editing should be more restricted there. For external EOIs the rep is the source of truth for signedAt anyway, so editing is safe.
- ~1-1.5h including dialog component + service PATCH + audit log + permission gate.
- **Effort:** ~3-4h total for all five sub-issues (was 1-1.5h before (d) + (e) landed). Captured 2026-05-21 from UAT.
- (a) `uploadExternallySignedEoi` returns `{ stageChanged, newStage }`; client toast branches on the flag.
- (b) `SignedPdfActions` now takes an `onView` callback; `InterestEoiTab` lifts a single `<FilePreviewDialog>` and forwards the callback to both call sites (active doc + history list).
- (d) Service splits metadata write (always: `dateEoiSigned ?? signedAt ?? now()`, `eoiStatus='signed'`) from stage advance (gated on past-EOI). Also fires `evaluateRule('eoi_signed', …)` so berth rules stay in lockstep.
- **Default title** for the external-EOI dialog now derives `External EOI — <Client> — <berth range> — <date>` via the existing `formatBerthRange` helper; rep can override.
- **(e) Edit-metadata UI deferred** to a later wave so it can share infra with the broader signing-flow rework (queued as task #14).
- **SHIPPED (e) in 235e064:** new `updateExternalEoiMetadata` service function patches `documents.title`, `documents.notes`, `interests.dateEoiSigned`, and full-replaces `document_signers` (rows with `id` are UPDATEd, rows without are INSERTed, existing rows whose id isn't in the array are DELETEd). Refuses Documenso-managed docs (`isManualUpload=false`) and non-EOI types with ConflictError. New `PATCH /api/v1/documents/[id]/metadata` route. `<ExternalEoiEditDialog>` mirrors the upload-dialog's signatory affordance (name + email + role + add/remove) plus title / signed date / notes. Document detail page gains an "Edit metadata" button (Pencil icon) that renders only when `isManualUpload && documentType === 'eoi'`. Edit trail recorded in `document_events` as `metadata_updated`.
7.**[high] Expense form: zod refine on `receiptFileIds` fires invisibly — Create button does nothing because the error renders nowhere** — _src/components/expenses/expense-form-dialog.tsx:64-77_ (form registers `useForm` + `zodResolver(createExpenseSchema)`) + _src/lib/validators/expenses.ts:40-47_ (schema-level `.refine()` requiring `receiptFileIds.length > 0 || noReceiptAcknowledged === true`, attached to `path: ['receiptFileIds']`). The form keeps `uploadedReceipt` + `noReceipt` in local React state, never injecting them into the form values via `setValue`. They're spliced into the payload INSIDE `onSubmit` (lines 188-189) — but `onSubmit` is never reached because validation fails first: zodResolver sees `receiptFileIds: undefined` in form values, the refine fails, `errors.receiptFileIds` is set. The form has NO `{errors.receiptFileIds && <p>...}` block, so the error is invisible. Browser scrolls to top of failed form. User reports "I filled everything in and uploaded a receipt — clicking Create does nothing."
- **Fix (recommended — single source of truth in react-hook-form):**
- When `handleFileChange` succeeds: `setValue('receiptFileIds', [uploadedReceipt.id], { shouldValidate: true })`.
- When the "no receipt" checkbox toggles: `setValue('noReceiptAcknowledged', noReceipt, { shouldValidate: true })`. Optionally also `setValue('receiptFileIds', undefined)` when noReceipt is checked.
- When `clearReceipt` runs: `setValue('receiptFileIds', undefined, { shouldValidate: true })`.
- Then drop the local `uploadedReceipt` / `noReceipt` state and read `watch('receiptFileIds')` / `watch('noReceiptAcknowledged')` instead for the UI (or keep them as a UI-only mirror for filename display, but make form state authoritative).
- **Alt (lighter touch):** keep the local state but drop the schema-level refine; move that validation into `onSubmit` manually after merging local state. Loses the form-error idiom — discouraged.
- **Belt + braces (sweep):** audit every form that has `.refine()` rules on fields NOT registered with the form. Same pattern likely exists elsewhere (any form with file uploads or sub-components managing their own state). Add a defensive check: on submit, log/toast a developer warning if a zod error fires on a field that has no error-rendering surface — would have surfaced this bug.
- **Effort:** ~30 min for the expense form fix; ~2-3h for the broader audit of similar refines + state-sync gaps. Captured 2026-05-21 from UAT. **Cross-ref:** the platform-wide form-error UX work in Bucket 2 (scroll-to-first-error + summary banner) would have surfaced this bug visibly — bundle the two as a single rollout so each form audited gets both the missing setValue + the missing error-render surface fixed in one pass.
- **SHIPPED (expense form only) in 2d57417:** `handleFileChange` / `clearReceipt` / `noReceipt` checkbox now mirror to form state via `setValue`; edit-mode `reset()` pre-fills `noReceiptAcknowledged` from the existing expense row. The platform-wide refine-vs-error-surface audit + the broader form-error UX work remain in Wave 3.
8.**[high] Documents filing model needs nested entity subfolders (Interests under Clients; Yachts/Companies parity) — design decisions locked 2026-05-21** — _src/lib/db/schema/files.ts_ (add nullable `interest_id` FK + indexes) + _src/lib/db/schema/document_folders.ts_ (extend entity-folder model to support nested entity folders) + _src/lib/services/files.ts_ (`uploadFile`, `ensureEntityFolder`) + _src/lib/services/document-folders.ts_ + _src/components/files/file-upload-zone.tsx_ (accept + forward `interestId`) + _src/components/interests/interest-documents-tab.tsx_ (caller wires interestId) — companion to bug #4 above. Today's schema can't represent per-interest filing: `files` has no `interest_id` and `document_folders.ensureEntityFolder` only knows top-level client/company/yacht roots. Reps want `Clients/<Client name>/<Interest folder>/<file>` so they can find "everything for this specific deal" in one place — including across multiple historical deals for the same client.
- **D1. Folder naming pattern (single-berth):** `<mooring> · <created month>` (e.g. `A1 · 2026-04`). Stable for the deal's lifetime — does NOT update on stage transitions. Only renames once, on close: appends ` (Lost)` / ` (Won)`. Bookmark / email references stay valid.
- **D2. Folder naming pattern (multi-berth):** `<berth range> · <created month>` using the existing `formatBerthRange()` helper from `src/lib/templates/berth-range.ts` — same idiom as the EOI Berth Number field (per CLAUDE.md). Example: `A1-A3, B5-B7 · 2026-04`.
- **D3. Default upload scope from an Interest page:** radio with two options, **default selected = "This deal (Interest <folder name>)"**, alternate = "Client-level (all deals)". Rep flips to client-level when uploading general docs like passport scans from the interest page.
- **D4. Scope of nesting:** apply to **Interests + Yachts + Companies** (full hierarchy). Yacht folders nest under their owner (Client or Company) per `yachts.current_owner_type/id`. Company-owned yachts nest under their company folder.
- **D5. Rename triggers:** ONLY on close (Won/Lost) or archive. Active deals keep stable names. Primary-berth changes during active life do NOT re-derive (avoids churn).
- **D6. Storage backend (S3 / MinIO / filesystem):** zero implications. Documents Hub folder tree is metadata-only (`document_folders` in Postgres); object keys stay UUID-based (`<portSlug>/<entity>/<entityId>/<uuid>.<ext>` per `generateStorageKey`) and never move on folder rename. Soft-rescue delete is also metadata-only.
- **Schema changes:**
- Add `files.interest_id uuid` nullable FK + index on `(port_id, interest_id) WHERE archived_at IS NULL`. Existing rows stay NULL (= client-level, no interest scope).
- Extend `document_folders.entity_type` to accept `'interest'` (and confirm `'yacht'`, `'company'` are already supported per CLAUDE.md). Existing partial unique index `uniq_document_folders_entity` on `(port_id, entity_type, entity_id) WHERE entity_id IS NOT NULL` still applies. Nested rows: `parent_id` points to the parent client/company folder (not the system root) so the tree carries the hierarchy.
- **Folder-name derivation helper:** new `src/lib/services/document-folder-naming.ts` exporting `deriveInterestFolderName(interest, interestBerths)`:
- Read `interest.dateCreated` (or `createdAt`) → format as `YYYY-MM`.
- Resolve berths via `interestBerths.filter(b => b.isInEoiBundle ?? b.isPrimary)` (fall back to all linked berths if none flagged).
- Append ` (Won)` / ` (Lost)` when `interest.outcome` is set; ` (Archived)` when `interest.archivedAt` is set without outcome.
- Pure function, unit-tested.
- **Service-layer wiring (combines with the #4 service-layer fix):**
-`uploadFile`: when `entityType==='interest'` OR `interestId` is set → resolve parent client via `interests.clientId`, call `ensureEntityFolder('client', clientId)`, then `ensureEntityFolder('interest', interestId, parentFolderId: clientFolderId, name: deriveInterestFolderName(...))`, file the row at the interest folder. Three-tier: PORT root → client subfolder → interest sub-subfolder.
-`uploadFile`: when `entityType==='yacht'` OR `yachtId` set → resolve owner (`yachts.currentOwnerType` + `currentOwnerId`), ensure owner folder, ensure yacht subfolder under it.
-`uploadFile`: when only `clientId` set (no interestId, no yachtId) → file at client folder (today's behavior).
- **The #4 derive-clientId-from-entityType fix collapses into this:** `uploadFile` now always derives the FK from `entityType + entityId` if not explicitly passed. The bug-#4 hot-fix is the trivial 1-line version; this larger work is the durable version.
- **Upload-time UI affordance (D3):**
-`FileUploadZone` accepts a new `scopeOptions?: Array<{ id, label, entityType, entityId }>` prop + a `defaultScopeId?: string`. Renders a small radio above the dropzone when ≥ 2 options.
- Client / Company / Yacht detail pages with no parent context render the dropzone without the radio (single-scope upload).
- **Lifecycle hooks (D5):**
- Interest outcome lands (Won / Lost): rename folder via a service helper that re-runs `deriveInterestFolderName` and `UPDATE document_folders SET name=...`.
- Interest archived: append ` (Archived)` if no outcome set.
- Soft-rescue per CLAUDE.md — never hard-delete folders even on `archive`.
- Primary-berth changes mid-deal: NO rename (per D5 — stable during active life). The folder name reflects creation-time berths; current berths are visible elsewhere in UI.
- **List query updates:**
- InterestDocumentsTab "Attachments" section: surface BOTH (i) files with `files.interest_id === interestId` under a "This deal" subheading + (ii) files with `files.client_id === clientId AND interest_id IS NULL` under a "From client" subheading. Mirrors the aggregated-projection idiom (per CLAUDE.md).
- Documents Hub tree: render interest subfolders inside parent client folder. Add a small outcome chip per interest folder (Won / Lost / Active).
- **Backfill (combines with #4 backfill):**
- Files with `entity_type='interest' + entity_id=<UUID>` but missing `interest_id` column → backfill `interest_id = entity_id`; derive parent `client_id` from `interests.client_id`; run `ensureEntityFolder` for both levels.
- Files with `entity_type='yacht'` + `entity_id` but missing `yacht_id` → mirror.
- Files with only `client_id` set pre-feature stay at client-folder level — no interest scope retroactively (can't infer which interest they belonged to).
- **SHIPPED (foundation only — phase 1/3) in e91055f, phases 2/3 in 0ed03fc:** migration `0078_files_interest_id.sql` adds `files.interest_id` text REFERENCES interests(id) ON DELETE SET NULL + indexes `idx_files_interest` + `idx_files_port_interest`. Drizzle schema picks up the column + `interestId` field. `EntityType` widened to include `'interest'` — `ensureEntityFolder('interest', ...)` recursively ensures the parent client folder first so the tree reads `Clients/<Name>/Deal <mooringNumber>/` nested. `resolveEntityDisplayName` derives the deal label from the primary berth via dynamic-import of `getPrimaryBerth` (circular-dep dodge), falling back to `Deal <YYYY-MM-DD>`. **Phases 2/3 SHIPPED in 0ed03fc:** UploadZone scope radio (`<FileUploadZone>` accepts optional `interestId`; when set, fieldset renders "File at: ⦿ This deal | ◯ Client-level"; default deal-scope so deal-specific docs don't bleed across historical interests of the client; interest FK forwarded only when "This deal" selected). Outcome → folder rename hook: `renameInterestFolderForOutcome(interestId, portId, outcome)` strips prior outcome suffix then appends (Won)/(Lost)/(Cancelled); fired fire-and-forget from `interests.service.setInterestOutcome` via dynamic import (circular-dep dodge); no-op when folder hasn't been created yet. Backfill script: `scripts/backfill-nested-document-folders.ts` iterates every (port_id, interest_id) pair in `files` with non-null interest_id and calls `ensureEntityFolder`; idempotent via per-port advisory lock (FNV-1a of port_id); dry-run by default, `--apply` to commit. **Still deferred:**`listFilesAggregatedByEntity` rewrite for "This deal" vs "From client" subheadings (UI polish; per-row filing already correct); Documents Hub tree rendering for nested interest folders (rows exist with parent_id; tree component picks them up automatically).
- **Final phase SHIPPED in this session:** `listFiles` now accepts an optional `interestId` filter (validator + service); `listFilesAggregatedByEntity` accepts `entityType='interest'` and routes to a new helper that returns "THIS DEAL" + "FROM CLIENT" + symmetric-reach company/yacht groups. `InterestDocumentsTab` Attachments section now fires two paginated queries (one scoped to `?interestId=<X>`, one scoped to `?clientId=<C>`), filters the client list to drop duplicates, and renders the two cohorts under "This deal" / "From client" subheadings. `FileRow` exposes the optional `interestId` so the de-dupe filter works without a re-fetch. Tree rendering in Documents Hub still relies on the tree component picking up child folders by `parent_id` (which already works); no additional UI surgery needed.
9.**SHIPPED in c14f80a (Q58):**`<SelectTrigger>` now accepts `size?: 'default' | 'sm'`; default = `h-11` so trigger matches Input's h-11 default. Existing compact call sites (FilterBar, dense table headers) opt back in via `size="sm"`. Nothing else breaks. **[medium] SelectTrigger height (`h-9`) doesn't match Input height (`h-11`) — platform-wide visual inconsistency** — _src/components/ui/select.tsx:22_ (SelectTrigger default `h-9` = 36px) + _src/components/ui/input.tsx:18_ (Input default `h-11` = 44px). Every form where an Input sits next to a Select has an 8px height mismatch. Surfaced specifically on _src/components/expenses/expense-form-dialog.tsx:222-247_ (the Amount + Currency two-column row) but affects ALL such combinations across the platform. Fixing locally with `className="h-11"` on each call site is a sweep over dozens of spots and creates drift the next time someone copies the pattern.
- **Fix (platform-wide):** introduce a `size` variant on SelectTrigger mirroring Button's idiom — `<SelectTrigger size="default" | "sm">`. Default to `"default"` = `h-11` so it pairs with the Input default out of the box. Migrate explicitly-compact uses (filter bars, dense table headers) to pass `size="sm"` = `h-9` to preserve their current density.
- **Audit step:** grep every `<SelectTrigger>` and `<Select>` call site; flag the ones in compact contexts (FilterBar, DataTable header dropdowns, dense admin lists) for the `size="sm"` override; everything else inherits the new h-11 default.
- **Effort:** ~1h for the component change + audit + sweeping the explicit `size="sm"` overrides. Higher upside: enforces visual parity for every future form. Captured 2026-05-21 from UAT.
10.**[medium] Platform-wide: every file-row surface should be click-to-preview by default (currently action is hidden behind kebab on FileGrid; Recent Files rows don't respond at all)** — confirmed on _src/components/files/file-grid.tsx:103-150_ (card body is a static `<div>` with no `onClick`; Preview action lives inside `MoreHorizontal` kebab → opacity-0 unless hovered) + _src/components/documents/_ "Recent Files" rendering surface (rows entirely non-clickable per earlier UAT — preview AND download both dead). Same UX gap repeats across every file-row surface; ship one fix pattern everywhere instead of per-component patches.
- **Fix shape (apply uniformly):**
- **Click target = preview** — the card/row body becomes a `<button onClick={() => onPreview(file)}>` (or accessible `<div role="button" tabIndex={0}>` with keyboard support). Click opens `FilePreviewDialog` directly. Hover state already implies clickability via `hover:border-primary/50 hover:shadow-sm` — wiring the click matches the visual affordance.
- **Kebab stays as "More actions"** — Download, Rename, Delete remain in the dropdown. Drop the redundant "Preview" entry from the kebab once the body click does it.
- **Non-previewable mime types** — still click-to-preview, but `FilePreviewDialog` renders its fallback empty state ("Preview not supported for this file type. [Download to view]"). Pairs with the universal-preview feature already queued in Bucket 3.
-`src/components/documents/document-list.tsx``DocRow` — table-row name cell should be click-to-preview (confirmed UAT 2026-05-21: clicking on the "External EOI — 2026-05-21" filename does nothing)
-`src/components/documents/aggregated-section.tsx` — the "Recent Files / Inflight Workflows" panels
- Any list surface that takes a `files` array + an `onPreview` callback
- **Title cell specifically:** wrap the filename cell in a button-styled span with `onClick={() => onPreview(row)}` so the rep's natural click target works. Keep the row's other action cells (View, Download, kebab) untouched — they're secondary affordances.
- **Bundle with Bucket 3 #000 (universal preview)** — pointless to make every row click-to-preview if half the file types render a blank dialog. Ship the two together: file-row surfaces all click-to-preview AND `FilePreviewDialog` handles every mime type (or shows a graceful fallback).
- **Effort:** ~1-1.5h for the click-target sweep across 4-5 surfaces; ~5-7h with the universal-preview piece bundled. Captured 2026-05-21 from UAT (FileGrid surfaced specifically; Recent Files captured earlier).
- **SHIPPED (FileGrid + DocumentList) in 52342ee:** FileGrid card body is now a `<button onClick={onPreview}>`. DocumentList title cell on rows with `signedFileId` opens `FilePreviewDialog`; kebab keeps More Actions, gains Download.
- **SHIPPED (EntityFolderView + HubRootView) in ded16f4:** filename cells on the entity-scoped aggregated Files panel and the Documents Hub root "Recent files" panel now wrap the name in a `<button>` that opens `FilePreviewDialog`. `HubRootFile` shape extended to include `mimeType` (already returned by `/api/v1/files`). Click-to-preview sweep across file-row surfaces is now complete.
11.**[high] Supplemental-info form blocked by portal kill-switch (route nested under `(portal)` group)** — _src/app/(portal)/public/supplemental-info/[token]/page.tsx_ (current location) + _src/app/(portal)/layout.tsx:25-37_ (`isPortalDisabledGlobally()` short-circuit returns "Client portal unavailable" screen for ALL children). The supplemental-info form is token-protected and conceptually independent of the portal login concept — it's a one-shot URL emailed to a client to fill in extra info for an EOI, and should always work as long as the token is valid. But because the route lives inside the `(portal)` route group, it inherits the layout's "portal disabled?" gate. Net effect: any port that hasn't opted into the client portal (the default state for most ports right now) cannot use the supplemental-info flow at all — clients see the "Client portal unavailable" screen when they click the emailed link, even though the rep just sent it successfully.
- **Fix:** move the file from `src/app/(portal)/public/supplemental-info/[token]/page.tsx` → `src/app/public/supplemental-info/[token]/page.tsx` (out of the route group). URL stays identical (`/public/supplemental-info/<token>`) because Next route groups don't affect URLs — the route group's only effect was layout inheritance, and moving it drops the portal gate. Verify the API route at `/api/public/supplemental-info/[token]` doesn't have a similar nesting issue (likely fine — `/api/` paths don't share the `(portal)` layout).
- **Sweep:** audit `src/app/(portal)/` for any other anonymous token routes that should be outside the group. Currently `find` only returns the one file, but worth verifying as new public flows are added (password-reset tokens, magic-link tokens for non-portal flows, etc.).
- **Effort:** ~10 min for the move + verify (no code change, just file relocation + manual click-through). Captured 2026-05-21 from UAT.
- **SHIPPED in 2d57417:** route relocated via `git mv` to `src/app/public/supplemental-info/[token]/page.tsx`. URL `/public/supplemental-info/<token>` unchanged (route groups don't affect URLs). Sweep of `src/app/(portal)/` confirmed no other public token routes were similarly nested.
12.**[high] Command-search quick-create buttons routed to dead `/new` pages** — _src/components/search/command-search.tsx_ — ZeroState "New client/yacht/company" buttons pushed `/<entity>/new?name=…` which matched the `[id]` dynamic segment and rendered the entity-not-found page. Fixed by switching to `/<entity>?create=1&prefill_name=…` (the existing `useCreateFromUrl` convention) + adding `prefill` prop support to `YachtForm` + `CompanyForm` and wiring `prefill_name` reads in their list components. Now correctly pops the create sheet pre-filled. Fixed in this session.
13.**[high] Dashboard widget cross-group reorder silently ignored by the Customize modal** — _src/components/dashboard/customize-widgets-menu.tsx:113-136_ vs _src/components/dashboard/dashboard-shell.tsx:88-90_ — the Customize modal exposes a single flat `SortableContext` over ALL visible widgets, so a rep can drag (e.g.) "My Reminders" (rail) above "Pipeline Funnel" (chart). The new order persists correctly (`setOrder(...)` → `dashboardWidgetOrder` PATCH → optimistic cache update), and `visibleWidgets` recomputes sorted by rank. BUT the shell then re-buckets `visibleWidgets.filter(w => w.group === 'chart' | 'rail' | 'feed')` into three independent slots before rendering — so any cross-group reorder leaves the dashboard visually unchanged. Intra-group reorders DO work (within charts column, within rails aside, within feed). User-perceived bug: "rearranging apps in the customize modal still does not change the order of them."
- **Decision needed** before fixing — two viable directions:
- **(a) Flatten the dashboard layout** to a single ordered grid (drop the chart/rail/feed bucketing). Honour the rep's exact order across the whole page. Implementation: replace the three-block layout in DashboardShell with one auto-fit grid + per-widget span hints on the registry (`{ colSpan: 1 | 2 | 'full' }`); rails would naturally widen to their hinted column count, feed becomes a `col-span-full` row. Bigger UI surgery, but most honest semantics.
- **(b) Scope the Customize modal sortable to per-group sub-lists.** Render three SortableContexts ("Charts", "Rails", "Feed") inside the modal, each with its own drag handles. Cross-group moves disallowed (or shown as a toggle to move a widget between groups). Smaller code change but loses the flexibility the current UI implies.
- **Recommended:** (b) for the short-term fix (matches the actual rendering reality), with (a) parked as a v2 follow-up after we see whether reps actually want the flat layout.
- **Effort:** ~30-45 min for (b); ~3-4 h for (a) including registry schema bump + responsive layout audit. Captured 2026-05-22 from UAT.
- **SHIPPED in this session:** combined approach. At xl viewports the Customize modal renders three region-scoped sortables (Charts / Side rail / Activity) — matches the actual side-by-side dashboard layout. Below xl where the dashboard stacks all three regions into one visual column, the modal renders a single flat sortable so the rep can drag across regions freely. Plus per-viewport saved orders: `userPreferences.dashboardWidgetOrder` (xl/desktop) + new `dashboardWidgetOrderMobile` (stacked), so reps can customize each layout independently. The hook auto-picks the right field based on viewport.
## Bucket 5 — Cross-references to active audit doc
_Manual findings that confirm or extend a finding from the full codebase audit. Format: `manual #N ↔ Audit X#N — note`._
_None yet._
---
## Append protocol
- Add new findings to the matching bucket as bullet points.
- Where a finding overlaps an audit entry, note `(see Audit X#N)` and add a back-reference line `→ confirmed in manual #<N>` in the corresponding row of `2026-05-18-full-codebase-audit.md`.
- Keep entries terse — one line where possible, file:line evidence inline.
- When promoted to a task or PR, append the commit hash inline (`fixed in <sha>`).