diff --git a/CLAUDE.md b/CLAUDE.md index fce21827..23250b15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ Reach for these before grinding through tasks manually: - `Explore` for any codebase search that would take > 3 queries - `feature-dev:code-explorer` / `code-architect` / `code-reviewer` for new feature work - **Doctrine**: skills override default behavior except user instructions in this file. If a CLAUDE.md rule conflicts with a skill, this file wins. -- **Manual UAT scaffold**: when the user starts a "manual testing" / "UAT" pass (Playwright + React Grab walkthrough), scaffold `docs/superpowers/audits/YYYY-MM-DD-manual-uat-findings.md` (append to today's if it exists) with buckets: Quick fixes (<15min), Medium (15min–2h), Features/larger (>2h), Bugs (severity-tagged), Cross-references to the active audit doc. Append findings to the matching bucket as they land in chat — don't ask for the format each time. +- **Manual UAT — single master doc**: all multi-day Playwright + React Grab UAT findings go into `docs/superpowers/audits/alpha-uat-master.md` (the cross-cutting "alpha" audit that spans many sessions). Append to it as findings land in chat — don't create per-day files. Buckets: Quick fixes (<15min), Medium (15min–2h), Features/larger (>2h), Bugs (severity-tagged), Cross-references to the active full-codebase audit. Don't ask for the format each time. ## Tech stack (non-obvious choices) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 08499384..04f7549a 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -317,6 +317,58 @@ Future PDF-related work (carry-over from §A of the PDF overhaul spec): --- +## J. Activity / timeline copy normalization + +Every "Activity" or "Timeline" surface across the app currently leaks +raw schema details — camelCase field names, UUID values, boolean +`on`/`off` — straight into the user-visible copy. Real examples seen +in production: + +- `Updated owner → mEcsLxo5kyFMyhbOSehxJjYSSD7CiLvv` (user UUID) +- `Updated primary berth → a53e3b1d-d589-4f11-9f7b-3b3a3c1ebb8e` (berth UUID) +- `Updated primary berth → a53e..., isInEoiBundle → on` (raw camelCase + boolean) + +Two distinct renderers need a single source of truth: + +1. **`InterestTimeline`** (`src/components/interests/interest-timeline.tsx`) reads pre-built `description` strings from `/api/v1/interests/[id]/timeline/route.ts` — see `buildAuditDescription` + `describeUpdateDiff` + `formatDiffValue`. Field-label catalog is partial; FK values are unresolved. +2. **`EntityActivityFeed`** (`src/components/shared/entity-activity-feed.tsx`) — used by clients, companies, yachts, berths, residential clients, residential interests. Builds copy client-side via `sentence()` + `formatValueForField`. Catalog is even thinner (only `pipelineStage` / `source` / `leadCategory` / `outcome` get human labels). + +**Plan-of-work:** + +- Build a shared `src/lib/audit/format-audit.ts` with: + - `FIELD_LABELS` per entity type (interest, client, company, yacht, berth, residential\_\*) covering every column we actually surface in audits. Today's gaps: `isInEoiBundle`, `isSpecificInterest`, `isPrimary`, `assignedTo`, `currentOwnerType/Id`, `companyId`, `parentCompanyId`, `mooringNumber`, `priceCurrency`, all the `*_at`/date fields beyond the EOI/contract handful. + - Value formatter that handles: booleans contextually (e.g. `isInEoiBundle: true` → "added to EOI bundle" / `false` → "removed from EOI bundle"; never `on`/`off`), enums via the `formatEnum`/`STAGE_LABELS`/`OUTCOME_LABELS` helpers in `src/lib/constants.ts`, currency+amount pairs, dates via `formatDate`. + - FK resolution: take a `Record` lookup that callers prefill (mooring number for berthId, user name for assignedTo, client name for clientId, etc.) so values render as "→ Anna Schmidt" not "→ mEcs…". +- Update `/timeline` (interests) AND the 6 `/activity` route handlers to: (a) collect FK ids per row, (b) batch-resolve in one query per FK type, (c) pass the lookup into the shared formatter. The audit log itself stores IDs — resolution happens at read time so historical entries stay correct even after renames/deletes (in which case fall back to "(deleted yacht)" etc.). +- Migrate `EntityActivityFeed` to call the same shared formatter on the row's `fieldChanged` + `oldValue`/`newValue` so the strikethrough+arrow rendering uses the same vocabulary. +- Audit-log writes that have meaningful application context but don't fit the column-diff model (e.g. interest-berth flag toggles, EOI bundle membership changes) probably should set `metadata.type` so the formatter can route to a dedicated phrase ("Added berth A12 to EOI bundle", "Made A12 the primary berth") instead of best-effort diffing. + +Acceptance: spot-check the timeline tab on a recently-edited interest, client, yacht, company, and berth. No UUIDs visible; no camelCase field names; no `on`/`off` booleans without context; all enum values render in their human label. + +**Done while scoping (cosmetic fix):** + +- Vertical-connector overshoot in `InterestTimeline` and `EntityActivityFeed` — both renderers used a container-level absolute line that trailed past the last bubble. Replaced with per-item connectors that omit on `isLast`. + +--- + +## I. Dashboard widget wishlist + +User-driven enhancements to the customizable main dashboard +(`src/components/dashboard/widget-registry.tsx`). Each entry is a new +opt-in tile users can add via the widget picker. + +- **More website-analytics stats cards** — expand the dashboard widget + catalogue with additional Umami-backed tiles users can pick from + (e.g. unique visitors, avg session duration, bounce rate, top + country, top referrer of the day, mobile vs desktop split, + pages-per-visit, returning vs new). Today only `WebsiteGlanceTile` + exists. Source data already flows through + `src/lib/services/umami.service.ts` and `useWebsiteAnalytics`. Each + new tile = one `KpiTile`-shaped component + a registry entry. Size: + small per tile, scope grows with the catalogue. + +--- + ## F. Historical audit docs (mostly resolved) These dossiers drove the audit-fix commit waves on 2026-05-05/06. Items diff --git a/docs/superpowers/audits/2026-05-18-full-codebase-audit.md b/docs/superpowers/audits/2026-05-18-full-codebase-audit.md index f18acc1d..14dc6342 100644 --- a/docs/superpowers/audits/2026-05-18-full-codebase-audit.md +++ b/docs/superpowers/audits/2026-05-18-full-codebase-audit.md @@ -1,5 +1,7 @@ # Full Codebase Audit — 2026-05-18 +> **Companion doc:** [Alpha UAT Master](./alpha-uat-master.md) — the multi-day cross-cutting Playwright/React-Grab walkthrough doc, findings cross-referenced here as `→ confirmed in manual #N`. +> > **Methodology:** Parallel sonnet[1m] audit team (16 narrow-scope agents), each assigned a specific subsystem with no overlap. Every finding includes file:line evidence; severity is `critical | high | medium | low | info`. Findings here are raw — triage + prioritization at the bottom. > > **Scope:** entire `src/` tree at commit `b3f8756` (post-audit-cleanup). Excludes `docs/`, `tests/` (covered by F3), build/Docker config, and node_modules. diff --git a/docs/superpowers/audits/alpha-uat-master.md b/docs/superpowers/audits/alpha-uat-master.md new file mode 100644 index 00000000..f0517e49 --- /dev/null +++ b/docs/superpowers/audits/alpha-uat-master.md @@ -0,0 +1,367 @@ +# 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 `
  3. + ); + })} + + {ranked.length > visible.length ? ( +
    + + View all by demand + + +
    + ) : null} + )} diff --git a/src/components/dashboard/pipeline-value-tile.tsx b/src/components/dashboard/pipeline-value-tile.tsx index fe6485a5..0be8a9bc 100644 --- a/src/components/dashboard/pipeline-value-tile.tsx +++ b/src/components/dashboard/pipeline-value-tile.tsx @@ -1,57 +1,230 @@ 'use client'; import { useQuery } from '@tanstack/react-query'; -import { DollarSign } from 'lucide-react'; +import { AlertTriangle, Info } from 'lucide-react'; import { apiFetch } from '@/lib/api/client'; -import { Card, CardContent } from '@/components/ui/card'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Skeleton } from '@/components/ui/skeleton'; +import { PIPELINE_STAGES, STAGE_WEIGHTS, stageLabel } from '@/lib/constants'; import { formatCurrency } from '@/lib/utils/currency'; +import { cn } from '@/lib/utils'; interface KpiResponse { pipelineValue: number; pipelineValueCurrency: string; + activeInterests: number; } +interface StageRow { + stage: string; + count: number; + grossValue: number; + weightedValue: number; + weight: number; + dealsMissingPrice: number; +} + +interface ForecastResponse { + totalGrossValue: number; + totalWeightedValue: number; + stageBreakdown: StageRow[]; + weightsSource: 'db' | 'default'; +} + +// Same brand-coloured family the pipeline-funnel chart uses so the two +// surfaces feel anchored to the same palette. +const STAGE_BAR_CLASS: Record = { + enquiry: 'bg-slate-300', + qualified: 'bg-brand-200', + nurturing: 'bg-brand-300', + eoi: 'bg-brand-400', + reservation: 'bg-amber-400', + deposit_paid: 'bg-orange-400', + contract: 'bg-success/70', +}; + /** - * Total pipeline value for active interests, converted to the port's - * default currency at display time. Sourced from the same KPIs endpoint - * as the active-deals tile so the two share a cache entry and render in - * lockstep. + * Headline pipeline value plus a per-stage breakdown showing gross + * value, deal count, and the weighted forecast (gross × stage close- + * probability). Replaces the single-number KPI: leadership can now see + * how much of the headline number is near-close vs speculative. + * + * Pulls from two endpoints: `/kpis` for the gross headline + currency + * and `/forecast` for the weighted breakdown. Both share cache entries + * with other widgets so this is mostly free. */ export function PipelineValueTile() { - const { data, isLoading } = useQuery({ + const kpis = useQuery({ queryKey: ['dashboard', 'kpis'], queryFn: () => apiFetch('/api/v1/dashboard/kpis'), staleTime: 60_000, }); + const forecast = useQuery({ + queryKey: ['dashboard', 'forecast'], + queryFn: () => apiFetch('/api/v1/dashboard/forecast'), + staleTime: 60_000, + }); + + const isLoading = kpis.isLoading || forecast.isLoading; + const currency = kpis.data?.pipelineValueCurrency ?? 'USD'; + const grossTotal = kpis.data?.pipelineValue ?? 0; + const weightedTotal = forecast.data?.totalWeightedValue ?? 0; + const activeDeals = kpis.data?.activeInterests ?? 0; + const activeStages = (forecast.data?.stageBreakdown ?? []).filter((s) => s.count > 0); + const stageMax = activeStages.reduce((m, s) => Math.max(m, s.grossValue), 0) || 1; + + const fmt = (v: number) => formatCurrency(v, currency, { maxFractionDigits: 0 }); return ( - {/* `pt-5 pb-5` is explicit because shadcn's default CardContent ships - with `pt-0` (it assumes a CardHeader sits above). Without these - overrides the tile content snaps to the top edge of the card. */} - -
    - -
    -
    -

    - Pipeline value -

    - {isLoading ? ( - - ) : ( -

    + Pipeline value + + + {activeDeals > 0 + ? `${activeDeals} active deal${activeDeals === 1 ? '' : 's'} · weighted by stage close-probability` + : 'Gross berth value across active deals, with weighted forecast.'} + + + - {formatCurrency(data?.pipelineValue ?? 0, data?.pipelineValueCurrency ?? 'USD', { - maxFractionDigits: 0, - })} + + + +

    How the weighted forecast works

    +

    + Each pipeline stage has a close-probability — how likely a deal at that stage is to + actually close. Multiplying the berth price by the stage weight gives an{' '} + expected value for that deal. Summing across every active deal + yields the weighted forecast — a defensible “what will likely land” + number, vs the gross which assumes every deal closes at full value. +

    +
    + {PIPELINE_STAGES.map((s) => { + const dbWeight = forecast.data?.stageBreakdown.find((r) => r.stage === s)?.weight; + const weight = dbWeight ?? STAGE_WEIGHTS[s]; + return ( +
    + {stageLabel(s)} + + {Math.round(weight * 100)}% + +
    + ); + })} +
    +

    + {forecast.data?.weightsSource === 'db' + ? 'Using per-port weights (admins tune these in Settings → Pipeline).' + : 'Using system defaults. Admins can override per port in Settings → Pipeline.'} +

    + + + + + + {/* ── Headline numbers ─────────────────────────────────────── */} +
    +
    +

    + Gross

    - )} + {isLoading ? ( + + ) : ( +

    + {fmt(grossTotal)} +

    + )} +
    +
    +

    + Weighted forecast +

    + {isLoading ? ( + + ) : ( +

    + {fmt(weightedTotal)} +

    + )} +
    + + {/* ── Per-stage breakdown ─────────────────────────────────── */} + {isLoading ? ( +
    + {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
    + ) : activeStages.length === 0 ? ( +

    No active deals with linked berths yet.

    + ) : ( +
      + {activeStages.map((s) => { + const widthPct = Math.max(6, Math.round((s.grossValue / stageMax) * 100)); + return ( +
    • +
      +

      + {stageLabel(s.stage)} +

      +
      + +
      +
      +
      +

      + {fmt(s.grossValue)} +

      +

      + {s.count} {s.count === 1 ? 'deal' : 'deals'} · {Math.round(s.weight * 100)}% +

      + {s.dealsMissingPrice > 0 ? ( +

      + + {s.dealsMissingPrice === s.count + ? 'berth price missing' + : `${s.dealsMissingPrice} of ${s.count} missing price`} +

      + ) : null} +
      +
    • + ); + })} +
    + )} + + {forecast.data?.weightsSource === 'default' ? ( +

    + Using default stage weights. Tune them in Settings → Pipeline. +

    + ) : null}
    ); diff --git a/src/components/dashboard/revenue-breakdown-chart.tsx b/src/components/dashboard/revenue-breakdown-chart.tsx deleted file mode 100644 index 8e192ab5..00000000 --- a/src/components/dashboard/revenue-breakdown-chart.tsx +++ /dev/null @@ -1,87 +0,0 @@ -'use client'; - -import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; - -import { CardSkeleton } from '@/components/shared/loading-skeleton'; -import { EmptyState } from '@/components/shared/empty-state'; -import { ChartCard } from './chart-card'; -import { useRevenue } from './use-analytics'; -import type { DateRange } from '@/lib/services/analytics.service'; -import { rangeToSlug } from '@/lib/analytics/range'; -import { formatCurrency } from '@/lib/utils/currency'; - -interface Props { - range: DateRange; -} - -const STATUS_LABELS: Record = { - draft: 'Draft', - sent: 'Sent', - paid: 'Paid', - overdue: 'Overdue', - cancelled: 'Cancelled', -}; - -export function RevenueBreakdownChart({ range }: Props) { - const { data, isLoading } = useRevenue(range); - const bars = data?.bars ?? []; - - function toCsv(): string | null { - if (!bars.length) return null; - const header = 'status,currency,amount'; - const rows = bars.map((b) => `${b.status},${b.currency},${b.amount}`); - return [header, ...rows].join('\n'); - } - - const chartData = bars.map((b) => ({ - label: `${STATUS_LABELS[b.status] ?? b.status} (${b.currency})`, - amount: b.amount, - currency: b.currency, - })); - - return ( - - {isLoading ? ( - - ) : !bars.length ? ( - - ) : ( - - - - - - { - const c = (item?.payload as { currency?: string } | undefined)?.currency ?? 'USD'; - const num = typeof value === 'number' ? value : Number(value); - return [formatCurrency(num, c), 'Amount']; - }} - /> - - - - )} - - ); -} diff --git a/src/components/dashboard/use-analytics.ts b/src/components/dashboard/use-analytics.ts index b47aa7d8..665e53ea 100644 --- a/src/components/dashboard/use-analytics.ts +++ b/src/components/dashboard/use-analytics.ts @@ -9,7 +9,6 @@ import type { MetricBase, OccupancyTimelineData, PipelineFunnelData, - RevenueBreakdownData, } from '@/lib/services/analytics.service'; interface MetricResponse { @@ -51,7 +50,5 @@ export const useFunnel = (range: DateRange) => useAnalyticsMetric('pipeline_funnel', range); export const useOccupancy = (range: DateRange) => useAnalyticsMetric('occupancy_timeline', range); -export const useRevenue = (range: DateRange) => - useAnalyticsMetric('revenue_breakdown', range); export const useLeadSource = (range: DateRange) => useAnalyticsMetric('lead_source_attribution', range); diff --git a/src/components/dashboard/widget-registry.tsx b/src/components/dashboard/widget-registry.tsx index 76ba9ce1..cfd0354e 100644 --- a/src/components/dashboard/widget-registry.tsx +++ b/src/components/dashboard/widget-registry.tsx @@ -48,10 +48,6 @@ const PipelineFunnelChart = dynamic( () => import('./pipeline-funnel-chart').then((m) => ({ default: m.PipelineFunnelChart })), { loading: ChartFallback, ssr: false }, ); -const RevenueBreakdownChart = dynamic( - () => import('./revenue-breakdown-chart').then((m) => ({ default: m.RevenueBreakdownChart })), - { loading: ChartFallback, ssr: false }, -); const SourceConversionChart = dynamic( () => import('./source-conversion-chart').then((m) => ({ default: m.SourceConversionChart })), { loading: ChartFallback, ssr: false }, @@ -123,12 +119,13 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [ { id: 'kpi_pipeline_value', label: 'Pipeline Value', - description: 'Total berth value of active deals, converted to the port default currency.', + description: + 'Gross + weighted forecast, broken down by pipeline stage so leadership can see what is near-close vs speculative.', render: () => , - group: 'rail', - // Flipped on by default 2026-05-14 — the dashboard wave prioritized - // investor-facing tiles, and this is the headline number leadership - // looks at first. + // Lives in the chart grid (not the narrow rail) so the per-stage + // breakdown rows have room to breathe alongside the headline numbers, + // and the rail stays reserved for reminders / alerts / glance tiles. + group: 'chart', defaultVisible: true, }, @@ -149,14 +146,6 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [ group: 'chart', defaultVisible: true, }, - { - id: 'revenue_breakdown', - label: 'Revenue Breakdown', - description: 'Invoice totals grouped by status and currency.', - render: (range) => , - group: 'chart', - defaultVisible: true, - }, { id: 'lead_source', label: 'Lead Source Attribution', @@ -186,8 +175,9 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [ }, { id: 'berth_heat', - label: 'Berth Heat', - description: 'Top 15 berths by active interest count. Investor-friendly demand pressure view.', + label: 'Berth Demand', + description: + 'Ranks berths by active interest. Surfaces the leading mooring with its runners-up.', render: () => , group: 'chart', defaultVisible: true, @@ -196,7 +186,7 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [ id: 'website_analytics', label: 'Website Analytics', description: 'Quick glance at marketing site traffic. Requires Umami.', - render: () => , + render: (range) => , group: 'rail', defaultVisible: true, selfGates: true, diff --git a/src/components/interests/berth-recommender-panel.tsx b/src/components/interests/berth-recommender-panel.tsx index b3f05295..78d2344b 100644 --- a/src/components/interests/berth-recommender-panel.tsx +++ b/src/components/interests/berth-recommender-panel.tsx @@ -81,6 +81,14 @@ interface BerthRecommenderPanelProps { * Falls back to 'ft' when missing. */ desiredUnit?: 'ft' | 'm' | null; + /** + * Number of berths already linked to the interest. When ≥ 1 the panel + * defaults to collapsed (header-only) so the LinkedBerthsList card above + * dominates the rep's attention. They can expand to browse more options + * (multi-berth deals, swap recommendations). Zero / undefined keeps the + * panel expanded so reps see options immediately. + */ + linkedBerthCount?: number; } const TIER_LABELS: Record = { @@ -358,6 +366,7 @@ export function BerthRecommenderPanel({ desiredWidthFt, desiredDraftFt, desiredUnit, + linkedBerthCount, }: BerthRecommenderPanelProps) { const params = useParams<{ portSlug: string }>(); const portSlug = params?.portSlug ?? ''; @@ -370,6 +379,11 @@ export function BerthRecommenderPanel({ // single pier (e.g. "show me only A-row matches"). Client-side over // the already-fetched result set; no service change required. const [selectedAreas, setSelectedAreas] = useState([]); + // Collapse state — defaults to collapsed when the deal already has at + // least one linked berth (recommender becomes a "browse more options" + // tool rather than the primary surface). Reps can manually expand any + // time. Header click toggles. + const [collapsed, setCollapsed] = useState((linkedBerthCount ?? 0) > 0); const hasDimensions = desiredLengthFt !== null; @@ -380,7 +394,9 @@ export function BerthRecommenderPanel({ const { data, isFetching, refetch } = useQuery({ queryKey, - enabled: hasDimensions, + // Skip the network call when collapsed — no point fetching options + // the rep won't see. Re-fires automatically on expand. + enabled: hasDimensions && !collapsed, queryFn: () => apiFetch<{ data: Recommendation[] }>(`/api/v1/interests/${interestId}/recommend-berths`, { method: 'POST', @@ -441,32 +457,56 @@ export function BerthRecommenderPanel({ ) : null}
    + {!collapsed ? ( + <> + + + + ) : null} -
- {filtersOpen && hasDimensions ? ( + {!collapsed && filtersOpen && hasDimensions ? ( ) : null} - {hasDimensions && areaChips.length > 1 ? ( + {!collapsed && hasDimensions && areaChips.length > 1 ? (
Area: {areaChips.map((letter) => { @@ -504,55 +544,57 @@ export function BerthRecommenderPanel({
) : null} - - {!hasDimensions ? ( -

- Once length, width, and draft are set on this interest, the recommender will surface - berths that fit. Edit the desired dimensions on the{' '} - - Overview tab - - . -

- ) : isFetching && recommendations.length === 0 ? ( -
- {[0, 1, 2].map((i) => ( -
- ))} -
- ) : recommendations.length === 0 ? ( -
-

- {showAll - ? 'No berths in the port match these dimensions and filters.' - : 'No berths fit inside the strict oversize tolerance.'} + {collapsed ? null : ( + + {!hasDimensions ? ( +

+ Once length, width, and draft are set on this interest, the recommender will surface + berths that fit. Edit the desired dimensions on the{' '} + + Overview tab + + .

- {!showAll && ( - + )} +
+ ) : ( +
+ {recommendations.map((rec) => ( + + ))} +
+ )} + {hasDimensions && recommendations.length > 0 ? ( +
+ - )} -
- ) : ( -
- {recommendations.map((rec) => ( - - ))} -
- )} - {hasDimensions && recommendations.length > 0 ? ( -
- -
- ) : null} - +
+ ) : null} +
+ )} {pendingBerth ? ( ({ value: c, @@ -381,8 +385,9 @@ function MilestoneSection({

{title}

{isActive ? ( - - Next + + + Next step ) : null}
@@ -844,15 +849,22 @@ function OverviewTab({ depositExpectedAmount={interest.depositExpectedAmount ?? null} depositExpectedCurrency={interest.depositExpectedCurrency ?? null} /> - ) : ( - // §7.2: replace the empty Payments slot with a stage-aware - // "next step" card on pre-reservation stages so the rep gets - // an actionable prompt instead of dead space. - 0} - /> - )} + ) : null} + {/* Pre-reservation: the dedicated "Next step" guidance card was + removed in favour of a brighter NEXT STEP pill on the active + MilestoneSection below (it already owns the workflow actions — + two surfaces was redundant). Nurturing keeps a slim helper + since no milestone is naturally "current" while a deal is + paused. */} + {interest.pipelineStage === 'nurturing' ? ( +
+

Deal is on nurture

+

+ Schedule a follow-up reminder or log a contact when the prospect re-engages, then move + them back to Qualified. +

+
+ ) : null} {/* Sales-process milestones — phase-aware so the user only sees what's actionable now. Past milestones collapse into a tight @@ -1007,6 +1019,41 @@ function OverviewTab({ + {/* Berth requirements — desired length / width / draft. Editable + inline so reps can capture or correct a buyer's needs without + leaving the Overview tab. These values drive the auto-tick on + the "Dimensions confirmed" qualification row + the + BerthRecommenderPanel rankings below. */} +
+

Berth requirements

+
+ + + + + + + + + +
+
+ {/* Reminder */} {interest.reminderEnabled && (
@@ -1102,6 +1149,7 @@ function OverviewTab({ desiredWidthFt={toNum(interest.desiredWidthFt)} desiredDraftFt={toNum(interest.desiredDraftFt)} desiredUnit={interest.desiredLengthUnit === 'm' ? 'm' : 'ft'} + linkedBerthCount={interest.linkedBerthCount ?? 0} /> {confirmDialog} {/* Mounted at the Overview level so the EOI milestone's "Generate EOI" @@ -1197,7 +1245,7 @@ export function getInterestTabs({ }, { id: 'recommendations', - label: 'Recommendations', + label: 'Berth Recommendations', content: ( - {/* Vertical line */} -
- - {events.map((event) => { + {events.map((event, idx) => { const actor = actorLabel(event); const isAuto = event.userId === 'system'; + const isLast = idx === events.length - 1; return (
+ {/* Vertical line — only between bubbles, never trailing past the last. */} + {!isLast && ( + + )} {/* Icon */}
{eventIcon(event)} diff --git a/src/components/interests/qualification-checklist.tsx b/src/components/interests/qualification-checklist.tsx index 1a96afa8..ebaeba77 100644 --- a/src/components/interests/qualification-checklist.tsx +++ b/src/components/interests/qualification-checklist.tsx @@ -21,6 +21,9 @@ interface QualificationRow { confirmedBy: string | null; notes: string | null; autoSatisfied: boolean; + /** Human-readable explanation of what data drove auto-satisfaction + * (e.g. "Desired: 60 × 25 × 6 ft"). Empty when not auto-satisfied. */ + evidence: string; } interface QualificationResponse { @@ -104,9 +107,20 @@ export function QualificationChecklist({ )}
-
    +
      {criteria.map((c) => ( -
    • +
    • @@ -146,6 +160,11 @@ export function QualificationChecklist({ {c.description ? (

      {c.description}

      ) : null} + {c.autoSatisfied && c.evidence ? ( +

      + {c.evidence} +

      + ) : null}
    • ))} diff --git a/src/components/interests/supplemental-info-request-button.tsx b/src/components/interests/supplemental-info-request-button.tsx index 79f3a895..d6a0aeee 100644 --- a/src/components/interests/supplemental-info-request-button.tsx +++ b/src/components/interests/supplemental-info-request-button.tsx @@ -60,7 +60,11 @@ export function SupplementalInfoRequestButton({ interestId, eoiStatus }: Props) return ( - + {/* shadcn's default CardContent ships with `pt-0 sm:pt-0` because it + assumes a CardHeader sits above. This card is intentionally + header-less, so we restore symmetric padding (`pt-` matches `p-`) + at both base and `sm:` breakpoints. */} +

      Need more info before drafting the EOI?

      diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index 46377fb0..8d8b5061 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -19,6 +19,9 @@ interface AppShellProps { isSuperAdmin: boolean; user: NonNullable; ports: TopbarProps['ports']; + /** Per-port logo URLs resolved server-side. Sidebar picks the entry + * matching the currently-active port from the UI store. */ + portLogoUrls: Record; /** * Server-rendered form-factor hint (from the request User-Agent). The * shell mounts the matching tree on first render so we never paint the @@ -59,6 +62,7 @@ export function AppShell({ isSuperAdmin, user, ports, + portLogoUrls, initialFormFactor, children, }: AppShellProps) { @@ -83,7 +87,13 @@ export function AppShell({ ) : ( - + ); const footer = isMobile ? ( diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 1a0a60ec..21111427 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -41,15 +41,16 @@ import type { UserPortRole } from '@/lib/db/schema/users'; import type { Role } from '@/lib/db/schema/users'; import type { Port } from '@/lib/db/schema/ports'; -const LOGO_URL = - 'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png'; - interface SidebarProps { portRoles: (UserPortRole & { port: { id: string; slug: string; name: string }; role: Role })[]; isSuperAdmin?: boolean; user?: { name: string; email: string }; /** Ports the user has access to. Drives the footer port switcher. */ ports?: Port[]; + /** Per-port logo URLs resolved server-side in the dashboard layout. + * The sidebar header swaps to the current port's logo via the UI + * store's `currentPortId`. Null entries render the wordmark fallback. */ + portLogoUrls?: Record; } interface NavItem { @@ -103,6 +104,22 @@ function buildNavSections(portSlug: string | undefined): NavSection[] { }, ], }, + { + title: 'Insights', + marinaRequired: true, + umamiRequired: true, + items: [ + // Marketing / Umami integration. Distinct from the main dashboard + // (which is sales-focused) so the audience and the metrics don't + // compete for visual real estate. Whole section is hidden when + // Umami isn't wired up — see SidebarContent. + { + href: `${base}/website-analytics`, + label: 'Website analytics', + icon: Globe, + }, + ], + }, { title: 'Documents', marinaRequired: true, @@ -127,22 +144,6 @@ function buildNavSections(portSlug: string | undefined): NavSection[] { }, ], }, - { - title: 'Insights', - marinaRequired: true, - umamiRequired: true, - items: [ - // Marketing / Umami integration. Distinct from the main dashboard - // (which is sales-focused) so the audience and the metrics don't - // compete for visual real estate. Whole section is hidden when - // Umami isn't wired up — see SidebarContent. - { - href: `${base}/website-analytics`, - label: 'Website analytics', - icon: Globe, - }, - ], - }, { title: 'Communication', marinaRequired: true, @@ -236,6 +237,8 @@ function SidebarContent({ hasResidentialAccess, user, ports, + currentPort, + currentLogoUrl, onToggleCollapse, }: { collapsed: boolean; @@ -247,6 +250,8 @@ function SidebarContent({ hasResidentialAccess: boolean; user?: SidebarProps['user']; ports?: Port[]; + currentPort: Port | null; + currentLogoUrl: string | null; /** When provided, renders the collapse toggle row above the user footer (desktop). */ onToggleCollapse?: () => void; }) { @@ -295,15 +300,21 @@ function SidebarContent({ collapsed ? 'h-16 px-2' : 'h-24 px-4', )} > - Port Nimara + {currentLogoUrl ? ( + {currentPort?.name + ) : ( +

      + {currentPort?.name ?? 'CRM'} +
      + )} {onToggleCollapse && ( + ))} +
      s.currentPortSlug); const fileRef = useRef(null); @@ -505,15 +510,17 @@ export function ScanShell() { {/* Brand header - logo centered, page title underneath. Establishes the standalone identity (this is the PWA home for the scanner). */}
      - Port Nimara + {logoUrl ? ( + {portName + ) : null}

      Scan a receipt

      {state.kind !== 'idle' ? ( diff --git a/src/components/search/command-search.tsx b/src/components/search/command-search.tsx index 92858516..75312ac5 100644 --- a/src/components/search/command-search.tsx +++ b/src/components/search/command-search.tsx @@ -650,23 +650,27 @@ function ZeroState({ query, portSlug }: { query: string; portSlug: string | null

      Quick create

      + {/* Land on the list page with `?create=1&prefill_name=…`; the list + page's `useCreateFromUrl` hook pops the create sheet with the + search query pre-filled. The bogus `//new` URLs used + previously hit the `[id]` route and rendered "not found". */}
      diff --git a/src/components/settings/dashboard-widgets-card.tsx b/src/components/settings/dashboard-widgets-card.tsx deleted file mode 100644 index b952de8c..00000000 --- a/src/components/settings/dashboard-widgets-card.tsx +++ /dev/null @@ -1,77 +0,0 @@ -'use client'; - -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Switch } from '@/components/ui/switch'; -import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets'; - -/** - * Per-user toggle list for dashboard widgets. The dashboard reads the - * same `useDashboardWidgets` hook, so flipping a switch here causes the - * dashboard to reflow on the next visit (or instantly if the user has - * both pages open in different tabs — TanStack Query's optimistic - * update + invalidate handles the cache sync). - * - * Mounted from UserSettings under the id `dashboard` so the dashboard - * "Customize" button can deep-link via `/settings#dashboard`. - */ -export function DashboardWidgetsCard() { - const { allWidgets, visibility, setVisible, setAll, isSaving } = useDashboardWidgets(); - - const visibleCount = Object.values(visibility).filter(Boolean).length; - const allVisible = visibleCount === allWidgets.length; - const allHidden = visibleCount === 0; - - return ( - - -
      -
      - Dashboard widgets - - Pick which cards show up on your dashboard. Hidden cards leave no empty space — the - layout reflows to fill the available width. - -
      -
      - - -
      -
      -
      - - {allWidgets.map((w) => ( -
      -
      -
      {w.label}
      -

      {w.description}

      -
      - setVisible(w.id, checked)} - /> -
      - ))} -
      -
      - ); -} diff --git a/src/components/settings/user-settings.tsx b/src/components/settings/user-settings.tsx index 371f25ef..b64d6496 100644 --- a/src/components/settings/user-settings.tsx +++ b/src/components/settings/user-settings.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useEffect, useMemo, useRef } from 'react'; +import { useRouter } from 'next/navigation'; import { Save, KeyRound, Globe, Upload } from 'lucide-react'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; @@ -16,7 +17,6 @@ import { TimezoneCombobox } from '@/components/shared/timezone-combobox'; import { ImageCropperDialog } from '@/components/shared/image-cropper-dialog'; import { NotificationPreferencesForm } from '@/components/notifications/notification-preferences-form'; import { ReminderDigestForm } from '@/components/notifications/reminder-digest-form'; -import { DashboardWidgetsCard } from '@/components/settings/dashboard-widgets-card'; import { apiFetch } from '@/lib/api/client'; import { primaryTimezoneFor } from '@/lib/i18n/timezones'; import type { CountryCode } from '@/lib/i18n/countries'; @@ -34,6 +34,7 @@ interface MeResponse { } export function UserSettings() { + const router = useRouter(); const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); const [displayName, setDisplayName] = useState(''); @@ -155,6 +156,10 @@ export function UserSettings() { }, }); setMessage('Profile saved'); + // Topbar + sidebar `user` props come from the dashboard server + // layout reading userProfiles.displayName — refresh so the new + // name flows back through without a hard reload. + router.refresh(); } catch (err: unknown) { setMessage(err instanceof Error ? err.message : 'Failed to save'); } finally { @@ -191,6 +196,7 @@ export function UserSettings() { await apiFetch('/api/v1/me/email', { method: 'PATCH', body: { email } }); setOriginalEmail(email); setEmailMsg('Email updated. Use the new address next time you sign in.'); + router.refresh(); } catch (err: unknown) { setEmailMsg(err instanceof Error ? err.message : 'Failed to update email'); } finally { @@ -369,8 +375,6 @@ export function UserSettings() { - - Account diff --git a/src/components/shared/dev-mode-banner.tsx b/src/components/shared/dev-mode-banner.tsx index 20ce9f47..33207797 100644 --- a/src/components/shared/dev-mode-banner.tsx +++ b/src/components/shared/dev-mode-banner.tsx @@ -1,7 +1,8 @@ 'use client'; +import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { AlertTriangle } from 'lucide-react'; +import { AlertTriangle, X } from 'lucide-react'; import { apiFetch } from '@/lib/api/client'; @@ -10,6 +11,8 @@ interface DevFlags { isDev: boolean; } +const DISMISS_KEY = 'pn-crm.devBanner.dismissed'; + /** * Single-line warning banner shown across the app whenever a dev-mode * safety net is active (today: `EMAIL_REDIRECT_TO`). Sticky at the top @@ -19,18 +22,35 @@ interface DevFlags { * Production hides the banner entirely because env.ts refuses to boot * with EMAIL_REDIRECT_TO set when NODE_ENV=production — the flag is * only ever non-null in dev / staging. + * + * Dismissal is persisted in localStorage keyed by the redirect address — + * changing `EMAIL_REDIRECT_TO` re-shows the banner so the new target + * can't be silently inherited. */ export function DevModeBanner() { const { data } = useQuery<{ data: DevFlags }>({ queryKey: ['internal', 'dev-flags'], queryFn: () => apiFetch<{ data: DevFlags }>('/api/v1/internal/dev-flags'), staleTime: 5 * 60_000, - // Don't refetch on focus; the flag changes only on a restart. refetchOnWindowFocus: false, }); const redirect = data?.data?.emailRedirectTo; - if (!redirect) return null; + const [overrideDismissed, setOverrideDismissed] = useState(false); + const persistedDismissed = + typeof window !== 'undefined' && !!redirect + ? window.localStorage.getItem(DISMISS_KEY) === redirect + : false; + const dismissed = overrideDismissed || persistedDismissed; + + if (!redirect || dismissed) return null; + + const handleDismiss = () => { + if (typeof window !== 'undefined') { + window.localStorage.setItem(DISMISS_KEY, redirect); + } + setOverrideDismissed(true); + }; return (
      Dev mode: outbound emails redirected to {redirect} +
      ); } diff --git a/src/components/shared/entity-activity-feed.tsx b/src/components/shared/entity-activity-feed.tsx index 522cfbf2..a0751b78 100644 --- a/src/components/shared/entity-activity-feed.tsx +++ b/src/components/shared/entity-activity-feed.tsx @@ -214,9 +214,13 @@ export function EntityActivityFeed({ endpoint, emptyText = 'No activity yet.' }: No activity matches the current filters.
) : ( -
    +
      {groups.map((group, gi) => ( - + ))}
    )} @@ -224,15 +228,25 @@ export function EntityActivityFeed({ endpoint, emptyText = 'No activity yet.' }: ); } -function SessionGroupItem({ group }: { group: SessionGroup }) { +function SessionGroupItem({ group, isLast }: { group: SessionGroup; isLast: boolean }) { const [expanded, setExpanded] = useState(group.rows.length <= 3); const first = group.rows[0]!; const created = new Date(first.createdAt); const ago = formatDistanceToNow(created, { addSuffix: true }); + // Vertical connector — runs from below this bubble down to the next item, + // omitted on the last item so the line never trails past the last bubble. + const connector = !isLast ? ( + + ) : null; + if (group.rows.length === 1) { return (
  1. + {connector}
  2. @@ -241,6 +255,7 @@ function SessionGroupItem({ group }: { group: SessionGroup }) { return (
  3. + {connector}