fix(uat): batch — timeline overshoot, name-sync, reset-password, dashboard cleanup, queue/seed hygiene + alpha UAT findings doc
UAT findings landed across the last few Playwright + React Grab passes; single grouped commit so the index doesn't fragment into 30 one-liners. User & auth: - `user-settings`: name now updates the avatar + topbar menu after save (was reading stale session). - `me/password-reset`: 3 bugs (token validation, error response shape, redirect chain). - Admin user permission-overrides route honours the same envelope as the rest of the admin surface. Dashboard: - Removed obsolete `revenue-breakdown-chart` + `dashboard-widgets-card` (replaced by the customisable widget grid). - Strip `revenue_breakdown` from analytics route + use-analytics + service + integration test so nothing renders an empty card. - Activity log timeline overshoot fix (`interest-timeline` + `entity-activity-feed`). - Tightened tiles: active-deals, berth-heat-widget, pipeline-value, kpi-tile. - `dev-mode-banner`: derive dismissed state synchronously instead of via an effect (set-state-in-effect lint rule). Forms & lists (assorted polish): - client / company / yacht / interest / reminder forms — validation + empty-state copy + tab transitions. - companies/yachts list tweaks; berth recommender panel; qualification checklist; supplemental info request button. Infra & misc: - Queue workers (ai / email / notifications) — log shape + per-job timeout consistency. - Auth / brochures / users schema small adjustments; seeds reflect permissions matrix changes. - Scan shell + scanner manifest + AI admin page small fixes. - `next.config.transpilePackages` adds `echarts`/`zrender`/`echarts-for-react` (recommended config from echarts-for-react inside Next). Docs: - `docs/superpowers/audits/alpha-uat-master.md` — single rolling cross-cutting UAT findings doc (per CLAUDE.md convention). - `docs/BACKLOG.md`: dashboard stats cards (§I) + activity-log normalization (§J). - 2026-05-18 audit log updated with this batch. - `CLAUDE.md` — small manual UAT scaffold notes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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<fkField, displayName>` 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
367
docs/superpowers/audits/alpha-uat-master.md
Normal file
367
docs/superpowers/audits/alpha-uat-master.md
Normal file
@@ -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._
|
||||
|
||||
<!-- Append findings as: `1. **Title** — _path:line_ — description. (see Audit X#N if applicable)` -->
|
||||
|
||||
> **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 (`<Card className="h-full flex flex-col">`); `CardHeader` keeps natural height; `CardContent` gets `flex-1 flex items-center` so the chart's wrapping div sits vertically centered in the remaining space. ResponsiveContainer stays at its declared fixed height. Affects all chart widgets via one wrapper change — no per-chart edits. ~10 min. Captured 2026-05-18 from UAT.
|
||||
> - **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 `<input type="datetime-local">`; reps had to manually pick a date/time for the 80% common cases. Added a row of quick-pick chips above the input (`In 1 hour`, `In 4 hours`, `Tomorrow`, `In 3 days`, `Next week`, `In 2 weeks`) — same idiom as the existing `snooze-dialog.tsx` presets. Day-based presets honour the user's `digestTimeOfDay` preference for hour-of-day. Fixed in this session.
|
||||
10. **Consolidate "Next step" guidance into milestone card** — _src/components/interests/interest-tabs.tsx_, _src/components/interests/stage-guidance-card.tsx_ — the separate `StageGuidanceCard` and the active `MilestoneSection` had overlapping intent (both said "do X next") and the guidance card's action buttons were silently never rendered (callbacks were never wired). Removed the StageGuidanceCard mount from OverviewTab; made the milestone card's existing `Next` pill more prominent — brand-600 background, white text, "NEXT STEP" copy with a leading dot. The milestone card already owns the workflow actions (Generate EOI, etc.), so the consolidation eliminates the dual surface. Nurturing keeps a slim inline helper ("Deal is on nurture — schedule a follow-up reminder or log a contact…") since no milestone is naturally "current" while a deal is paused. `stage-guidance-card.tsx` left in the tree for potential future use but no longer mounted. Fixed in this session.
|
||||
11. **Interest create form: Source defaults to 'manual'** — _src/components/interests/interest-form.tsx_ — same gap as the client form (#3). Added `source: 'manual'` to the form's RHF `defaultValues` so the Select renders with "Manual" selected on create. Inquiry / website conversion flows can later override via prefill when that path lands. Fixed in this session.
|
||||
12. **Qualification checklist: highlight open items** — _src/components/interests/qualification-checklist.tsx_ — confirmed and unconfirmed rows rendered with near-identical styling, making it hard for reps to scan what's outstanding. Confirmed rows now sit in muted-foreground (still readable but de-emphasized); unconfirmed rows get a subtle amber left-border accent + `bg-warning-bg/40` tint so the rep's eye jumps to what still needs attention. Auto-satisfied rows follow confirmed styling (functionally complete). Fixed in this session.
|
||||
13. **BerthRecommenderPanel: collapsible on Overview when a berth is linked** — _src/components/interests/berth-recommender-panel.tsx_, _src/components/interests/interest-tabs.tsx_ — added a `linkedBerthCount` prop; when ≥ 1 the panel mounts collapsed (header-only with a "Show recommendations" toggle button), so the LinkedBerthsList card dominates the rep's attention once a berth is picked. Network call is gated on `!collapsed && hasDimensions` so the recommender doesn't fetch options the rep won't see. The dedicated Recommendations tab keeps `linkedBerthCount` unset → always expanded (the rep navigated there explicitly). Fixed in this session.
|
||||
14. **Pipeline Value tile moved from rail → chart grid** — _src/components/dashboard/widget-registry.tsx:130_ — the tile shipped in the narrow rail column but its per-stage breakdown + headline numbers + info popover needed more horizontal room to read, and the rail's reserved for reminders/alerts/glance tiles. Changed `group: 'rail'` → `'chart'` so it sits alongside the funnel/timeline/lead-source tiles. Fixed in this session.
|
||||
15. **Umami v3.x integration fixed end-to-end** — _src/lib/services/umami.service.ts_, _src/app/api/v1/website-analytics/route.ts_, _src/components/website-analytics/use-website-analytics.ts_, _src/components/website-analytics/website-analytics-shell.tsx_, _src/components/website-analytics/pageviews-chart.tsx_, _src/components/dashboard/website-glance-tile.tsx_, _src/components/dashboard/widget-registry.tsx_, _src/components/ui/kpi-tile.tsx_ — entire Umami integration was built against the v1 nested response shape; v2 + v3 use a flat shape with a sibling `comparison` block. Every consumer was reading `.pageviews.value` → undefined → falling back to `0`. Probed the live instance with the configured port creds and verified the real shape, then rewrote types + readers + the dashboard tile end-to-end:
|
||||
- **`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/<slug>` once the composer button (Bucket 3) ships. Cannot be automated — needs a real inbox. Captured 2026-05-19.
|
||||
|
||||
> **Outstanding (gaps on shipped work + rapid UAT capture):**
|
||||
>
|
||||
> - **Inline phone editor on the Contact row** — _src/components/interests/interest-tabs.tsx:973_ — current implementation uses a plain `InlineEditableField` text variant on Phone, so reps can't pick a country code from a dropdown or get AsYouType formatting (both available via `<PhoneInput>` in `src/components/shared/phone-input.tsx`). Wrap `PhoneInput` in a display-vs-edit toggle and PATCH both `value` (national string) + `valueE164` + `valueCountry` to `/api/v1/clients/{id}/contacts/{contactId}`. ~30-60 min.
|
||||
> - **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 `<DimensionUnitToggle />` component (label + segmented toggle `ft | m`). Column body cells render via the formatter. Apply to all 5 surfaces in one pass for visual consistency.
|
||||
> - (e) Document-generation path: leave EOI / contract / template merge-field rendering untouched — it already pulls entry-unit values per `effectiveDimensionUnit` in the EOI dialog (per CLAUDE.md merge-field architecture).
|
||||
> - **Effort:** ~1.5-2h end-to-end (helper + pref + hook + toggle component + 5 column-definition swaps + a vitest for the formatter). The toggle persists across page reloads + tabs by virtue of going through `/me/preferences`. Captured 2026-05-18 from UAT.
|
||||
> - **Berth list "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 `<DataTable />` — checkbox column, "select all on page" / "select all matching filters" header, persistent selection across pagination (~1h, mirror `InterestList`'s `bulkActions` pattern). (b) Bulk-actions bar on ≥1 row selected: change status, change area, set price / % adjust (folds in the already-built endpoint), add/remove tags, archive/restore, export selection CSV — each opens a small confirm/edit dialog (~2-3h). (c) Unified backend `POST /api/v1/berths/bulk` (mirror `/interests/bulk`) taking `{ action, ids, ...args }`, port-validates IDs, per-row transactional with per-row failure summary so the rep sees which of 50 berths failed and why; per-row audit + realtime fan-out; cap 500 IDs (~2-3h incl tests). (d) Each action gated by the appropriate berth perm (`berths.edit`, `berths.update_prices`, `berths.archive`, `tags.manage`); endpoint enforces the most-restrictive perm of the requested action (~30min).
|
||||
> - **Effort:** ~5-7h end-to-end. Captured 2026-05-18 from UAT.
|
||||
> - **BulkAddBerthsWizard: block proceed when any mooring already exists in the port** — _src/components/admin/bulk-add-berths-wizard.tsx_ + _src/lib/services/berths.service.ts_ (new pre-flight check) — the wizard's review/preview step should validate the to-be-added mooring numbers against existing rows for the port and block "Submit" if any duplicates are found (rather than relying on a DB-constraint error mid-insert, which today doesn't even fire because there's no partial unique index on `(port_id, mooring_number)` — see Bucket 4 #1 "Duplicate E17 row" which captured the missing constraint).
|
||||
> - **Fix:** (a) on entering the preview step, fire a `GET /api/v1/berths/check-moorings?port=<id>&moorings=A1,A2,...` (cap ~500 per call) that returns `{ existing: [{ mooringNumber, id }] }`. (b) If non-empty, show an inline error panel listing the conflicts (linked to the existing berths) and disable Submit; offer a "Remove conflicts and continue" button that drops the dupes from the wizard payload before re-enabling Submit. (c) Pair this with the partial unique index fix from Bucket 4 #1 so the DB-level guard exists as a backstop — UI validation prevents the friction; DB constraint prevents the silent dup. (d) Same pre-flight should run on per-row "single add" flow for parity.
|
||||
> - **Effort:** ~1.5h (endpoint + index + UI panel + tests). Captured 2026-05-18 from UAT.
|
||||
> - **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 `<BerthPdfParserAiSettingsForm embedded />` reading registry section `ai.berth_pdf_parser`.
|
||||
> - **Receipt OCR** — already there ✓
|
||||
> - **Future-feature placeholders explicitly NOT included until they ship:** berth recommender (currently "Pure SQL (no AI)" per CLAUDE.md — surfacing it as an AI setting today would mislead admins into thinking they're tuning an LLM); AI-assisted contact-log action extraction (Bucket 3 #7 future feature); AI inquiry intake parsing if/when it ships. Add each to `/admin/ai` only when the underlying feature lands.
|
||||
> - **AI spend dashboard at the bottom of the page** — new card showing: current month spend total (across all AI features), top 3 features by spend, recent expensive calls (model, feature, cost, timestamp). Reads from `ai_usage` table. Helps admins debug cost spikes without leaving the AI page. Optional but high-leverage for an admin who just saw a budget alert.
|
||||
> - **Cross-linking principle:** each per-feature AI section on `/admin/ai` shows a small "Non-AI settings for this feature live at →" link to the corresponding admin page (e.g. for berth PDF parser, link to wherever the OCR confidence + AcroForm overrides live). Vice-versa: each feature page gets a "AI fallback settings live at /admin/ai →" link in the relevant section. Keeps the split-brain risk in check — admins always have a one-click path between the two.
|
||||
> - **Effort:** ~30 min for the berth PDF parser embedded section + registry definition, ~1.5h for the AI spend dashboard, ~30 min for the cross-link sweep. 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 <Port Name> admin — if you got this, SMTP is working."). (c) On the server: pull the live transport config via the resolver chain (port-override → env), construct via nodemailer, send, return `{ success: true, messageId }` or `{ success: false, error: ... }` with the raw SMTP error reason. (d) Audit log a `test_email_sent` row so operators can see who tested and when.
|
||||
> - **Honour the dev `EMAIL_REDIRECT_TO`** — same as production transactional emails: if set, prefix subject and reroute so QA doesn't spam users.
|
||||
> - **Cross-ref:** related to the Documenso-config diagnosis loop (Bucket 3 #8 platform-wide error message audit) — same pattern of "configure-then-verify-without-real-workflow." Apply the same idiom to other integrations: Documenso test-send, S3 ping, Redis ping, IMAP test-connect.
|
||||
> - **Effort:** ~1.5h for email (UI + endpoint + audit + dev-redirect honour). +1-2h each for the sibling integration test-ping buttons if pursued in the same pass. Captured 2026-05-18 from UAT.
|
||||
> - **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 <uuid-prefix>` when not in the autocomplete results** — _src/components/yachts/yacht-picker.tsx:75-79_ — the trigger button label is `match?.name ?? `Yacht ${value.slice(0, 8)}``— the fallback fires whenever the currently-selected yacht isn't in`rawOptions`(e.g. picker was opened with a pre-set value from a URL param / parent default and the autocomplete results don't include it, OR the user typed a search that filtered it out). Result: reps see`"Yacht 3bd83076"` instead of the yacht's name.
|
||||
> - **Fix:** add a second `useQuery` keyed on `['yacht-detail-label', value]` that fetches `/api/v1/yachts/{value}?fields=name` when `value` is set AND not present in `rawOptions`. Use its result as the fallback label in priority order: `match?.name ?? fallbackQuery.data?.name ?? `Yacht ${value.slice(0, 8)}``. Cache hit on repeat opens; tiny request. (b) Also pre-select the currently-managed yacht as the default `value`for any picker rendered in a context where "the current yacht" makes sense — that's a parent-prop concern; this picker handles whatever`value` it's given. (c) Sweep for the same pattern in other pickers (ClientPicker, CompanyPicker, BerthPicker if they exist) — same root cause + same fix shape.
|
||||
> - **Effort:** ~20 min per picker; ~1h with the sweep. Captured 2026-05-18 from UAT.
|
||||
> - **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 `<NotesList>` (parity with clients / interests)** — _src/components/yachts/yacht-tabs.tsx:227-236_ (the legacy single-text-field at the bottom of OverviewTab) + _src/components/yachts/yacht-tabs.tsx:351_ (the full `<NotesList entityType="yachts" />` already rendered in the dedicated Notes tab) + _src/components/shared/notes-list.tsx_ — Overview today shows `<InlineEditableField variant="textarea" value={yacht.notes} ... />` — a single `yachts.notes` string column, last-edit-wins. The dedicated Notes tab has the full threaded `<NotesList>` (one entry per note, author + timestamp + edit/delete + aggregate). Clients and interests already surface threaded notes without leaving Overview.
|
||||
> - **Fix:** replace the OverviewTab notes block (lines 227-236) with `<NotesList entityType="yachts" entityId={yachtId} currentUserId={currentUserId} />`. The `yachtNotes` table already exists (per CLAUDE.md polymorphic notes architecture: `notes.service.ts` dispatches across `clientNotes`/`interestNotes`/`yachtNotes`/`companyNotes`) so no backend work.
|
||||
> - **Legacy `yachts.notes` column:** verify (a) anything else writes it (other than this textarea); (b) anything reads it (EOI / contract / template merge fields). If unused elsewhere, deprecate the column and stop surfacing it on Overview; the threaded NotesList becomes the canonical write path. If still in use, leave the column but stop surfacing on Overview.
|
||||
> - **Companion decision:** with NotesList on Overview, the dedicated Notes tab may become redundant — same tradeoff applies to clients/interests today. Defer that decision; ship the inline NotesList first.
|
||||
> - **Effort:** ~30 min for the swap + verify `currentUserId` is plumbed through to OverviewTab. Captured 2026-05-18 from UAT.
|
||||
> - **`/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 `<section>` blocks; ids (`inbox-section-alerts`, `inbox-section-reminders`), URL-hash deep-link logic, and the localStorage open-state keys all remain untouched (they're keyed on section id, not order). PageHeader copy "Alerts & Reminders" should also flip to "Reminders & Alerts" to mirror the new visual order. ~3 min. Captured 2026-05-18 from UAT.
|
||||
> - **Inbox → Reminders: move filter row inline with the "New Reminder" button (embedded mode)** — _src/components/reminders/reminder-list.tsx:298-315_ — in embedded mode (used by Inbox), the "New Reminder" button renders on its own line at line 298-311 (`<div className="mb-3 flex justify-end">`), and the filters row (My/All tabs + status filter + priority filter) renders separately below at line 315. The two should share one row: filters left, button right. Fix: merge the two into a single `<div className="mb-4 flex flex-wrap items-center gap-3 sm:gap-4">`, keep the filter controls in their current order at the start, and append the "New Reminder" button with `className="ml-auto"` (or wrap the filters in a container + put the button as a sibling and use `justify-between`). Non-embedded mode (PageHeader path at lines 282-297) is unaffected. ~10 min. Captured 2026-05-18 from UAT.
|
||||
> - **Breadcrumb wrap looks broken: orphaned separator + back-chevron misaligned** — _src/components/ui/breadcrumb.tsx:15-27_ + _src/components/layout/topbar.tsx:55-75_ — when the breadcrumb wraps (e.g. `Administration › Berths › Bulk Add` in the narrow left topbar slot), three visual issues stack: (1) trailing `›` separator after "Berths" hangs at the end of line 1 with nothing after it (orphaned, because separators are siblings of items in the `<ol>` so the flex-wrap break can land between an item and its separator); (2) "Bulk Add" wraps to line 2 indented; (3) the back-chevron `<` sits left of the wrapped line and is taller than the wrapped line, throwing off vertical alignment. Together it reads as a layout bug, not a wrap.
|
||||
> - **Three coordinated fixes — ship (a) at minimum, do (b) for the real polish:**
|
||||
> - **(a) Quick: make separator inline with the preceding item so wrap can't strand one** — restructure so each `<li>` contains both the label AND its trailing separator (single inline-flex unit), except the last crumb which has no separator. Drop the standalone `<BreadcrumbSeparator>` `<li>` from `Breadcrumbs` consumer. The primitive's `BreadcrumbSeparator` stays exported for backcompat. Wrap then breaks between full crumbs cleanly. ~15 min.
|
||||
> - **(b) Better: ellipsis-collapse middle crumbs on overflow** — industry-standard pattern. When crumb count > 3 OR available width can't fit all crumbs single-line (detect via `ResizeObserver` on the `<nav>` or a CSS `:has(+ overflow)` trick), collapse middle crumbs to a `<BreadcrumbEllipsis>` button that opens a dropdown listing the hidden crumbs. First (root) + last (current page) always visible. Primitive already exports `BreadcrumbEllipsis` — just wire it. ~45 min. Result: breadcrumb stays single-line at every width, no wrap at all.
|
||||
> - **(c) Layout polish: top-align the back-chevron** — _topbar.tsx:59_ — change the wrapping `<div className="min-w-0 flex items-center gap-1.5">` to `items-start` so even if the breadcrumb does wrap, the back-button stays top-aligned with the first crumb line instead of vertical-centering across the wrapped block. Also worth considering: hide the back-button when meaningful breadcrumbs are visible (the breadcrumb's parent link already does "go back"; two affordances is one too many). ~10 min.
|
||||
> - **Topbar grid sizing observation:** topbar columns are `[minmax(0,1fr)_minmax(360px,640px)_minmax(0,1fr)]` — left slot competes for space with the centered search bar's `minmax(360px,640px)`. When search hits its max width, left slot is squeezed → breadcrumb wraps sooner. Consider bumping to `minmax(0,1.5fr)` OR letting the search shrink below 360px when needed. Optional, evaluate after (a)+(b) land.
|
||||
> - **Effort:** ~15 min for (a), ~45 min for (b), ~10 min for (c). Bundle ~1h. Captured 2026-05-18 from UAT.
|
||||
> - **BulkAddBerthsWizard: currency field should use `<CurrencySelect>` (already exists, used elsewhere)** — _src/components/admin/bulk-add-berths-wizard.tsx_ (the `priceCurrency` `<Input>` in the apply-to-all row at ~lines 282-290, and the per-row instance below it) — currently a free-text `<Input>` that uppercases on blur, defaulting to `USD`. Reps can type any string (including invalid codes); no auto-complete; no consistency with other forms. The `<CurrencySelect>` component already exists at _src/components/shared/currency-select.tsx_, backed by the curated `SUPPORTED_CURRENCIES` list in _src/lib/utils/currency.ts_, and is used by the single-berth edit form (_berth-form.tsx:414_) + the expense form dialog (_expense-form-dialog.tsx:238_). Quick fix: import `CurrencySelect`, replace both the apply-to-all and per-row currency inputs with the dropdown bound to the same handlers (`applyToAll('priceCurrency', v)` / `setRowField(idx, 'priceCurrency', v)`). ~10 min. Captured 2026-05-18 from UAT.
|
||||
> - **BulkAddBerthsWizard + single-berth editor: toggleable input units (ft/m) for dimension fields** — _src/components/admin/bulk-add-berths-wizard.tsx_ (the "Width (ft)" / "Length (ft)" / "Draft (ft)" inline-table headers + input parsing), _src/components/berths/berth-form.tsx_ (or equivalent single-edit) — the wizard's column headers and input parsing are hard-coded to feet. The schema supports per-dimension entry-unit discriminators (`lengthUnit`, `widthUnit`, `draftUnit` on `berths`, all defaulting to `'ft'`) plus separate `_M` numeric columns where metres-original values live — but neither the bulk wizard nor the single editor lets the rep pick which unit they're typing in. Reps who think in metres convert manually and the entry-unit discriminator never gets set.
|
||||
> - **Fix:** (a) add a small `ft | m` toggle in the wizard header (and on the single-berth edit form) that flips the column header labels (e.g. "Width (ft)" → "Width (m)") and the parser. The toggle should default to whichever unit the user's `dimensionUnit` preference is set to (see the Dimensions-column-toggle finding earlier — same preference). (b) On submit, if entered unit is `'m'`, convert to ft for the stored numeric (`berths.lengthM` is the canonical metres column; `lengths.lengthFt` would be the feet column — verify the actual column names) AND set `lengthUnit='m'` so downstream document generation honours the rep's original input. Same for width / draft / nominalBoatSize / waterDepth. (c) Reuse the `src/lib/utils/dimensions.ts` helper from the Dimensions-column finding so conversion is centralized.
|
||||
> - **Why this matters beyond UX:** document-generation merge fields (EOI / contract) already pull entry-unit values per `effectiveDimensionUnit` so the legal doc matches the rep's intent. Hard-coding ft on input silently coerces metric reps' values through a mental conversion, then renders the resulting ft figure on documents — losing fidelity for European customers.
|
||||
> - **Effort:** ~1.5-2h end-to-end (wizard toggle + single-form toggle + parser + tests). Coordinate with the Dimensions-display toggle finding so both UI surfaces use the same preference key + helper. Captured 2026-05-18 from UAT.
|
||||
> - **BulkAddBerthsWizard: allow defining new dock/pontoon letters in-flow (or surface the admin path)** — _src/components/admin/bulk-add-berths-wizard.tsx:78_ + _the dock/area model_ — current wizard appears to assume the dock letter already exists (per CLAUDE.md the mooring format is `[A-Z]+\d+` like `A1`, `B12` — the letter prefix is a dock/pontoon identifier). When a rep is adding berths for a _new_ dock, there's no inline way to introduce the new letter; they have to abandon the wizard, create the dock elsewhere, then come back. Two possible models — confirm which one applies in this codebase before building:
|
||||
> - **(a) Dock letters are free-form / inferred from `berths.mooring_number`** (no separate `docks` table): then the wizard just needs to allow a new letter prefix in its input. UI fix: replace the letter input (or dropdown) with a combobox-style "pick existing or type a new letter" control — same idiom as Tag picker. Backend: nothing — first insert with the new prefix establishes the dock. ~30 min.
|
||||
> - **(b) Docks are a first-class entity** (separate `docks` table with `port_id` + `letter` + metadata like `position`, `pontoon_type`, `power_capacity`): then the wizard needs a "+ New dock" affordance opening a small dock-create dialog (letter + name + optional metadata), then returning to the wizard with the new dock pre-selected. Permission: `berths.manage_docks` (or whichever owns dock metadata). The user's question — "_or is this an admin setting?_" — suggests they're not sure either; if it IS an admin-only concern (docks are infrastructure not data the rep should touch), then keep it admin-side and just surface a contextual link in the wizard ("New dock? Add it in Admin → Docks first → [link]"). ~1-2h depending on the model.
|
||||
> - **Action item:** check whether `docks` / `pontoons` / `marina_sections` table exists in the schema (`grep -r "docks\|pontoons" src/lib/db/schema/`); shape the fix accordingly. If no dedicated table, the wizard fix is trivial; if there is one, decide admin-only vs in-wizard-create with the team. Captured 2026-05-18 from UAT.
|
||||
> - **DropdownMenu content stretches to fill viewport — cap it** — _src/components/ui/dropdown-menu.tsx:66_ — the shadcn `DropdownMenuContent` primitive uses `max-h-(--radix-dropdown-menu-content-available-height)` (Radix's CSS variable that exposes the room between the trigger and the viewport edge). On long lists the menu visually stretches all the way to the viewport bottom even though the items don't need that height; reads as a wall of options. Internal `overflow-y-auto` is already on so scrolling works. Fix: replace the Radix `max-h-(...)` token with a fixed `max-h-96` (384px) or `max-h-[28rem]` (448px) so the menu caps at a comfortable height regardless of available space, scrolling internally for longer lists. Global change in the base primitive — affects every dropdown in the app, which is the right call (no consumer currently relies on the "fill the viewport" behaviour). ~2 min. If a specific dropdown needs the old behaviour, it can pass `className="max-h-[var(--radix-dropdown-menu-content-available-height)]"` to opt back in. Captured 2026-05-18 from UAT.
|
||||
> - **DocumentsHub aside column: flush-left with the app sidebar (kill the AppShell padding for this page)** — _src/components/documents/documents-hub.tsx:246_ + _src/components/layout/app-shell.tsx:113-121_ — the desktop `<main>` wrapper applies `px-6 pt-3 pb-6` to all dashboard pages, so the DocumentsHub two-pane (`ResizablePanelGroup` with the `<aside>` folder column on the left) gets 24px of whitespace between the global app sidebar and its own border. The folder column should sit flush against the app sidebar — it reads as "an extension of the navigation," not "a card inside the page." Fix (surgical): change DocumentsHub's root `<div className="h-full">` at line 246 to `<div className="h-full -mx-6 -mt-3 -mb-6">` (mirror the AppShell desktop padding so the hub renders full-bleed inside the main viewport). Add a comment explaining the intentional escape. The right-pane content keeps its own internal `p-4` so it doesn't run flush with the viewport edge. **Alternative (cleaner long-term):** make the AppShell padding route-aware via a prop on `<main>` (or a layout-level opt-out for hub-style pages); but (a) is the right call until a second page needs the same treatment. ~5 min for the negative-margin fix. Captured 2026-05-18 from UAT.
|
||||
> - **DocumentsHub: hide breadcrumb on root "All documents" view, move PageHeader up to fill the space** — _src/components/documents/documents-hub.tsx:196-209_ — the top row currently always renders the `FolderBreadcrumb` (and conditionally the `NewDocumentMenu` when a folder is selected); on the root view (`selectedFolderId === undefined`) the breadcrumb shows only a "Home / All documents" label with no useful navigation, eating vertical space above the `PageHeader` that already says "Documents" + description. Fix: wrap the entire breadcrumb row at line 196-209 in `{selectedFolderId !== undefined && ( … )}` so the row is gone on the root; the PageHeader becomes the top element. When the rep navigates into a folder, the row reappears with both breadcrumb + NewDocumentMenu (the existing folder views don't render PageHeader, so the breadcrumb is the wayfinding cue). ~5 min. Captured 2026-05-18 from UAT.
|
||||
> - **Residential InterestsTab: whole row should navigate to the interest, not just the "View" link** — _src/components/residential/residential-client-tabs.tsx:273-289_ — current `<li>` lays out `[stage chip] [preferences/notes truncated text] [View → link]` and only the "View" text on the right is clickable. The whole row should be a target, matching the idiom used in the main client's `InterestRowItem` (`src/components/clients/client-interests-tab.tsx:53`) — the entire card is a `<button>`/`<Link>` so reps can tap anywhere. Fix: wrap the `<li>`'s flex container in `<Link href={…}>` (`className="block w-full"` to preserve layout), drop the trailing "View" link, add `hover:bg-muted/50` to make the affordance discoverable. ~10 min. Captured 2026-05-18 from UAT.
|
||||
> - **Residential namespace breadcrumb link is 404** — _src/components/layout/breadcrumbs.tsx_ (the breadcrumb generator splits the URL and makes every segment a link) + missing _src/app/(dashboard)/[portSlug]/residential/page.tsx_ — on any `/{portSlug}/residential/clients` or `/{portSlug}/residential/interests` page, the breadcrumb renders "Residential" as a link to `/{portSlug}/residential` but no `page.tsx` exists at that path (only `clients/` and `interests/` subdirectories). Clicking the breadcrumb yields a 404. Two reasonable fixes:
|
||||
> - **(a) Quickest:** create `src/app/(dashboard)/[portSlug]/residential/page.tsx` as a server component that calls `redirect(`/${portSlug}/residential/clients`)`. Single file, ~5 min, breadcrumb works immediately. Same pattern works for any other namespace-only segment that lacks a real landing page.
|
||||
> - **(b) Cleaner long-term:** add a "namespace" concept to the breadcrumb generator — segments that exist only as URL parents (residential, admin if applicable, …) render as plain text (`<BreadcrumbPage>`) rather than `<BreadcrumbLink>`. Centralized in `breadcrumbs.tsx`'s `SEGMENT_LABELS` map by extending the value to `{ label, namespace?: boolean }`. ~30 min, fixes the class of problem instead of one instance.
|
||||
> - **Recommendation:** ship (a) now, queue (b) if/when a second namespace-only segment hits the same issue.
|
||||
> - Captured 2026-05-18 from UAT.
|
||||
> - **Residential client detail header: match the main ClientDetailHeader layout** — _src/components/residential/residential-client-detail-header.tsx_ vs _src/components/clients/client-detail-header.tsx_ — the main client header is rich (`Email` / `Call` / `WhatsApp` deeplink button row using primary contact channels, `PortalInviteButton`, `GdprExportButton`, tag chips, top-right action menu with Bell/reminder + Archive/Restore state-aware + perm-gated hard-delete, archived badge with conditional dialog routing). The residential header (33 lines vs 244) shows only an eyebrow, an inline-editable name, a status badge, and place-of-residence — visually orphaned next to the main client experience.
|
||||
> - **Data-model gap to bridge:** residential clients store contacts inline (`email`, `phone`, `phoneE164`, `phoneCountry` columns on `residentialClients`) rather than via the polymorphic `clientContacts` table the main model uses. Action buttons can still be wired by synthesizing a `[{ channel: 'email', value, isPrimary: true }, { channel: 'phone', value: phone, valueE164, isPrimary: true }]` shape from the inline columns. Other features need verification per residential: tags table exists? portal invite (`residential_clients` has no `clientPortalEnabled` flag → likely N/A); GDPR export (yes — applies to any natural person in EU residence; need a `residential-gdpr-export` route if not already there); archive/restore (residential uses its own service; verify the dialog component expects a `residentialClientId` or needs a separate `ResidentialSmartArchiveDialog`).
|
||||
> - **Approach options:**
|
||||
> - **(a) Copy-and-adapt the JSX shape, residential-specific dialogs** — fastest path. Rebuild `residential-client-detail-header.tsx` with the same layout: title row (truncated name + archived badge), meta line (country · added date), action button row (Email / Call / WhatsApp synthesized from inline columns + optional GDPR export), tag chips (if/when residential gets tags), top-right Bell + Archive/Restore + perm-gated hard-delete. Skip features that don't apply to residential (PortalInviteButton). Parallel residential-specific dialogs where the existing client dialogs don't accept a residential type. ~1.5h.
|
||||
> - **(b) Extract a shared `EntityDetailHeader` primitive** — better long-term. Refactor the main `ClientDetailHeader` to consume a generic `EntityDetailHeader` that takes `{ title, eyebrow?, meta[], contacts[], tags[], topRightActions[], archived }` and renders the layout. Both client headers become thin wrappers that map their entity to the shape. ~3-4h, eliminates the divergence that just got reported, and future entity headers (companies, yachts) can adopt it too — the visual idiom would propagate for free.
|
||||
> - **Recommendation:** ship (a) now for fast visual parity; queue (b) as a separate Bucket 3 refactor when there's appetite for cross-cutting work. Captured 2026-05-18 from UAT.
|
||||
> - **StageStepper: surface stage names visibly on reached slices** — _src/components/clients/client-pipeline-summary.tsx:43-82_ (the shared `StageStepper`, used on every client → Interest row card via `InterestRowItem` at `src/components/clients/client-interests-tab.tsx:87`, in the hero/panel variants of `ClientPipelineSummary` — including the per-interest links rendered by `PanelVariant` — and any other caller; fix-once-in-the-shared-component means every surface benefits) — the bar today is a 6px segmented track where each of the 7 pipeline stages is an equal-width slice (filled = reached, hollow = pending). Stage names live only in the `title=` attribute (hover tooltip), so reps have to mouse over to know which slices are filled. User wants the names visible — at least for stages the interest has reached or is currently in.
|
||||
> - **Recommended approach (concise):** Keep the segmented bar exactly as-is (preserves the visual rhythm + works in narrow cards). Render an inline breadcrumb row underneath with one chip per _reached_ stage — chronological left-to-right, last chip = current stage (filled-emphasis using the stage's `STAGE_BADGE` colour), prior chips in the muted variant of the same colour family with a connecting `→`. Pending stages are not labelled (the bar carries that info). Reads as: `Enquiry → Qualified → EOI` for a deal currently in EOI. ~45min.
|
||||
> - **Alternative (verbose):** Convert `StageStepper` to a true horizontal stepper layout — text label above each tick, current stage bolded, past stages muted, pending stages greyed. More familiar pattern but takes more vertical space and wraps awkwardly on narrow containers (a client card with 4-5 active interests stacks them all). ~1.5h, including a `compact` prop so the hero variant can keep the dense form.
|
||||
> - **Recommendation:** ship the inline breadcrumb (concise) — solves the "I can't tell what stage this is at without hovering" complaint with minimum visual footprint, and the existing `STAGE_BADGE` colour map provides the per-stage tint for free. Add a `showLabels?: boolean` prop to `StageStepper` so the dense rail-tile variants (`size="xs"`) can opt out. Captured 2026-05-18 from UAT.
|
||||
> - **EntityActivityFeed: rewrite per-row rendering to surface _what_ changed** — _src/components/shared/entity-activity-feed.tsx_ (the shared per-entity timeline used on Clients / Yachts / Berths / Residential / Interest detail pages) — each row currently reads `"<actor> updated the <field>"` with the old→new values dropped on a second line, often null or rendered as a truncated JSON dump. Reps can see _something_ changed but not _what_. Several coordinated fixes — pick the subset that's worth doing in one pass:
|
||||
> - **(a) Bake the value into the sentence line.** Replace `sentence()` (lines 70-77) so when both `fieldChanged` and `newValue` are present the row reads `"<actor> set <field> to <new>"` (with `(was <old>)` appended in muted text on the same line if `oldValue` exists). Eliminates the separate strikethrough line in 80% of cases and reads like a sentence, not a diff. Keeps the separate diff line only for long-form changes (notes body, descriptions) where truncation matters.
|
||||
> - **(b) Type-aware value formatting beyond the four enums already handled.** `formatValueForField()` (lines 48-66) special-cases `pipelineStage`, `source`, `leadCategory`, `outcome`. Extend with: user-FK fields (`assignedTo`, `ownerId`, `createdBy`) resolved to display names via the same bulk-resolution pattern queued in the actor/diff UUID finding above; berth-FK fields (`berthId`, `primaryBerthId`) resolved to mooring number; yacht-FK / company-FK fields resolved to entity name; date columns (`outcomeAt`, `dueDate`, `startDate`) formatted as `MMM d, yyyy`; currency columns (`price`, `total`) formatted via `formatCurrency` with the row's currency code from `metadata`; boolean toggles rendered "enabled" / "disabled" instead of "true" / "false"; JSON / object values get a one-line summary (e.g. address → `"Address updated: 123 Main St → 456 Elm St"` rather than the JSON dump).
|
||||
> - **(c) Compound-action verbs.** The seven `ACTION_VERBS` (lines 26-34) cover only the generic CRUD set. Many real audit-log entries use compound actions (`linked`, `unlinked`, `signed`, `sent`, `viewed`, `archived`, `set_primary`, `merged_into`, `reassigned`, …) that fall back to printing the raw action verb. Audit `audit_logs.action` distinct values for the active port and add a verb + sentence template per case, e.g. `linked` → `"<actor> linked <related-entity-label>"` (reads metadata for the related entity's id + type and renders a clickable link). Templates per action keep the sentence rendering type-safe instead of a giant switch in `sentence()`.
|
||||
> - **(d) Use `metadata` for create rows.** `create` rows currently say `"<actor> created this record"`. Pull the entity's name/mooring/identifier out of `metadata` (or a small lookup if metadata's empty) so it reads `"<actor> created client <Name>"` / `"<actor> created berth <A12>"`.
|
||||
> - **(e) Collapsed-session preview text.** The `SessionGroupItem` collapse (lines 245-260) currently reads `"<actor> made N changes in this session"`. Show a one-line preview of _which_ fields were touched (e.g. `"Matt changed pipeline stage, owner, and 2 more fields"`) so reps can see if the session is worth expanding without clicking.
|
||||
> - Effort: ~2h for (a)+(b)+(d) (the most user-visible wins, all in this one file plus a thin bulk-resolution helper in the activity-feed service). ~1h for (c) (registry of action templates). ~30min for (e). Total ~3.5h for the full bundle, or pick (a)+(b)+(d) as the high-value MVP at ~2h. Captured 2026-05-18 from UAT — same surface as the activity-feed UUID resolution finding above (the bulk-resolution helper introduced for that finding is the prereq for (b)'s user-FK resolution; do these in one pass).
|
||||
> - **Client → Companies tab: add CTA to link or create a company membership** — _src/components/clients/client-companies-tab.tsx_ (the tab, including the EmptyState at lines 44-51 and the table-populated branch at 53-101) — the tab currently shows a list of company memberships pulled from `company_memberships`; the EmptyState literally tells the rep "Add a membership from a company's detail page" — a backwards workflow that forces them to leave the client they're working on, navigate to a company, then come back. The populated view also has no "Add another" affordance.
|
||||
> - **Backend ready:** `POST /api/v1/companies/[id]/members` already exists (with corresponding `PATCH` and `DELETE` on `/members/[mid]`, plus `POST /members/[mid]/set-primary`) and accepts a `clientId` in the body. No new schema work needed.
|
||||
> - **UI work:** (1) Add a primary "Link or add company" button in the tab header (next to the `Company affiliations` heading), gated by `memberships.manage`. (2) Sheet with two modes — **(a) Link existing**: combobox-search across companies (use existing `/api/v1/companies/autocomplete`) + role select + isPrimary toggle + optional startDate; on submit calls `POST /api/v1/companies/{selectedCompanyId}/members` with this client's `clientId`. **(b) Create new + link**: opens `CompanyForm` in create mode (drawer-in-drawer or step 2 of the sheet); on successful create, chains the same membership POST. Toast on completion, invalidate `['client', clientId]` so the tab refreshes. (3) Replace the EmptyState's copy with one matching the new CTA ("No company memberships yet — link this client to a company below.") and surface the same button there too. (4) Each row in the populated table gets a kebab menu: "Set as primary" (POST set-primary), "Edit role / dates" (PATCH), "Remove" (DELETE with confirm).
|
||||
> - **Symmetry note:** The "Companies → Members" tab already has the inverse flow (add a client to a company) — same UI primitives should be reusable; consider lifting the membership form into a shared `MembershipForm` if the divergence is small. ~1.5-2 h end-to-end. Captured 2026-05-18 from UAT.
|
||||
> - **Activity feed: resolve actor + diff UUIDs to display names** — _src/components/dashboard/activity-feed.tsx (ActivityFeedInner ~line 175)_, plus the activity-feed service that loads `audit_logs` rows, plus the diff-rendering helper that produces the `"old → new"` strings — two related findings from UAT, both UUIDs leaking into the rendered card:
|
||||
> - Diff entries with FK columns (e.g. `assignedTo: "—" → "mEcsLxo5kyFMyhbOSehxJjYSSD7CiLvv"`) print the raw user UUID instead of the user's display name. Root cause: `audit_logs.fieldChanged='assignedTo'` rows store the new column value as a raw string; the formatter has no type info that tells it to resolve as a user FK.
|
||||
> - Actor / subject identifiers in the row meta (e.g. `"d62aadbf"` — short UUID prefix) also render raw. Same root cause: the renderer falls back to a UUID slice when the row's `actorName`/`subjectLabel` is empty.
|
||||
> - Fix shape: (1) extend the audit-logs schema (or the activity-feed service) with a typed-field registry — `{ field: 'assignedTo', kind: 'user_fk' }`, `{ field: 'ownerId', kind: 'user_fk' }`, `{ field: 'reassignedTo', kind: 'user_fk' }` etc. (2) When the service hydrates rows for the feed, bulk-fetch every referenced user (`SELECT id, firstName, lastName, email FROM users WHERE id IN (…)`) and replace the raw UUID strings with display names in both the diff `old`/`new` AND the `actorName`/`subjectLabel` columns. (3) Render fallback: if the user no longer exists (deleted/never-existed), show `"Unknown user (#<short-uuid>)"` so the feed remains useful for forensics. (4) Same treatment for any _other_ FK fields that may have slipped in (yacht IDs, berth IDs, etc. — audit at finding time).
|
||||
> - ~1.5-2 h end-to-end (schema-light approach via a per-field registry in code, no migration). If we ever expand to non-user FKs, generalize the registry to dispatch by entity type. Captured 2026-05-18 from UAT.
|
||||
> - **EOI bundle UX rework (multi-berth interests)** — _src/lib/services/interest-berths.service.ts_, _src/components/interests/linked-berths-list.tsx_, _src/components/documents/eoi-generate-dialog.tsx_ — **DESIGN CONFIRMED 2026-05-18.** Workflow assumption: half+ of interests are multi-berth; typically one signed EOI covers many berths (e.g. A1-A10) but only the website-entry / "main" berth (e.g. A2) should show "Under Offer" on the public map. The current schema defaults (`is_specific_interest=true`, `is_in_eoi_bundle=false`) invert this — every linked berth shows publicly + nothing is bundled until ticked. Three coordinated changes:
|
||||
> - **(a) Smarter insert-time defaults** in `addInterestBerth()`:
|
||||
> - `is_in_eoi_bundle` → default **`true`** (any linked berth is presumed covered by the signed EOI; rep unticks for the rare carve-out case).
|
||||
> - `is_specific_interest` → default **`false`** for non-primary rows; **`true`** only when the row is primary (matches "only the main berth gets publicly marked Under Offer").
|
||||
> - ~30 min including unit-test coverage for the new defaults and a clarifying comment.
|
||||
> - **(b) Rename + tooltip on LinkedBerthsList toggle** — "Mark in EOI bundle" → "Include in EOI" + an info popover explaining the bundle-vs-public distinction (matters more now that the two flags routinely diverge). ~15 min.
|
||||
> - **(c) "EOI berth scope" picker inside the EOI Generate dialog** — at the moment of EOI generation, surface every linked berth as a row with **two** checkboxes: "In EOI bundle" and "Show on public map". Pre-fill from current flag state (which, post-(a), is mostly already correct). The picker forces the rep to consciously confirm signature scope + public visibility at the moment that question is live in their head, instead of relying on them having visited the LinkedBerthsList toggles upstream. Saving the dialog updates all `interest_berths` rows in one call before kicking off the Documenso envelope. ~1.5-2 h.
|
||||
>
|
||||
> Total ~2.5-3 h end-to-end. Closes the multi-berth EOI discoverability gap (plan §1 + §4.6) and matches the documented workflow expectation that public map visibility is a _subset_ of EOI bundle coverage.
|
||||
|
||||
1. **Berth-demand widget visual overhaul** — _src/components/dashboard/berth-heat-widget.tsx_ — original "Berth heat" widget was a generic table that read as uninspired. First pass added an editorial hero + gradient — that strayed from the standard `CardHeader`/`CardContent` idiom and looked out of place next to siblings. Final version matches `hot-deals-card.tsx`'s layout exactly (icon + title + description in CardHeader, list of `-mx-2 hover:bg-accent/60` rows in CardContent); the visual upgrade is the per-row status-coloured magnitude bar. UI label renamed "Berth Heat" → "Berth Demand" in `widget-registry.tsx`. Fixed in this session.
|
||||
2. **First-class "demand" sort on the berths list** — _src/lib/services/berths.service.ts_, _src/components/berths/berth-columns.tsx_, _src/lib/validators_ — added `?sort=activeInterestCount` to the berths-list service via a correlated subquery in `customOrderBy`; attached `activeInterestCount` per row using the existing two-pass post-fetch pattern (alongside tags/latestInterestStage); added the "Active interests" column to `BERTH_COLUMN_OPTIONS` (default-visible, sortable). Widget's "View all by demand →" link deep-links to `/berths?sort=activeInterestCount&order=desc`. Saved views and the column picker can now use the same lens. Fixed in this session.
|
||||
3. **Pipeline Value tile expanded with per-stage breakdown** — _src/components/dashboard/pipeline-value-tile.tsx_, _src/lib/services/dashboard.service.ts_ — replaced the single-number KPI with a richer card: gross headline + weighted forecast on top, per-stage rows below (label · mini bar · gross value · count + close-probability), and a footnote when default stage weights are in use. Service `getRevenueForecast` extended to return `grossValue`, `weight`, `totalGrossValue`, and `dealsMissingPrice` alongside the existing weighted shape; the tile pulls from `/kpis` (for gross + currency + activeInterests) and `/forecast` (for breakdown). Per-stage warning chip surfaces when berths are missing a `price` so a silently undercounted gross is visible (full coverage → "berth price missing", partial → "N of M missing price"). Leadership can now see how much of the headline is near-close vs speculative. Fixed in this session.
|
||||
4. **"How weighted forecast works" info popover on the Pipeline Value tile** — _src/components/dashboard/pipeline-value-tile.tsx_ — added an `Info` icon next to the description that opens a `Popover` (click or hover) explaining the close-probability model + showing the per-stage weight table (live from `/forecast`, fallback to `STAGE_WEIGHTS` constant) + a note about whether default or per-port weights are in use. Fixed in this session.
|
||||
5. **Bulk + inline berth price editing — backend complete** — _src/lib/db/schema/users.ts_, _src/lib/db/seed-permissions.ts_, _src/components/admin/roles/role-form.tsx_, _src/components/admin/users/user-permission-matrix.tsx_, _src/app/api/v1/admin/users/[id]/permission-overrides/route.ts_, _src/lib/validators/berths.ts_, _src/lib/services/berths.service.ts_, _src/app/api/v1/berths/[id]/price/route.ts_, _src/app/api/v1/berths/bulk-update-prices/route.ts_, _tests/helpers/factories.ts_ — new `berths.update_prices` permission carved out from generic `berths.edit` so sales reps can update prices without exposing the full edit surface. Permission seeded on for super_admin/director/sales_manager/sales_agent, off for viewer/residential_partner. New validators (`updateBerthPriceSchema`, `bulkUpdateBerthPricesSchema` capped at 500/batch), services (`updateBerthPrice`, `bulkUpdateBerthPrices`, both transactional + per-row audited with `fieldChanged='price'` + realtime `berth:updated` + webhook fan-out), and routes (`PATCH /api/v1/berths/[id]/price`, `POST /api/v1/berths/bulk-update-prices`). UI shipping in a follow-up — see Features bucket #1. Fixed in this session.
|
||||
|
||||
---
|
||||
|
||||
## Bucket 3 — Features / larger (> 2 h)
|
||||
|
||||
_New UI surfaces, new endpoints, schema migrations, multi-step flows._
|
||||
|
||||
> **[Umami] Larger follow-ups parked at end of 2026-05-19 build session:**
|
||||
>
|
||||
> - **[Umami] Tracked-link composer button (Phase 4c UI)** — _src/components/email-composer/_ (find/create) + _src/lib/services/tracked-links.service.ts (already shipped)_ — backend shipped this session: `tracked_links` + `tracked_link_clicks` tables, `/q/[slug]` redirect endpoint, `createTrackedLink` + `buildTrackedUrl` helpers, Umami `link-clicked` cross-post. The missing piece is the rep-facing UI. Recommendation: a "🔗 Tracked link" button inside the sales email composer that takes the currently-selected URL (or prompts for one), calls `createTrackedLink({portId, targetUrl, sendId})`, and inserts the resulting `/q/<slug>` URL in place of the original. Show per-link click stats on the document_sends list (companion to the Bucket 2 open-rate column). Cap: ~3-4 h including the list-side rendering of click stats. Captured 2026-05-19.
|
||||
> - **[Umami] Marketing-site instrumentation (Phase 4a)** — _separate marketing-site repo, NOT this one_ — adds `umami.track('cta-clicked', {…})`, `umami.track('eoi-page-reached')`, etc. calls on the marketing site so the Events tab + cross-system funnels (Phase 3 + Phase 5) light up. Also adds a `do_not_track` opt-out checkbox to the marketing-site cookie banner so visitors who decline tracking get `localStorage.setItem('umami.disabled', '1')` and skip the script entirely. Needs to be coordinated with whoever owns the marketing-site repo — capture the schema we want them to emit (event names + payload shapes) in `docs/marketing-site-event-catalogue.md` once we know which CRM funnels we actually want to drive. ~4-6 h of marketing-repo work + ~2 h of CRM-side cataloguing. Captured 2026-05-19.
|
||||
> - **[Umami] Events tab (Phase 3)** — _src/components/website-analytics/events-list.tsx (new)_ + new route — Umami's `/api/websites/:id/events` is already wrapped in `umami.service.ts` (`getEvents`, `getEventsStats`, `getEventsSeries`). Surface as a new "Events" tab on the analytics page. BLOCKED on Phase 4a — the tab is empty until the marketing site fires custom events. Cap: ~3-4 h once 4a lands. Captured 2026-05-19.
|
||||
> - **[Umami] Funnels + Journeys (Phase 5)** — _src/components/website-analytics/funnel-builder.tsx (new)_ + _src/components/website-analytics/journey-flow.tsx (new)_ — Umami's `/api/websites/:id/reports/funnel` and `/journey` endpoints are wrapped (`runFunnelReport`, `runJourneyReport`). Funnel builder = pick N steps (URL or event), see per-step conversion. Journey flow = sankey-style visualisation of where visitors go after a chosen entry page. BLOCKED on Phase 4a for the event-driven half. Cap: ~6-8 h. Captured 2026-05-19; deferred to end per earlier scoping.
|
||||
> - **[Umami] Click-to-filter the page from the world map** — _src/components/website-analytics/visitor-world-map.tsx_ + new `country` filter store + thread through every `useUmamiTop*` hook — `VisitorWorldMap` already accepts an `onCountryClick(iso2)` prop that's unused. Wire it to a page-wide country filter (Zustand store or URL search param `country=US`) that scopes every card on the page to that country's data. Mirrors Umami's own click-through behaviour. Cap: ~2-3 h. Captured 2026-05-19.
|
||||
> - **[Umami] Per-rep `identify()` calls for attribution** — _src/components/auth/use-session.tsx (or wherever the session is hydrated)_ + _src/lib/services/umami.service.ts (new `identifyRep` wrapper)_ — call `umami.identify({sessionId, role: 'rep', repId: user.id})` on every authenticated CRM session so Umami's Sessions list can show "this lead came in while Matt was working hours". Privacy-gated: only fires for super-admin / sales-manager / sales-agent roles, never for residential-partner, never for portal-side users. Captured 2026-05-19; deferred as the privacy/value trade-off needs a product call before building.
|
||||
|
||||
0. **Platform-wide chart library migration: recharts → ECharts** — _src/components/dashboard/_ + _src/components/website-analytics/_ + _src/components/berths/_ — we now run two chart libraries side-by-side: ECharts (just adopted for the world choropleth + tree-shaken, canvas renderer, d3-geo projection) and recharts (everything else: berth-status donut, occupancy-timeline line, pipeline-funnel bar, lead-source pie, source-conversion bar, berth-heat-widget bars, pageviews-vs-sessions area, pipeline-value-tile mini-bars — ~8+ components). **Trade-off analysis (done 2026-05-19 during analytics build):** ECharts wins on visual polish (better default styling, smoother animations, native legend/tooltip behaviour), comprehensive chart types (sunbursts, sankeys, parallel coords, heatmaps, geo all out of the box), and canvas-renderer performance on dense series; recharts wins on React-idiom (declarative `<Area>` / `<Bar>` children vs imperative option objects) and bundle size for the very simplest charts. **Migration cost:** ~6–10 h to port the existing 8 components; each is a 50–150 LOC swap from `<ResponsiveContainer><AreaChart>…` to an `<ReactEChartsCore option={…} />` with tree-shaken module imports. **Pre-reqs already in place:** `transpilePackages: ['echarts', 'zrender', 'echarts-for-react']` added to `next.config.ts`, `d3-geo` installed, dynamic-import + canvas-renderer pattern proven on the world map. **Recommendation:** do as a single coordinated pass (consistency wins over piecemeal), gated on a free afternoon — none of the existing recharts components are buggy, this is purely about platform-wide visual + capability parity with the new analytics surfaces. Captured 2026-05-19 during the Umami flesh-out work.
|
||||
|
||||
1. **Bulk-price editing UI** — _src/components/berths/_, _src/components/berths/berth-columns.tsx_ — backend shipped this session (new `berths.update_prices` permission across schema + 6 role maps + admin UI + factories; validators `updateBerthPriceSchema` + `bulkUpdateBerthPricesSchema`; services `updateBerthPrice` + `bulkUpdateBerthPrices` — both per-row audited with `fieldChanged='price'`; routes `PATCH /api/v1/berths/[id]/price` + `POST /api/v1/berths/bulk-update-prices`, ≤500 berths per batch). UI work pending: (a) wire `InlineEditableField` into the price cell of `berth-columns.tsx` (click → input → PATCH) gated by `can('berths', 'update_prices')`; (b) add `bulk-price-edit-sheet.tsx` (right-side Sheet, per-row inputs, "Set all to" + "Apply % adjust" shortcuts) wired to `bulkActions` on the `<DataTable />` in `berth-list.tsx`. ~2–3 h to ship the UI.
|
||||
2. **Pipeline Value tile should respect dashboard timeframe** — _src/components/dashboard/pipeline-value-tile.tsx_, _src/lib/services/dashboard.service.ts_ — the dashboard has a Today / 7d / 30d / 90d / Custom filter at the top (`Last 30 days` shown beside the greeting) but the Pipeline Value tile shows an absolute snapshot regardless. Should be constrained to the active timeframe: e.g. "Pipeline as of end of range" + "Revenue actually realized in range" (closed-won × berth price for interests whose `outcome_at` falls in the window). Needs: dashboard-wide timeframe context (Zustand store or React Query keyed by range), forecast/KPI service variants that accept a `range`, and a "realized vs forecast" line in the tile. ~3–4 h.
|
||||
3. **Stylized branded PDF report exporter from the dashboard** — _new feature_ — let users pick which analytics widgets to include and export a branded (port logo + colours, header/footer) PDF report. Builds on existing `pdfme` (templates) and `pdf-lib` (filling) infra plus the per-port branding already in `system_settings`. Trigger from a "Export report" button on the dashboard header; modal lets the user toggle widgets to include (KPIs, pipeline funnel, occupancy timeline, revenue breakdown, source attribution, berth demand, hot deals, recent activity, …) and optional sections (web analytics if a per-port integration is configured — GA4/Plausible/Umami — not yet wired). Server-side rendering of the report PDF via headless Chromium or pdfme. ~6–10 h (new product surface).
|
||||
4. **Web analytics integration (companion to #3)** — _new feature_ — per-port web analytics provider config in admin (GA4 / Plausible / Umami / Cloudflare), surfaced as widgets on the dashboard and ingestable into the branded PDF report. Needs: settings UI, provider adapter layer (`src/lib/integrations/analytics/`), dashboard widgets, and inclusion in the report exporter. ~8–12 h.
|
||||
5. **Supplemental-info-request email: branded HTML styling** — _src/lib/email/templates/_ — the email is plain HTML (logo missing, no header card, no blurred background), inconsistent with the other branded transactional emails (portal activation / reset / login wrap content in a `BrandedAuthShell`-equivalent HTML layout per CLAUDE.md). Rebuild the template to match the table-based, max-width 600, logo + blurred overhead background look, pulling port branding from `system_settings`. ~1-2 h.
|
||||
6. **Residential interests list: visual + functional parity with the main InterestList** — _src/components/residential/residential-interests-list.tsx_ vs _src/components/interests/interest-list.tsx_ + _interest-card.tsx_ + _interest-columns.tsx_ + _interest-filters.tsx_ — the residential interests page today is a slim search + stage-filter list (~200 lines). The main InterestList (~700 lines + supporting files) carries the bulk of the product idiom: card / table / kanban view modes (kanban is desktop-only), `usePaginatedQuery` with sort + saved views, full `FilterBar` (search, stage, tags, owner, source, date ranges), `ColumnPicker` for table mode, bulk actions wired to `/interests/bulk` (archive, change stage, add/remove tag), realtime invalidation across multiple event names, per-row archive flow, kebab actions, `InterestCard` rich row component. Reps switching between berth interests and residential interests today get two visually-divergent experiences for what is effectively the same conceptual surface.
|
||||
> - **Scope breakdown:**
|
||||
> - **(a) Card view + visual parity (highest leverage)** — replace the table-style `<li>`-per-row layout with a `ResidentialInterestCard` mirroring `InterestCard` (header with client name + stage chip + last-activity, body with preferences/notes preview, footer with quick actions). Reuse the existing `<DataTable />` primitive for the table mode so column picker + sort + bulk-select come for free. ~3-4h.
|
||||
> - **(b) Export to PDF + CSV** — match the export affordance the main page has (or, if the main page lacks it, add it to both surfaces in the same pass — captured here so it lands on both). PDF: render rows + summary header via `pdfme` / `pdf-lib` (existing infra per CLAUDE.md), branded with port logo. CSV: server-side endpoint `/api/v1/residential/interests/export?format=csv|pdf` (or client-side generation if the dataset is bounded — residential volumes are typically small). Trigger from a kebab menu on the page header. ~2h.
|
||||
> - **(c) Filter / sort / pagination parity** — extend the residential interests endpoint to accept the same `FilterDefinition` shape (stage, source, assignee, date range, tags) and wire `usePaginatedQuery` + `FilterBar` on the page. ~2-3h.
|
||||
> - **(d) Bulk actions + saved views** — only if residential workflows actually use them (verify with the external partner team first — residential volumes may be low enough that bulk-mutate is unused). ~2h if needed, skip if not.
|
||||
> - **Refactor opportunity:** much of the InterestList scaffolding is generic — there's a latent opportunity to extract an `EntityList<T>` primitive that takes `{ endpoint, columns, cardComponent, filterDefinitions, bulkActions }` and renders the whole shell. Both surfaces become thin configs. ~6-8h for the extraction + porting both lists, but pays off the next time a similar list ships (companies, yachts already have parallel lists that could adopt it). Out-of-scope for this finding; capture as a follow-up if appetite exists.
|
||||
> - **Recommendation:** ship (a) + (b) in one ~5-6h pass for the high-visibility wins (cards + export). Defer (c) until the residential team complains about filter gaps. Skip (d) unless verified-needed.
|
||||
> - **Companion fix:** see Bucket 1 finding "Residential namespace breadcrumb link is 404" — if the parity work lands a `/residential` landing page, that breadcrumb finding folds into this.
|
||||
> - Captured 2026-05-18 from UAT.
|
||||
7. **Residential inquiry → auto-forward to external partner email(s)** — _src/lib/services/residential.service.ts_ (`createResidentialInterest`), _src/app/api/public/residential-inquiries/route.ts:97_ (public intake), _src/lib/services/settings.service.ts_ + admin settings UI, _src/lib/email/templates/_ (new template), BullMQ enqueue — residential clients are managed by an external partner; every new residential inquiry needs to be forwarded automatically to one or more configured email addresses so the partner can act on it.
|
||||
> - **Settings model:** new per-port `system_settings` keys: `residential_forward_enabled` (bool, default false), `residential_forward_recipients` (JSON array of email addresses — `to`), `residential_forward_cc` (JSON array, optional), `residential_forward_filter` (optional discriminator — e.g. only forward inquiries with certain `source` values or above a price/size threshold; v1 ships without this and forwards everything).
|
||||
> - **Admin UI:** new section in `src/app/(dashboard)/[portSlug]/admin/settings/` ("Residential routing") with: enable toggle, recipient list editor (add/remove emails, drag-reorder, per-row "primary" flag for the To field vs CC), template preview ("Send sample to me"), and a small "Last forwarded N inquiries in the past 7 days" stat for confidence. Permission-gated by `admin.manage_settings`.
|
||||
> - **Email template:** new branded HTML template `residential-inquiry-forwarded.tsx` in `src/lib/email/templates/` matching the existing branded-shell idiom (port logo + table-based layout per CLAUDE.md) — body includes inquiry fields (client name, contact channel, preferences, notes, source, submission timestamp, link to the residential interest in the CRM if the partner has portal access; otherwise a "view in CRM" stub).
|
||||
> - **Send pipeline:** enqueue a BullMQ job in `createResidentialInterest` (don't send inline — keeps public intake fast + retries handle SMTP flakes). Job: render template with port branding + inquiry payload, send via existing nodemailer transport, audit a `document_sends` row per recipient for forensics. Honour the dev-only `EMAIL_REDIRECT_TO` envar (per CLAUDE.md) so QA doesn't spam the real partners.
|
||||
> - **Edge cases:** retry on SMTP failure (BullMQ default retry policy); de-dup if the same inquiry triggers create twice within the dedup window (already a residential-intake concern — verify); skip forwarding when forwarding is disabled mid-flight (settings read at job time, not enqueue time, so toggle takes effect immediately).
|
||||
> - **Effort:** ~3-4h for settings + template + service hook + BullMQ wiring; +1h for admin UI + sample-send button. Captured 2026-05-18 from UAT.
|
||||
>
|
||||
> - **Related:** see Feature 6 below — auto-link residential to existing main-client records, which fires at the same moment in the create pipeline; build (5) and (6) in one pass so the forwarded email can carry the "matched to existing CRM client X" context if a link was found.
|
||||
8. **Auto-link residential interests to existing main-client records (same person)** — _src/lib/services/residential.service.ts_ (`createResidentialClient` + `createResidentialInterest`), _src/app/api/public/residential-inquiries/route.ts_, new schema migration adding _src/lib/db/schema/residential.ts_ join table, _src/components/residential/residential-client-detail-header.tsx_ + _src/components/clients/client-detail-header.tsx_ (surface the link on both sides), new admin/dev script for backfill — when the same person who exists in the main berth client list registers a residential interest (or vice-versa), the two records should auto-link so reps can see the full relationship at a glance.
|
||||
> - **Why a link, not a merge:** the two pipelines are operationally distinct (different team handles residential, different lifecycle stages, different downstream services). A hard merge would conflate records that should remain queryable separately. A symbolic link preserves both records while making the relationship discoverable.
|
||||
> - **Schema:** new join table `residential_client_links (id, port_id, residential_client_id, client_id, linked_at, linked_by_user_id, link_method enum('auto_email_match' | 'auto_phone_match' | 'manual'), confidence numeric(3,2), notes text)` — composite unique on `(port_id, residential_client_id, client_id)` so the same pair can't be linked twice. Both FKs ON DELETE CASCADE so dropping either side cleans the link automatically.
|
||||
> - **Match logic** (at residential client/interest create time): normalize the residential `email` to lowercase and check against `client_contacts.value` WHERE `channel='email'`; normalize `phoneE164` and check against `client_contacts.valueE164` WHERE `channel='phone'`. Email match → confidence 0.95 (auto-link, log audit); phone match → confidence 0.80 (auto-link with a "candidate match" badge so the rep can confirm); both match → confidence 0.99. If multiple candidate main-clients match (shared email — family/spouse case), DO NOT auto-link; instead surface all candidates in a UI banner for the rep to pick. Same logic runs in reverse when a new main-client is created (look for matching residential client).
|
||||
> - **UI surface:** on residential client detail header — small "Linked to <Main client name>" pill below the name, click-through to the main client; if a candidate match was surfaced but not auto-linked, a banner: "Possible match: <Name> (same email/phone). [Link] [Dismiss]". Mirror on the main client header. Add a "Link to existing residential client" / "Link to existing main client" button on each side for manual link creation (combobox-search across the other side). Add an "Unlink" affordance with confirm — useful when an auto-match was wrong (e.g. shared family email).
|
||||
> - **Audit + telemetry:** every auto-link writes an `audit_logs` row with `action='auto_linked'`, `metadata={method, confidence}` so the org can audit auto-link accuracy. Optional admin dashboard tile showing "N residential links auto-created / manually overridden this week" for ongoing confidence in the match logic.
|
||||
> - **Backfill script:** `pnpm tsx scripts/backfill-residential-links.ts` — one-pass scan of existing residential_clients vs clients for matching email/phoneE164; idempotent (skips pairs already linked); dry-run by default, `--apply` to commit. Required because the join table is new and existing records won't be auto-linked retroactively.
|
||||
> - **Effort:** ~4-6h end-to-end (migration + service hook with match logic + UI on both header sides + backfill script + tests + audit). Significant scope but high-leverage: gives reps a single mental model of "this person across our two product lines" instead of two parallel records. Captured 2026-05-18 from UAT.
|
||||
|
||||
- **World-map heatmap of Umami visitor origins** — _new file_ `src/components/website-analytics/visitor-world-map.tsx` (heatmap card) + extend _src/lib/services/umami.service.ts_ (already returns `top-country` data via `getMetric(type: 'country')`) + viz lib choice (e.g. `react-simple-maps` + Natural Earth TopoJSON, or `@visx/geo`, or a simple SVG world from D3) — render a world choropleth colour-scaled by visitor count per country, surfaced on the Website Analytics page (and optionally on the dashboard as a separate rail widget). Hover any country to see the visitor count tooltip; click to filter the page's other widgets to that country (uses Umami `filters` query param if we extend the route to support it). Implementation notes: ISO 3166-1 alpha-2 codes map cleanly to country features in Natural Earth; cache the topojson in `public/` to avoid per-load fetch. Bundle weight ~50-80KB gzipped depending on lib choice; dynamic-import to keep it off the dashboard bundle when the widget is collapsed. ~4-6h end-to-end. Companion / overlap candidate: the "Clients by country" widget below — a single map could surface both data sources via a toggle (Umami visitors vs CRM clients/prospects) instead of two separate widgets. Captured 2026-05-18 from UAT.
|
||||
|
||||
7. **"Clients by country" dashboard widget** — _src/components/dashboard/_ (new file `clients-by-country-widget.tsx`), _src/components/dashboard/widget-registry.tsx_, _src/lib/services/dashboard.service.ts_ (or `analytics.service.ts` if it should live in the snapshot-cached family), _new endpoint or extension to `/api/v1/dashboard/...`_ — surface a per-country breakdown of clients (and optionally prospects — interests with `outcome` still open) so leadership can see geographic distribution at a glance. Data shape: aggregate `client_addresses` (or `clients.country` if that column exists) by `country_code` for clients that are non-archived and (for the prospect overlay) join through interests-with-open-outcome. UI options to pick from at build time: (a) compact ranked list with mini bars per row (matches `BerthHeatWidget` / `HotDealsCard` idiom — fits the rail), or (b) a choropleth/world-map (heavier; needs a viz lib like `react-simple-maps` + a topojson; better fit for the chart grid). Pick (a) by default — same footprint as existing rail tiles, no new bundle weight, and clicking a country could deep-link `/clients?country=DE`. Permissioning: gate on `clients.view`. Registry: defaultVisible: true. Effort: ~2-3 h for variant (a) + endpoint + tests; ~6-8 h for variant (b) with a real map. Captured 2026-05-18 from UAT (user request: "add a widget that breaks down prospects/clients by country as a card on the dashboard").
|
||||
8. **Drag-and-drop rearrangable dashboard widgets** — _src/components/dashboard/dashboard-shell.tsx_, _src/components/dashboard/widget-registry.tsx_, _src/hooks/use-dashboard-widgets.ts_ (assumed name), _src/lib/db/schema/users.ts_ (`user_profiles.preferences`), _src/app/api/v1/me/preferences/route.ts_ — today widget order is hard-coded by registry array order, and visibility is the only user-controllable axis (persisted in `user_profiles.preferences.dashboardWidgets` as a `{ [id]: boolean }` map). Reps want to choose **which analytics show where** on their dashboard (e.g. push Pipeline Funnel to the top, demote Berth Status, swap rail order). Approach: (a) introduce a parallel `dashboardWidgetOrder: string[]` preference (ordered list of widget IDs; missing IDs render after the list in registry order so newly-added widgets always surface); (b) extend `useDashboardWidgets` to return `visibleWidgets` already sorted by this order; (c) keep the three-group layout (`chart` / `rail` / `feed`) — drag-reorder is scoped _within_ a group so the rail's narrower min-col doesn't get a chart-sized tile dropped into it (and vice versa) — moving a widget between groups stays a registry-level concern (the move-out-of-rail request that triggered this entry is an example); (d) add `@dnd-kit/core` + `@dnd-kit/sortable` (lightweight, RSC-safe, already shadcn-adjacent); (e) wrap each group's grid in a `SortableContext`, render a small grip handle on each card header that's only visible in "rearrange mode" (toggle in the existing Customize dropdown — keeps casual users from accidentally grabbing tiles); (f) on drop, optimistic-update the preference and PATCH `/api/v1/me/preferences` with the new order array; (g) realtime: not needed (per-user state). Tests: vitest for the order-merge helper, Playwright smoke for drag-drop + persistence across reload. ~4-6 h end-to-end. Captured 2026-05-18 from UAT after moving the Pipeline Value tile from rail → chart group exposed that re-shuffling widgets is currently a code change, not a user action.
|
||||
9. **AI-assisted action extraction from contact-log entries** — _src/components/interests/interest-contact-log-tab.tsx_, new LLM service — current dialog already has quick-template buttons that seed `"Called the client. Discussed:\n\n• \n\nNext step: "` (and similar for in-person / email) into the summary textarea — soft structure without enforcement. Adding rigid form fields ("Topic", "Next step", "Outcome") risks killing rep adoption (sales reps notoriously avoid form-y CRMs). Better path: keep the freeform textarea + templates exactly as-is, add an **"Extract action items"** button beside Save that LLM-parses the body and returns proposed follow-ups — `create reminder for {datetime}`, `update desiredLengthFt to {n}`, `suggest stage advance to deposit_paid`, etc. Each proposal lands as a confirm-each list; rep approves individually. **AI assists, rep approves** — never silently mutates the record. Scope: ~6-10 h end-to-end (prompt engineering + LLM client + extraction schema + per-action confirm UI + audit logging of accepted/rejected proposals). Privacy considerations: contact-log entries can contain PII / financial details — route through an in-region LLM provider per the existing email/storage approach. Defer until a user is genuinely asking for it; the current template-seed pattern is fine for now.
|
||||
10. **Documenso-first templates: pull templates from Documenso instead of uploading through CRM (admin UI gap)** — _src/components/admin/document-templates/template-form.tsx_ (template create/edit UI, currently uploads source PDF/HTML), _src/lib/db/schema/documents.ts:254_ (`documensoTemplateId` column already exists), _src/lib/services/document-templates.ts:611_ (`pathway: 'documenso-template'` already routes through Documenso), _src/lib/services/documenso-template-sync.service.ts_ (existing per-port EOI sync; needs generalization), _src/lib/services/documenso-client.ts_ (need a `listTemplates()` wrapper) — the schema and signing pathway support Documenso-hosted templates (the CRM stores only the Documenso template ID, Documenso owns rendering), but the admin UI today assumes the source PDF/HTML lives in the CRM. Reps who maintain their templates in Documenso can wire ONE per port (the EOI, via the existing per-port sync) but can't add other types (welcome letter, handover checklist, correspondence) as Documenso-hosted entries without DB-level intervention. Real product gap — closes the "is Documenso the source of truth, or is the CRM?" question for ports that prefer to author in Documenso.
|
||||
> - **Scope:**
|
||||
> - **(a) Template-source toggle** in `template-form.tsx`: radio between "Upload to CRM" (current behaviour) and "Pull from Documenso". Selecting the latter changes the form below.
|
||||
> - **(b) Documenso template picker** — new combobox that calls a new `GET /api/v1/admin/documenso/templates` endpoint backed by `listTemplates()` (new wrapper in `documenso-client.ts` — v1: `GET /api/v1/templates`; v2: `GET /api/v2/envelope/template`). Lists Documenso-side templates by name + id; selecting one populates `documensoTemplateId` and `templateFormat='documenso_render'`. Cache the list for ~5 minutes per port.
|
||||
> - **(c) Per-template field-mapping editor** — once a Documenso template is picked, show its field labels (pulled via `getTemplate(id)` — already exists in the sync service) alongside a select-from-merge-tokens dropdown per row. Save the mapping into the `fieldMapping` JSONB column (currently used for AcroForm; reuse the shape: `{ documensoFieldLabel: mergeToken }`). Validate against `VALID_MERGE_TOKENS` on save so the field map can't reference a non-existent CRM token.
|
||||
> - **(d) "Sync now" button** — re-fetch the Documenso template, diff field labels against the saved `fieldMapping`, surface added / renamed / removed fields so the admin can update the mapping when the Documenso template changes. Generalizes the existing per-port EOI sync (`documenso-template-sync.service.ts`) to per-template.
|
||||
> - **(e) Template-list page treatment** — each template row in the list shows a small badge "Hosted in Documenso" vs "CRM-managed source" so admins can tell at a glance which is which.
|
||||
> - **(f) `generateAndSign` already handles this** — `pathway: 'documenso-template'` skips CRM PDF generation and calls Documenso's template-generate endpoint. No service-layer work needed beyond the new admin UI plumbing.
|
||||
> - **Migration consideration:** the existing per-port EOI sync (single Documenso template ID stored in port settings) becomes redundant once per-template mapping ships — migrate the per-port pointer into a row in `document_templates` with `templateFormat='documenso_render'` + the existing `templateType='eoi'`. Then deprecate the port-setting key. Single-port-EOI flow continues to work via the same templateType lookup; admins gain the ability to add additional Documenso-hosted templates (welcome letter, etc.) using the same UI.
|
||||
> - **Webhook + auto-file integration:** untouched — signing webhooks (`DOCUMENT_SIGNED`, `DOCUMENT_COMPLETED`, etc.) key on document/envelope ID, not template source, so Documenso-first templates inherit the same signing-status tracking + auto-deposit into the entity folder.
|
||||
> - **Effort:** ~5-7h end-to-end (toggle + picker + listTemplates wrapper + field-mapping UI + sync button + list-row badge + migration of the per-port EOI pointer + tests). Smaller (~3-4h) if (d) sync button is deferred. Captured 2026-05-18 from UAT in answer to "what happens if we upload templates straight to Documenso? Can we pull the template through?" — answer: yes, but only the EOI flows through today; this finding closes the UI gap for the other template types.
|
||||
|
||||
- **[Deferred — blocked on embeddings-based recommender] Berth recommender AI admin section on `/admin/ai`** — _src/app/(dashboard)/[portSlug]/admin/ai/page.tsx_ + _src/lib/services/berth-recommender.service.ts_ — the berth recommender is currently pure SQL (per CLAUDE.md: "Rule-based today; future versions will optionally use embeddings for soft preference matching"). When/if the embeddings-based version ships, surface its admin controls on `/admin/ai` alongside the other AI-feature sections: provider override, embedding model, similarity threshold, per-call budget cap. Until then, the recommender does not call an LLM — including it under `/admin/ai` today would mislead admins into thinking they're tuning an LLM. **Action: revisit when an AI/embeddings tier is added to the recommender.** Cross-ref: parent finding "Consolidate every AI-feature admin control onto /admin/ai" in Bucket 2. Captured 2026-05-18 from UAT.
|
||||
- **[Deferred — depends on Bucket 3 #7 contact-log action extraction] Contact-log AI admin section on `/admin/ai`** — when "AI-assisted action extraction from contact-log entries" (Bucket 3 #7) ships, add its admin controls to `/admin/ai`: provider override, prompt-template editor, per-call budget cap, accepted/rejected proposal stats. Cross-ref: parent finding "Consolidate every AI-feature admin control onto /admin/ai" in Bucket 2 + Bucket 3 #7 "AI-assisted action extraction from contact-log entries". Captured 2026-05-18 from UAT.
|
||||
- **[Deferred — no design exists] AI inquiry-intake parsing admin section on `/admin/ai`** — if/when AI-assisted inquiry intake parsing is built (e.g. LLM normalizes inbound web-form / email inquiries into structured fields before the rep sees them), surface its admin controls on `/admin/ai`: provider override, confidence threshold for auto-accept vs human-review, fallback behaviour when the AI tier fails, per-call budget cap. No design or scope exists for this feature today — captured as a placeholder so the thought isn't lost when the AI-feature page expands. Cross-ref: parent finding "Consolidate every AI-feature admin control onto /admin/ai" in Bucket 2. Captured 2026-05-18 from UAT.
|
||||
|
||||
8. **Platform-wide error message audit for prod debuggability** — _cross-cutting_ — triggered by the Documenso-config diagnosis loop: the user got a generic 502 + "Invalid token" upstream message when the real cause was "no Documenso creds entered for this port (silently fell back to a stale env value)." Operators in prod can't see logs the way we can in dev; the error surface should self-describe. Two layers of work:
|
||||
- **(a) Pre-flight config-shape errors at known integration boundaries** — _src/lib/services/documenso-client.ts_, _src/lib/services/storage/\*_, _src/lib/email/_, _src/lib/services/imap-bounce-poller.ts_, IMAP, SMS providers, payment gateways, etc. — when a call would fail because admin/env config is empty or unparseable, raise a typed `CodedError` _before_ the network call with an operator-facing message like `"Documenso is not configured for {portName}. Open Admin → Documenso settings to enter the API key, or set DOCUMENSO_API_KEY in env."` Include the offending setting key + port name. The `documenso-client` `resolveCreds()` is the canonical example to template from — others (IMAP, S3, SMTP, Stripe etc.) should follow the same pattern.
|
||||
- **(b) User-facing error-message audit** — _src/lib/errors.ts_, all `try/catch` blocks in `src/app/api/*`, all `toastError` consumers in `src/components/*` — scan for `errorResponse(err)` paths that return generic "Something went wrong" / status codes only, and enrich with: (i) the operation that failed ("EOI generation", "Send invoice", "Upload file"), (ii) the likely cause (config missing, permission denied, conflict, etc.), (iii) the next step (where to fix it). Especially important for setting-driven features (email send accounts, storage backends, Documenso config, webhook secrets) where the real cause is one config field off-screen. The error catalog in `src/lib/errors.ts` already supports `CodedError` with operator-friendly `userMessage` — most call sites just need to populate it.
|
||||
- Total scope: probably a 1-2 day audit + remediation pass. Out-of-scope items to consider during the pass: a per-port "Integrations health" admin page that probes each external integration and shows green/red with the same diagnostic copy.
|
||||
|
||||
---
|
||||
|
||||
## Bucket 4 — Bugs (severity-tagged)
|
||||
|
||||
_Functional defects. Tag each with `[critical|high|medium|low]` prefix._
|
||||
|
||||
-1. **[high] BulkAddBerthsWizard side-pontoon dropdown uses a wrong, locally-defined enum (not the canonical / admin-editable vocabulary)** — _src/components/admin/bulk-add-berths-wizard.tsx:42_ — the wizard hard-codes `const SIDE_PONTOON_OPTIONS = ['Port', 'Starboard', 'Bow', 'Stern', '']` (nautical directions). The **actual** canonical list in _src/lib/constants.ts:187_ `BERTH_SIDE_PONTOON_OPTIONS` is: `'No', 'Quay SB', 'Quay PT', 'Quay SB, Yes PT', 'Quay PT, Yes SB', 'Yes SB', 'Yes PT', 'Yes SB, PT', 'Finger SB', 'Finger PT'` — these match the original NocoDB enum + the single-berth edit form + EOI/contract surfaces. Reps using the bulk wizard end up writing `side_pontoon='Port'` / `'Starboard'` etc. to the DB — values that no other surface in the app produces or filters on. Filtering / reporting / search across the same column gives misleading results because the data has two parallel vocabularies.
|
||||
|
||||
> - **Additional problem:** the codebase has a full per-port vocabularies system (_src/lib/vocabularies.ts_) where `berth_side_pontoon_options` is registered as admin-editable, with defaults sourced from `BERTH_SIDE_PONTOON_OPTIONS`. The wizard not only uses the wrong list — it bypasses the admin-editability entirely. Even after fixing the values, admins won't be able to tune the list per-port unless the wizard reads through `getVocabulary('berth_side_pontoon_options')` like other surfaces should.
|
||||
> - **Fix:** (a) delete `SIDE_PONTOON_OPTIONS` at line 42. (b) Replace the two `SIDE_PONTOON_OPTIONS.filter(Boolean).map(...)` blocks (lines 264 + 334) with a call to the vocabulary hook — confirm the pattern used by `BerthForm` / single-berth edit (likely `useVocabulary('berth_side_pontoon_options')` or a server-component read). (c) Audit every other dropdown in the wizard for the same pattern: `BERTH_MOORING_TYPES`, `BERTH_CLEAT_TYPES`, `BERTH_BOLLARD_TYPES`, `BERTH_ACCESS_OPTIONS` are all registered as admin-editable vocabularies — verify the wizard reads through `getVocabulary` for all of them, not a local constant. (d) **Data backfill:** the four wrong values (`Port` / `Starboard` / `Bow` / `Stern`) may already be in production rows added via this wizard — write a one-off script to either remap them (`Port → Quay PT` or similar based on the port team's intent) or null them out + flag for manual review. Coordinate with the port team before running.
|
||||
> - **Effort:** ~30min for the wizard fix + dropdown audit, ~30min for the backfill script + dry-run. Total ~1h plus a stakeholder check on the remap mapping. **Severity high** because (i) silently writing out-of-vocabulary data is a long-tail data-integrity problem and (ii) it shadows the existing admin-editability infra (operators may not realize the vocab is overridable for this field because the wizard ignores it). Captured 2026-05-18 from UAT.
|
||||
>
|
||||
> 0. **[high] All file downloads land with a blob-UUID filename + no extension** — _src/components/dashboard/chart-card.tsx:34_ (PNG/CSV exports), _src/app/(dashboard)/[portSlug]/expenses/page.tsx:95_ (CSV/XLSX export), _src/components/clients/client-files-tab.tsx:42_, _src/components/companies/company-files-tab.tsx:42_, _src/components/interests/interest-documents-tab.tsx:72_, _src/components/interests/interest-eoi-tab.tsx:597_, _src/components/admin/backup-admin-panel.tsx:90_ — 7 separate download sites share a near-identical anchor-click pattern that creates `<a download="<name>">`, calls `.click()`, and revokes the URL — but **the anchor is never appended to the document**, so Chromium-based browsers (Comet/Arc/Chrome) silently ignore the `download` attribute and fall back to using the blob URL's UUID for the filename (no extension). Captured UAT screenshot: dashboard chart "Download PNG" lands as `939c78df-48cc-466c-a22e-53e9dea69294` 35.5 KB instead of `<chart-name>.png`. Fix: extract a single `triggerBlobDownload(blob, filename)` helper into `src/lib/utils/download.ts` that (1) `document.body.appendChild(a)`, (2) `a.click()`, (3) `a.remove()`, (4) `URL.revokeObjectURL(url)` on a microtask/next-tick so Chrome has time to read the URL. Refactor all 7 call sites to import it; delete the local copies (and the chart-card-local `triggerBlobDownload` declared at chart-card.tsx:34). ~20-30 min including manual verification of each download surface. **Affects every file-export flow** — bumping severity to high. Captured 2026-05-18 from UAT.
|
||||
|
||||
1. **[high] Duplicate row for berth E17 in port-nimara** — DB: two `berths` rows with `mooring_number='E17'`, both with `price=NULL`. The canonical mooring format is meant to be unique per port (see CLAUDE.md "Mooring number canonical format"). Surfaced by the dashboard tile via the new "berth price missing" chip but the root cause is missing/leaked unique constraint. Recommend: dedupe + add partial unique index on `(port_id, mooring_number) WHERE archived_at IS NULL`. Deferred per session call (warning-only UI ships now).
|
||||
2. **[medium] Stage advance allowed without berth price** — Service-level: `changeInterestStage` lets an interest reach EOI/Reservation/Deposit Paid/Contract on a primary berth whose `price` is NULL. EOI doc generation downstream presumably renders blank/$0 for the quote field. Cross-port impact unknown. Recommend: add a `ValidationError("Berth price must be set before advancing past Qualified")` gate in `changeInterestStage` for stages eoi+. Deferred per session call.
|
||||
3. **[high] Command-search quick-create buttons routed to dead `/new` pages** — _src/components/search/command-search.tsx_ — ZeroState "New client/yacht/company" buttons pushed `/<entity>/new?name=…` which matched the `[id]` dynamic segment and rendered the entity-not-found page. Fixed by switching to `/<entity>?create=1&prefill_name=…` (the existing `useCreateFromUrl` convention) + adding `prefill` prop support to `YachtForm` + `CompanyForm` and wiring `prefill_name` reads in their list components. Now correctly pops the create sheet pre-filled. Fixed in this session.
|
||||
|
||||
---
|
||||
|
||||
## Bucket 5 — Cross-references to active audit doc
|
||||
|
||||
_Manual findings that confirm or extend a finding from the full codebase audit. Format: `manual #N ↔ Audit X#N — note`._
|
||||
|
||||
_None yet._
|
||||
|
||||
---
|
||||
|
||||
## Append protocol
|
||||
|
||||
- Add new findings to the matching bucket as bullet points.
|
||||
- Where a finding overlaps an audit entry, note `(see Audit X#N)` and add a back-reference line `→ confirmed in manual #<N>` in the corresponding row of `2026-05-18-full-codebase-audit.md`.
|
||||
- Keep entries terse — one line where possible, file:line evidence inline.
|
||||
- When promoted to a task or PR, append the commit hash inline (`fixed in <sha>`).
|
||||
@@ -118,6 +118,10 @@ const nextConfig: NextConfig = {
|
||||
remotePatterns: [{ protocol: 'https', hostname: '*.portnimara.com' }],
|
||||
},
|
||||
typedRoutes: true,
|
||||
// ECharts ships ES modules that older Next/webpack versions can't parse
|
||||
// without a transpile-pass. Listing here is the official recommendation
|
||||
// from echarts-for-react when used inside Next.
|
||||
transpilePackages: ['echarts', 'zrender', 'echarts-for-react'],
|
||||
outputFileTracingIncludes: {
|
||||
// Bundle the EOI source PDF so the in-app EOI pathway can read it at
|
||||
// runtime in the standalone build. Reading via fs.readFile from
|
||||
|
||||
@@ -1,35 +1,10 @@
|
||||
import Link from 'next/link';
|
||||
import { Bot, FileText, Brain, ExternalLink } from 'lucide-react';
|
||||
import { Bot } from 'lucide-react';
|
||||
|
||||
import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-form';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { OcrSettingsForm } from '@/components/admin/ocr-settings-form';
|
||||
|
||||
interface FeatureLink {
|
||||
href: string;
|
||||
icon: typeof Bot;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const FEATURE_LINKS: FeatureLink[] = [
|
||||
{
|
||||
href: '../berth-pdf-parser',
|
||||
icon: FileText,
|
||||
title: 'Berth PDF parser',
|
||||
description:
|
||||
'Three-tier AcroForm → OCR → AI pipeline. The AI pass costs tokens; reps invoke it manually when OCR confidence is low.',
|
||||
},
|
||||
{
|
||||
href: '../recommender',
|
||||
icon: Brain,
|
||||
title: 'Berth recommender',
|
||||
description:
|
||||
'Rule-based today; future versions will optionally use embeddings for soft preference matching. AI use is gated by the master switch above.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function AiAdminPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -65,34 +40,6 @@ export default function AiAdminPage() {
|
||||
<OcrSettingsForm embedded />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Bot className="h-4 w-4" /> Per-feature settings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Feature-specific tuning lives on each feature's admin page. They all read the
|
||||
master switch + provider credentials configured above.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{FEATURE_LINKS.map((f) => (
|
||||
<Link
|
||||
key={f.href}
|
||||
href={f.href as never}
|
||||
className="rounded-md border bg-card p-3 hover:border-primary transition-colors block"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<f.icon className="h-4 w-4 text-muted-foreground" />
|
||||
{f.title}
|
||||
<ExternalLink className="ml-auto h-3 w-3 opacity-50" />
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{f.description}</p>
|
||||
</Link>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ports } from '@/lib/db/schema/ports';
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ portSlug: string }> }) {
|
||||
const { portSlug } = await params;
|
||||
const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) });
|
||||
const portName = port?.name ?? 'Port Nimara';
|
||||
const portName = port?.name ?? 'CRM';
|
||||
|
||||
const manifest = {
|
||||
name: `${portName} - Scanner`,
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { ScanShell } from '@/components/scan/scan-shell';
|
||||
import { db } from '@/lib/db';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { getPortBrandingConfig } from '@/lib/services/port-config';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Scan receipt - Port Nimara',
|
||||
title: 'Scan receipt',
|
||||
};
|
||||
|
||||
export default function ScanPage() {
|
||||
return <ScanShell />;
|
||||
export default async function ScanPage({ params }: { params: Promise<{ portSlug: string }> }) {
|
||||
const { portSlug } = await params;
|
||||
const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) });
|
||||
const branding = port ? await getPortBrandingConfig(port.id).catch(() => null) : null;
|
||||
return <ScanShell logoUrl={branding?.logoUrl ?? null} portName={port?.name ?? null} />;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@/lib/email/templates/residential-inquiry';
|
||||
import { resolveSubject } from '@/lib/email/resolve-subject';
|
||||
import { getBrandingShell } from '@/lib/email/branding-resolver';
|
||||
import { getPortBrandingConfig, getPortEmailConfig } from '@/lib/services/port-config';
|
||||
import { env } from '@/lib/env';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse, RateLimitError, ValidationError } from '@/lib/errors';
|
||||
@@ -142,12 +143,22 @@ async function sendResidentialNotifications(args: {
|
||||
}): Promise<void> {
|
||||
const { portId, data, crmDeepLink } = args;
|
||||
|
||||
const branding = await getBrandingShell(portId);
|
||||
const [branding, portBrand, emailCfg] = await Promise.all([
|
||||
getBrandingShell(portId),
|
||||
getPortBrandingConfig(portId).catch(() => null),
|
||||
getPortEmailConfig(portId).catch(() => null),
|
||||
]);
|
||||
// Use the port's configured From address (or branding app name) for
|
||||
// the "contact us" line on the confirmation email so other ports don't
|
||||
// direct replies to sales@portnimara.com.
|
||||
const contactEmail = emailCfg?.fromAddress ?? '';
|
||||
const portName = portBrand?.appName ?? 'our team';
|
||||
|
||||
// Client confirmation
|
||||
const confirmation = await residentialClientConfirmation(
|
||||
{
|
||||
firstName: data.firstName,
|
||||
contactEmail: 'sales@portnimara.com',
|
||||
contactEmail,
|
||||
},
|
||||
{ branding },
|
||||
);
|
||||
@@ -155,7 +166,7 @@ async function sendResidentialNotifications(args: {
|
||||
key: 'residential_inquiry_client_confirmation',
|
||||
portId,
|
||||
fallback: confirmation.subject,
|
||||
tokens: { portName: 'Port Nimara', recipientName: data.firstName },
|
||||
tokens: { portName, recipientName: data.firstName },
|
||||
});
|
||||
await sendEmail(data.email, confirmationSubject, confirmation.html, undefined, undefined, portId);
|
||||
|
||||
@@ -204,7 +215,7 @@ async function sendResidentialNotifications(args: {
|
||||
portId,
|
||||
fallback: alert.subject,
|
||||
tokens: {
|
||||
portName: 'Port Nimara',
|
||||
portName,
|
||||
clientName: `${data.firstName} ${data.lastName}`.trim(),
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
|
||||
@@ -46,7 +46,7 @@ const ALLOWED_RESOURCE_ACTIONS: Record<string, Set<string>> = {
|
||||
'generate_eoi',
|
||||
'export',
|
||||
]),
|
||||
berths: new Set(['view', 'edit', 'import', 'manage_waiting_list']),
|
||||
berths: new Set(['view', 'edit', 'import', 'manage_waiting_list', 'update_prices']),
|
||||
documents: new Set([
|
||||
'view',
|
||||
'create',
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
getLeadSourceAttribution,
|
||||
getOccupancyTimeline,
|
||||
getPipelineFunnel,
|
||||
getRevenueBreakdown,
|
||||
type DateRange,
|
||||
type MetricBase,
|
||||
type PresetDateRange,
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<unknown>> = {
|
||||
pipeline_funnel: getPipelineFunnel,
|
||||
occupancy_timeline: getOccupancyTimeline,
|
||||
revenue_breakdown: getRevenueBreakdown,
|
||||
lead_source_attribution: getLeadSourceAttribution,
|
||||
};
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ export const POST = withAuth(
|
||||
|
||||
await sendSigningInvitation({
|
||||
portId: ctx.portId,
|
||||
portName: port?.name ?? 'Port Nimara',
|
||||
portName: port?.name ?? 'the marina',
|
||||
recipient: { name: target.signerName, email: target.signerEmail },
|
||||
documensoSigningUrl: target.signingUrl,
|
||||
documentLabel: DOC_TYPE_LABEL[doc.documentType] ?? 'Expression of Interest',
|
||||
|
||||
@@ -20,7 +20,9 @@ export const POST = withAuth(async (_req, ctx) => {
|
||||
await auth.api.requestPasswordReset({
|
||||
body: {
|
||||
email: ctx.user.email,
|
||||
redirectTo: '/reset-password',
|
||||
// /set-password is the form that actually consumes the token.
|
||||
// /reset-password is the "request a link" entry point.
|
||||
redirectTo: '/set-password',
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
|
||||
@@ -32,7 +32,13 @@ const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
|
||||
generate_eoi: false,
|
||||
export: false,
|
||||
},
|
||||
berths: { view: false, edit: false, import: false, manage_waiting_list: false },
|
||||
berths: {
|
||||
view: false,
|
||||
edit: false,
|
||||
import: false,
|
||||
manage_waiting_list: false,
|
||||
update_prices: false,
|
||||
},
|
||||
documents: {
|
||||
view: false,
|
||||
create: false,
|
||||
|
||||
@@ -104,7 +104,7 @@ const KNOWN_SETTINGS: Array<{
|
||||
description:
|
||||
'Reply-to email shown in client confirmation emails when a new interest is registered',
|
||||
type: 'string',
|
||||
defaultValue: 'sales@portnimara.com',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'inquiry_notification_recipients',
|
||||
@@ -129,8 +129,8 @@ const KNOWN_SETTINGS: Array<{
|
||||
'Internal staff who countersign every EOI. JSON object with `developer` (signs after the client) and `approver` (final approval). Both fields take `{ name, email }`.',
|
||||
type: 'json',
|
||||
defaultValue: {
|
||||
developer: { name: 'David Mizrahi', email: 'dm@portnimara.com' },
|
||||
approver: { name: 'Abbie May', email: 'sales@portnimara.com' },
|
||||
developer: { name: '', email: '' },
|
||||
approver: { name: '', email: '' },
|
||||
},
|
||||
},
|
||||
// ─── Berth recommender (src/lib/services/berth-recommender.service.ts) ──────
|
||||
|
||||
@@ -67,7 +67,7 @@ const PERMISSION_LEAVES: Record<string, string[]> = {
|
||||
'generate_eoi',
|
||||
'export',
|
||||
],
|
||||
berths: ['view', 'edit', 'import', 'manage_waiting_list'],
|
||||
berths: ['view', 'edit', 'import', 'manage_waiting_list', 'update_prices'],
|
||||
documents: [
|
||||
'view',
|
||||
'create',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm, useFieldArray } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -22,6 +22,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { TagPicker } from '@/components/shared/tag-picker';
|
||||
import { CountryCombobox } from '@/components/shared/country-combobox';
|
||||
import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
|
||||
import { TimezoneCombobox } from '@/components/shared/timezone-combobox';
|
||||
import { PhoneInput } from '@/components/shared/phone-input';
|
||||
import { DedupSuggestionPanel } from '@/components/clients/dedup-suggestion-panel';
|
||||
@@ -107,6 +108,18 @@ export function ClientForm({
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'contacts' });
|
||||
const tagIds = watch('tagIds') ?? [];
|
||||
|
||||
// Primary-address fields. Live outside RHF because the API splits
|
||||
// client creation (`POST /api/v1/clients`) from address creation
|
||||
// (`POST /api/v1/clients/{id}/addresses`) — the address gets chained
|
||||
// after the client POST returns the new id. Edit mode uses the
|
||||
// dedicated Addresses tab; the form here is create-only.
|
||||
const [addressOpen, setAddressOpen] = useState(false);
|
||||
const [addrStreet, setAddrStreet] = useState('');
|
||||
const [addrCity, setAddrCity] = useState('');
|
||||
const [addrSubdivisionIso, setAddrSubdivisionIso] = useState<string | null>(null);
|
||||
const [addrPostal, setAddrPostal] = useState('');
|
||||
const [addrCountryIso, setAddrCountryIso] = useState<string | null>(null);
|
||||
|
||||
// When the rep picks a country and no timezone is set yet, pre-fill the
|
||||
// timezone with the country's primary IANA zone. Skips when the user has
|
||||
// already chosen a zone explicitly so we never clobber a deliberate pick.
|
||||
@@ -169,10 +182,21 @@ export function ClientForm({
|
||||
reset({
|
||||
fullName: prefill?.fullName ?? '',
|
||||
contacts,
|
||||
source: prefill?.source,
|
||||
// Default a manually-created client to `source='manual'` so reps
|
||||
// don't have to remember to pick it. The inquiry-inbox triage
|
||||
// flow overrides this via `prefill.source='website'`; the global
|
||||
// command-search quick-create has no `prefill.source` and
|
||||
// therefore correctly lands on 'manual'.
|
||||
source: prefill?.source ?? 'manual',
|
||||
sourceInquiryId: prefill?.sourceInquiryId,
|
||||
tagIds: [],
|
||||
});
|
||||
setAddressOpen(false);
|
||||
setAddrStreet('');
|
||||
setAddrCity('');
|
||||
setAddrSubdivisionIso(null);
|
||||
setAddrPostal('');
|
||||
setAddrCountryIso(null);
|
||||
}
|
||||
}, [client, open, reset, prefill]);
|
||||
|
||||
@@ -224,7 +248,39 @@ export function ClientForm({
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await apiFetch('/api/v1/clients', { method: 'POST', body: payload });
|
||||
const res = await apiFetch<{ data: { id: string } }>('/api/v1/clients', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
});
|
||||
// Chain the address POST when any field is filled. Address errors
|
||||
// don't unwind the client create — surface a toast warning and
|
||||
// leave the client in place so the rep can finish in the
|
||||
// Addresses tab.
|
||||
const hasAddress =
|
||||
addrStreet.trim() ||
|
||||
addrCity.trim() ||
|
||||
addrPostal.trim() ||
|
||||
addrSubdivisionIso ||
|
||||
addrCountryIso;
|
||||
if (hasAddress) {
|
||||
try {
|
||||
await apiFetch(`/api/v1/clients/${res.data.id}/addresses`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
streetAddress: addrStreet.trim() || null,
|
||||
city: addrCity.trim() || null,
|
||||
subdivisionIso: addrSubdivisionIso ?? undefined,
|
||||
postalCode: addrPostal.trim() || null,
|
||||
countryIso: addrCountryIso ?? undefined,
|
||||
isPrimary: true,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
toast.error(
|
||||
'Client created but the address could not be saved. Add it from the Addresses tab.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -533,6 +589,95 @@ export function ClientForm({
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Primary Address — create-only. Editing happens in the
|
||||
client detail page's Addresses tab. */}
|
||||
{!isEdit ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Primary Address
|
||||
</h3>
|
||||
{!addressOpen ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAddressOpen(true)}
|
||||
>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
Add address
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAddressOpen(false);
|
||||
setAddrStreet('');
|
||||
setAddrCity('');
|
||||
setAddrSubdivisionIso(null);
|
||||
setAddrPostal('');
|
||||
setAddrCountryIso(null);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{addressOpen ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2 space-y-1">
|
||||
<Label>Street address</Label>
|
||||
<Input
|
||||
value={addrStreet}
|
||||
onChange={(e) => setAddrStreet(e.target.value)}
|
||||
placeholder="123 Marina Way, Suite 4"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>City</Label>
|
||||
<Input
|
||||
value={addrCity}
|
||||
onChange={(e) => setAddrCity(e.target.value)}
|
||||
placeholder="Anguilla"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Postal code</Label>
|
||||
<Input
|
||||
value={addrPostal}
|
||||
onChange={(e) => setAddrPostal(e.target.value)}
|
||||
placeholder="AI-2640"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Country</Label>
|
||||
<CountryCombobox
|
||||
value={addrCountryIso}
|
||||
onChange={(iso) => {
|
||||
setAddrCountryIso(iso ?? null);
|
||||
// Clear region if country changes — keeps the
|
||||
// subdivision picker consistent with its country.
|
||||
setAddrSubdivisionIso(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Region / State</Label>
|
||||
<SubdivisionCombobox
|
||||
country={addrCountryIso as CountryCode | null}
|
||||
value={addrSubdivisionIso}
|
||||
onChange={(iso) => setAddrSubdivisionIso(iso)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isEdit ? <Separator /> : null}
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-2">
|
||||
<Label>Tags</Label>
|
||||
|
||||
@@ -75,9 +75,17 @@ interface CompanyFormProps {
|
||||
billingEmail: string | null;
|
||||
notes: string | null;
|
||||
};
|
||||
/**
|
||||
* Optional initial values for the create flow — used by the global
|
||||
* command-search quick-create ("New company 'matthew'" → lands on
|
||||
* `/companies?create=1&prefill_name=matthew`). Ignored in edit mode.
|
||||
*/
|
||||
prefill?: {
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
|
||||
export function CompanyForm({ open, onOpenChange, company, prefill }: CompanyFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const isEdit = !!company;
|
||||
@@ -141,10 +149,10 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
|
||||
tagIds: [],
|
||||
});
|
||||
} else if (!company && open) {
|
||||
reset({ name: '', status: 'active', tagIds: [] });
|
||||
reset({ name: prefill?.name ?? '', status: 'active', tagIds: [] });
|
||||
}
|
||||
setFormError(null);
|
||||
}, [company, open, reset]);
|
||||
}, [company, open, reset, prefill]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: CreateCompanyInput) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { Plus, Archive, Tag as TagIcon, TagsIcon } from 'lucide-react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
@@ -49,6 +49,16 @@ export function CompanyList() {
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
useCreateFromUrl(() => setCreateOpen(true));
|
||||
|
||||
// Quick-create from the global command-search lands here with
|
||||
// `?create=1&prefill_name=…`. Hydrate the company-form's name field
|
||||
// so the rep doesn't retype.
|
||||
const searchParams = useSearchParams();
|
||||
const createPrefill = useMemo(() => {
|
||||
const name = searchParams?.get('prefill_name');
|
||||
return name ? { name } : undefined;
|
||||
}, [searchParams]);
|
||||
|
||||
const [editCompany, setEditCompany] = useState<CompanyRow | null>(null);
|
||||
const [archiveCompany, setArchiveCompany] = useState<CompanyRow | null>(null);
|
||||
const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>(
|
||||
@@ -274,7 +284,7 @@ export function CompanyList() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<CompanyForm open={createOpen} onOpenChange={setCreateOpen} />
|
||||
<CompanyForm open={createOpen} onOpenChange={setCreateOpen} prefill={createPrefill} />
|
||||
|
||||
{editCompany && (
|
||||
<CompanyForm
|
||||
|
||||
@@ -35,10 +35,11 @@ export function ActiveDealsTile() {
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{/* `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. */}
|
||||
<CardContent className="flex items-center gap-3 pt-5 pb-5">
|
||||
{/* shadcn's default CardContent ships with `pt-0 sm:pt-0` (it assumes a
|
||||
CardHeader sits above). The `sm:` variants are required — without
|
||||
them `sm:pt-0` wins at the sm breakpoint and the content snaps to
|
||||
the top edge. */}
|
||||
<CardContent className="flex items-center gap-3 pt-5 pb-5 sm:pt-5 sm:pb-5">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-accent text-foreground">
|
||||
<TrendingUp className="size-5" aria-hidden />
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Berth Heat widget — ranked table of berths by active interest count.
|
||||
* Investor-friendly "where is the demand pressure?" surface. Renders
|
||||
* a sortable table that exports cleanly to PDF/CSV. A future heatmap
|
||||
* visualization can sit beside this table reading the same data.
|
||||
* Berth-demand widget — ranks berths by active interest count, with a
|
||||
* horizontal bar per row encoding magnitude relative to the leader.
|
||||
* Matches the standard CardHeader / CardContent layout of its dashboard
|
||||
* siblings; the bars (not chrome) do the visual work.
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { ArrowRight, TrendingUp } from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { StatusPill } from '@/components/ui/status-pill';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface HeatRow {
|
||||
berthId: string;
|
||||
@@ -27,13 +28,16 @@ interface HeatResponse {
|
||||
data: { rows: HeatRow[] };
|
||||
}
|
||||
|
||||
// Render the raw status — StatusPill recognizes 'available' /
|
||||
// 'under_offer' / 'sold' as canonical tokens and applies the right tone.
|
||||
function statusToken(s: string): 'available' | 'under_offer' | 'sold' | 'pending' {
|
||||
if (s === 'available' || s === 'under_offer' || s === 'sold') return s;
|
||||
return 'pending';
|
||||
function formatStatus(s: string): string {
|
||||
return s.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
const BAR_COLOR_BY_STATUS: Record<string, string> = {
|
||||
available: 'bg-success/70',
|
||||
under_offer: 'bg-warning/80',
|
||||
sold: 'bg-error/60',
|
||||
};
|
||||
|
||||
export function BerthHeatWidget() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
@@ -43,52 +47,87 @@ export function BerthHeatWidget() {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const ranked = (data?.data?.rows ?? []).filter((r) => r.activeInterestCount > 0);
|
||||
const visible = ranked.slice(0, 6);
|
||||
const max = visible[0]?.activeInterestCount ?? 1;
|
||||
const totalActive = ranked.reduce((sum, r) => sum + r.activeInterestCount, 0);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Berth heat</CardTitle>
|
||||
<CardDescription>Top 15 berths by active interest count.</CardDescription>
|
||||
<CardTitle className="flex items-center gap-1.5 text-base">
|
||||
<TrendingUp className="size-4 text-brand-600" aria-hidden />
|
||||
Berth demand
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{ranked.length > 0
|
||||
? `${totalActive} active interest${totalActive === 1 ? '' : 's'} across ${ranked.length} ${ranked.length === 1 ? 'berth' : 'berths'}.`
|
||||
: 'Berths ranked by active interest count.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" aria-hidden /> Loading…
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" aria-hidden />
|
||||
))}
|
||||
</div>
|
||||
) : !data || data.data.rows.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">No active interests yet.</p>
|
||||
) : visible.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
No active demand yet. Berths will appear here as interests land.
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-muted-foreground">
|
||||
<th className="py-1.5 text-left font-medium">Berth</th>
|
||||
<th className="py-1.5 text-left font-medium">Dock</th>
|
||||
<th className="py-1.5 text-left font-medium">Status</th>
|
||||
<th className="py-1.5 text-right font-medium">Interests</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.data.rows.map((r) => (
|
||||
<tr key={r.berthId} className="border-b last:border-b-0">
|
||||
<td className="py-1.5">
|
||||
<>
|
||||
<ul className="space-y-1">
|
||||
{visible.map((r) => {
|
||||
const widthPct = Math.max(6, Math.round((r.activeInterestCount / max) * 100));
|
||||
return (
|
||||
<li key={r.berthId}>
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/berths/${r.berthId}` as any}
|
||||
className="font-medium hover:underline"
|
||||
className="-mx-2 grid grid-cols-[3.25rem_1fr_2rem] items-center gap-3 rounded-md px-2 py-2 hover:bg-accent/60"
|
||||
>
|
||||
{r.mooringNumber}
|
||||
<span className="truncate font-mono text-sm font-medium text-foreground">
|
||||
{r.mooringNumber}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
BAR_COLOR_BY_STATUS[r.status] ?? 'bg-brand-400',
|
||||
)}
|
||||
style={{ width: `${widthPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{r.area ?? 'Unassigned'} ·{' '}
|
||||
<span className="capitalize">{formatStatus(r.status)}</span>
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-right text-sm font-semibold text-foreground tabular-nums">
|
||||
{r.activeInterestCount}
|
||||
</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-1.5 text-muted-foreground">{r.area ?? '—'}</td>
|
||||
<td className="py-1.5">
|
||||
<StatusPill status={statusToken(r.status)}>
|
||||
{r.status.replace(/_/g, ' ')}
|
||||
</StatusPill>
|
||||
</td>
|
||||
<td className="py-1.5 text-right font-semibold">{r.activeInterestCount}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{ranked.length > visible.length ? (
|
||||
<div className="mt-3 border-t pt-2 text-right">
|
||||
<Link
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/berths?sort=activeInterestCount&order=desc` as any}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
View all by demand
|
||||
<ArrowRight className="size-3" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<KpiResponse>({
|
||||
const kpis = useQuery<KpiResponse>({
|
||||
queryKey: ['dashboard', 'kpis'],
|
||||
queryFn: () => apiFetch<KpiResponse>('/api/v1/dashboard/kpis'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const forecast = useQuery<ForecastResponse>({
|
||||
queryKey: ['dashboard', 'forecast'],
|
||||
queryFn: () => apiFetch<ForecastResponse>('/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 (
|
||||
<Card>
|
||||
{/* `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. */}
|
||||
<CardContent className="flex items-center gap-3 pt-5 pb-5">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-accent text-foreground">
|
||||
<DollarSign className="size-5" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Pipeline value
|
||||
</p>
|
||||
{isLoading ? (
|
||||
<Skeleton className="mt-1 h-7 w-24" aria-hidden />
|
||||
) : (
|
||||
<p
|
||||
className="truncate text-2xl font-bold leading-tight text-foreground"
|
||||
title={formatCurrency(data?.pipelineValue ?? 0, data?.pipelineValueCurrency ?? 'USD')}
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Pipeline value</CardTitle>
|
||||
<CardDescription className="flex items-center gap-1.5">
|
||||
<span>
|
||||
{activeDeals > 0
|
||||
? `${activeDeals} active deal${activeDeals === 1 ? '' : 's'} · weighted by stage close-probability`
|
||||
: 'Gross berth value across active deals, with weighted forecast.'}
|
||||
</span>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
type="button"
|
||||
aria-label="How does the weighted forecast work?"
|
||||
className="inline-flex size-4 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400"
|
||||
>
|
||||
{formatCurrency(data?.pipelineValue ?? 0, data?.pipelineValueCurrency ?? 'USD', {
|
||||
maxFractionDigits: 0,
|
||||
})}
|
||||
<Info className="size-3.5" aria-hidden />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-80 text-xs leading-relaxed">
|
||||
<p className="font-semibold text-foreground">How the weighted forecast works</p>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
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{' '}
|
||||
<strong>expected</strong> 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.
|
||||
</p>
|
||||
<div className="mt-3 grid grid-cols-[1fr_auto] gap-x-3 gap-y-1 rounded-md bg-muted/50 p-2.5 text-[11px]">
|
||||
{PIPELINE_STAGES.map((s) => {
|
||||
const dbWeight = forecast.data?.stageBreakdown.find((r) => r.stage === s)?.weight;
|
||||
const weight = dbWeight ?? STAGE_WEIGHTS[s];
|
||||
return (
|
||||
<div key={s} className="contents">
|
||||
<span className="text-muted-foreground">{stageLabel(s)}</span>
|
||||
<span className="text-right font-medium tabular-nums text-foreground">
|
||||
{Math.round(weight * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-3 text-[11px] text-muted-foreground">
|
||||
{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.'}
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* ── Headline numbers ─────────────────────────────────────── */}
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Gross
|
||||
</p>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<Skeleton className="mt-1 h-7 w-28" aria-hidden />
|
||||
) : (
|
||||
<p
|
||||
className="truncate text-2xl font-bold leading-tight text-foreground"
|
||||
title={formatCurrency(grossTotal, currency)}
|
||||
>
|
||||
{fmt(grossTotal)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Weighted forecast
|
||||
</p>
|
||||
{isLoading ? (
|
||||
<Skeleton className="ml-auto mt-1 h-6 w-24" aria-hidden />
|
||||
) : (
|
||||
<p
|
||||
className="text-lg font-semibold leading-tight text-foreground tabular-nums"
|
||||
title={formatCurrency(weightedTotal, currency)}
|
||||
>
|
||||
{fmt(weightedTotal)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Per-stage breakdown ─────────────────────────────────── */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-1.5">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-7 w-full" aria-hidden />
|
||||
))}
|
||||
</div>
|
||||
) : activeStages.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No active deals with linked berths yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{activeStages.map((s) => {
|
||||
const widthPct = Math.max(6, Math.round((s.grossValue / stageMax) * 100));
|
||||
return (
|
||||
<li
|
||||
key={s.stage}
|
||||
className="grid grid-cols-[1fr_auto] items-center gap-x-3 gap-y-0.5 py-1"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{stageLabel(s.stage)}
|
||||
</p>
|
||||
<div className="mt-1 flex h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
STAGE_BAR_CLASS[s.stage] ?? 'bg-brand-400',
|
||||
)}
|
||||
style={{ width: `${widthPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-foreground tabular-nums">
|
||||
{fmt(s.grossValue)}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{s.count} {s.count === 1 ? 'deal' : 'deals'} · {Math.round(s.weight * 100)}%
|
||||
</p>
|
||||
{s.dealsMissingPrice > 0 ? (
|
||||
<p
|
||||
className="mt-0.5 inline-flex items-center gap-1 text-[10px] font-medium text-warning"
|
||||
title={`${s.dealsMissingPrice} of ${s.count} ${s.count === 1 ? 'deal has' : 'deals have'} a berth with no price set — gross is undercounted here.`}
|
||||
>
|
||||
<AlertTriangle className="size-3" aria-hidden />
|
||||
{s.dealsMissingPrice === s.count
|
||||
? 'berth price missing'
|
||||
: `${s.dealsMissingPrice} of ${s.count} missing price`}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{forecast.data?.weightsSource === 'default' ? (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Using default stage weights. Tune them in Settings → Pipeline.
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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 (
|
||||
<ChartCard
|
||||
title="Revenue Breakdown"
|
||||
description="Invoice totals grouped by status and currency"
|
||||
exportFilename={`revenue-breakdown-${rangeToSlug(range)}`}
|
||||
toCsv={toCsv}
|
||||
>
|
||||
{isLoading ? (
|
||||
<CardSkeleton />
|
||||
) : !bars.length ? (
|
||||
<EmptyState
|
||||
title="No invoices in range"
|
||||
description="Issued, paid, and overdue totals appear here once you create invoices."
|
||||
/>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -8, bottom: 40 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
|
||||
angle={-30}
|
||||
textAnchor="end"
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'hsl(var(--popover))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
fontSize: 12,
|
||||
}}
|
||||
formatter={(value, _name, item) => {
|
||||
const c = (item?.payload as { currency?: string } | undefined)?.currency ?? 'USD';
|
||||
const num = typeof value === 'number' ? value : Number(value);
|
||||
return [formatCurrency(num, c), 'Amount'];
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="amount" fill="hsl(var(--chart-3))" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
MetricBase,
|
||||
OccupancyTimelineData,
|
||||
PipelineFunnelData,
|
||||
RevenueBreakdownData,
|
||||
} from '@/lib/services/analytics.service';
|
||||
|
||||
interface MetricResponse<T> {
|
||||
@@ -51,7 +50,5 @@ export const useFunnel = (range: DateRange) =>
|
||||
useAnalyticsMetric<PipelineFunnelData>('pipeline_funnel', range);
|
||||
export const useOccupancy = (range: DateRange) =>
|
||||
useAnalyticsMetric<OccupancyTimelineData>('occupancy_timeline', range);
|
||||
export const useRevenue = (range: DateRange) =>
|
||||
useAnalyticsMetric<RevenueBreakdownData>('revenue_breakdown', range);
|
||||
export const useLeadSource = (range: DateRange) =>
|
||||
useAnalyticsMetric<LeadSourceAttributionData>('lead_source_attribution', range);
|
||||
|
||||
@@ -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: () => <PipelineValueTile />,
|
||||
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) => <RevenueBreakdownChart range={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: () => <BerthHeatWidget />,
|
||||
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: () => <WebsiteGlanceTile />,
|
||||
render: (range) => <WebsiteGlanceTile range={range} />,
|
||||
group: 'rail',
|
||||
defaultVisible: true,
|
||||
selfGates: true,
|
||||
|
||||
@@ -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<Tier, { label: string; tone: string }> = {
|
||||
@@ -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<string[]>([]);
|
||||
// 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<boolean>((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}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!collapsed ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setFiltersOpen((v) => !v)}
|
||||
disabled={!hasDimensions}
|
||||
>
|
||||
<Filter className="mr-1.5 size-3.5" aria-hidden />
|
||||
{filtersOpen ? 'Hide filters' : 'Add filters'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={!hasDimensions || isFetching}
|
||||
>
|
||||
<RefreshCw className={cn('mr-1.5 size-3.5', isFetching && 'animate-spin')} />
|
||||
Refresh
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setFiltersOpen((v) => !v)}
|
||||
disabled={!hasDimensions}
|
||||
variant="ghost"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
aria-expanded={!collapsed}
|
||||
aria-controls={`recommender-body-${interestId}`}
|
||||
>
|
||||
<Filter className="mr-1.5 size-3.5" aria-hidden />
|
||||
{filtersOpen ? 'Hide filters' : 'Add filters'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={!hasDimensions || isFetching}
|
||||
>
|
||||
<RefreshCw className={cn('mr-1.5 size-3.5', isFetching && 'animate-spin')} />
|
||||
Refresh
|
||||
{collapsed ? (
|
||||
<>
|
||||
<ChevronDown className="mr-1.5 size-3.5" aria-hidden />
|
||||
Show recommendations
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronUp className="mr-1.5 size-3.5" aria-hidden />
|
||||
Hide
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{filtersOpen && hasDimensions ? (
|
||||
{!collapsed && filtersOpen && hasDimensions ? (
|
||||
<AmenityFilterForm filters={amenityFilters} onChange={setAmenityFilters} />
|
||||
) : null}
|
||||
{hasDimensions && areaChips.length > 1 ? (
|
||||
{!collapsed && hasDimensions && areaChips.length > 1 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">Area:</span>
|
||||
{areaChips.map((letter) => {
|
||||
@@ -504,55 +544,57 @@ export function BerthRecommenderPanel({
|
||||
</div>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{!hasDimensions ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Once length, width, and draft are set on this interest, the recommender will surface
|
||||
berths that fit. Edit the desired dimensions on the{' '}
|
||||
<Link href="?tab=overview" className="text-primary underline">
|
||||
Overview tab
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
) : isFetching && recommendations.length === 0 ? (
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="h-16 animate-pulse rounded-lg bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
) : recommendations.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground space-y-2">
|
||||
<p>
|
||||
{showAll
|
||||
? 'No berths in the port match these dimensions and filters.'
|
||||
: 'No berths fit inside the strict oversize tolerance.'}
|
||||
{collapsed ? null : (
|
||||
<CardContent className="space-y-3" id={`recommender-body-${interestId}`}>
|
||||
{!hasDimensions ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Once length, width, and draft are set on this interest, the recommender will surface
|
||||
berths that fit. Edit the desired dimensions on the{' '}
|
||||
<Link href="?tab=overview" className="text-primary underline">
|
||||
Overview tab
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
{!showAll && (
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => setShowAll(true)}>
|
||||
Show oversized matches too
|
||||
) : isFetching && recommendations.length === 0 ? (
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="h-16 animate-pulse rounded-lg bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
) : recommendations.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground space-y-2">
|
||||
<p>
|
||||
{showAll
|
||||
? 'No berths in the port match these dimensions and filters.'
|
||||
: 'No berths fit inside the strict oversize tolerance.'}
|
||||
</p>
|
||||
{!showAll && (
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => setShowAll(true)}>
|
||||
Show oversized matches too
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recommendations.map((rec) => (
|
||||
<RecommendationCard
|
||||
key={rec.berthId}
|
||||
rec={rec}
|
||||
portSlug={portSlug}
|
||||
onAdd={setPendingBerth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasDimensions && recommendations.length > 0 ? (
|
||||
<div className="flex justify-center pt-1">
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => setShowAll((v) => !v)}>
|
||||
{showAll ? 'Show top in-tolerance only' : 'Show oversized matches too'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recommendations.map((rec) => (
|
||||
<RecommendationCard
|
||||
key={rec.berthId}
|
||||
rec={rec}
|
||||
portSlug={portSlug}
|
||||
onAdd={setPendingBerth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasDimensions && recommendations.length > 0 ? (
|
||||
<div className="flex justify-center pt-1">
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => setShowAll((v) => !v)}>
|
||||
{showAll ? 'Show top in-tolerance only' : 'Show oversized matches too'}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{pendingBerth ? (
|
||||
<AddBerthToInterestDialog
|
||||
|
||||
@@ -108,6 +108,11 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
||||
clientId: '',
|
||||
yachtId: undefined,
|
||||
pipelineStage: 'enquiry',
|
||||
// Default a manually-created interest's source to 'manual' so the
|
||||
// rep doesn't have to remember to pick it (mirrors the same
|
||||
// default on client-form.tsx). Inquiry-inbox / website conversion
|
||||
// flows can override via prefill once that path lands here.
|
||||
source: 'manual',
|
||||
reminderEnabled: false,
|
||||
tagIds: [],
|
||||
},
|
||||
|
||||
@@ -50,7 +50,6 @@ import { InterestEoiTab } from '@/components/interests/interest-eoi-tab';
|
||||
import { InterestContactLogTab } from '@/components/interests/interest-contact-log-tab';
|
||||
import { QualificationChecklist } from '@/components/interests/qualification-checklist';
|
||||
import { PaymentsSection } from '@/components/interests/payments-section';
|
||||
import { StageGuidanceCard } from '@/components/interests/stage-guidance-card';
|
||||
import { SkipAheadBanner } from '@/components/interests/skip-ahead-banner';
|
||||
import { InterestBerthStatusBanner } from '@/components/interests/interest-berth-status-banner';
|
||||
import { InterestContractTab } from '@/components/interests/interest-contract-tab';
|
||||
@@ -59,7 +58,12 @@ import { useConfirmation } from '@/hooks/use-confirmation';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type InterestPatchField = 'leadCategory' | 'source';
|
||||
type InterestPatchField =
|
||||
| 'leadCategory'
|
||||
| 'source'
|
||||
| 'desiredLengthFt'
|
||||
| 'desiredWidthFt'
|
||||
| 'desiredDraftFt';
|
||||
|
||||
const LEAD_CATEGORY_OPTIONS = LEAD_CATEGORIES.map((c) => ({
|
||||
value: c,
|
||||
@@ -381,8 +385,9 @@ function MilestoneSection({
|
||||
<Icon className={cn('size-4', isActive ? 'text-brand-600' : 'text-muted-foreground')} />
|
||||
<h3 className="text-sm font-semibold tracking-tight text-foreground">{title}</h3>
|
||||
{isActive ? (
|
||||
<span className="rounded-full bg-brand-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-brand-700">
|
||||
Next
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-brand-600 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] text-white shadow-sm">
|
||||
<span className="size-1.5 rounded-full bg-white/90" aria-hidden />
|
||||
Next step
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -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.
|
||||
<StageGuidanceCard
|
||||
stage={interest.pipelineStage as PipelineStage}
|
||||
hasLinkedBerth={(interest.linkedBerthCount ?? 0) > 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' ? (
|
||||
<div className="rounded-xl border bg-card p-4 text-sm">
|
||||
<p className="font-medium text-foreground">Deal is on nurture</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Schedule a follow-up reminder or log a contact when the prospect re-engages, then move
|
||||
them back to Qualified.
|
||||
</p>
|
||||
</div>
|
||||
) : 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({
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Berth requirements</h3>
|
||||
<dl>
|
||||
<EditableRow label="Desired length (ft)">
|
||||
<InlineEditableField
|
||||
value={interest.desiredLengthFt ?? null}
|
||||
onSave={save('desiredLengthFt')}
|
||||
placeholder="e.g. 60"
|
||||
emptyText="—"
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Desired width (ft)">
|
||||
<InlineEditableField
|
||||
value={interest.desiredWidthFt ?? null}
|
||||
onSave={save('desiredWidthFt')}
|
||||
placeholder="e.g. 25"
|
||||
emptyText="—"
|
||||
/>
|
||||
</EditableRow>
|
||||
<EditableRow label="Desired draft (ft)">
|
||||
<InlineEditableField
|
||||
value={interest.desiredDraftFt ?? null}
|
||||
onSave={save('desiredDraftFt')}
|
||||
placeholder="e.g. 6"
|
||||
emptyText="—"
|
||||
/>
|
||||
</EditableRow>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Reminder */}
|
||||
{interest.reminderEnabled && (
|
||||
<div className="space-y-1">
|
||||
@@ -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: (
|
||||
<BerthRecommenderPanel
|
||||
interestId={interestId}
|
||||
|
||||
@@ -109,14 +109,19 @@ export function InterestTimeline({ interestId }: InterestTimelineProps) {
|
||||
|
||||
return (
|
||||
<div className="relative space-y-0">
|
||||
{/* Vertical line */}
|
||||
<div className="absolute left-4 top-2 bottom-2 w-px bg-border" />
|
||||
|
||||
{events.map((event) => {
|
||||
{events.map((event, idx) => {
|
||||
const actor = actorLabel(event);
|
||||
const isAuto = event.userId === 'system';
|
||||
const isLast = idx === events.length - 1;
|
||||
return (
|
||||
<div key={event.id} className="relative flex gap-4 pb-6">
|
||||
{/* Vertical line — only between bubbles, never trailing past the last. */}
|
||||
{!isLast && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-4 top-8 bottom-0 -translate-x-1/2 w-px bg-border"
|
||||
/>
|
||||
)}
|
||||
{/* Icon */}
|
||||
<div className="relative z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-background border">
|
||||
{eventIcon(event)}
|
||||
|
||||
@@ -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({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
<ul className="space-y-1.5">
|
||||
{criteria.map((c) => (
|
||||
<li key={c.key} className="flex items-start gap-2.5">
|
||||
<li
|
||||
key={c.key}
|
||||
className={cn(
|
||||
'flex items-start gap-2.5 rounded-md px-2 py-1.5 transition-colors',
|
||||
// Unconfirmed rows get a subtle amber accent (left border +
|
||||
// tinted background) so reps can scan the checklist and
|
||||
// immediately see what's outstanding. Confirmed rows stay
|
||||
// muted with line-through; auto-satisfied rows are functionally
|
||||
// confirmed and follow the confirmed styling.
|
||||
!c.confirmed && 'border-l-2 border-warning bg-warning-bg/40',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={`qual-${c.key}`}
|
||||
checked={c.confirmed}
|
||||
@@ -125,7 +139,7 @@ export function QualificationChecklist({
|
||||
className={cn(
|
||||
'flex-1 text-sm',
|
||||
c.autoSatisfied ? 'cursor-default' : 'cursor-pointer',
|
||||
c.confirmed ? 'text-foreground' : 'text-foreground/90',
|
||||
c.confirmed ? 'text-muted-foreground' : 'text-foreground',
|
||||
)}
|
||||
>
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
@@ -146,6 +160,11 @@ export function QualificationChecklist({
|
||||
{c.description ? (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{c.description}</p>
|
||||
) : null}
|
||||
{c.autoSatisfied && c.evidence ? (
|
||||
<p className="mt-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||
{c.evidence}
|
||||
</p>
|
||||
) : null}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -60,7 +60,11 @@ export function SupplementalInfoRequestButton({ interestId, eoiStatus }: Props)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
{/* 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. */}
|
||||
<CardContent className="space-y-3 p-4 pt-4 sm:p-6 sm:pt-6">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Need more info before drafting the EOI?</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -19,6 +19,9 @@ interface AppShellProps {
|
||||
isSuperAdmin: boolean;
|
||||
user: NonNullable<SidebarProps['user']>;
|
||||
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<string, string | null>;
|
||||
/**
|
||||
* 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({
|
||||
<MobileTopbar />
|
||||
</>
|
||||
) : (
|
||||
<Sidebar portRoles={portRoles} isSuperAdmin={isSuperAdmin} user={user} ports={ports} />
|
||||
<Sidebar
|
||||
portRoles={portRoles}
|
||||
isSuperAdmin={isSuperAdmin}
|
||||
user={user}
|
||||
ports={ports}
|
||||
portLogoUrls={portLogoUrls}
|
||||
/>
|
||||
);
|
||||
|
||||
const footer = isMobile ? (
|
||||
|
||||
@@ -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<string, string | null>;
|
||||
}
|
||||
|
||||
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',
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src={LOGO_URL}
|
||||
alt="Port Nimara"
|
||||
width={collapsed ? 40 : 72}
|
||||
height={collapsed ? 40 : 72}
|
||||
className="rounded-full shadow-sm"
|
||||
unoptimized
|
||||
priority
|
||||
/>
|
||||
{currentLogoUrl ? (
|
||||
<Image
|
||||
src={currentLogoUrl}
|
||||
alt={currentPort?.name ?? 'Logo'}
|
||||
width={collapsed ? 40 : 72}
|
||||
height={collapsed ? 40 : 72}
|
||||
className="rounded-full shadow-sm"
|
||||
unoptimized
|
||||
priority
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-12 items-center px-3 text-sm font-semibold text-slate-700">
|
||||
{currentPort?.name ?? 'CRM'}
|
||||
</div>
|
||||
)}
|
||||
{onToggleCollapse && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -439,12 +450,21 @@ function SidebarContent({
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({ portRoles, isSuperAdmin = false, user, ports }: SidebarProps) {
|
||||
export function Sidebar({
|
||||
portRoles,
|
||||
isSuperAdmin = false,
|
||||
user,
|
||||
ports,
|
||||
portLogoUrls,
|
||||
}: SidebarProps) {
|
||||
// Sidebar collapse removed — design preference is the always-expanded
|
||||
// form. Forcibly false; the store flag stays for backwards-compat with
|
||||
// any code still reading it.
|
||||
const sidebarCollapsed = false;
|
||||
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
|
||||
const currentPortId = useUIStore((s) => s.currentPortId);
|
||||
const currentPort = ports?.find((p) => p.id === currentPortId) ?? ports?.[0] ?? null;
|
||||
const currentLogoUrl = currentPortId ? (portLogoUrls?.[currentPortId] ?? null) : null;
|
||||
|
||||
// Super admins see every section regardless of role rows.
|
||||
const hasAdminAccess =
|
||||
@@ -478,6 +498,8 @@ export function Sidebar({ portRoles, isSuperAdmin = false, user, ports }: Sideba
|
||||
hasResidentialAccess={hasResidentialAccess}
|
||||
user={user}
|
||||
ports={ports}
|
||||
currentPort={currentPort}
|
||||
currentLogoUrl={currentLogoUrl}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -33,6 +33,62 @@ function toLocalDatetimeLocal(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Date relative to "now" for the quick-pick chips. Day-based
|
||||
* presets land on the user's preferred time-of-day (`digestTimeOfDay`
|
||||
* from user_profiles.preferences) — same source the default dueAt uses.
|
||||
* Hour-based presets use the current time + N hours.
|
||||
*/
|
||||
function buildPresetDate(
|
||||
preset:
|
||||
| { kind: 'hours'; hours: number }
|
||||
| { kind: 'tomorrow' }
|
||||
| { kind: 'in_days'; days: number }
|
||||
| { kind: 'next_monday' },
|
||||
timeOfDay: string | null,
|
||||
): Date {
|
||||
const now = new Date();
|
||||
let h = 9;
|
||||
let m = 0;
|
||||
if (timeOfDay && /^\d{2}:\d{2}$/.test(timeOfDay)) {
|
||||
const [hh = '09', mm = '00'] = timeOfDay.split(':');
|
||||
const parsedH = Number.parseInt(hh, 10);
|
||||
const parsedM = Number.parseInt(mm, 10);
|
||||
if (Number.isFinite(parsedH) && parsedH >= 0 && parsedH <= 23) h = parsedH;
|
||||
if (Number.isFinite(parsedM) && parsedM >= 0 && parsedM <= 59) m = parsedM;
|
||||
}
|
||||
if (preset.kind === 'hours') {
|
||||
return new Date(now.getTime() + preset.hours * 60 * 60 * 1000);
|
||||
}
|
||||
if (preset.kind === 'tomorrow') {
|
||||
const t = new Date(now);
|
||||
t.setDate(t.getDate() + 1);
|
||||
t.setHours(h, m, 0, 0);
|
||||
return t;
|
||||
}
|
||||
if (preset.kind === 'in_days') {
|
||||
const t = new Date(now);
|
||||
t.setDate(t.getDate() + preset.days);
|
||||
t.setHours(h, m, 0, 0);
|
||||
return t;
|
||||
}
|
||||
// next_monday
|
||||
const t = new Date(now);
|
||||
const daysUntilMonday = (8 - t.getDay()) % 7 || 7;
|
||||
t.setDate(t.getDate() + daysUntilMonday);
|
||||
t.setHours(h, m, 0, 0);
|
||||
return t;
|
||||
}
|
||||
|
||||
const DUE_PRESETS = [
|
||||
{ label: 'In 1 hour', spec: { kind: 'hours', hours: 1 } as const },
|
||||
{ label: 'In 4 hours', spec: { kind: 'hours', hours: 4 } as const },
|
||||
{ label: 'Tomorrow', spec: { kind: 'tomorrow' } as const },
|
||||
{ label: 'In 3 days', spec: { kind: 'in_days', days: 3 } as const },
|
||||
{ label: 'Next week', spec: { kind: 'next_monday' } as const },
|
||||
{ label: 'In 2 weeks', spec: { kind: 'in_days', days: 14 } as const },
|
||||
] as const;
|
||||
|
||||
interface UserOption {
|
||||
id: string;
|
||||
displayName: string;
|
||||
@@ -230,6 +286,24 @@ function ReminderFormBody({
|
||||
<div className="grid grid-cols-[2fr_1fr] gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reminder-due">Due Date & Time</Label>
|
||||
{/* Quick-pick chips. Same idiom as the snooze dialog so reps
|
||||
don't have to think about month-day-time for the 80%
|
||||
common cases. Each chip writes the formatted datetime
|
||||
into the input, leaving the rep free to tweak. */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{DUE_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.label}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setDueAt(toLocalDatetimeLocal(buildPresetDate(p.spec, userTodPref)))
|
||||
}
|
||||
className="rounded-full border bg-background px-2.5 py-0.5 text-xs font-medium text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Input
|
||||
id="reminder-due"
|
||||
type="datetime-local"
|
||||
|
||||
@@ -5,9 +5,6 @@ import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Camera, Loader2, RotateCcw, AlertTriangle, CheckCircle2, Save } from 'lucide-react';
|
||||
|
||||
const LOGO_URL =
|
||||
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -322,7 +319,15 @@ function VerifyForm({
|
||||
|
||||
// ─── Shell ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ScanShell() {
|
||||
interface ScanShellProps {
|
||||
/** Per-port brand logo resolved server-side by the page wrapper.
|
||||
* Null hides the logo block — we never fall back to another tenant's
|
||||
* imagery. */
|
||||
logoUrl?: string | null;
|
||||
portName?: string | null;
|
||||
}
|
||||
|
||||
export function ScanShell({ logoUrl, portName }: ScanShellProps = {}) {
|
||||
const router = useRouter();
|
||||
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||
const fileRef = useRef<HTMLInputElement>(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). */}
|
||||
<header className="flex flex-col items-center gap-3">
|
||||
<Image
|
||||
src={LOGO_URL}
|
||||
alt="Port Nimara"
|
||||
width={64}
|
||||
height={64}
|
||||
className="rounded-full shadow-md"
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
{logoUrl ? (
|
||||
<Image
|
||||
src={logoUrl}
|
||||
alt={portName ?? 'Logo'}
|
||||
width={64}
|
||||
height={64}
|
||||
className="rounded-full shadow-md"
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Scan a receipt</h1>
|
||||
{state.kind !== 'idle' ? (
|
||||
|
||||
@@ -650,23 +650,27 @@ function ZeroState({ query, portSlug }: { query: string; portSlug: string | null
|
||||
</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground/70 mb-2">Quick create</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* 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 `/<entity>/new` URLs used
|
||||
previously hit the `[id]` route and rendered "not found". */}
|
||||
<QuickCreateButton
|
||||
icon={User}
|
||||
label={`New client "${query}"`}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/clients/new?fullName=${encodeURIComponent(query)}` as any}
|
||||
href={`/${portSlug}/clients?create=1&prefill_name=${encodeURIComponent(query)}` as any}
|
||||
/>
|
||||
<QuickCreateButton
|
||||
icon={Ship}
|
||||
label={`New yacht "${query}"`}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/yachts/new?name=${encodeURIComponent(query)}` as any}
|
||||
href={`/${portSlug}/yachts?create=1&prefill_name=${encodeURIComponent(query)}` as any}
|
||||
/>
|
||||
<QuickCreateButton
|
||||
icon={Building2}
|
||||
label={`New company "${query}"`}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/companies/new?name=${encodeURIComponent(query)}` as any}
|
||||
href={`/${portSlug}/companies?create=1&prefill_name=${encodeURIComponent(query)}` as any}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<Card id="dashboard">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<CardTitle>Dashboard widgets</CardTitle>
|
||||
<CardDescription>
|
||||
Pick which cards show up on your dashboard. Hidden cards leave no empty space — the
|
||||
layout reflows to fill the available width.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAll(true)}
|
||||
disabled={allVisible || isSaving}
|
||||
>
|
||||
Show all
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAll(false)}
|
||||
disabled={allHidden || isSaving}
|
||||
>
|
||||
Hide all
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
{allWidgets.map((w) => (
|
||||
<div
|
||||
key={w.id}
|
||||
className="flex items-start justify-between gap-4 rounded-md px-3 py-2 hover:bg-accent/40"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">{w.label}</div>
|
||||
<p className="text-xs text-muted-foreground">{w.description}</p>
|
||||
</div>
|
||||
<Switch
|
||||
aria-label={`Show ${w.label}`}
|
||||
checked={visibility[w.id] ?? false}
|
||||
disabled={isSaving}
|
||||
onCheckedChange={(checked) => setVisible(w.id, checked)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DashboardWidgetsCard />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account</CardTitle>
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -42,6 +62,14 @@ export function DevModeBanner() {
|
||||
<span>
|
||||
Dev mode: outbound emails redirected to <code className="font-mono">{redirect}</code>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
aria-label="Dismiss dev mode banner"
|
||||
className="ml-2 inline-flex size-5 shrink-0 items-center justify-center rounded hover:bg-amber-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500"
|
||||
>
|
||||
<X className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -214,9 +214,13 @@ export function EntityActivityFeed({ endpoint, emptyText = 'No activity yet.' }:
|
||||
No activity matches the current filters.
|
||||
</div>
|
||||
) : (
|
||||
<ol className="relative border-l border-muted-foreground/20 ml-3 pl-6 space-y-4 py-2">
|
||||
<ol className="relative ml-3 pl-6 space-y-4 py-2">
|
||||
{groups.map((group, gi) => (
|
||||
<SessionGroupItem key={`${group.actorKey}-${gi}`} group={group} />
|
||||
<SessionGroupItem
|
||||
key={`${group.actorKey}-${gi}`}
|
||||
group={group}
|
||||
isLast={gi === groups.length - 1}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
@@ -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 ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-[-26px] top-3 bottom-[-1rem] w-px bg-muted-foreground/20"
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (group.rows.length === 1) {
|
||||
return (
|
||||
<li className="relative">
|
||||
{connector}
|
||||
<span className="absolute left-[-31px] top-1 h-2.5 w-2.5 rounded-full bg-primary/70 ring-2 ring-background" />
|
||||
<RowBody row={first} actor={group.actorLabel} ago={ago} />
|
||||
</li>
|
||||
@@ -241,6 +255,7 @@ function SessionGroupItem({ group }: { group: SessionGroup }) {
|
||||
|
||||
return (
|
||||
<li className="relative">
|
||||
{connector}
|
||||
<span className="absolute left-[-31px] top-1 h-2.5 w-2.5 rounded-full bg-primary/70 ring-2 ring-background" />
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { TrendingUp, TrendingDown, Minus, Info } from 'lucide-react';
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface KPITileProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
@@ -7,10 +9,19 @@ interface KPITileProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value: React.ReactNode;
|
||||
/** Signed delta vs. prior period; positive = green, negative = red, undefined = no chip. */
|
||||
delta?: number;
|
||||
/** Optional suffix appended to the delta value (e.g. '%' for percentage deltas). */
|
||||
deltaSuffix?: string;
|
||||
/** Pre-rendered sparkline (recharts) - caller decides shape. */
|
||||
sparkline?: React.ReactNode;
|
||||
/** Optional accent stripe colour token; defaults to brand. */
|
||||
accent?: 'brand' | 'success' | 'warning' | 'mint' | 'teal' | 'purple';
|
||||
/** Optional explainer rendered in a Popover when the user clicks the (i)
|
||||
* icon next to the title. Keep it short — one sentence ideally. */
|
||||
tooltip?: React.ReactNode;
|
||||
/** For metrics where lower is better (bounce rate, error count). The
|
||||
* delta number is still shown verbatim, but the colour flips: a
|
||||
* negative delta renders green (improvement) instead of red. */
|
||||
lowerIsBetter?: boolean;
|
||||
}
|
||||
|
||||
const ACCENT_STRIPES: Record<NonNullable<KPITileProps['accent']>, string> = {
|
||||
@@ -26,19 +37,27 @@ export function KPITile({
|
||||
title,
|
||||
value,
|
||||
delta,
|
||||
deltaSuffix,
|
||||
sparkline,
|
||||
accent = 'brand',
|
||||
tooltip,
|
||||
lowerIsBetter = false,
|
||||
className,
|
||||
...props
|
||||
}: KPITileProps) {
|
||||
// Colour logic is independent of the displayed number. For normal
|
||||
// metrics, positive=good (green); for `lowerIsBetter` metrics, flip so
|
||||
// a drop renders green.
|
||||
const isImprovement = typeof delta === 'number' ? (lowerIsBetter ? delta < 0 : delta > 0) : false;
|
||||
const isRegression = typeof delta === 'number' ? (lowerIsBetter ? delta > 0 : delta < 0) : false;
|
||||
const deltaClass =
|
||||
typeof delta === 'number'
|
||||
? delta > 0
|
||||
typeof delta !== 'number'
|
||||
? ''
|
||||
: isImprovement
|
||||
? 'text-success'
|
||||
: delta < 0
|
||||
: isRegression
|
||||
? 'text-error'
|
||||
: 'text-muted-foreground'
|
||||
: '';
|
||||
: 'text-muted-foreground';
|
||||
const deltaPrefix = typeof delta === 'number' ? (delta > 0 ? '+' : '') : '';
|
||||
|
||||
return (
|
||||
@@ -53,18 +72,50 @@ export function KPITile({
|
||||
<div className={cn('absolute inset-x-0 top-0 h-1', ACCENT_STRIPES[accent])} aria-hidden />
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-xs">
|
||||
{title}
|
||||
<div className="flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-xs">
|
||||
<span>{title}</span>
|
||||
{tooltip ? (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
type="button"
|
||||
aria-label={`What is ${title}?`}
|
||||
className="inline-flex size-3.5 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400"
|
||||
>
|
||||
<Info className="size-3" aria-hidden />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-64 text-xs leading-relaxed normal-case tracking-normal text-muted-foreground"
|
||||
>
|
||||
{tooltip}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-lg font-semibold tabular-nums text-foreground sm:mt-2 sm:text-2xl">
|
||||
{value}
|
||||
<div className="mt-1 flex items-baseline gap-2 sm:mt-2">
|
||||
<span className="truncate text-lg font-semibold tabular-nums text-foreground sm:text-2xl">
|
||||
{value}
|
||||
</span>
|
||||
{typeof delta === 'number' ? (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center gap-0.5 text-xs font-medium',
|
||||
deltaClass,
|
||||
)}
|
||||
>
|
||||
{delta > 0 ? (
|
||||
<TrendingUp className="size-3" aria-hidden />
|
||||
) : delta < 0 ? (
|
||||
<TrendingDown className="size-3" aria-hidden />
|
||||
) : (
|
||||
<Minus className="size-3" aria-hidden />
|
||||
)}
|
||||
{deltaPrefix}
|
||||
{delta}
|
||||
{deltaSuffix ?? ''}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{typeof delta === 'number' ? (
|
||||
<div className={cn('mt-1 text-xs font-medium', deltaClass)}>
|
||||
{deltaPrefix}
|
||||
{delta}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{sparkline ? <div className="h-12 w-24 shrink-0 opacity-80">{sparkline}</div> : null}
|
||||
</div>
|
||||
|
||||
@@ -75,6 +75,14 @@ interface YachtFormProps {
|
||||
* just-spawned yacht in scope.
|
||||
*/
|
||||
onCreated?: (yacht: { id: string; name: string }) => void | Promise<void>;
|
||||
/**
|
||||
* Optional initial values for the create flow — used by the global
|
||||
* command-search quick-create ("New yacht 'matthew'" → lands on
|
||||
* `/yachts?create=1&prefill_name=matthew`). Ignored in edit mode.
|
||||
*/
|
||||
prefill?: {
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type YachtStatus = 'active' | 'retired' | 'sold_away';
|
||||
@@ -86,6 +94,7 @@ export function YachtForm({
|
||||
initialOwner,
|
||||
createExtras,
|
||||
onCreated,
|
||||
prefill,
|
||||
}: YachtFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEdit = !!yacht;
|
||||
@@ -139,14 +148,14 @@ export function YachtForm({
|
||||
});
|
||||
} else if (!yacht && open) {
|
||||
reset({
|
||||
name: '',
|
||||
name: prefill?.name ?? '',
|
||||
status: 'active',
|
||||
tagIds: [],
|
||||
...(initialOwner ? { owner: initialOwner } : {}),
|
||||
});
|
||||
}
|
||||
setFormError(null);
|
||||
}, [yacht, open, reset, initialOwner]);
|
||||
}, [yacht, open, reset, initialOwner, prefill]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: CreateYachtInput) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { Plus, Archive, Tag as TagIcon, TagsIcon } from 'lucide-react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
@@ -51,6 +51,16 @@ export function YachtList() {
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
useCreateFromUrl(() => setCreateOpen(true));
|
||||
|
||||
// Quick-create from the global command-search lands here with
|
||||
// `?create=1&prefill_name=…`. Hydrate the yacht-form's name field so
|
||||
// the rep doesn't retype.
|
||||
const searchParams = useSearchParams();
|
||||
const createPrefill = useMemo(() => {
|
||||
const name = searchParams?.get('prefill_name');
|
||||
return name ? { name } : undefined;
|
||||
}, [searchParams]);
|
||||
|
||||
const [editYacht, setEditYacht] = useState<YachtRow | null>(null);
|
||||
const [archiveYacht, setArchiveYacht] = useState<YachtRow | null>(null);
|
||||
const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>(
|
||||
@@ -269,7 +279,7 @@ export function YachtList() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<YachtForm open={createOpen} onOpenChange={setCreateOpen} />
|
||||
<YachtForm open={createOpen} onOpenChange={setCreateOpen} prefill={createPrefill} />
|
||||
|
||||
{editYacht && (
|
||||
<YachtForm
|
||||
|
||||
@@ -77,14 +77,40 @@ function buildAuth() {
|
||||
// through the shared SMTP infra so EMAIL_REDIRECT_TO honours it
|
||||
// in dev.
|
||||
sendResetPassword: async ({ user, url }) => {
|
||||
const { sendEmail } = await import('@/lib/email');
|
||||
const subject = 'Reset your Port Nimara CRM password';
|
||||
const html = `
|
||||
<p>Hi ${user.name || 'there'},</p>
|
||||
<p>You requested a password reset for your Port Nimara CRM account.</p>
|
||||
<p><a href="${url}">Click here to set a new password</a> — the link expires in 1 hour.</p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
`;
|
||||
const [{ sendEmail }, { renderShell, safeUrl }, { resolveAuthShellBranding }] =
|
||||
await Promise.all([
|
||||
import('@/lib/email'),
|
||||
import('@/lib/email/shell'),
|
||||
import('@/lib/email/auth-shell-branding'),
|
||||
]);
|
||||
|
||||
const branding = await resolveAuthShellBranding();
|
||||
const appName = branding?.appName ?? 'CRM';
|
||||
const subject = `Reset your ${appName} password`;
|
||||
const safeName = (user.name || 'there').replace(/[<>&]/g, '');
|
||||
const body = `
|
||||
<p style="margin-bottom:16px;">Hi ${safeName},</p>
|
||||
<p style="margin-bottom:16px;">You requested a password reset for your ${appName} account.</p>
|
||||
<p style="margin-bottom:16px;">
|
||||
<a href="${safeUrl(url)}" style="color:#2563eb;font-weight:600;">Click here to set a new password</a>
|
||||
— the link expires in 1 hour.
|
||||
</p>
|
||||
<p style="color:#64748b;">If you didn't request this, you can safely ignore this email.</p>
|
||||
`;
|
||||
|
||||
const html = renderShell({
|
||||
title: subject,
|
||||
body,
|
||||
branding: branding
|
||||
? {
|
||||
logoUrl: branding.logoUrl,
|
||||
backgroundUrl: branding.backgroundUrl,
|
||||
primaryColor: null,
|
||||
emailHeaderHtml: null,
|
||||
emailFooterHtml: null,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
const text = `Reset your password: ${url}`;
|
||||
await sendEmail(user.email, subject, html, undefined, text);
|
||||
},
|
||||
|
||||
@@ -150,6 +150,13 @@ export const documentSends = pgTable(
|
||||
bounceStatus: text('bounce_status'), // 'hard' | 'soft' | 'ooo'
|
||||
bounceReason: text('bounce_reason'),
|
||||
bounceDetectedAt: timestamp('bounce_detected_at', { withTimezone: true }),
|
||||
// Phase 4b — email open tracking. When `trackOpens` is true the send
|
||||
// includes a 1×1 pixel pointing at /api/public/email-pixel/[sendId].
|
||||
// `firstOpenedAt` + `openCount` are denormalised aggregates so the
|
||||
// sends list can render an "opened" pill without a JOIN.
|
||||
trackOpens: boolean('track_opens').notNull().default(false),
|
||||
firstOpenedAt: timestamp('first_opened_at', { withTimezone: true }),
|
||||
openCount: integer('open_count').notNull().default(0),
|
||||
},
|
||||
(t) => [
|
||||
index('idx_ds_client').on(t.clientId, t.sentAt),
|
||||
@@ -164,9 +171,40 @@ export const documentSends = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Per-open log for emails with `trackOpens=true`. The 1×1 pixel
|
||||
* endpoint inserts here on every fetch (Apple Mail privacy proxy will
|
||||
* over-count; most other clients under-count when images are blocked —
|
||||
* this is the universal email-tracking caveat). Cached aggregates on
|
||||
* `document_sends` keep list rendering fast.
|
||||
*/
|
||||
export const documentSendOpens = pgTable(
|
||||
'document_send_opens',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id),
|
||||
sendId: text('send_id')
|
||||
.notNull()
|
||||
.references(() => documentSends.id, { onDelete: 'cascade' }),
|
||||
openedAt: timestamp('opened_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
userAgent: text('user_agent'),
|
||||
referer: text('referer'),
|
||||
},
|
||||
(t) => [
|
||||
index('idx_dso_send').on(t.sendId, t.openedAt),
|
||||
index('idx_dso_port').on(t.portId, t.openedAt),
|
||||
],
|
||||
);
|
||||
|
||||
export type Brochure = typeof brochures.$inferSelect;
|
||||
export type NewBrochure = typeof brochures.$inferInsert;
|
||||
export type BrochureVersion = typeof brochureVersions.$inferSelect;
|
||||
export type NewBrochureVersion = typeof brochureVersions.$inferInsert;
|
||||
export type DocumentSend = typeof documentSends.$inferSelect;
|
||||
export type NewDocumentSend = typeof documentSends.$inferInsert;
|
||||
export type DocumentSendOpen = typeof documentSendOpens.$inferSelect;
|
||||
export type NewDocumentSendOpen = typeof documentSendOpens.$inferInsert;
|
||||
|
||||
@@ -31,6 +31,15 @@ export type RolePermissions = {
|
||||
edit: boolean;
|
||||
import: boolean;
|
||||
manage_waiting_list: boolean;
|
||||
/**
|
||||
* Update berth `price` / `priceCurrency` via the dedicated single
|
||||
* (`PATCH /api/v1/berths/[id]/price`) and bulk
|
||||
* (`POST /api/v1/berths/bulk-update-prices`) endpoints. Carved out
|
||||
* from generic `berths.edit` so admins can grant sales reps the
|
||||
* ability to retune prices without exposing the full berth-edit
|
||||
* surface (dimensions, mooring type, etc.). Always audited.
|
||||
*/
|
||||
update_prices: boolean;
|
||||
};
|
||||
documents: {
|
||||
view: boolean;
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
* per-port fixture builders.
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db } from './index';
|
||||
import { ports } from './schema/ports';
|
||||
import { roles, userProfiles } from './schema/users';
|
||||
import { systemSettings } from './schema/system';
|
||||
import {
|
||||
ALL_PERMISSIONS,
|
||||
DIRECTOR_PERMISSIONS,
|
||||
@@ -28,12 +29,21 @@ export interface BootstrappedPort {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
interface PortBrandingSeed {
|
||||
logoUrl?: string;
|
||||
emailBackgroundUrl?: string;
|
||||
appName?: string;
|
||||
emailHeaderHtml?: string;
|
||||
emailFooterHtml?: string;
|
||||
}
|
||||
|
||||
export const PORT_DEFINITIONS: Array<{
|
||||
name: string;
|
||||
slug: string;
|
||||
primaryColor: string;
|
||||
defaultCurrency: string;
|
||||
timezone: string;
|
||||
branding?: PortBrandingSeed;
|
||||
}> = [
|
||||
{
|
||||
name: 'Port Nimara',
|
||||
@@ -41,6 +51,12 @@ export const PORT_DEFINITIONS: Array<{
|
||||
primaryColor: '#0F4C81',
|
||||
defaultCurrency: 'USD',
|
||||
timezone: 'America/Anguilla',
|
||||
branding: {
|
||||
logoUrl:
|
||||
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png',
|
||||
emailBackgroundUrl: 'https://s3.portnimara.com/images/Overhead_1_blur.png',
|
||||
appName: 'Port Nimara CRM',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Port Amador',
|
||||
@@ -48,6 +64,8 @@ export const PORT_DEFINITIONS: Array<{
|
||||
primaryColor: '#D97706',
|
||||
defaultCurrency: 'USD',
|
||||
timezone: 'America/Panama',
|
||||
// Branding intentionally left empty — admin uploads their own assets
|
||||
// via /admin/branding rather than inheriting Port Nimara's look.
|
||||
},
|
||||
];
|
||||
|
||||
@@ -75,16 +93,51 @@ export async function seedBootstrap(): Promise<BootstrappedPort[]> {
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
|
||||
let portId: string | null = null;
|
||||
if (inserted) {
|
||||
console.log(` Port created: ${def.name} (${inserted.id})`);
|
||||
portIds.push({ id: inserted.id, name: def.name, slug: def.slug });
|
||||
portId = inserted.id;
|
||||
} else {
|
||||
const [existing] = await db.select().from(ports).where(eq(ports.slug, def.slug)).limit(1);
|
||||
if (existing) {
|
||||
console.log(` Port exists: ${def.name} (${existing.id})`);
|
||||
portIds.push({ id: existing.id, name: def.name, slug: def.slug });
|
||||
portId = existing.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Seed branding settings idempotently. `onConflictDoNothing` so an
|
||||
// admin who's tweaked the values via /admin/branding doesn't get
|
||||
// their work overwritten by a re-seed.
|
||||
if (portId && def.branding) {
|
||||
const brandingPairs: Array<[string, unknown]> = [];
|
||||
if (def.branding.logoUrl) brandingPairs.push(['branding_logo_url', def.branding.logoUrl]);
|
||||
if (def.branding.emailBackgroundUrl)
|
||||
brandingPairs.push(['branding_email_background_url', def.branding.emailBackgroundUrl]);
|
||||
if (def.branding.appName) brandingPairs.push(['branding_app_name', def.branding.appName]);
|
||||
if (def.branding.emailHeaderHtml)
|
||||
brandingPairs.push(['branding_email_header_html', def.branding.emailHeaderHtml]);
|
||||
if (def.branding.emailFooterHtml)
|
||||
brandingPairs.push(['branding_email_footer_html', def.branding.emailFooterHtml]);
|
||||
brandingPairs.push(['branding_primary_color', def.primaryColor]);
|
||||
|
||||
for (const [key, value] of brandingPairs) {
|
||||
// Skip when an existing row is already present — preserves admin
|
||||
// edits across re-seeds. Pair (key, portId) is uniquely indexed.
|
||||
const existing = await db.query.systemSettings.findFirst({
|
||||
where: and(eq(systemSettings.key, key), eq(systemSettings.portId, portId)),
|
||||
});
|
||||
if (existing) continue;
|
||||
await db.insert(systemSettings).values({
|
||||
key,
|
||||
value: value as Record<string, unknown>,
|
||||
portId,
|
||||
updatedBy: SUPER_ADMIN_USER_ID,
|
||||
});
|
||||
}
|
||||
console.log(` Branding seeded for ${def.name} (${brandingPairs.length} keys)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── System roles ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -23,7 +23,7 @@ export const ALL_PERMISSIONS: RolePermissions = {
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: true, import: true, manage_waiting_list: true },
|
||||
berths: { view: true, edit: true, import: true, manage_waiting_list: true, update_prices: true },
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -102,7 +102,7 @@ export const DIRECTOR_PERMISSIONS: RolePermissions = {
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: true, import: true, manage_waiting_list: true },
|
||||
berths: { view: true, edit: true, import: true, manage_waiting_list: true, update_prices: true },
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -181,7 +181,7 @@ export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: true, import: false, manage_waiting_list: true },
|
||||
berths: { view: true, edit: true, import: false, manage_waiting_list: true, update_prices: true },
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -260,7 +260,7 @@ export const SALES_AGENT_PERMISSIONS: RolePermissions = {
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: true, import: false, manage_waiting_list: true },
|
||||
berths: { view: true, edit: true, import: false, manage_waiting_list: true, update_prices: true },
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -339,7 +339,13 @@ export const VIEWER_PERMISSIONS: RolePermissions = {
|
||||
generate_eoi: false,
|
||||
export: false,
|
||||
},
|
||||
berths: { view: true, edit: false, import: false, manage_waiting_list: false },
|
||||
berths: {
|
||||
view: true,
|
||||
edit: false,
|
||||
import: false,
|
||||
manage_waiting_list: false,
|
||||
update_prices: false,
|
||||
},
|
||||
documents: {
|
||||
view: true,
|
||||
create: false,
|
||||
@@ -421,7 +427,13 @@ export const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = {
|
||||
generate_eoi: false,
|
||||
export: false,
|
||||
},
|
||||
berths: { view: false, edit: false, import: false, manage_waiting_list: false },
|
||||
berths: {
|
||||
view: false,
|
||||
edit: false,
|
||||
import: false,
|
||||
manage_waiting_list: false,
|
||||
update_prices: false,
|
||||
},
|
||||
documents: {
|
||||
view: false,
|
||||
create: false,
|
||||
|
||||
@@ -92,6 +92,12 @@ async function generateEmailDraft(payload: GenerateEmailDraftPayload): Promise<D
|
||||
const primaryBerth = await getPrimaryBerth(interestId);
|
||||
const berthMooring = primaryBerth?.mooringNumber ?? null;
|
||||
|
||||
// Resolve the port branding app name once so template-fallback drafts
|
||||
// sign off as "{Port} Team" instead of leaking another tenant's name.
|
||||
const { getPortBrandingConfig } = await import('@/lib/services/port-config');
|
||||
const portBrand = await getPortBrandingConfig(portId).catch(() => null);
|
||||
const brandingAppName = portBrand?.appName?.trim() || 'our marina';
|
||||
|
||||
// Fetch last 5 notes
|
||||
const recentNotes = await db
|
||||
.select({ content: interestNotes.content, createdAt: interestNotes.createdAt })
|
||||
@@ -117,6 +123,7 @@ async function generateEmailDraft(payload: GenerateEmailDraftPayload): Promise<D
|
||||
context,
|
||||
berthMooring,
|
||||
pipelineStage: interest.pipelineStage,
|
||||
portName: brandingAppName,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -253,6 +260,7 @@ async function generateEmailDraft(payload: GenerateEmailDraftPayload): Promise<D
|
||||
context,
|
||||
berthMooring,
|
||||
pipelineStage: interest.pipelineStage,
|
||||
portName: brandingAppName,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -266,26 +274,28 @@ function buildTemplateDraft(opts: {
|
||||
context: string;
|
||||
berthMooring: string | null;
|
||||
pipelineStage: string;
|
||||
portName: string;
|
||||
}): DraftResult {
|
||||
const { clientName, context, berthMooring, pipelineStage } = opts;
|
||||
const { clientName, context, berthMooring, pipelineStage, portName } = opts;
|
||||
const berthText = berthMooring ? `berth ${berthMooring}` : 'your requested berth';
|
||||
const signoff = `Kind regards,\n${portName} Team`;
|
||||
|
||||
const templates: Record<string, { subject: string; body: string }> = {
|
||||
introduction: {
|
||||
subject: `Welcome to Port Nimara – ${clientName}`,
|
||||
body: `Dear ${clientName},\n\nThank you for your interest in Port Nimara. We are delighted to introduce our marina facilities and look forward to discussing how we can accommodate your needs for ${berthText}.\n\nPlease feel free to reach out at any time.\n\nKind regards,\nPort Nimara Team`,
|
||||
subject: `Welcome to ${portName} – ${clientName}`,
|
||||
body: `Dear ${clientName},\n\nThank you for your interest in ${portName}. We are delighted to introduce our marina facilities and look forward to discussing how we can accommodate your needs for ${berthText}.\n\nPlease feel free to reach out at any time.\n\n${signoff}`,
|
||||
},
|
||||
follow_up: {
|
||||
subject: `Following up – ${clientName}`,
|
||||
body: `Dear ${clientName},\n\nI wanted to follow up regarding your interest in ${berthText}. Please let us know if you have any questions or if there is anything we can assist you with.\n\nWe look forward to hearing from you.\n\nKind regards,\nPort Nimara Team`,
|
||||
body: `Dear ${clientName},\n\nI wanted to follow up regarding your interest in ${berthText}. Please let us know if you have any questions or if there is anything we can assist you with.\n\nWe look forward to hearing from you.\n\n${signoff}`,
|
||||
},
|
||||
stage_update: {
|
||||
subject: `Update on your application – ${clientName}`,
|
||||
body: `Dear ${clientName},\n\nWe are pleased to inform you that your application for ${berthText} has progressed to the "${stageLabel(pipelineStage)}" stage.\n\nWe will be in touch shortly with the next steps.\n\nKind regards,\nPort Nimara Team`,
|
||||
body: `Dear ${clientName},\n\nWe are pleased to inform you that your application for ${berthText} has progressed to the "${stageLabel(pipelineStage)}" stage.\n\nWe will be in touch shortly with the next steps.\n\n${signoff}`,
|
||||
},
|
||||
general: {
|
||||
subject: `Message from Port Nimara – ${clientName}`,
|
||||
body: `Dear ${clientName},\n\nThank you for your continued interest in Port Nimara. We appreciate your patience and look forward to assisting you with ${berthText}.\n\nKind regards,\nPort Nimara Team`,
|
||||
subject: `Message from ${portName} – ${clientName}`,
|
||||
body: `Dear ${clientName},\n\nThank you for your continued interest in ${portName}. We appreciate your patience and look forward to assisting you with ${berthText}.\n\n${signoff}`,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export const emailWorker = new Worker(
|
||||
portId,
|
||||
fallback: email.subject,
|
||||
tokens: {
|
||||
portName: portName ?? 'Port Nimara',
|
||||
portName: portName ?? 'the marina',
|
||||
recipientName: firstName,
|
||||
mooringNumber: mooringNumber ?? '',
|
||||
},
|
||||
@@ -83,7 +83,7 @@ export const emailWorker = new Worker(
|
||||
portId,
|
||||
fallback: notification.subject,
|
||||
tokens: {
|
||||
portName: portName ?? 'Port Nimara',
|
||||
portName: portName ?? 'the marina',
|
||||
clientName: fullName,
|
||||
mooringNumber: mooringNumber ?? '',
|
||||
email,
|
||||
|
||||
@@ -83,9 +83,18 @@ export const notificationsWorker = new Worker(
|
||||
const linkHtml = notif.link
|
||||
? `<p><a href="${safeUrl(`${process.env.APP_URL ?? ''}${notif.link}`)}">View in CRM</a></p>`
|
||||
: '';
|
||||
|
||||
// Subject prefix = port branding `appName` so multi-tenant
|
||||
// deploys read "[Port Amador]"/"[Other Marina]" instead of
|
||||
// a hardcoded "[Port Nimara]".
|
||||
const { getPortBrandingConfig } = await import('@/lib/services/port-config');
|
||||
const portBrand = notif.portId
|
||||
? await getPortBrandingConfig(notif.portId).catch(() => null)
|
||||
: null;
|
||||
const prefix = portBrand?.appName?.trim() || 'CRM';
|
||||
await sendEmail(
|
||||
authUser.email,
|
||||
`[Port Nimara] ${notif.title}`,
|
||||
`[${prefix}] ${notif.title}`,
|
||||
`<p>${bodyText}</p>${linkHtml}`,
|
||||
);
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { and, between, eq, isNull, sql } from 'drizzle-orm';
|
||||
import { db } from '@/lib/db';
|
||||
import { analyticsSnapshots } from '@/lib/db/schema/insights';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { invoices } from '@/lib/db/schema/financial';
|
||||
import { PIPELINE_STAGES } from '@/lib/constants';
|
||||
import {
|
||||
ALL_RANGES,
|
||||
@@ -26,11 +25,7 @@ import {
|
||||
export { ALL_RANGES, isCustomRange, rangeToBounds };
|
||||
export type { DateRange, PresetDateRange, CustomDateRange };
|
||||
|
||||
export type MetricBase =
|
||||
| 'pipeline_funnel'
|
||||
| 'occupancy_timeline'
|
||||
| 'revenue_breakdown'
|
||||
| 'lead_source_attribution';
|
||||
export type MetricBase = 'pipeline_funnel' | 'occupancy_timeline' | 'lead_source_attribution';
|
||||
|
||||
/**
|
||||
* Snapshot key. Only preset ranges are cached - custom ranges have an
|
||||
@@ -41,7 +36,6 @@ export type MetricId = `${MetricBase}.${PresetDateRange}`;
|
||||
export const ALL_METRICS: readonly MetricBase[] = [
|
||||
'pipeline_funnel',
|
||||
'occupancy_timeline',
|
||||
'revenue_breakdown',
|
||||
'lead_source_attribution',
|
||||
] as const;
|
||||
|
||||
@@ -61,19 +55,11 @@ export interface OccupancyTimelineData {
|
||||
points: Array<{ date: string; occupied: number; total: number; occupancyPct: number }>;
|
||||
}
|
||||
|
||||
export interface RevenueBreakdownData {
|
||||
bars: Array<{ status: string; amount: number; currency: string }>;
|
||||
}
|
||||
|
||||
export interface LeadSourceAttributionData {
|
||||
slices: Array<{ source: string; count: number }>;
|
||||
}
|
||||
|
||||
export type SnapshotData =
|
||||
| PipelineFunnelData
|
||||
| OccupancyTimelineData
|
||||
| RevenueBreakdownData
|
||||
| LeadSourceAttributionData;
|
||||
export type SnapshotData = PipelineFunnelData | OccupancyTimelineData | LeadSourceAttributionData;
|
||||
|
||||
// ─── Cache layer ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -219,36 +205,6 @@ export async function computeOccupancyTimeline(
|
||||
return { points };
|
||||
}
|
||||
|
||||
export async function computeRevenueBreakdown(
|
||||
portId: string,
|
||||
range: DateRange,
|
||||
): Promise<RevenueBreakdownData> {
|
||||
const { from, to } = rangeToBounds(range);
|
||||
const rows = await db
|
||||
.select({
|
||||
status: invoices.status,
|
||||
currency: invoices.currency,
|
||||
amount: sql<string>`coalesce(sum(${invoices.total}), 0)::text`,
|
||||
})
|
||||
.from(invoices)
|
||||
.where(
|
||||
and(
|
||||
eq(invoices.portId, portId),
|
||||
isNull(invoices.archivedAt),
|
||||
between(invoices.createdAt, from, to),
|
||||
),
|
||||
)
|
||||
.groupBy(invoices.status, invoices.currency);
|
||||
|
||||
return {
|
||||
bars: rows.map((r) => ({
|
||||
status: r.status,
|
||||
currency: r.currency,
|
||||
amount: Number(r.amount),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function computeLeadSourceAttribution(
|
||||
portId: string,
|
||||
range: DateRange,
|
||||
@@ -307,19 +263,6 @@ export async function getOccupancyTimeline(
|
||||
return fresh;
|
||||
}
|
||||
|
||||
export async function getRevenueBreakdown(
|
||||
portId: string,
|
||||
range: DateRange,
|
||||
): Promise<RevenueBreakdownData> {
|
||||
if (isCustomRange(range)) return computeRevenueBreakdown(portId, range);
|
||||
const metricId = `revenue_breakdown.${range}` as const;
|
||||
const cached = await readSnapshot<RevenueBreakdownData>(portId, metricId);
|
||||
if (cached) return cached;
|
||||
const fresh = await computeRevenueBreakdown(portId, range);
|
||||
await writeSnapshot(portId, metricId, fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
export async function getLeadSourceAttribution(
|
||||
portId: string,
|
||||
range: DateRange,
|
||||
@@ -337,16 +280,14 @@ export async function getLeadSourceAttribution(
|
||||
|
||||
export async function refreshSnapshotsForPort(portId: string): Promise<void> {
|
||||
for (const range of ALL_RANGES) {
|
||||
const [funnel, occupancy, revenue, leadSource] = await Promise.all([
|
||||
const [funnel, occupancy, leadSource] = await Promise.all([
|
||||
computePipelineFunnel(portId, range),
|
||||
computeOccupancyTimeline(portId, range),
|
||||
computeRevenueBreakdown(portId, range),
|
||||
computeLeadSourceAttribution(portId, range),
|
||||
]);
|
||||
await Promise.all([
|
||||
writeSnapshot(portId, `pipeline_funnel.${range}`, funnel),
|
||||
writeSnapshot(portId, `occupancy_timeline.${range}`, occupancy),
|
||||
writeSnapshot(portId, `revenue_breakdown.${range}`, revenue),
|
||||
writeSnapshot(portId, `lead_source_attribution.${range}`, leadSource),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -165,8 +165,15 @@ export async function getRevenueForecast(portId: string) {
|
||||
.innerJoin(berths, eq(interestBerths.berthId, berths.id))
|
||||
.where(activeInterestsWhere(portId));
|
||||
|
||||
// Build stageBreakdown
|
||||
const stageMap: Record<string, { count: number; weightedValue: number }> = {};
|
||||
// Build stageBreakdown — gross value, weighted value, per-stage weight,
|
||||
// and `dealsMissingPrice` (deals whose primary berth has no/zero price)
|
||||
// all surface to callers. The dashboard tile shows a warning chip when
|
||||
// any deals in a stage are missing a berth price so the $0 line item
|
||||
// doesn't read as legitimate.
|
||||
const stageMap: Record<
|
||||
string,
|
||||
{ count: number; grossValue: number; weightedValue: number; dealsMissingPrice: number }
|
||||
> = {};
|
||||
|
||||
for (const row of interestRows) {
|
||||
const stage = row.pipelineStage ?? 'open';
|
||||
@@ -175,21 +182,28 @@ export async function getRevenueForecast(portId: string) {
|
||||
const weighted = price * weight;
|
||||
|
||||
if (!stageMap[stage]) {
|
||||
stageMap[stage] = { count: 0, weightedValue: 0 };
|
||||
stageMap[stage] = { count: 0, grossValue: 0, weightedValue: 0, dealsMissingPrice: 0 };
|
||||
}
|
||||
stageMap[stage]!.count += 1;
|
||||
stageMap[stage]!.grossValue += price;
|
||||
stageMap[stage]!.weightedValue += weighted;
|
||||
if (!(price > 0)) stageMap[stage]!.dealsMissingPrice += 1;
|
||||
}
|
||||
|
||||
const stageBreakdown = PIPELINE_STAGES.map((stage) => ({
|
||||
stage,
|
||||
count: stageMap[stage]?.count ?? 0,
|
||||
grossValue: stageMap[stage]?.grossValue ?? 0,
|
||||
weightedValue: stageMap[stage]?.weightedValue ?? 0,
|
||||
weight: weights[stage] ?? 0,
|
||||
dealsMissingPrice: stageMap[stage]?.dealsMissingPrice ?? 0,
|
||||
}));
|
||||
|
||||
const totalGrossValue = stageBreakdown.reduce((acc, s) => acc + s.grossValue, 0);
|
||||
const totalWeightedValue = stageBreakdown.reduce((acc, s) => acc + s.weightedValue, 0);
|
||||
|
||||
return {
|
||||
totalGrossValue,
|
||||
totalWeightedValue,
|
||||
stageBreakdown,
|
||||
weightsSource,
|
||||
|
||||
@@ -46,8 +46,11 @@ import {
|
||||
import { inArray } from 'drizzle-orm';
|
||||
import type { DocumentSend } from '@/lib/db/schema';
|
||||
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import { env } from '@/lib/env';
|
||||
import { injectTrackingPixel } from '@/lib/email/tracking-pixel';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { checkRateLimit } from '@/lib/rate-limit';
|
||||
import { getSetting } from '@/lib/services/settings.service';
|
||||
import { getStorageBackend } from '@/lib/storage';
|
||||
import {
|
||||
EMAIL_BODY_MAX_BYTES,
|
||||
@@ -433,10 +436,24 @@ async function performSend(args: {
|
||||
}): Promise<SendResult> {
|
||||
// 1. Build attachment vs link preamble.
|
||||
const delivery = await streamAttachmentOrLink(args.portId, args.attachment);
|
||||
const finalHtml = delivery.bodySuffixHtml
|
||||
let finalHtml = delivery.bodySuffixHtml
|
||||
? `${args.bodyHtml}\n${delivery.bodySuffixHtml}`
|
||||
: args.bodyHtml;
|
||||
|
||||
// 1b. Phase 4b — open tracking. Pre-allocate the send-row UUID so we
|
||||
// can embed a per-send tracking pixel before we know whether the SMTP
|
||||
// call will succeed. The pixel endpoint itself gates on
|
||||
// `track_opens=true`, so a failed send with the pixel still embedded
|
||||
// is a harmless no-op even if a recipient somehow opens the partial.
|
||||
const trackOpens = await isOpenTrackingEnabled(args.portId);
|
||||
const preallocatedId = trackOpens ? crypto.randomUUID() : undefined;
|
||||
if (trackOpens && preallocatedId && env.NEXT_PUBLIC_APP_URL) {
|
||||
finalHtml = injectTrackingPixel(finalHtml, {
|
||||
appBaseUrl: env.NEXT_PUBLIC_APP_URL,
|
||||
sendId: preallocatedId,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Create the transporter (per-port sales account).
|
||||
let transporter, fromAddress;
|
||||
try {
|
||||
@@ -447,6 +464,7 @@ async function performSend(args: {
|
||||
.insert(documentSends)
|
||||
.values({
|
||||
...args.recordSeed,
|
||||
...(preallocatedId ? { id: preallocatedId, trackOpens: true } : {}),
|
||||
fromAddress: args.recordSeed.fromAddress || 'unknown',
|
||||
bodyMarkdown: args.recordSeed.bodyMarkdown ?? null,
|
||||
failedAt: new Date(),
|
||||
@@ -473,11 +491,21 @@ async function performSend(args: {
|
||||
.insert(documentSends)
|
||||
.values({
|
||||
...args.recordSeed,
|
||||
...(preallocatedId ? { id: preallocatedId, trackOpens: true } : {}),
|
||||
fromAddress,
|
||||
messageId: info.messageId ?? null,
|
||||
fallbackToLinkReason: delivery.deliveredAsAttachment ? null : 'size_above_threshold',
|
||||
})
|
||||
.returning();
|
||||
// Phase 7 — Umami attribution. Send completion is the "email sent"
|
||||
// half of the email funnel; opens (Phase 4b) and click-throughs
|
||||
// (Phase 4c) follow as separate events keyed by sendId.
|
||||
void import('@/lib/services/umami.service').then(({ trackEvent }) =>
|
||||
trackEvent(args.portId, 'email-sent', {
|
||||
sendId: row!.id,
|
||||
documentKind: row!.documentKind,
|
||||
}),
|
||||
);
|
||||
return { send: row!, deliveredAsAttachment: delivery.deliveredAsAttachment };
|
||||
} catch (sendErr) {
|
||||
const msg = sendErr instanceof Error ? sendErr.message : String(sendErr);
|
||||
@@ -486,6 +514,7 @@ async function performSend(args: {
|
||||
.insert(documentSends)
|
||||
.values({
|
||||
...args.recordSeed,
|
||||
...(preallocatedId ? { id: preallocatedId, trackOpens: true } : {}),
|
||||
fromAddress,
|
||||
failedAt: new Date(),
|
||||
errorReason: msg,
|
||||
@@ -686,3 +715,24 @@ export async function listSends(filters: ListSendsFilters): Promise<DocumentSend
|
||||
.limit(filters.limit ?? 100);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Phase 4b — per-port kill switch for email open tracking. Stored in
|
||||
// `system_settings` under `email_open_tracking_enabled` (boolean). Default
|
||||
// FALSE so the feature is explicit-opt-in by an admin. Cached per-port
|
||||
// for 60 s to avoid hitting `system_settings` on every send.
|
||||
const trackingEnabledCache = new Map<string, { value: boolean; expiresAt: number }>();
|
||||
const TRACKING_TTL_MS = 60_000;
|
||||
|
||||
async function isOpenTrackingEnabled(portId: string): Promise<boolean> {
|
||||
const cached = trackingEnabledCache.get(portId);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
||||
const row = await getSetting('email_open_tracking_enabled', portId);
|
||||
// value is stored as a JSON-encoded primitive — accept boolean true OR
|
||||
// the strings "true" / "1" for resilience against admin UIs that
|
||||
// serialize booleans as strings.
|
||||
const raw = row?.value as unknown;
|
||||
const enabled =
|
||||
raw === true || raw === 1 || (typeof raw === 'string' && (raw === 'true' || raw === '1'));
|
||||
trackingEnabledCache.set(portId, { value: enabled, expiresAt: Date.now() + TRACKING_TTL_MS });
|
||||
return enabled;
|
||||
}
|
||||
|
||||
@@ -1618,6 +1618,16 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
|
||||
'EOI signed via Documenso',
|
||||
'eoi_signed',
|
||||
);
|
||||
|
||||
// Phase 7 — Umami attribution. EOI signed is the headline
|
||||
// conversion event so it gets its own Umami event for funnel
|
||||
// visibility (rather than rolling up into "interest-stage-changed").
|
||||
void import('@/lib/services/umami.service').then(({ trackEvent }) =>
|
||||
trackEvent(doc.portId, 'eoi-signed', {
|
||||
interestId: doc.interestId,
|
||||
documentId: doc.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -777,6 +777,16 @@ export async function createInterest(portId: string, data: CreateInterestInput,
|
||||
}),
|
||||
);
|
||||
|
||||
// Phase 6 — CRM → Umami attribution. Fire an inbound-lead event so
|
||||
// marketing can correlate inquiry volume with website traffic by
|
||||
// source / referrer.
|
||||
void import('@/lib/services/umami.service').then(({ trackEvent }) =>
|
||||
trackEvent(portId, 'interest-created', {
|
||||
interestId: result.id,
|
||||
source: result.source ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1016,6 +1026,15 @@ export async function changeInterestStage(
|
||||
}),
|
||||
);
|
||||
|
||||
// Phase 6 — CRM → Umami attribution for pipeline movement.
|
||||
void import('@/lib/services/umami.service').then(({ trackEvent }) =>
|
||||
trackEvent(portId, 'interest-stage-changed', {
|
||||
interestId: id,
|
||||
oldStage: oldStage ?? null,
|
||||
newStage: data.pipelineStage,
|
||||
}),
|
||||
);
|
||||
|
||||
// Fire-and-forget notification to the acting user. Resolve a friendly
|
||||
// label (client full name → primary mooring number → "this interest") so
|
||||
// the inbox doesn't surface a raw UUID; stage names go through the
|
||||
@@ -1216,6 +1235,18 @@ export async function setInterestOutcome(
|
||||
// via system_settings.berth_rules.
|
||||
void evaluateRule('interest_completed', id, portId, meta);
|
||||
|
||||
// Phase 6 — CRM → Umami attribution. Fire a custom Umami event so
|
||||
// marketing can correlate inbound website traffic with the resulting
|
||||
// deal outcome. Dynamic import to avoid a circular service dep at
|
||||
// module-load time.
|
||||
void import('@/lib/services/umami.service').then(({ trackEvent }) =>
|
||||
trackEvent(portId, 'interest-outcome-set', {
|
||||
interestId: id,
|
||||
outcome: data.outcome,
|
||||
stageAtOutcome,
|
||||
}),
|
||||
);
|
||||
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
|
||||
@@ -229,6 +229,14 @@ export interface QualificationRow {
|
||||
* they just have to know the berth size they want.
|
||||
*/
|
||||
autoSatisfied: boolean;
|
||||
/**
|
||||
* Human-readable summary of WHY a criterion is auto-satisfied (e.g.
|
||||
* "Desired: 60 × 25 × 6 ft"). Empty string when the criterion is not
|
||||
* auto-satisfied OR when no derivation rule applies. Surfaced on the
|
||||
* checklist row so the rep can see the evidence behind the tick — the
|
||||
* "why is this checked?" question came up in UAT.
|
||||
*/
|
||||
evidence: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -296,6 +304,16 @@ export async function listInterestQualifications(
|
||||
},
|
||||
});
|
||||
const explicit = s?.confirmed ?? false;
|
||||
const evidence = autoSatisfied
|
||||
? computeEvidence(c.key, {
|
||||
yachtDims,
|
||||
desiredDims: {
|
||||
lengthFt: interest.desiredLengthFt ?? null,
|
||||
widthFt: interest.desiredWidthFt ?? null,
|
||||
draftFt: interest.desiredDraftFt ?? null,
|
||||
},
|
||||
})
|
||||
: '';
|
||||
return {
|
||||
key: c.key,
|
||||
label: c.label,
|
||||
@@ -311,6 +329,7 @@ export async function listInterestQualifications(
|
||||
confirmedBy: s?.confirmedBy ?? null,
|
||||
notes: s?.notes ?? null,
|
||||
autoSatisfied,
|
||||
evidence,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -340,6 +359,37 @@ function computeAutoSatisfied(
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a short human-readable string explaining what data drove the
|
||||
* auto-satisfaction. Mirrors `computeAutoSatisfied`'s branching so the UI
|
||||
* can render "Auto · <evidence>" — closes the "why is this ticked?" gap.
|
||||
*/
|
||||
function computeEvidence(
|
||||
key: string,
|
||||
ctx: {
|
||||
yachtDims: { lengthFt: string | null; widthFt: string | null; draftFt: string | null } | null;
|
||||
desiredDims: { lengthFt: string | null; widthFt: string | null; draftFt: string | null };
|
||||
},
|
||||
): string {
|
||||
if (key === 'dimensions') {
|
||||
const hasYacht =
|
||||
!!ctx.yachtDims &&
|
||||
!!ctx.yachtDims.lengthFt &&
|
||||
!!ctx.yachtDims.widthFt &&
|
||||
!!ctx.yachtDims.draftFt;
|
||||
if (hasYacht && ctx.yachtDims) {
|
||||
return `Yacht: ${ctx.yachtDims.lengthFt} × ${ctx.yachtDims.widthFt} × ${ctx.yachtDims.draftFt} ft`;
|
||||
}
|
||||
const hasDesired =
|
||||
!!ctx.desiredDims.lengthFt && !!ctx.desiredDims.widthFt && !!ctx.desiredDims.draftFt;
|
||||
if (hasDesired) {
|
||||
return `Desired: ${ctx.desiredDims.lengthFt} × ${ctx.desiredDims.widthFt} × ${ctx.desiredDims.draftFt} ft`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a single criterion's confirmed-state for an interest. Stamping the
|
||||
* server-side fields (confirmedBy / confirmedAt) makes the row a proper
|
||||
|
||||
@@ -313,7 +313,13 @@ export function makeFullPermissions(): RolePermissions {
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: true, import: true, manage_waiting_list: true },
|
||||
berths: {
|
||||
view: true,
|
||||
edit: true,
|
||||
import: true,
|
||||
manage_waiting_list: true,
|
||||
update_prices: true,
|
||||
},
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -395,7 +401,13 @@ export function makeViewerPermissions(): RolePermissions {
|
||||
generate_eoi: false,
|
||||
export: false,
|
||||
},
|
||||
berths: { view: true, edit: false, import: false, manage_waiting_list: false },
|
||||
berths: {
|
||||
view: true,
|
||||
edit: false,
|
||||
import: false,
|
||||
manage_waiting_list: false,
|
||||
update_prices: false,
|
||||
},
|
||||
documents: {
|
||||
view: true,
|
||||
create: false,
|
||||
@@ -477,7 +489,13 @@ export function makeSalesAgentPermissions(): RolePermissions {
|
||||
generate_eoi: true,
|
||||
export: false,
|
||||
},
|
||||
berths: { view: true, edit: false, import: false, manage_waiting_list: false },
|
||||
berths: {
|
||||
view: true,
|
||||
edit: false,
|
||||
import: false,
|
||||
manage_waiting_list: false,
|
||||
update_prices: false,
|
||||
},
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
@@ -559,7 +577,13 @@ export function makeSalesManagerPermissions(): RolePermissions {
|
||||
generate_eoi: true,
|
||||
export: true,
|
||||
},
|
||||
berths: { view: true, edit: true, import: false, manage_waiting_list: true },
|
||||
berths: {
|
||||
view: true,
|
||||
edit: true,
|
||||
import: false,
|
||||
manage_waiting_list: true,
|
||||
update_prices: true,
|
||||
},
|
||||
documents: {
|
||||
view: true,
|
||||
create: true,
|
||||
|
||||
@@ -8,12 +8,10 @@ import { eq, and } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { interests, interestBerths } from '@/lib/db/schema/interests';
|
||||
import { invoices } from '@/lib/db/schema/financial';
|
||||
import { analyticsSnapshots } from '@/lib/db/schema/insights';
|
||||
import {
|
||||
computePipelineFunnel,
|
||||
computeOccupancyTimeline,
|
||||
computeRevenueBreakdown,
|
||||
computeLeadSourceAttribution,
|
||||
getPipelineFunnel,
|
||||
refreshSnapshotsForPort,
|
||||
@@ -108,33 +106,6 @@ describe('analytics service', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeRevenueBreakdown', () => {
|
||||
it('groups invoice totals by status and currency', async () => {
|
||||
const port = await makePort();
|
||||
const baseInvoice = {
|
||||
portId: port.id,
|
||||
clientName: 'Acme',
|
||||
billingEntityType: 'client' as const,
|
||||
billingEntityId: 'client-id',
|
||||
dueDate: '2026-12-31',
|
||||
currency: 'USD',
|
||||
subtotal: '0',
|
||||
createdBy: 'seed',
|
||||
};
|
||||
await db.insert(invoices).values([
|
||||
{ ...baseInvoice, invoiceNumber: 'INV-001', total: '1000', status: 'paid' },
|
||||
{ ...baseInvoice, invoiceNumber: 'INV-002', total: '500', status: 'paid' },
|
||||
{ ...baseInvoice, invoiceNumber: 'INV-003', total: '2000', status: 'sent' },
|
||||
]);
|
||||
|
||||
const result = await computeRevenueBreakdown(port.id, '30d');
|
||||
const paid = result.bars.find((b) => b.status === 'paid');
|
||||
const sent = result.bars.find((b) => b.status === 'sent');
|
||||
expect(paid?.amount).toBe(1500);
|
||||
expect(sent?.amount).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeLeadSourceAttribution', () => {
|
||||
it('counts interests grouped by source descending', async () => {
|
||||
const port = await makePort();
|
||||
|
||||
Reference in New Issue
Block a user