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>
28 KiB
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,outcomeAtsource(website/manual/referral/broker),leadCategory(general/qualified/hot)assignedTo(rep),clientId,yachtIddepositExpectedAmount+ 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,mooringNumberprice,priceCurrencylengthFt/widthFt/draftFt+ metric counterpartstenureType,tenureYears,tenureStartDate,tenureEndDatestatusLastModified,statusLastChangedReason
tenancies
status(pending/active/ended/cancelled)startDate,endDate,tenureType- Links to
berthId,clientId,yachtId,interestId previousTenancyId(chain),transferredFromTenancyId
clients
nationalityIso,preferredContactMethod,sourcecreatedAt,archivedAtclientContacts(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), linkedinterestId - 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)
-
Pipeline funnel (echarts horizontal funnel)
- Frame: counts per stage, all 7 stages including
nurturingas its own step - Active interests only (
archivedAt IS NULL AND outcome IS NULL) - Drop-off label on each connector:
Enquiry 24 → Qualified 12 (−50%)
- Frame: counts per stage, all 7 stages including
-
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)
-
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
-
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)
-
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
assignedTogets 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_hotthreshold - 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)
-
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)
-
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
-
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
-
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
-
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 byoutcome
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
- Revenue by month (bar chart) — Stacked by
kind(general vs deposit). 12 months trailing window default. - Revenue by quarter / year (toggleable granularity) — Same data, different bucket.
- Funnel: EOI → Deposit → Contract → Revenue (funnel chart, echarts) — Counts at each stage in the period to highlight leakage.
- AR aging (stacked horizontal bar) — Buckets: current, 1-30, 31-60, 61-90, 90+. Per bucket: count + total value.
- Cash flow (line chart, two series) — Inflow (payments received) and outflow (expenses paid) over time.
- Expense breakdown (donut) — By
categoryfor the period.
Tables
- Outstanding invoices — Invoice #, client, due date, days overdue, amount, payment terms. Sort by overdue desc.
- Recent payments — Date, invoice, client, amount, method.
- Refund / write-off log — Cancelled invoices with reasons.
- 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
- Inquiries by source (donut + bar) — Count per source for the period.
- Source ROI (stacked horizontal bar) — Per source: total count, won count, won value. Sort by value desc.
- Funnel: Inquiry → Qualified → EOI → Reservation → Won (vertical funnel) — Conversion at each stage.
- Conversion trend (line chart) — Inquiry → won conversion % plotted weekly.
- Country of origin (geo map via
react-simple-maps, already approved) — Inquiries bynationalityIsoof resulting client. - Time-to-respond histogram — Buckets of "minutes from inquiry to first contact." Highlights slow response times.
Tables
- Top-converting sources — Source, count, win rate, total revenue, avg time-to-close.
- Recent inquiries — Date, source, name, mooring, status (open / converted / discarded), rep.
- 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)
-
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_logsreconstruction (same engine as KPI 2)
- Grid:
-
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
-
Tenancy churn waterfall (module ON) (echarts waterfall)
- Per bucket:
+ new active,− ended,= net Δ - Bucket: auto-pick — monthly if avg >2 events/month, else quarterly
- Per bucket:
-
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)
- Marina-tuned buckets:
-
Signing turnaround box plot (echarts)
- One box per document type (EOI / Reservation / Contract)
- Median + quartiles + whiskers + outlier dots
- Excludes voided + declined
-
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)
-
Documents in pipeline (recharts stacked bar)
- Per document type, count by current status (
pending/sent/signed/declined/voided) - Spots stuck batches at a glance
- Per document type, count by current status (
Tables (4)
-
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:
endDateasc
-
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)
-
Stuck signing
- Document-type-aware thresholds: EOI >10d / Reservation >7d / Contract >5d
- Columns: document type · client · sent date · days outstanding · next signer · resend button
-
Highest-value vacant berths
- Available berths sorted by
pricedesc - Columns: mooring · area · dimensions · price · days available
- Sales-focus list
- Available berths sorted by
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
- Pick an entity (one): Clients, Yachts, Companies, Interests, Berths, Tenancies, Invoices, Expenses, Documents, Website Submissions.
- Pick columns — checkbox list of available columns for that
entity, with sensible defaults pre-checked. Includes computed
columns where they exist (e.g.
daysOverdueon invoices). - 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.
- Group by (optional single dimension) — column from the entity.
- Sort — column + direction.
- Aggregate (when group-by is set) — count, sum, avg, min, max on each numeric column.
- Live preview — first 50 rows render as you build, server query re-runs on debounced change.
- 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_piicannot pickemailorphonecolumns. 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
- Open a builder — defaults to "Untitled" config.
- Modify any filter / column / range — header shows "Modified ●" indicator.
- Save — three options:
- Overwrite the loaded template (if any).
- Save as new (prompts for name + scope).
- Discard changes.
- 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_templatestable already exists (perschema/reports.ts), audit to confirm shape matches the lifecycle above.
Schedules
Schedule object
templateId— the report to runcronexpression 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 whenemailEnabled)format— pdf / csv / xlsx — what to attach to the emaillastRunAt,nextRunAt,lastResult(success / failure)
Worker
- BullMQ recurring job already exists in the stack; one queue
report-runsdoes 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
- AR aging buckets. Do we use 30-day buckets or 14-day buckets? 30 is industry standard; 14 catches issues earlier.
- Currency normalisation for revenue. USD or EUR as default? Or
the port's
branding_default_currency? - 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?
- Inquiry → interest auto-link rule. We've got
convertedAtonwebsiteSubmissionsandsourceInquiryIdonclients. Is every conversion captured today, or are some manual links missed (which would skew the marketing report)? - "Pulse" / heat data. Should the Sales report surface the deal pulse metric, or is that a separate "Deal Pulse" report?
- Geographic chart. The
react-simple-mapslibrary is approved (per memory). Are we OK to use it for the Marketing country chart, or is that scope creep? - Custom builder entity scope. All 10 entities above, or start with the 4 sales-core ones (Clients, Yachts, Interests, Berths) and expand later?