From 6af75eda01c8fd9aecbf95a57145c25ae421d815 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 22 May 2026 13:30:25 +0200 Subject: [PATCH] docs(uat): backfill SHIPPED markers across master doc Previous "annotate plan with per-group SHIPPED commits" pass (6aaccb6) touched only the per-session plan doc; the long-lived alpha-uat-master.md was missing markers for ~20 ships across Groups C-T and two regression catches from the current session. Added markers for: 991e222 (C21+C22+C23 ft/m + bulk), 431375d (D24 wizard ft/m + D25 dock letters + E26 regenerate/resend/history), 94c24a1 (F28 past-milestones + F29 watchers + G30 invitations merge + H32 email explainer + H33 branded supplemental email), 989cc4d (I34 residential header + I35 interests parity + I36 partner forward + I37 auto-link), 03a7521 (J38 set-X-to-Y + J39 link company + K40 resolver chain), 65ff596 (L41 upload-for-signing rework), 0ddaf46 (M42 universal preview), a147cbc (N44/45/46), a7cbee0 (O48/52/53/54), 0ed03fc (P56 phases 2/3), c14f80a (Q58/59/61), aa1f5d2 (R62/T64/T65). Two fresh entries: be261f3 LAN-dev fix, adf4e2b dashboard PDF widget split. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/superpowers/audits/alpha-uat-master.md | 66 +++++++++++---------- 1 file changed, 34 insertions(+), 32 deletions(-) diff --git a/docs/superpowers/audits/alpha-uat-master.md b/docs/superpowers/audits/alpha-uat-master.md index 6ce8ff33..ddcb601f 100644 --- a/docs/superpowers/audits/alpha-uat-master.md +++ b/docs/superpowers/audits/alpha-uat-master.md @@ -39,7 +39,7 @@ _Copy tweaks, alignment, single-prop edits, obvious typos._ > - **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. -> - **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. +> - **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. @@ -87,6 +87,8 @@ _Copy tweaks, alignment, single-prop edits, obvious typos._ > - **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. > - **SHIPPED (full sweep) in f0dbefc:** 176 em-dashes replaced with " - " across 49 files in `src/components` + `src/app`, skipping pure-comment lines (// /\* \* \*/). Two `—` HTML entity cases (system-monitoring-dashboard + interest-stage-picker) caught separately. Lint rule bumped from `warn` → `error` so new code reintroducing em-dashes in JSX text fails the gate. Templates / audit-logs / structured logging stayed untouched per scope. +> - **[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 `` 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 `` 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.** @@ -148,8 +150,8 @@ _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. +> - **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/` 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):** @@ -217,14 +219,14 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._ > - **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 `` 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. -> - **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. +> - **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. > - **Fix:** > - **(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 · expires in `) 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. -> - **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. +> - **SHIPPED in 94c24a1 (F28):** Past strip on Interest overview becomes an `` — each row collapses to the same one-line summary as before, expands to render the full `` (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 `` 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. @@ -242,7 +244,7 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._ > - **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. -> - **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. +> - **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. > - **Fix (platform-wide, single PR):** > - **(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. @@ -253,7 +255,7 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._ > - **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). -> - **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. +> - **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 `` — 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). @@ -265,7 +267,7 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._ > - **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. -> - **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. +> - **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. > - **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. @@ -293,7 +295,7 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._ > - **(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. -> - **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. +> - **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. @@ -323,7 +325,7 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._ > - **Alias catalog (lives next to `STAGE_LABELS`):** add a small `STAGE_SEARCH_ALIASES: Record` 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=`. 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. -> - **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. +> - **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. @@ -357,7 +359,7 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._ > - (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. -> - **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. +> - **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::`), 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: @@ -445,11 +447,11 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._ > - **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 ``; flex-wrap can no longer strand a separator. Ellipsis-collapse (b) + back-chevron alignment (c) parked. > - **BulkAddBerthsWizard: currency field should use `` (already exists, used elsewhere)** — _src/components/admin/bulk-add-berths-wizard.tsx_ (the `priceCurrency` `` in the apply-to-all row at ~lines 282-290, and the per-row instance below it) — currently a free-text `` that uppercases on blur, defaulting to `USD`. Reps can type any string (including invalid codes); no auto-complete; no consistency with other forms. The `` 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.** -> - **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. +> - **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. -> - **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: +> - **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. @@ -462,7 +464,7 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._ > - **(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 (``) rather than ``. 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. -> - **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. +> - **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. @@ -473,14 +475,14 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._ > - **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. -> - **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 `" updated the "` 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: +> - **SHIPPED (sentence rendering) in 03a7521 (J38):** Was `" updated the X"`; now `" set X to "` 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 `" updated the "` 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 `" set to "` (with `(was )` 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` → `" linked "` (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 `" created this record"`. Pull the entity's name/mooring/identifier out of `metadata` (or a small lookup if metadata's empty) so it reads `" created client "` / `" created berth "`. > - **(e) Collapsed-session preview text.** The `SessionGroupItem` collapse (lines 245-260) currently reads `" 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). -> - **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. +> - **SHIPPED in 03a7521 (J39):** Empty state gains "Link to a company" action; populated state grows top-right "Link to company" button. New `` wraps existing `` + 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. @@ -515,11 +517,11 @@ _New UI surfaces, new endpoints, schema migrations, multi-step flows._ > **[Umami] Larger follow-ups parked at end of 2026-05-19 build session:** > -> - **[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/` 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. +> - **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`. `` opens a dialog: paste destination → Create → returns public /q/ URL with Copy + "Insert into message" action. Wired into ``'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/` 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. -> - **[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. +> - **SHIPPED (partial) in a7cbee0 (O54):** `VisitorWorldMap` already supported `onCountryClick`; wired through to copy the `//clients?nationality=` 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; `` + 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. @@ -551,7 +553,7 @@ _New UI surfaces, new endpoints, schema migrations, multi-step flows._ > - **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. **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). +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 `` (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). - **Coverage tiers:** - **Tier 1 (cheap, native-browser):** plain text (`text/plain`), CSV, Markdown → fetch + render in a styled `
` or via a small markdown renderer (`react-markdown` already a likely dep — verify); video (`video/*`) → `