# Alpha UAT Master — Multi-Day Findings > **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 > - `medium` — UX regression, partial functionality, recoverable error > - `low` — cosmetic, copy, polish --- ## Bucket 1 — Quick fixes (<15 min) _Copy tweaks, alignment, single-prop edits, obvious typos._ > **Outstanding quick-fixes (rapid UAT capture — not yet shipped):** > > - **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. > - **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. > - **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. > - **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. > - **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. > - **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 (``); `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. > - **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. 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 ``; 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: - **`UmamiStats` type** flipped from nested `{pageviews: {value, prev}, ...}` to flat `{pageviews: number, ..., comparison?: {pageviews: number, ...}}` matching Umami v3.1.0. - **`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. --- ## Bucket 2 — Medium (15 min – 2 h) _Component refactors, multi-file edits, single-service tweaks, new validators._ > **[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. > - **[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. > - **[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/` 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):** > > - **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 `` 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. > - **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. > - **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. > - **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. > - **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. > - **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. > - **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. > - **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.) > - **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 `` 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 "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. > - **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 `` — 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=&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. > - **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. > - **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. > - **Approach:** > - **(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`** — _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 `` 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. Total ~2.5h. Captured 2026-05-18 from UAT. > - **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. > - **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 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. > - **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. > - **YachtPicker: selected yacht renders as `Yacht ` 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. > - **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. > - **Suspected causes (likely a combination):** > - **(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. > - **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). > - **Yacht Overview: replace single-textarea notes with the threaded `` (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 `` already rendered in the dedicated Notes tab) + _src/components/shared/notes-list.tsx_ — Overview today shows `` — a single `yachts.notes` string column, last-edit-wins. The dedicated Notes tab has the full threaded `` (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 ``. 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. > - **`/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. > - **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. > - **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. > - **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. > - **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 `
` 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. > - **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 (`
`), 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 `
`, 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. > - **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 `
    ` 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 `
  1. ` contains both the label AND its trailing separator (single inline-flex unit), except the last crumb which has no separator. Drop the standalone `` `
  2. ` 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 `