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

28 KiB
Raw Blame History

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