# 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` / `1–5y` / `5–10y` / `10–20y` / `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?
