Files
pn-new-crm/docs/reports-content-spec.md
Matt 3bdf59e917 feat(reports-overhaul): sales + operational + custom reports, templates, schedules, exports
End-to-end reports build covering Phases 1, 2, 5, 6, 7 of Initiative 1
in docs/launch-readiness.md. Phases 3 (Marketing) + 4 (Financial)
remain deferred per the gap audit at the bottom of that doc.

Highlights:
- Sales performance report: 7 KPI tiles, pipeline funnel + stage
  velocity + win-rate-over-time + source conversion + rep leaderboard
  charts, deal-heat section, 5 detail tables, stage / lead-cat /
  outcome filters.
- Operational report: 7 KPIs, 7 charts (heatmap, status mix, tenancy
  churn, tenure histogram, signing box plot, occupancy by area, docs
  in pipeline), 4 tables. Module-OFF banner when tenancies disabled.
- Custom (ad-hoc) builder v1: 4 entities (clients, interests, berths,
  tenancies), column-whitelist composer, date filter, CSV download,
  save-as-template. Registry-only extension path for the remaining 6
  entities documented at src/lib/reports/custom/registry.ts.
- Templates: load / modify / save / save-as on Sales / Operational /
  Custom. ?templateId= URL deep-link hydration via useRef guard.
  Active-template badge clears when the user drives view-state via
  wrapped setters; raw setters used on template apply so the badge
  survives.
- Scheduled runs: BullMQ poll fires due schedules, mints report_runs,
  renders, optionally emails. Recipients optional (zero-recipient
  schedules archive without sending). PDF-only output for v1.
  Schedule dialog re-mounts via key prop on schedule.id transitions
  to avoid setState-in-effect reset patterns.
- Server-side PDF endpoint + shared payload renderer
  (lib/pdf/reports/payload-report.tsx) so client + scheduler share
  one rendering path.
- Shared currency formatter (lib/reports/format-currency.ts)
  consolidates 5 duplicated formatMoney helpers; fixes hardcoded
  'USD' in detail tables; pre-formats money rows so PDF export
  (which strips column.format callbacks at the JSON boundary)
  renders consistently with CSV / XLSX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:41:53 +02:00

