# 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?