525 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Reports — content spec (draft for review)
> Source of truth for what each report category will contain. Driven by
> the actual data we have in the schema; nothing here is aspirational
> data we'd need to start collecting. Once locked, this drives the
> builder implementations.
---
## Raw materials — data we already capture
The proposals below are bounded by what we already store. A quick map of
the load-bearing fields per entity:
### `interests` (the sales pipeline source of truth)
- `pipelineStage` — one of 7 canonical stages
- Per-stage timestamps: `dateFirstContact`, `dateLastContact`,
`dateEoiSent`, `dateEoiSigned`, `dateReservationSigned`,
`dateContractSent`, `dateContractSigned`, `dateDepositReceived`
- `outcome` (won/lost variants), `outcomeReason`, `outcomeAt`
- `source` (website/manual/referral/broker), `leadCategory`
(general/qualified/hot)
- `assignedTo` (rep), `clientId`, `yachtId`
- `depositExpectedAmount` + currency
- Per-doc status fields: `eoiDocStatus`, `reservationDocStatus`,
`contractDocStatus` (pending/sent/signed/declined/voided)
- `archivedAt`
### `interest_berths` (multi-berth pipeline)
- `is_primary`, `is_specific_interest`, `is_in_eoi_bundle`
- One interest can target N berths; status of those berths drives
"Under Offer" public flag
### `berths`
- `status` (available/under_offer/sold)
- `area`, `mooringNumber`
- `price`, `priceCurrency`
- `lengthFt/widthFt/draftFt` + metric counterparts
- `tenureType`, `tenureYears`, `tenureStartDate`, `tenureEndDate`
- `statusLastModified`, `statusLastChangedReason`
### `tenancies`
- `status` (pending/active/ended/cancelled)
- `startDate`, `endDate`, `tenureType`
- Links to `berthId`, `clientId`, `yachtId`, `interestId`
- `previousTenancyId` (chain), `transferredFromTenancyId`
### `clients`
- `nationalityIso`, `preferredContactMethod`, `source`
- `createdAt`, `archivedAt`
- `clientContacts` (email/phone/whatsapp values)
- `clientNotes`, `clientTags` (categorisation)
### `invoices` + `payments` + `expenses`
- Invoices: status (draft/sent/paid/overdue/cancelled), `total`,
`subtotal`, `currency`, `dueDate`, `paymentDate`, `paymentTerms`,
`kind` (general/deposit), linked `interestId`
- Payments: amounts, dates, method, linked invoice
- Expenses: `amount`, `amountUsd`, `category`, `paymentStatus`,
`expenseDate`, `establishmentName`, `payer`
### `documents` + `document_signers` + `document_events`
- Send timestamps, sign timestamps, status per signer
- Document type, template id
- Full event audit (sent/viewed/signed/declined per recipient)
- `signedFileId`, `currentPdfVersionId`
### `websiteSubmissions` (inquiry intake)
- Source page, UTM-style attribution columns, raw payload, conversion
state (linked to which interest / client / berth)
- `convertedAt`, `convertedToInterestId`
### `audit_logs`
- Every entity mutation with `action`, `actor`, `oldValue`, `newValue`,
`createdAt` — full timeline of who-changed-what
### Already-aggregated data (existing dashboard endpoints we can reuse)
- `/api/v1/dashboard/forecast` — revenue forecast by stage × probability
- `/api/v1/dashboard/pipeline` — count + value per stage
- `/api/v1/dashboard/hot-deals` — high-pulse deals
- `/api/v1/dashboard/tenancy-occupancy` — occupancy timeline by area
- `/api/v1/dashboard/tenancy-revenue` — recognised revenue by month
- `/api/v1/dashboard/tenancy-renewals` — upcoming renewals
- `/api/v1/dashboard/tenancy-tenure` — tenure distribution
- `/api/v1/dashboard/source-conversion` — funnel by source
- `/api/v1/dashboard/clients-by-country` — geographic distribution
- `/api/v1/dashboard/berth-status` — status mix
- `/api/v1/dashboard/berth-heat` — recommender heat scores
- `/api/v1/dashboard/activity` — activity feed
- `/api/v1/dashboard/kpis` — top-line numbers
---
## Cross-cutting capabilities (apply to every report)
- **Date range filter** — preset (last 7d / 30d / quarter / year / YTD)
plus custom range picker.
- **Period comparison** — toggle to show "this period vs prior period"
(same length window immediately before). Drives delta arrows on KPI
cards.
- **Rep / assignee filter** — multi-select. Defaults to "all". For
ports with one rep this is hidden.
- **Source filter** — multi-select on `source` (website / referral /
broker / manual). Defaults to "all".
- **Currency normalization** — money values render in port-default
currency; underlying records may be USD/EUR/etc., conversion already
exists on expenses and can be extended to invoices.
- **Empty state** — every report renders gracefully on a port with no
data yet (e.g. fresh deploys) with a "this report needs data first"
hint pointing at the right onboarding step.
---
## Report 01 — Sales performance ✅ LOCKED 2026-05-27
**Purpose:** answer "how is the sales team doing, who is doing the
work, where are deals stuck."
### KPI strip (7 tiles)
| # | Tile | Formula | Notes |
| --- | --------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | **Active interests** | `count(interests) WHERE archivedAt IS NULL AND outcome IS NULL` | All stages incl. nurturing |
| 2 | **Won this period** | `count(interests) WHERE outcome='won' AND outcomeAt IN range` | |
| 3 | **Lost this period** | `count(interests) WHERE outcome LIKE 'lost_%' OR outcome='cancelled' AND outcomeAt IN range` | **Breakdown chip:** `Lost: 8 (3 to competitor · 2 unqualified · 2 no-response · 1 cancelled)` |
| 4 | **Win rate** | `won / (won + lost_*) × 100%` — excludes `cancelled` | Render `—` when denom = 0. Period-over-period delta arrow when comparison toggle is on (`↑ +12pp`) |
| 5 | **Pipeline value** | `Σ ((berth.price OR depositExpectedAmount) × STAGE_WEIGHTS[stage])` for active interests | Berth price used when an `is_primary` interest_berth is set; else depositExpectedAmount; else 0. Currency normalised to port-default. Footnote: "X of Y interests have no value and aren't included." |
| 6 | **Avg time-to-close** | `median(outcomeAt - dateFirstContact)` for won deals in window | Adaptive unit: days (<60) / weeks (<24) / months. Skip interests with null `dateFirstContact`; footnote "based on N of M won deals." |
| 7 | **New leads** | `count(interests) WHERE createdAt IN range`**includes archived** | **Breakdown chip:** `New leads: 24 (10 website · 8 referral · 4 broker · 2 manual)` |
### Charts (5)
1. **Pipeline funnel** (echarts horizontal funnel)
- **Frame:** counts per stage, all 7 stages including `nurturing` as its own step
- **Active interests only** (`archivedAt IS NULL AND outcome IS NULL`)
- **Drop-off label** on each connector: `Enquiry 24 → Qualified 12 (50%)`
2. **Stage velocity** (recharts horizontal bar)
- Median days in each stage + faint p90 mark per bar
- Source: `audit_logs WHERE action='interest.stage_changed'` for transition timestamps
- Exclude stages with no exits yet (interests still sitting there)
3. **Win rate over time** (recharts line + faint area underlay)
- Line: win rate per bucket
- Underlay: total deals closed per bucket (gives volume context)
- **Bucket granularity (auto):** weekly ≤6mo · monthly ≤2yr · quarterly beyond
- Sparse buckets render as gaps, not zero
4. **Source → win conversion** (recharts stacked horizontal bar)
- One bar per source (website / referral / broker / manual)
- Segments coloured by outcome (won / lost-\* / cancelled / in-flight)
- PDF-friendly (no sankey)
5. **Rep leaderboard** (table with embedded mini-bars)
- Columns: rep · new · won · lost · in-flight · pipeline value · win rate · avg time-to-close
- Sortable by any numeric column
- **Single-rep collapse:** when only one rep has deals in the window, skip this chart and render the Rep performance detail (Table 1) directly
- **Attribution:** current `assignedTo` gets full credit; tooltip flags deals that were reassigned mid-cycle
### Deal heat section (between leaderboard and tables)
Folded-in pulse data from existing dashboard infrastructure.
- **Hot deals count** — KPI-style tile, count of interests above `pulse_label_hot` threshold
- **Pulse distribution** — 3-segment horizontal bar (hot / warm / cold counts)
- **Hottest deals right now** — top 5 by pulse score: client · stage · value · pulse · rep
### Tables (5)
1. **Rep performance detail** — leaderboard columns + expandable open-deals list per rep
- Open deals list columns: client · primary berth · stage · stage value · days in stage · last contact
- **Web:** collapsed by default, expand chevron
- **PDF:** always rendered inline (no expander affordance possible in print)
2. **Stalled deals** — active interests not contacted within stage-aware thresholds
- **Thresholds:** enquiry 21d · qualified 14d · nurturing 60d · eoi 10d · reservation 7d · deposit_paid 7d · contract 5d (admin-configurable later)
- Columns: client · stage · days since last contact · days in stage · value · rep · quick "log contact" button
- Sort: stage value desc (most valuable stalled deals first)
- **Null `dateLastContact`** → treat as never contacted → always stalled
3. **Closing this month** — late-stage active deals (`reservation` / `deposit_paid` / `contract`) sorted by stage value desc
- The inverse of stalled; the "don't drop these" list
- Same columns as stalled minus the "days since contact" column
4. **Recent wins** — last 5 won deals, celebratory strip
- Columns: client · primary berth · final value · days to close · rep
- Source: `interests WHERE outcome='won' ORDER BY outcomeAt DESC LIMIT 5`
5. **Lost-reason breakdown** — detail of the KPI 3 chip
- Columns: outcome reason · count · total value lost · avg days from first contact to loss
- Source: group `interests WHERE outcome LIKE 'lost_%' OR outcome='cancelled'` AND outcomeAt IN range by `outcome`
### Filters
- **Cross-cutting** (every report): date range preset/custom, period comparison toggle, rep multi-select (hidden when 1 rep), source multi-select (hidden when 1 source)
- **Sales-specific:**
- **Stage filter** — restrict funnel + tables to subset of stages
- **Lead category filter** — general / qualified / hot
- **Outcome filter** — won / each lost-reason variant (mostly for the lost-reason breakdown post-mortem)
### Currency handling
- All monetary values render in port-default currency (per branding settings)
- Underlying records can be in any currency; convert at render time
- Render with thousand-separator + currency symbol (e.g. `€1,250,000`)
---
## Report 02 — Financial
**Purpose:** answer "what revenue did we collect, what's outstanding,
where is the cash flow going."
### KPI strip
| Metric | Source | Notes |
| ----------------------------- | ---------------------------------------------------------------------------------- | --------------------------------- |
| Revenue collected | Σ `invoices.total WHERE paymentStatus='paid' AND paymentDate IN range` | Sum across currencies, normalised |
| Pipeline (forecasted revenue) | Existing dashboard `forecast` endpoint | Σ deposit_expected × stage weight |
| Deposits collected | Σ `invoices.total WHERE kind='deposit' AND status='paid' AND paymentDate IN range` | |
| Outstanding AR | Σ `invoices.total WHERE status IN ('sent','overdue') AND archivedAt IS NULL` | |
| Overdue AR | Σ above filtered to `dueDate < today` | |
| Expenses (period) | Σ `expenses.amountUsd WHERE expenseDate IN range AND archivedAt IS NULL` | USD-normalised |
| Net contribution | revenue - expenses | Optional |
### Charts
1. **Revenue by month** (bar chart) — Stacked by `kind` (general vs
deposit). 12 months trailing window default.
2. **Revenue by quarter / year** (toggleable granularity) — Same data,
different bucket.
3. **Funnel: EOI → Deposit → Contract → Revenue** (funnel chart,
echarts) — Counts at each stage in the period to highlight leakage.
4. **AR aging** (stacked horizontal bar) — Buckets: current, 1-30,
31-60, 61-90, 90+. Per bucket: count + total value.
5. **Cash flow** (line chart, two series) — Inflow (payments received)
and outflow (expenses paid) over time.
6. **Expense breakdown** (donut) — By `category` for the period.
### Tables
1. **Outstanding invoices** — Invoice #, client, due date, days
overdue, amount, payment terms. Sort by overdue desc.
2. **Recent payments** — Date, invoice, client, amount, method.
3. **Refund / write-off log** — Cancelled invoices with reasons.
4. **Expense ledger** — Date, payer, category, amount, payment status,
linked trip.
### Filters
- Invoice kind (deposit / general)
- Payment status
- Currency
- Billing entity type (client / company)
---
## Report 03 — Marketing & funnel
**Purpose:** answer "where are leads coming from, which sources are
worth the marketing spend, where do we lose people in the funnel."
### KPI strip
| Metric | Source | Notes |
| -------------------------------- | ------------------------------------------------------------------- | ---------------------- |
| Inquiries this period | `count(websiteSubmissions WHERE createdAt IN range)` | |
| Inquiries → interest conversion | `count(websiteSubmissions WHERE convertedAt IN range) / count(...)` | % |
| Inquiries → EOI conversion | Same with `interest.dateEoiSent NOT NULL` | |
| Inquiries → won conversion | Same with `interest.outcome='won'` | |
| Top source | `source` with highest converted count | Card with name + count |
| Avg time inquiry → first contact | Median(`interest.dateFirstContact - websiteSubmission.createdAt`) | Hrs / days |
### Charts
1. **Inquiries by source** (donut + bar) — Count per source for the
period.
2. **Source ROI** (stacked horizontal bar) — Per source: total count,
won count, won value. Sort by value desc.
3. **Funnel: Inquiry → Qualified → EOI → Reservation → Won** (vertical
funnel) — Conversion at each stage.
4. **Conversion trend** (line chart) — Inquiry → won conversion %
plotted weekly.
5. **Country of origin** (geo map via `react-simple-maps`, already
approved) — Inquiries by `nationalityIso` of resulting client.
6. **Time-to-respond histogram** — Buckets of "minutes from inquiry to
first contact." Highlights slow response times.
### Tables
1. **Top-converting sources** — Source, count, win rate, total revenue,
avg time-to-close.
2. **Recent inquiries** — Date, source, name, mooring, status (open /
converted / discarded), rep.
3. **Stuck inquiries** — Submitted >X days ago, not yet contacted.
### Filters
- Specific source (drill-down)
- Mooring (which berth pages drive conversion)
- UTM campaign (if/when we add UTM tracking — currently only `source`)
---
## Report 04 — Operational ✅ LOCKED 2026-05-27
**Purpose:** answer "how full are we, how long do tenancies last,
where are operational bottlenecks (signing, occupancy turnover)."
**Conditional behaviour:** half this report (tenancy charts + KPIs)
depends on `tenancies_module_enabled = true`. When the module is off,
those tiles render `—` with a "Tenancies module disabled" hint and
the tenancy charts/tables are omitted entirely (replaced with a
single "Enable tenancies in System Settings to populate this section"
banner).
### KPI strip (7 tiles; some auto-hide)
| # | Tile | Formula | Notes |
| --- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | **Total berths** | `count(berths) WHERE archivedAt IS NULL` | Physical inventory |
| 2 | **Sold %** | `count(status='sold') / total × 100%` | Period-over-period delta computed from `audit_logs` (entity_type='berth', action='status_changed'). All historical changes incl. accidental/manual ones are reflected — the audit log is the truth source |
| 3 | **Under offer %** | Live compute from `interest_berths`: any berth with an active `is_specific_interest=true` link whose interest has open outcome | Quality-first source; catches drift where `berths.status` column lags the link table |
| 4 | **Active tenancies** | `count(berth_tenancies) WHERE status='active'` | Module-OFF → `—` |
| 5 | **Avg tenancy length** | `median(endDate - startDate)` for `status='ended'` tenancies, in years (1 decimal) | Module-OFF → `—`. Need ≥3 ended tenancies for meaningful median; otherwise `—` with hint |
| 6 | **Signing turnaround (per type)** | `median(document.completedAt - document.sentAt)` per document type | Three small stats in one tile: `EOI 4.2d · Reservation 6.8d · Contract 12.4d`. Excludes voided + declined |
| 7 | **Berths in conflict** | `count(berths WHERE >1 active interest has is_specific_interest=true)` | **Hidden when 0**; appears (and reads red) when ≥1 conflict — the "two clients want the same berth" alarm |
### Charts (7)
1. **Berth utilisation timeline** (echarts heatmap)
- Grid: `area × month`; cell colour = % occupied (sold + under-offer) in that area that month
- **Range:** user-pickable, default trailing 24 months
- Reuses `audit_logs` reconstruction (same engine as KPI 2)
2. **Status mix over time** (recharts stacked area, with **toggle**)
- Two views: proportional (100%-stacked) AND absolute counts
- Toggle button on the chart switches between them
- 3 series: available / under_offer / sold
3. **Tenancy churn waterfall** _(module ON)_ (echarts waterfall)
- Per bucket: `+ new active`, ` ended`, `= net Δ`
- **Bucket: auto-pick** — monthly if avg >2 events/month, else quarterly
4. **Tenure distribution** _(module ON)_ (recharts histogram bar)
- Marina-tuned buckets: `<1y` / `15y` / `510y` / `1020y` / `20y+`
- Ended tenancies only (active ones have no end date yet)
5. **Signing turnaround box plot** (echarts)
- One box per document type (EOI / Reservation / Contract)
- Median + quartiles + whiskers + outlier dots
- Excludes voided + declined
6. **Occupancy by area** (recharts stacked horizontal bar)
- One bar per area; segments coloured sold / under_offer / available
- Scales cleanly to 10+ areas (vs donut-per-area which doesn't)
7. **Documents in pipeline** (recharts stacked bar)
- Per document type, count by current status (`pending` / `sent` / `signed` / `declined` / `voided`)
- Spots stuck batches at a glance
### Tables (4)
1. **Tenancies ending soon** _(module ON)_
- Window: **next 6 months** (default)
- Columns: client · berth · tenure type · end date · days until end · quick action (renew / end)
- Sort: `endDate` asc
2. **Berths with no current owner**
- Threshold: available for **>60 days**
- Columns: mooring · area · dimensions · price · days available · last viewed date (from public berth-page analytics if available)
3. **Stuck signing**
- **Document-type-aware thresholds:** EOI >10d / Reservation >7d / Contract >5d
- Columns: document type · client · sent date · days outstanding · next signer · resend button
4. **Highest-value vacant berths**
- Available berths sorted by `price` desc
- Columns: mooring · area · dimensions · price · days available
- Sales-focus list
### Filters
- **Cross-cutting** (auto-hidden when not relevant): date range + comparison toggle + rep + source
- **Operational-specific:**
- **Berth area** — multi-select; restricts heatmap + tables
- **Tenure type** — permanent / fixed-term (affects tenancy charts + ending-soon table)
- **Document type** — EOI / Reservation / Contract (affects signing chart + stuck-signing)
- **Status filter** — for the heatmap/status-mix views: which statuses to display
### Currency handling
- All berth prices render in port-default currency
- Underlying records can be in any currency; convert at render time
- Render with thousand-separator + currency symbol
---
## Report 05 — Custom (ad-hoc composer)
**Purpose:** answer questions the canonical reports don't cover.
### Composition surface
1. **Pick an entity** (one): Clients, Yachts, Companies, Interests,
Berths, Tenancies, Invoices, Expenses, Documents,
Website Submissions.
2. **Pick columns** — checkbox list of available columns for that
entity, with sensible defaults pre-checked. Includes computed
columns where they exist (e.g. `daysOverdue` on invoices).
3. **Add filters** — one row per filter; each row: column → operator
(=, ≠, in, contains, > <, between, is null) → value picker
appropriate to the column type. AND/OR between rows.
4. **Group by** (optional single dimension) — column from the entity.
5. **Sort** — column + direction.
6. **Aggregate** (when group-by is set) — count, sum, avg, min, max
on each numeric column.
7. **Live preview** — first 50 rows render as you build, server query
re-runs on debounced change.
8. **Save** — three buttons:
- **Run once** — generate the report and add to library, no
template saved.
- **Save as template** — name + scope (personal / port-wide).
- **Update existing template** — only visible if you opened from a
template.
### Permissions
- Column whitelist per entity per role. A rep without
`clients.view_pii` cannot pick `email` or `phone` columns. Same
enforcement on the server-side row filter.
- Filtering is always tenant-scoped via `port_id` (defense in depth).
### Output
- Same export buttons (PDF / CSV / Excel) as canonical reports.
- PDF treatment uses the standard branded shell.
---
## Templates system
Applies to all 5 categories.
### Lifecycle
1. **Open a builder** — defaults to "Untitled" config.
2. **Modify any filter / column / range** — header shows "Modified ●"
indicator.
3. **Save** — three options:
- Overwrite the loaded template (if any).
- Save as new (prompts for name + scope).
- Discard changes.
4. **Templates page** — list of all templates, per-template actions:
open, run, schedule, share, archive.
### Scope
- **Personal** — visible only to creator. Can be promoted to port-wide
later.
- **Port-wide** — visible to all reps in the port; editable only by
admins. "Owned by" name shown.
### Storage
- `report_templates` table already exists (per `schema/reports.ts`),
audit to confirm shape matches the lifecycle above.
---
## Schedules
### Schedule object
- `templateId` — the report to run
- `cron` expression OR friendly cadence (daily 9am, weekly Mondays,
monthly 1st)
- `emailEnabled` — boolean. When true, fires email; when false, only
drops into runs library.
- `recipients` — array of email addresses (only used when
`emailEnabled`)
- `format` — pdf / csv / xlsx — what to attach to the email
- `lastRunAt`, `nextRunAt`, `lastResult` (success / failure)
### Worker
- BullMQ recurring job already exists in the stack; one queue
`report-runs` does both on-demand and scheduled runs.
- Failure surface: email the schedule creator on first failure (with
short error), backoff retry once, mark `lastResult='failure'`.
---
## Open questions for the user
1. **AR aging buckets.** Do we use 30-day buckets or 14-day buckets?
30 is industry standard; 14 catches issues earlier.
2. **Currency normalisation for revenue.** USD or EUR as default? Or
the port's `branding_default_currency`?
3. **Sales rep visibility.** Should a rep see ONLY their own metrics
on Sales Performance by default (with admins seeing the full
leaderboard), or always the full team?
4. **Inquiry → interest auto-link rule.** We've got `convertedAt` on
`websiteSubmissions` and `sourceInquiryId` on `clients`. Is every
conversion captured today, or are some manual links missed (which
would skew the marketing report)?
5. **"Pulse" / heat data.** Should the Sales report surface the deal
pulse metric, or is that a separate "Deal Pulse" report?
6. **Geographic chart.** The `react-simple-maps` library is approved
(per memory). Are we OK to use it for the Marketing country chart,
or is that scope creep?
7. **Custom builder entity scope.** All 10 entities above, or start
with the 4 sales-core ones (Clients, Yachts, Interests, Berths)
and expand later?