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>
This commit is contained in:
2026-05-27 22:41:53 +02:00
parent 909dd44605
commit 3bdf59e917
41 changed files with 10704 additions and 203 deletions

View File

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

View File

@@ -1,60 +1,155 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import type { Route } from 'next';
import { ArrowLeft } from 'lucide-react';
import { ArrowRight, ChevronLeft, Wrench } from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DashboardReportBuilder } from '@/components/reports/builders/dashboard-report-builder';
import { SimpleReportBuilder } from '@/components/reports/builders/simple-report-builder';
const KINDS = ['dashboard', 'clients', 'berths', 'interests'] as const;
type Kind = (typeof KINDS)[number];
/**
* Two generations of report kinds live here:
*
* - LEGACY_KINDS: the original 2026-Q1 builders (dashboard, clients,
* berths, interests). Functional today via the existing
* SimpleReportBuilder / DashboardReportBuilder.
* - NEW_KINDS: the four canonical categories from the 2026-05-27 launch
* initiative (sales, financial, marketing, operational), plus the
* custom ad-hoc composer. Each currently renders a placeholder so
* the new landing page routes here without 404-ing; the actual
* builders ship per the launch-readiness doc.
*/
const LEGACY_KINDS = ['dashboard', 'clients', 'berths', 'interests'] as const;
const NEW_KINDS = ['sales', 'financial', 'marketing', 'operational', 'custom'] as const;
type LegacyKind = (typeof LEGACY_KINDS)[number];
type NewKind = (typeof NEW_KINDS)[number];
interface PageProps {
params: Promise<{ portSlug: string; kind: string }>;
searchParams: Promise<{ from?: string; to?: string }>;
}
const KIND_LABELS: Record<Kind, { title: string; description: string }> = {
const LEGACY_LABELS: Record<LegacyKind, { title: string; description: string }> = {
dashboard: {
title: 'Dashboard report',
description: 'Multi-section PDF of the port dashboard pick which sections to include.',
description: 'Multi-section PDF of the port dashboard - pick which sections to include.',
},
clients: { title: 'Clients report', description: 'Activity snapshot for active clients.' },
berths: { title: 'Berths report', description: 'Occupancy + status mix per berth.' },
interests: { title: 'Interests report', description: 'Pipeline value + stage distribution.' },
};
const NEW_LABELS: Record<NewKind, { title: string; description: string }> = {
sales: {
title: 'Sales performance',
description: 'Rep leaderboards, win rates, time-to-close, stalled deals, conversion funnel.',
},
financial: {
title: 'Financial',
description: 'Revenue by month, deposits collected, AR aging, EOI to revenue conversion.',
},
marketing: {
title: 'Marketing & funnel',
description: 'Lead source ROI, inquiry-to-EOI conversion, attribution by campaign.',
},
operational: {
title: 'Operational',
description: 'Berth utilisation, occupancy heatmap, tenancy churn, signing turnaround.',
},
custom: {
title: 'Custom report',
description:
'Compose your own. Pick an entity, choose columns and filters, group by any dimension.',
},
};
export default async function ReportBuilderPage({ params, searchParams }: PageProps) {
const { portSlug, kind } = await params;
const { from, to } = await searchParams;
if (!(KINDS as readonly string[]).includes(kind)) notFound();
const typedKind = kind as Kind;
const labels = KIND_LABELS[typedKind];
const isLegacy = (LEGACY_KINDS as readonly string[]).includes(kind);
const isNew = (NEW_KINDS as readonly string[]).includes(kind);
if (!isLegacy && !isNew) notFound();
if (isLegacy) {
const typedKind = kind as LegacyKind;
const labels = LEGACY_LABELS[typedKind];
return (
<div className="space-y-4">
<PageHeader eyebrow="Reports" title={labels.title} description={labels.description} />
{typedKind === 'dashboard' ? (
<DashboardReportBuilder portSlug={portSlug} initialFrom={from} initialTo={to} />
) : (
<SimpleReportBuilder portSlug={portSlug} kind={typedKind} />
)}
</div>
);
}
// New-kind placeholder. Uses the standard PageHeader + Card pattern so
// it reads as part of the same app while the actual builders ship.
const typedKind = kind as NewKind;
const labels = NEW_LABELS[typedKind];
return (
<div className="space-y-4">
<PageHeader
eyebrow="Reports"
title={labels.title}
description={labels.description}
actions={
<Button asChild variant="outline" size="sm">
<Link href={`/${portSlug}/reports` as Route}>
<ArrowLeft className="mr-1.5 h-4 w-4" aria-hidden />
All reports
</Link>
</Button>
}
/>
<PageHeader eyebrow="Reports" title={labels.title} description={labels.description} />
{typedKind === 'dashboard' ? (
<DashboardReportBuilder portSlug={portSlug} initialFrom={from} initialTo={to} />
) : (
<SimpleReportBuilder portSlug={portSlug} kind={typedKind} />
)}
<Card>
<CardHeader className="flex flex-row items-start gap-3 space-y-0">
<Wrench className="h-5 w-5 mt-0.5 text-muted-foreground" aria-hidden />
<div className="flex-1">
<CardTitle className="text-base">Builder in development</CardTitle>
<CardDescription className="mt-1">
The {labels.title.toLowerCase()} builder is shipping as part of the active launch
initiative. In the meantime the legacy builders below cover most of the same data.
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm">
<Link href={`/${portSlug}/reports` as Route}>
<ChevronLeft className="mr-1.5 h-4 w-4" aria-hidden />
Back to reports
</Link>
</Button>
<Button asChild size="sm">
<Link href={`/${portSlug}/reports/dashboard` as Route}>
Open dashboard report
<ArrowRight className="ml-1.5 h-4 w-4" aria-hidden />
</Link>
</Button>
</div>
</CardContent>
</Card>
<section className="space-y-3">
<div>
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Legacy builders
</h2>
<p className="text-xs text-muted-foreground/80">
Available now while the new category builders are filled in.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{(LEGACY_KINDS as readonly LegacyKind[]).map((k) => (
<Link key={k} href={`/${portSlug}/reports/${k}` as Route} className="block group">
<Card className="h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30">
<CardHeader className="pb-2">
<CardTitle className="text-base">{LEGACY_LABELS[k].title}</CardTitle>
</CardHeader>
<CardContent>
<CardDescription>{LEGACY_LABELS[k].description}</CardDescription>
</CardContent>
</Card>
</Link>
))}
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { CustomReportBuilder } from '@/components/reports/custom/custom-report-builder';
export const dynamic = 'force-dynamic';
/**
* Custom (ad-hoc) report builder. Sibling of the dynamic [kind] route
* so this page wins over the placeholder for /reports/custom.
*
* v1 ships 4 entities: clients / interests / berths / tenancies.
* Additional entities (companies, yachts, invoices, payments, deals,
* sends) layer in via `src/lib/reports/custom/registry.ts` without
* touching this page.
*/
export default async function CustomReportPage({
params,
}: {
params: Promise<{ portSlug: string }>;
}) {
const { portSlug } = await params;
return <CustomReportBuilder portSlug={portSlug} />;
}

View File

@@ -0,0 +1,19 @@
import { OperationalReportClient } from '@/components/reports/operational/operational-report-client';
export const dynamic = 'force-dynamic';
/**
* Operational report.
*
* Sibling of the dynamic [kind] route so this page wins for
* /reports/operational specifically. Spec lives in
* docs/reports-content-spec.md § Report 04.
*/
export default async function OperationalReportPage({
params,
}: {
params: Promise<{ portSlug: string }>;
}) {
const { portSlug } = await params;
return <OperationalReportClient portSlug={portSlug} />;
}

View File

@@ -1,76 +1,147 @@
import Link from 'next/link';
import type { Route } from 'next';
import { ArrowRight, BarChart3, Calendar, Clock, FileText, Layers, Users } from 'lucide-react';
import {
BookOpen,
Calendar,
Clock,
DollarSign,
Layers,
Megaphone,
Sparkles,
TrendingUp,
} from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ReportsPageClient } from '@/components/reports/reports-page-client';
import { cn } from '@/lib/utils';
interface PageProps {
params: Promise<{ portSlug: string }>;
}
interface KindCard {
kind: 'dashboard' | 'clients' | 'berths' | 'interests';
title: string;
description: string;
icon: typeof BarChart3;
}
const KINDS: KindCard[] = [
{
kind: 'dashboard',
title: 'Dashboard report',
description:
'Multi-section PDF of the port dashboard — pipeline funnel, occupancy timeline, KPIs, lead sources.',
icon: BarChart3,
},
{
kind: 'clients',
title: 'Clients report',
description: 'Activity snapshot across every active client in a date window.',
icon: Users,
},
{
kind: 'berths',
title: 'Berths report',
description: 'Occupancy + status mix for every berth across the requested window.',
icon: Layers,
},
{
kind: 'interests',
title: 'Interests report',
description: 'Pipeline value + stage distribution for every interest.',
icon: FileText,
},
];
const SUB_PAGES: Array<{
href: string;
label: string;
description: string;
icon: typeof BarChart3;
}> = [
icon: typeof TrendingUp;
}
/**
* Five entry points - four canonical categories from the launch
* initiative (sales / financial / marketing / operational) plus the
* ad-hoc custom composer. Rendered with the same CardHeader +
* CardDescription pattern as the admin sections browser so this surface
* reads as part of the same app.
*/
const KIND_CARDS: KindCard[] = [
{
href: 'sales',
label: 'Sales performance',
description:
'Rep leaderboards, win rates, average time-to-close, stalled deals, conversion funnel by stage.',
icon: TrendingUp,
},
{
href: 'financial',
label: 'Financial',
description: 'Revenue by month, deposits collected, AR aging, EOI to revenue conversion.',
icon: DollarSign,
},
{
href: 'marketing',
label: 'Marketing & funnel',
description:
'Lead source ROI, inquiry-to-EOI conversion, attribution by campaign, lead reports.',
icon: Megaphone,
},
{
href: 'operational',
label: 'Operational',
description:
'Berth utilisation timeline, occupancy heatmap, tenancy churn, signing turnaround.',
icon: Layers,
},
{
href: 'custom',
label: 'Custom report',
description:
'Build your own: pick an entity, choose columns and filters, group by any dimension, save as a template.',
icon: Sparkles,
},
];
interface LibraryCard {
href: string;
label: string;
description: string;
icon: typeof Calendar;
}
const LIBRARY_CARDS: LibraryCard[] = [
{
href: '/reports/templates',
label: 'Templates',
description: 'Saved configurations reps can re-run with one click.',
icon: Layers,
description: 'Saved configurations. Modify, re-run, or save as a new template.',
icon: BookOpen,
},
{
href: '/reports/runs',
label: 'Runs',
description: 'Every report you have generated, with re-run and re-email links.',
description: 'Every report generated, with re-run and re-send.',
icon: Clock,
},
{
href: '/reports/schedules',
label: 'Schedules',
description: 'Recurring reports that auto-email to your recipient list.',
description: 'Recurring runs. Email delivery is optional per schedule.',
icon: Calendar,
},
];
interface SectionCardProps {
href: string;
label: string;
description: string;
icon: typeof TrendingUp;
/** Optional small uppercase label rendered above the title, mirroring
* the admin-sections-browser pattern. */
eyebrow?: string;
}
/**
* Matches the SectionCard pattern used on the Administration landing
* page so cards across the app share one visual + interactive idiom.
* Don't restyle this independently - if the admin card style changes,
* propagate here.
*/
function ReportSectionCard({ href, label, description, icon: Icon, eyebrow }: SectionCardProps) {
return (
<Link href={href as Route} className="block group">
<Card
className={cn(
'h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30',
)}
>
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
<Icon
className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary"
aria-hidden
/>
<div className="flex-1">
<CardTitle className="text-base">{label}</CardTitle>
{eyebrow ? (
<p className="text-xs uppercase tracking-wider text-muted-foreground">{eyebrow}</p>
) : null}
</div>
</CardHeader>
<CardContent>
<CardDescription>{description}</CardDescription>
</CardContent>
</Card>
</Link>
);
}
export default async function ReportsLandingPage({ params }: PageProps) {
const { portSlug } = await params;
@@ -78,85 +149,51 @@ export default async function ReportsLandingPage({ params }: PageProps) {
<div className="space-y-6">
<PageHeader
title="Reports"
description="Generate port reports as PDF — on-demand or on a recurring schedule."
description="Generate curated and ad-hoc reports as PDF, CSV, or Excel. Schedule recurring runs with optional email delivery."
/>
<section>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Build a new report
</h2>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{KINDS.map((k) => {
const Icon = k.icon;
return (
<Link
key={k.kind}
href={`/${portSlug}/reports/${k.kind}` as Route}
className="group rounded-lg border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-accent/40"
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-md bg-primary/10 text-primary">
<Icon className="h-4 w-4" aria-hidden />
</div>
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium">{k.title}</h3>
<ArrowRight
className="h-3.5 w-3.5 text-muted-foreground transition-transform group-hover:translate-x-0.5"
aria-hidden
/>
</div>
<p className="mt-1 text-xs text-muted-foreground">{k.description}</p>
</Link>
);
})}
<section className="space-y-3">
<div>
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Compose a report
</h2>
<p className="text-xs text-muted-foreground/80">
Four canonical categories plus an ad-hoc composer for anything else.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{KIND_CARDS.map((k) => (
<ReportSectionCard
key={k.href}
href={`/${portSlug}/reports/${k.href}`}
label={k.label}
description={k.description}
icon={k.icon}
/>
))}
</div>
</section>
<section>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Library
</h2>
<div className="grid gap-3 md:grid-cols-3">
{SUB_PAGES.map((s) => {
const Icon = s.icon;
return (
<Link
key={s.href}
href={`/${portSlug}${s.href}` as Route}
className="group rounded-lg border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-accent/40"
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-md bg-muted text-muted-foreground">
<Icon className="h-4 w-4" aria-hidden />
</div>
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium">{s.label}</h3>
<ArrowRight
className="h-3.5 w-3.5 text-muted-foreground transition-transform group-hover:translate-x-0.5"
aria-hidden
/>
</div>
<p className="mt-1 text-xs text-muted-foreground">{s.description}</p>
</Link>
);
})}
<section className="space-y-3">
<div>
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Library
</h2>
<p className="text-xs text-muted-foreground/80">
Saved templates, generated runs, and recurring schedules. Re-run anything in one click.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{LIBRARY_CARDS.map((l) => (
<ReportSectionCard
key={l.href}
href={`/${portSlug}${l.href}`}
label={l.label}
description={l.description}
icon={l.icon}
/>
))}
</div>
</section>
<section>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Legacy library
</h2>
<Card>
<CardHeader>
<CardTitle className="text-sm">Older reports + ad-hoc generator</CardTitle>
<CardDescription>
Pre-P4 reports surface. Stays available so historical PDFs are still downloadable
while the new template / run / schedule surfaces fill in.
</CardDescription>
</CardHeader>
<CardContent>
<ReportsPageClient />
</CardContent>
</Card>
</section>
</div>
);

View File

@@ -0,0 +1,19 @@
import { SalesReportClient } from '@/components/reports/sales/sales-report-client';
export const dynamic = 'force-dynamic';
/**
* Sales Performance report.
*
* Sibling of the dynamic [kind] route so the page wins over the
* placeholder for /reports/sales specifically. Spec lives in
* docs/reports-content-spec.md § Report 01.
*/
export default async function SalesReportPage({
params,
}: {
params: Promise<{ portSlug: string }>;
}) {
const { portSlug } = await params;
return <SalesReportClient portSlug={portSlug} />;
}

View File

@@ -0,0 +1,97 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { ENTITY_KEYS, ENTITY_REGISTRY, type EntityKey } from '@/lib/reports/custom/registry';
/**
* POST /api/v1/reports/custom/run
*
* Executes a custom-report query and returns raw rows. The UI calls
* this with the entity + selected columns + optional date range; the
* service resolves the column allowlist and runs the underlying
* Drizzle query.
*
* Permission: `reports.export` — same gate as the saved-template
* endpoints (anyone who can export reports can run a custom slice).
*
* The handler returns JSON `{ data: rows[] }` rather than streaming
* CSV — the client serializes via the existing CSV exporter so all
* download formats (CSV/XLSX/PDF) reuse one code path.
*/
const bodySchema = z.object({
entity: z.enum(ENTITY_KEYS),
columns: z.array(z.string().min(1)).min(1).max(50),
from: z.string().datetime().optional(),
to: z.string().datetime().optional(),
});
export const POST = withAuth(
withPermission('reports', 'export', async (req, ctx) => {
try {
const body = await parseBody(req, bodySchema);
const def = ENTITY_REGISTRY[body.entity as EntityKey];
// Cross-validate columns against the registry's allowlist.
const allowedKeys = new Set(def.columns.map((c) => c.key));
const requested = body.columns.filter((k) => allowedKeys.has(k));
if (requested.length === 0) {
return NextResponse.json(
{ error: `No valid columns selected for entity "${body.entity}"` },
{ status: 400 },
);
}
const filter = {
from: body.from ? new Date(body.from) : undefined,
to: body.to ? new Date(body.to) : undefined,
};
const rows = await def.runQuery({
portId: ctx.portId,
columns: requested,
filter,
});
// Money columns travel with a hidden sibling currency column so the
// client formatter can render `€1,234,567` instead of bare numbers
// even when the user didn't tick the currency column for display.
// The sibling is stripped from the meta-column list below (so the
// table doesn't render it twice) but survives in the row payload.
const MONEY_SIBLINGS: Record<string, string> = {
price: 'priceCurrency',
depositExpectedAmount: 'depositExpectedCurrency',
};
const siblingsToAttach = new Set<string>();
for (const k of requested) {
const sib = MONEY_SIBLINGS[k];
if (sib && allowedKeys.has(sib)) siblingsToAttach.add(sib);
}
const projectionKeys = [...requested, ...siblingsToAttach].filter(
(k, idx, arr) => arr.indexOf(k) === idx,
);
const projected = rows.map((row) => {
const out: Record<string, unknown> = {};
for (const k of projectionKeys) out[k] = row[k];
return out;
});
return NextResponse.json({
data: projected,
meta: {
entity: body.entity,
columns: requested.map((k) => ({
key: k,
label: def.columns.find((c) => c.key === k)?.label ?? k,
})),
rowCount: projected.length,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,123 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { renderToBuffer } from '@react-pdf/renderer';
import { createElement } from 'react';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { parseBody } from '@/lib/api/route-helpers';
import { absolutizeBrandingUrl } from '@/lib/branding/url';
import { getPortBrandingConfig } from '@/lib/services/port-config';
import { PayloadReportDocument } from '@/lib/pdf/reports/payload-report';
/**
* POST /api/v1/reports/export-pdf
*
* Generic PDF generator. Client posts a JSON `ReportPayload`; server
* resolves branding for the active port, renders the payload through
* the shared PayloadReportDocument, and streams back the PDF bytes.
*
* Used by every report's export-button dropdown ("Download PDF"
* option) so we don't have to keep adding routes per report kind.
*/
// Minimal shape validation — full ReportPayload is structurally typed
// in TS; here we just check it has the basic envelope.
const payloadSchema = z.object({
title: z.string().min(1),
description: z.string().optional(),
filenameSlug: z.string().min(1),
range: z.object({
from: z.string().datetime(),
to: z.string().datetime(),
}),
kpis: z.array(
z.object({
label: z.string(),
value: z.union([z.string(), z.number()]),
hint: z.string().optional(),
}),
),
sections: z.array(
z.object({
title: z.string(),
columns: z.array(
z.object({
key: z.string(),
label: z.string(),
align: z.enum(['left', 'right', 'center']).optional(),
}),
),
rows: z.array(z.record(z.string(), z.unknown())),
}),
),
/** Optional filename override (without extension) — the client
* passes the slug derived from the custom title. */
filenameOverride: z.string().optional(),
});
export const POST = withAuth(
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
try {
const body = await parseBody(req, payloadSchema);
// Resolve port branding (logo + primary color + name)
const portRow = await db.query.ports.findFirst({
where: eq(ports.id, ctx.portId),
columns: { name: true },
});
if (!portRow) throw new NotFoundError('Port');
const cfg = await getPortBrandingConfig(ctx.portId);
const branding = {
logoUrl: absolutizeBrandingUrl(cfg.logoUrl),
primaryColor: cfg.primaryColor,
portName: portRow.name,
};
const generatedAt = new Date().toISOString();
// Convert ISO date strings back to Date objects for the payload
// (client side serialised them through JSON).
const payload = {
...body,
range: {
from: new Date(body.range.from),
to: new Date(body.range.to),
},
// The format-callback isn't transferable through JSON; the
// PDF document falls back to formatPlain when undefined,
// which is the same default the CSV exporter falls back to.
sections: body.sections.map((s) => ({
...s,
columns: s.columns.map((c) => ({ ...c, format: undefined })),
})),
};
const element = createElement(PayloadReportDocument, {
payload,
branding,
generatedAt,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const buffer = await renderToBuffer(element as any);
const filename =
body.filenameOverride ??
`${body.filenameSlug}-${body.range.from.slice(0, 10)}_${body.range.to.slice(0, 10)}.pdf`;
return new NextResponse(buffer as unknown as BodyInit, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
'Cache-Control': 'no-store',
},
});
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,98 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import {
getOperationalKpis,
getUtilisationHeatmap,
getStatusMixOverTime,
getTenancyChurn,
getTenureDistribution,
getSigningBoxPlot,
getOccupancyByArea,
getDocumentsInPipeline,
getTenanciesEndingSoon,
getVacantBerths,
getStuckSigning,
getHighestValueVacant,
} from '@/lib/services/reports/operational.service';
const querySchema = z.object({
from: z.string().datetime().optional(),
to: z.string().datetime().optional(),
});
function resolveRange(from?: string, to?: string): { from: Date; to: Date } {
const now = new Date();
const defaultFrom = new Date(now);
defaultFrom.setDate(defaultFrom.getDate() - 30);
return {
from: from ? new Date(from) : defaultFrom,
to: to ? new Date(to) : now,
};
}
export const GET = withAuth(
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
try {
const params = req.nextUrl.searchParams;
const { from, to } = querySchema.parse({
from: params.get('from') ?? undefined,
to: params.get('to') ?? undefined,
});
const range = resolveRange(from, to);
const [
kpis,
utilisationHeatmap,
statusMix,
tenancyChurn,
tenureDistribution,
signingBoxPlot,
occupancyByArea,
docsInPipeline,
endingSoon,
vacantBerths,
stuckSigning,
highestValueVacant,
] = await Promise.all([
getOperationalKpis(ctx.portId, range),
getUtilisationHeatmap(ctx.portId),
getStatusMixOverTime(ctx.portId),
getTenancyChurn(ctx.portId),
getTenureDistribution(ctx.portId),
getSigningBoxPlot(ctx.portId),
getOccupancyByArea(ctx.portId),
getDocumentsInPipeline(ctx.portId),
getTenanciesEndingSoon(ctx.portId),
getVacantBerths(ctx.portId),
getStuckSigning(ctx.portId),
getHighestValueVacant(ctx.portId),
]);
return NextResponse.json({
data: {
kpis,
utilisationHeatmap,
statusMix,
tenancyChurn,
tenureDistribution,
signingBoxPlot,
occupancyByArea,
docsInPipeline,
endingSoon,
vacantBerths,
stuckSigning,
highestValueVacant,
range: {
from: range.from.toISOString(),
to: range.to.toISOString(),
},
},
});
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,161 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
import {
getSalesKpis,
getPipelineFunnel,
getStageVelocity,
getWinRateOverTime,
getSourceConversion,
getRepLeaderboard,
getDealHeat,
getRepPerformanceDetail,
getStalledDeals,
getClosingThisMonth,
getRecentWins,
getLostReasonBreakdown,
type SalesFilters,
} from '@/lib/services/reports/sales.service';
const LEAD_CATEGORIES = ['general_interest', 'specific_qualified', 'hot_lead'] as const;
const OUTCOMES = [
'won',
'lost_other_marina',
'lost_unqualified',
'lost_no_response',
'lost_other',
'cancelled',
] as const;
/**
* GET /api/v1/reports/sales?from=&to=
*
* Returns the Sales Performance report payload for the active port:
* the 7 KPI tiles + the pipeline funnel (chart 1). Further charts +
* tables ship on this same endpoint as they're built; the response
* shape grows additively under a single `data` envelope.
*
* Permission: `reports.view_dashboard` (same gate as the existing
* dashboard report endpoints; the Sales report is the canonical "for
* leadership" surface).
*/
const querySchema = z.object({
from: z.string().datetime().optional(),
to: z.string().datetime().optional(),
// CSV-style list params. Empty string → undefined → no filter.
stage: z.string().optional(),
leadCategory: z.string().optional(),
outcome: z.string().optional(),
});
/**
* Parse a CSV filter param into a typed allowlist. Unknown values are
* silently dropped — that way a stale bookmark with a removed enum
* value degrades to "no filter" instead of 400.
*/
function parseCsv<T extends string>(
raw: string | undefined,
allowed: ReadonlyArray<T>,
): T[] | undefined {
if (!raw) return undefined;
const parts = raw
.split(',')
.map((s) => s.trim())
.filter((s): s is T => (allowed as ReadonlyArray<string>).includes(s));
return parts.length > 0 ? parts : undefined;
}
function resolveRange(from?: string, to?: string): { from: Date; to: Date } {
const now = new Date();
// Defaults: trailing 30 days. Matches the "Last 30 days" preset on
// the date-range picker so a no-argument GET returns the same thing
// the default UI state shows.
const defaultFrom = new Date(now);
defaultFrom.setDate(defaultFrom.getDate() - 30);
return {
from: from ? new Date(from) : defaultFrom,
to: to ? new Date(to) : now,
};
}
export const GET = withAuth(
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
try {
const params = req.nextUrl.searchParams;
const { from, to, stage, leadCategory, outcome } = querySchema.parse({
from: params.get('from') ?? undefined,
to: params.get('to') ?? undefined,
stage: params.get('stage') ?? undefined,
leadCategory: params.get('leadCategory') ?? undefined,
outcome: params.get('outcome') ?? undefined,
});
const range = resolveRange(from, to);
const filters: SalesFilters | undefined = (() => {
const stages = parseCsv<PipelineStage>(stage, PIPELINE_STAGES);
const leadCategories = parseCsv<(typeof LEAD_CATEGORIES)[number]>(
leadCategory,
LEAD_CATEGORIES,
);
const outcomes = parseCsv<(typeof OUTCOMES)[number]>(outcome, OUTCOMES);
if (!stages && !leadCategories && !outcomes) return undefined;
return { stages, leadCategories, outcomes };
})();
const [
kpis,
funnel,
stageVelocity,
winRateOverTime,
sourceConversion,
repLeaderboard,
dealHeat,
repPerformanceDetail,
stalledDeals,
closingThisMonth,
recentWins,
lostReasonBreakdown,
] = await Promise.all([
getSalesKpis(ctx.portId, range),
getPipelineFunnel(ctx.portId),
getStageVelocity(ctx.portId),
getWinRateOverTime(ctx.portId, range),
getSourceConversion(ctx.portId),
getRepLeaderboard(ctx.portId, range),
getDealHeat(ctx.portId),
getRepPerformanceDetail(ctx.portId, range, filters),
getStalledDeals(ctx.portId, filters),
getClosingThisMonth(ctx.portId, filters),
getRecentWins(ctx.portId, filters),
getLostReasonBreakdown(ctx.portId, range, filters),
]);
return NextResponse.json({
data: {
kpis,
funnel,
stageVelocity,
winRateOverTime,
sourceConversion,
repLeaderboard,
dealHeat,
repPerformanceDetail,
stalledDeals,
closingThisMonth,
recentWins,
lostReasonBreakdown,
range: {
from: range.from.toISOString(),
to: range.to.toISOString(),
},
},
});
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -7,7 +7,12 @@ import { errorResponse } from '@/lib/errors';
import { createReportTemplate, listReportTemplates } from '@/lib/services/report-templates.service';
const createBodySchema = z.object({
kind: z.enum(['dashboard', 'clients', 'berths', 'interests']),
// 'sales' + 'operational' don't go through /api/v1/reports/generate;
// they're standalone report pages with their own routes. The config
// for these kinds is a thin view-state snapshot (date range +
// filters) that the report client applies on load. 'custom' is the
// ad-hoc composer's saved config — entity + columns + filter.
kind: z.enum(['dashboard', 'clients', 'berths', 'interests', 'sales', 'operational', 'custom']),
name: z.string().min(1).max(120),
description: z.string().max(400).nullable().optional(),
// Config is the raw discriminated-union payload; the

View File

@@ -0,0 +1,408 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useMutation } from '@tanstack/react-query';
import { Download, FileText, Loader2, Play, Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import { PageHeader } from '@/components/shared/page-header';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ReportTemplatesButton } from '@/components/reports/shared/report-templates-button';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { ENTITY_KEYS, ENTITY_REGISTRY, type EntityKey } from '@/lib/reports/custom/registry';
import { formatMoney, formatNumber } from '@/lib/reports/format-currency';
/**
* Map from money-amount column → adjacent currency column. When both
* are selected the on-screen + CSV output formats the amount with the
* row's currency. When only the amount is selected we still pretty-
* print with thousand separators but skip the currency glyph (the
* analyst presumably has context elsewhere).
*/
const MONEY_COLUMN_PAIRS: Record<string, string> = {
price: 'priceCurrency',
depositExpectedAmount: 'depositExpectedCurrency',
};
function isMoneyColumnKey(key: string): boolean {
return key in MONEY_COLUMN_PAIRS;
}
interface RunResponse {
data: Array<Record<string, unknown>>;
meta: {
entity: EntityKey;
columns: Array<{ key: string; label: string }>;
rowCount: number;
};
}
interface CustomTemplateConfig extends Record<string, unknown> {
kind: 'custom';
entity: EntityKey;
columns: string[];
from?: string;
to?: string;
}
function defaultColumnsFor(entity: EntityKey): string[] {
return ENTITY_REGISTRY[entity].columns.filter((c) => c.defaultSelected).map((c) => c.key);
}
export function CustomReportBuilder({ portSlug: _portSlug }: { portSlug: string }) {
const searchParams = useSearchParams();
const initialTemplateId = searchParams?.get('templateId') ?? null;
const [entity, setEntity] = useState<EntityKey>('clients');
const [columns, setColumns] = useState<string[]>(defaultColumnsFor('clients'));
const [from, setFrom] = useState<string>('');
const [to, setTo] = useState<string>('');
const [activeTemplateId, setActiveTemplateId] = useState<string | null>(initialTemplateId);
const [rows, setRows] = useState<Array<Record<string, unknown>>>([]);
const [columnLabels, setColumnLabels] = useState<Array<{ key: string; label: string }>>([]);
// When the user picks a different entity, reset columns to the
// entity's defaults (carrying forward column keys would be confusing
// since they're entity-specific). Also clear the active template
// badge since the rep is composing a new query.
function handleEntityChange(next: EntityKey) {
setEntity(next);
setColumns(defaultColumnsFor(next));
setRows([]);
setColumnLabels([]);
setActiveTemplateId(null);
}
function toggleColumn(key: string, checked: boolean) {
setColumns((prev) => {
if (checked) return prev.includes(key) ? prev : [...prev, key];
return prev.filter((k) => k !== key);
});
setActiveTemplateId(null);
}
const handleFromChange = useCallback((next: string) => {
setFrom(next);
setActiveTemplateId(null);
}, []);
const handleToChange = useCallback((next: string) => {
setTo(next);
setActiveTemplateId(null);
}, []);
const currentConfig: CustomTemplateConfig = useMemo(
() => ({
kind: 'custom',
entity,
columns,
from: from || undefined,
to: to || undefined,
}),
[entity, columns, from, to],
);
const handleApplyTemplate = useCallback((config: CustomTemplateConfig) => {
// Raw setters: template apply MUST NOT clear the active-template
// badge that the user-facing handlers above clear.
if (config.entity) setEntity(config.entity);
if (config.columns) setColumns(config.columns);
setFrom(config.from ?? '');
setTo(config.to ?? '');
setRows([]);
setColumnLabels([]);
}, []);
const runMutation = useMutation({
mutationFn: async () => {
// Convert the date-only YYYY-MM-DD strings (DatePicker output)
// into ISO-8601 boundaries so the API zod schema accepts them.
const fromIso = from ? new Date(`${from}T00:00:00.000Z`).toISOString() : undefined;
const toIso = to ? new Date(`${to}T23:59:59.999Z`).toISOString() : undefined;
return apiFetch<RunResponse>(`/api/v1/reports/custom/run`, {
method: 'POST',
body: {
entity,
columns,
from: fromIso,
to: toIso,
},
});
},
onSuccess: (res) => {
setRows(res.data);
setColumnLabels(res.meta.columns);
toast.success(`Loaded ${res.meta.rowCount} rows`);
},
onError: (err) => toastError(err),
});
function downloadCsv() {
if (rows.length === 0) {
toast.error('Run the query first');
return;
}
const headerLabels = columnLabels.map((c) => csvCell(c.label));
const lines = [headerLabels.join(',')];
for (const row of rows) {
const cells = columnLabels.map((c) => csvCell(formatCellValue(c.key, row)));
lines.push(cells.join(','));
}
const filenameSlug = `custom-${entity}`;
const dateSuffix = new Date().toISOString().slice(0, 10);
const filename = `${filenameSlug}-${dateSuffix}.csv`;
const bom = '';
const blob = new Blob([bom + lines.join('\r\n') + '\r\n'], {
type: 'text/csv;charset=utf-8',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
toast.success(`Downloaded ${filename}`);
}
const def = ENTITY_REGISTRY[entity];
return (
<div className="space-y-6">
<PageHeader
eyebrow="Reports"
title="Custom report"
description="Pick an entity, choose columns, set an optional date range, download as CSV. Save the configuration as a template to re-run later."
actions={
<div className="flex items-center gap-2">
<ReportTemplatesButton<CustomTemplateConfig>
kind="custom"
currentConfig={currentConfig}
onApply={handleApplyTemplate}
activeTemplateId={activeTemplateId}
onActiveTemplateChange={setActiveTemplateId}
initialTemplateId={initialTemplateId}
/>
</div>
}
/>
<Card>
<CardContent className="space-y-4 pt-6">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[260px_1fr] lg:items-start">
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="custom-entity" className="text-xs">
Entity
</Label>
<Select value={entity} onValueChange={(v) => handleEntityChange(v as EntityKey)}>
<SelectTrigger id="custom-entity">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ENTITY_KEYS.map((k) => (
<SelectItem key={k} value={k}>
{ENTITY_REGISTRY[k].label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">{def.description}</p>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Date filter ({def.dateAxis})</Label>
<div className="space-y-1.5">
<DatePicker
id="custom-date-from"
value={from}
onChange={handleFromChange}
placeholder="From"
size="sm"
/>
<DatePicker
id="custom-date-to"
value={to}
onChange={handleToChange}
placeholder="To"
size="sm"
/>
</div>
<p className="text-[11px] text-muted-foreground">
Optional. Leave blank for all-time.
</p>
</div>
<div className="flex flex-col gap-2 pt-2">
<Button
onClick={() => runMutation.mutate()}
disabled={runMutation.isPending || columns.length === 0}
size="sm"
>
{runMutation.isPending ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />
) : (
<Play className="mr-1.5 h-4 w-4" aria-hidden />
)}
Run query
</Button>
<Button
onClick={downloadCsv}
variant="outline"
size="sm"
disabled={rows.length === 0}
>
<Download className="mr-1.5 h-4 w-4" aria-hidden />
Download CSV
</Button>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs">Columns</Label>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{def.columns.map((c) => {
const checked = columns.includes(c.key);
return (
<label
key={c.key}
className="flex cursor-pointer items-center gap-2 rounded-md border bg-muted/20 px-2 py-1.5 text-sm hover:bg-muted/40"
>
<Checkbox
checked={checked}
onCheckedChange={(v) => toggleColumn(c.key, Boolean(v))}
/>
<span>{c.label}</span>
</label>
);
})}
</div>
{columns.length === 0 ? (
<p className="text-xs text-amber-600">Select at least one column to run.</p>
) : (
<p className="text-[11px] text-muted-foreground">
{columns.length} of {def.columns.length} columns selected.
</p>
)}
</div>
</div>
</CardContent>
</Card>
{/* Results table — only shows after Run query. Caps the visible
rows; CSV export gives the full set. */}
{rows.length > 0 ? (
<Card>
<CardContent className="p-0">
<div className="flex items-center justify-between border-b bg-muted/30 px-4 py-2 text-xs">
<span className="font-medium">{rows.length} rows</span>
<span className="text-muted-foreground">
Showing first {Math.min(rows.length, 50)} · download CSV for full set
</span>
</div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
{columnLabels.map((c) => (
<TableHead key={c.key}>{c.label}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.slice(0, 50).map((row, idx) => (
<TableRow key={idx}>
{columnLabels.map((c) => (
<TableCell key={c.key} className="text-sm">
{formatCellValue(c.key, row)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
) : runMutation.isSuccess ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<FileText className="mb-3 h-8 w-8 text-muted-foreground" aria-hidden />
<p className="text-sm font-medium">No rows match this query</p>
<p className="mt-1 text-xs text-muted-foreground">
Try widening the date range, picking a different entity, or removing filters.
</p>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Sparkles className="mb-3 h-8 w-8 text-muted-foreground" aria-hidden />
<p className="text-sm font-medium">Configure your query above, then Run.</p>
<p className="mt-1 text-xs text-muted-foreground">
Results appear here. Save the configuration as a template to schedule recurring runs
or share it with the team.
</p>
</CardContent>
</Card>
)}
</div>
);
}
/**
* Per-cell value formatter. Falls through to a generic string render
* except for known money columns (per MONEY_COLUMN_PAIRS) where we
* pretty-print with the row's currency when available. Numeric columns
* outside the money set get thousand-separator formatting for
* readability.
*/
function formatCellValue(key: string, row: Record<string, unknown>): string {
const v = row[key];
if (v === null || v === undefined) return '';
if (isMoneyColumnKey(key) && typeof v === 'number') {
const currencyKey = MONEY_COLUMN_PAIRS[key];
if (currencyKey) {
const ccy = row[currencyKey];
if (typeof ccy === 'string' && ccy.length > 0) return formatMoney(v, ccy);
}
// Currency unknown — drop the glyph, keep the readable number.
return formatNumber(v);
}
if (v instanceof Date) return v.toISOString().slice(0, 10);
if (typeof v === 'number') return formatNumber(v);
if (typeof v === 'string') {
if (/^\d{4}-\d{2}-\d{2}T/.test(v)) return v.slice(0, 10);
return v;
}
return String(v);
}
function csvCell(value: string): string {
if (value === '') return '""';
return `"${value.replace(/"/g, '""')}"`;
}

View File

@@ -0,0 +1,129 @@
'use client';
/**
* Operational — Berth utilisation heatmap (Report 04 Chart 1).
*
* Pure CSS grid (no chart library) — each cell coloured by occupancy %.
* Months across the X-axis (most recent on the right), areas down the
* Y-axis. Hover shows the occupancy % and underlying count.
*/
import { cn } from '@/lib/utils';
interface UtilisationCell {
area: string;
month: string;
occupancyPct: number;
}
interface Props {
cells: UtilisationCell[];
}
export function OperationalHeatmap({ cells }: Props) {
if (cells.length === 0) {
return (
<div className="py-10 text-center text-sm text-muted-foreground">
No berth history captured yet. The heatmap fills in as status changes accumulate.
</div>
);
}
// Build the unique area + month axes
const areas = Array.from(new Set(cells.map((c) => c.area))).sort();
const months = Array.from(new Set(cells.map((c) => c.month))).sort();
// Build a lookup so we can render in O(1) per cell
const byKey = new Map<string, UtilisationCell>();
for (const c of cells) byKey.set(`${c.area}|${c.month}`, c);
return (
<div className="overflow-x-auto">
<div className="inline-block min-w-full">
<div
className="grid gap-0.5"
style={{ gridTemplateColumns: `120px repeat(${months.length}, 1fr)` }}
>
{/* Header row: month labels */}
<div />
{months.map((m) => (
<div
key={m}
className="text-[10px] text-muted-foreground text-center font-mono"
style={{ writingMode: months.length > 18 ? 'vertical-rl' : undefined }}
>
{formatMonth(m)}
</div>
))}
{/* Body */}
{areas.map((area) => (
<FragmentRow key={area} area={area} months={months} byKey={byKey} />
))}
</div>
{/* Legend */}
<div className="mt-4 flex items-center gap-3 text-[11px] text-muted-foreground">
<span>Occupancy:</span>
<div className="flex items-center gap-0.5">
{[0, 20, 40, 60, 80, 100].map((pct) => (
<div key={pct} className={cn('h-3 w-6', colorForPct(pct))} title={`${pct}%`} />
))}
</div>
<span>0% 100%</span>
</div>
</div>
</div>
);
}
function FragmentRow({
area,
months,
byKey,
}: {
area: string;
months: string[];
byKey: Map<string, UtilisationCell>;
}) {
return (
<>
<div className="text-xs font-medium text-foreground truncate pr-2 self-center">{area}</div>
{months.map((month) => {
const cell = byKey.get(`${area}|${month}`);
const pct = cell?.occupancyPct ?? 0;
return (
<div
key={`${area}|${month}`}
className={cn('h-7 rounded-sm transition-colors', colorForPct(pct))}
title={`${area} · ${formatMonthLong(month)}: ${pct.toFixed(0)}%`}
/>
);
})}
</>
);
}
function colorForPct(pct: number): string {
// 6-step ramp using the existing brand palette
if (pct >= 90) return 'bg-brand-700';
if (pct >= 70) return 'bg-brand-500';
if (pct >= 50) return 'bg-brand-300';
if (pct >= 30) return 'bg-brand-100';
if (pct > 0) return 'bg-brand-50';
return 'bg-muted/30';
}
function formatMonth(month: string): string {
const [year, m] = month.split('-');
if (!year || !m) return month;
const d = new Date(parseInt(year), parseInt(m) - 1, 1);
return d.toLocaleDateString(undefined, { month: 'short' });
}
function formatMonthLong(month: string): string {
const [year, m] = month.split('-');
if (!year || !m) return month;
const d = new Date(parseInt(year), parseInt(m) - 1, 1);
return d.toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,129 @@
'use client';
/**
* Operational — Signing turnaround box plot (Report 04 Chart 5).
*
* Pure CSS / SVG-free box plot. Each row is one document type; the
* horizontal bar shows min-Q1-median-Q3-max distribution. Simpler +
* lighter than echarts' boxplot and renders identically in PDF.
*/
import { cn } from '@/lib/utils';
interface SigningBoxPlot {
documentType: string;
min: number;
q1: number;
median: number;
q3: number;
max: number;
sampleSize: number;
}
interface Props {
rows: SigningBoxPlot[];
}
const TYPE_COLOR: Record<string, string> = {
eoi: 'bg-brand-300',
reservation_agreement: 'bg-brand-500',
contract: 'bg-brand-700',
};
export function OperationalSigningBoxPlot({ rows }: Props) {
if (rows.length === 0) {
return (
<div className="py-10 text-center text-sm text-muted-foreground">
No completed documents yet. The distribution fills in once documents complete their full
signing cycle.
</div>
);
}
// Universal scale across all rows so types are visually comparable
const max = Math.max(1, ...rows.map((r) => r.max));
return (
<div className="space-y-3">
{rows.map((row) => {
const color = TYPE_COLOR[row.documentType] ?? 'bg-brand-500';
return (
<div
key={row.documentType}
className="grid items-center gap-3"
style={{ gridTemplateColumns: '160px 1fr 120px' }}
>
<div className="text-sm font-medium text-foreground">
{formatType(row.documentType)}
</div>
{/* Box plot rendered with CSS:
- whisker line: min → max (faint)
- box: Q1 → Q3 (brand color)
- median tick inside box (white) */}
<div className="relative h-8 rounded-sm bg-muted/20">
{/* Whisker (min to max) */}
<div
className="absolute top-1/2 -translate-y-1/2 h-px bg-foreground/40"
style={{
left: `${(row.min / max) * 100}%`,
width: `${((row.max - row.min) / max) * 100}%`,
}}
/>
{/* Min cap */}
<div
className="absolute top-1/2 -translate-y-1/2 w-px h-3 bg-foreground/60"
style={{ left: `${(row.min / max) * 100}%` }}
/>
{/* Max cap */}
<div
className="absolute top-1/2 -translate-y-1/2 w-px h-3 bg-foreground/60"
style={{ left: `${(row.max / max) * 100}%` }}
/>
{/* Box (Q1 to Q3) */}
<div
className={cn('absolute top-1 bottom-1 rounded-sm', color)}
style={{
left: `${(row.q1 / max) * 100}%`,
width: `${((row.q3 - row.q1) / max) * 100}%`,
}}
title={`Q1: ${row.q1.toFixed(1)}d, Q3: ${row.q3.toFixed(1)}d`}
/>
{/* Median tick */}
<div
className="absolute top-1 bottom-1 w-0.5 bg-white"
style={{ left: `${(row.median / max) * 100}%` }}
title={`Median: ${row.median.toFixed(1)}d`}
/>
</div>
<div className="text-[11px] text-muted-foreground tabular-nums text-right">
<span className="font-medium text-foreground">{row.median.toFixed(1)}d</span> median
<br />
<span className="text-muted-foreground/80">n={row.sampleSize}</span>
</div>
</div>
);
})}
{/* X-axis tick reference */}
<div className="grid pt-2" style={{ gridTemplateColumns: '160px 1fr 120px' }}>
<div />
<div className="relative h-4">
<span className="absolute left-0 text-[10px] text-muted-foreground">0d</span>
<span className="absolute right-0 text-[10px] text-muted-foreground">
{max.toFixed(0)}d
</span>
</div>
<div />
</div>
</div>
);
}
function formatType(t: string): string {
return t
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase())
.replace(/Eoi/i, 'EOI');
}

View File

@@ -0,0 +1,160 @@
'use client';
/**
* Sales Performance — Deal heat section (between leaderboard + tables).
*
* Three things in one section:
* 1. Hot deals count (KPI tile)
* 2. Heat distribution mini-chart (3-segment horizontal bar)
* 3. Hottest deals right now (top 5 table)
*
* Pulls from /api/v1/reports/sales `dealHeat`. Heat semantics defined
* in the service (sales.service.ts § getDealHeat).
*/
import Link from 'next/link';
import type { Route } from 'next';
import { Flame } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { STAGE_LABELS, type PipelineStage } from '@/lib/constants';
import { useUIStore } from '@/stores/ui-store';
import { formatMoneyCompact as formatMoney } from '@/lib/reports/format-currency';
type HeatBucket = 'hot' | 'warm' | 'cold';
interface DealHeatSummary {
distribution: Record<HeatBucket, number>;
topDeals: Array<{
id: string;
clientName: string;
mooringNumber: string | null;
stage: PipelineStage;
bucket: HeatBucket;
daysSinceLastContact: number | null;
pipelineValue: number;
pipelineValueCurrency: string;
}>;
}
interface Props {
data: DealHeatSummary;
}
const HEAT_LABEL: Record<HeatBucket, string> = { hot: 'Hot', warm: 'Warm', cold: 'Cold' };
const HEAT_COLOR: Record<HeatBucket, string> = {
hot: 'bg-rose-500',
warm: 'bg-amber-400',
cold: 'bg-slate-400',
};
const HEAT_BADGE: Record<HeatBucket, string> = {
hot: 'bg-rose-100 text-rose-800',
warm: 'bg-amber-100 text-amber-800',
cold: 'bg-slate-100 text-slate-700',
};
export function SalesDealHeat({ data }: Props) {
const portSlug = useUIStore((s) => s.currentPortSlug) ?? '';
const total = data.distribution.hot + data.distribution.warm + data.distribution.cold;
return (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
{/* Hot deals tile + distribution bar (lg col-span 1) */}
<Card className="p-4 lg:col-span-1 space-y-3">
<div className="flex items-center justify-between">
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Hot deals right now
</p>
<Flame className="h-4 w-4 text-rose-500" aria-hidden />
</div>
<div className="flex items-baseline gap-1.5">
<p className="text-2xl font-semibold tracking-tight text-foreground tabular-nums">
{data.distribution.hot}
</p>
<p className="text-xs text-muted-foreground">
of {total} active {total === 1 ? 'deal' : 'deals'}
</p>
</div>
{/* Distribution bar */}
{total > 0 ? (
<div className="space-y-2 pt-2">
<div className="relative h-2.5 rounded-full bg-muted/40 overflow-hidden flex">
{(['hot', 'warm', 'cold'] as HeatBucket[]).map((bucket) => {
const count = data.distribution[bucket];
if (count === 0) return null;
const pct = (count / total) * 100;
return (
<div
key={bucket}
className={cn(HEAT_COLOR[bucket], 'h-full')}
style={{ width: `${pct}%` }}
title={`${HEAT_LABEL[bucket]}: ${count}`}
aria-hidden
/>
);
})}
</div>
<div className="flex justify-between text-[11px] text-muted-foreground">
{(['hot', 'warm', 'cold'] as HeatBucket[]).map((bucket) => (
<span key={bucket} className="inline-flex items-center gap-1">
<span className={cn('h-1.5 w-1.5 rounded-sm', HEAT_COLOR[bucket])} aria-hidden />
{HEAT_LABEL[bucket]} {data.distribution[bucket]}
</span>
))}
</div>
</div>
) : null}
</Card>
{/* Hottest 5 deals (lg col-span 2) */}
<Card className="p-4 lg:col-span-2 space-y-3">
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Hottest deals right now
</p>
{data.topDeals.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">No active deals yet.</p>
) : (
<ul className="divide-y divide-border">
{data.topDeals.map((deal) => (
<li key={deal.id} className="py-2 flex items-center gap-3">
<Link
href={`/${portSlug}/interests/${deal.id}` as Route}
className="text-sm font-medium text-foreground hover:text-primary transition-colors flex-1 truncate"
>
{deal.clientName}
{deal.mooringNumber ? (
<span className="text-muted-foreground font-normal">
{' '}
· {deal.mooringNumber}
</span>
) : null}
</Link>
<span
className={cn(
'text-[10px] uppercase tracking-wider font-semibold rounded px-1.5 py-0.5',
HEAT_BADGE[deal.bucket],
)}
>
{HEAT_LABEL[deal.bucket]}
</span>
<span className="text-xs text-muted-foreground">{STAGE_LABELS[deal.stage]}</span>
<span className="text-xs text-muted-foreground tabular-nums w-24 text-right">
{deal.pipelineValue > 0
? formatMoney(deal.pipelineValue, deal.pipelineValueCurrency)
: '—'}
</span>
<span className="text-xs text-muted-foreground tabular-nums w-20 text-right hidden sm:inline">
{deal.daysSinceLastContact === null
? 'never contacted'
: `${deal.daysSinceLastContact}d ago`}
</span>
</li>
))}
</ul>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,488 @@
'use client';
/**
* Sales Performance — 5 detail tables (Report 01 Tables 1-5).
*
* 1. Rep performance detail (only when single-rep ⇒ replaces
* leaderboard, which auto-hides in the parent)
* 2. Stalled deals (stage-aware thresholds)
* 3. Closing this month
* 4. Recent wins (last 5)
* 5. Lost-reason breakdown
*
* All five share the same Card primitive + table styling so they read
* as a coherent block. Rep performance detail is rendered conditionally
* by the parent (only shown for single-rep ports).
*/
import { useState } from 'react';
import Link from 'next/link';
import type { Route } from 'next';
import { ChevronDown, ChevronRight, ArrowRight } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { STAGE_LABELS, type PipelineStage } from '@/lib/constants';
import { useUIStore } from '@/stores/ui-store';
import { formatMoneyCompact as formatMoney } from '@/lib/reports/format-currency';
// ─── Shared types (mirror service shapes) ────────────────────────────────────
interface OpenDealRow {
id: string;
clientName: string;
primaryBerth: string | null;
stage: PipelineStage;
stageValue: number;
stageValueCurrency: string;
daysInStage: number | null;
lastContact: string | null;
}
interface RepPerformanceDetailRow {
userId: string | null;
displayName: string;
newDeals: number;
won: number;
lost: number;
inFlight: number;
pipelineValue: number;
pipelineValueCurrency: string;
winRate: number | null;
medianTimeToCloseDays: number | null;
openDeals: OpenDealRow[];
}
interface StalledDealRow {
id: string;
clientName: string;
stage: PipelineStage;
daysSinceLastContact: number | null;
daysInStage: number | null;
stageValue: number;
stageValueCurrency: string;
rep: string;
primaryBerth: string | null;
}
interface ClosingThisMonthRow {
id: string;
clientName: string;
stage: PipelineStage;
stageValue: number;
stageValueCurrency: string;
daysInStage: number | null;
rep: string;
primaryBerth: string | null;
}
interface RecentWinRow {
id: string;
clientName: string;
primaryBerth: string | null;
finalValue: number;
currency: string;
daysToClose: number | null;
rep: string;
outcomeAt: string;
}
interface LostReasonRow {
outcome: string;
count: number;
totalValueLost: number;
currency: string;
avgDaysFromFirstContactToLoss: number | null;
}
const LOSS_LABEL: Record<string, string> = {
lost_other_marina: 'Lost to competitor',
lost_unqualified: 'Unqualified',
lost_no_response: 'No response',
cancelled: 'Cancelled',
};
// ─── Public component bundles the four always-shown tables ───────────────────
interface Props {
repPerformanceDetail: RepPerformanceDetailRow[];
stalledDeals: StalledDealRow[];
closingThisMonth: ClosingThisMonthRow[];
recentWins: RecentWinRow[];
lostReasonBreakdown: LostReasonRow[];
/** When false (multi-rep port), don't show Rep performance detail
* (the leaderboard above already handles that audience). */
showRepPerformanceDetail: boolean;
}
export function SalesDetailTables({
repPerformanceDetail,
stalledDeals,
closingThisMonth,
recentWins,
lostReasonBreakdown,
showRepPerformanceDetail,
}: Props) {
return (
<div className="space-y-6">
{showRepPerformanceDetail ? <RepPerformanceDetailTable rows={repPerformanceDetail} /> : null}
<StalledDealsTable rows={stalledDeals} />
<ClosingThisMonthTable rows={closingThisMonth} />
<RecentWinsTable rows={recentWins} />
<LostReasonTable rows={lostReasonBreakdown} />
</div>
);
}
// ─── 1. Rep performance detail (single-rep collapse) ─────────────────────────
function RepPerformanceDetailTable({ rows }: { rows: RepPerformanceDetailRow[] }) {
const portSlug = useUIStore((s) => s.currentPortSlug) ?? '';
// Always expand in single-rep mode: there's only one rep so collapsing
// it would be pointless. Multi-rep gets per-row toggles.
const [expanded, setExpanded] = useState<Set<string>>(
() => new Set(rows.length === 1 ? rows.map((r) => r.userId ?? 'unassigned') : []),
);
function toggle(key: string) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Rep performance detail</CardTitle>
<p className="text-xs text-muted-foreground">
Per-rep summary + their open deals. Click a row to expand the open-deals list.
</p>
</CardHeader>
<CardContent>
{rows.length === 0 ? (
<EmptyRow>No rep activity in the period.</EmptyRow>
) : (
<div className="space-y-2">
{rows.map((row) => {
const key = row.userId ?? 'unassigned';
const isOpen = expanded.has(key);
return (
<div key={key} className="rounded-md border border-border overflow-hidden">
<button
type="button"
onClick={() => toggle(key)}
className="w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-muted/40 transition-colors"
>
{isOpen ? (
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden />
) : (
<ChevronRight
className="h-4 w-4 text-muted-foreground shrink-0"
aria-hidden
/>
)}
<span className="font-medium text-foreground flex-1">{row.displayName}</span>
<span className="text-xs text-muted-foreground tabular-nums">
{row.newDeals} new · {row.won} won · {row.lost} lost · {row.inFlight} active
</span>
</button>
{isOpen && (
<div className="border-t border-border bg-muted/20">
{row.openDeals.length === 0 ? (
<p className="px-3 py-3 text-xs text-muted-foreground">No active deals.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-[11px] uppercase tracking-wider text-muted-foreground">
<th className="px-3 py-2 text-left font-medium">Client</th>
<th className="px-3 py-2 text-left font-medium">Berth</th>
<th className="px-3 py-2 text-left font-medium">Stage</th>
<th className="px-3 py-2 text-right font-medium">Value</th>
<th className="px-3 py-2 text-right font-medium">Days in stage</th>
</tr>
</thead>
<tbody>
{row.openDeals.map((d) => (
<tr key={d.id} className="border-t border-border">
<td className="px-3 py-2">
<Link
href={`/${portSlug}/interests/${d.id}` as Route}
className="text-foreground hover:text-primary transition-colors"
>
{d.clientName}
</Link>
</td>
<td className="px-3 py-2 text-muted-foreground">
{d.primaryBerth ?? '—'}
</td>
<td className="px-3 py-2 text-muted-foreground">
{STAGE_LABELS[d.stage]}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{d.stageValue > 0
? formatMoney(d.stageValue, row.pipelineValueCurrency)
: '—'}
</td>
<td className="px-3 py-2 text-right tabular-nums text-muted-foreground">
{d.daysInStage ?? '—'}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}
// ─── 2. Stalled deals ────────────────────────────────────────────────────────
function StalledDealsTable({ rows }: { rows: StalledDealRow[] }) {
const portSlug = useUIStore((s) => s.currentPortSlug) ?? '';
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Stalled deals</CardTitle>
<p className="text-xs text-muted-foreground">
Active deals not contacted within their stage&apos;s threshold (enquiry 21d · qualified
14d · nurturing 60d · eoi 10d · reservation 7d · deposit 7d · contract 5d).
</p>
</CardHeader>
<CardContent>
{rows.length === 0 ? (
<EmptyRow>Nothing stalled everything&apos;s being worked.</EmptyRow>
) : (
<TableShell
headers={['Client', 'Stage', 'Days since contact', 'Days in stage', 'Value', 'Rep']}
rightAligned={[2, 3, 4]}
>
{rows.map((r) => (
<tr key={r.id} className="border-t border-border hover:bg-muted/40 transition-colors">
<td className="px-3 py-2">
<Link
href={`/${portSlug}/interests/${r.id}` as Route}
className="text-foreground hover:text-primary transition-colors"
>
{r.clientName}
{r.primaryBerth ? (
<span className="text-muted-foreground"> · {r.primaryBerth}</span>
) : null}
</Link>
</td>
<td className="px-3 py-2 text-muted-foreground">{STAGE_LABELS[r.stage]}</td>
<td className="px-3 py-2 text-right tabular-nums text-rose-700 font-medium">
{r.daysSinceLastContact === null ? 'never' : `${r.daysSinceLastContact}d`}
</td>
<td className="px-3 py-2 text-right tabular-nums text-muted-foreground">
{r.daysInStage ?? '—'}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{r.stageValue > 0 ? formatMoney(r.stageValue, r.stageValueCurrency) : '—'}
</td>
<td className="px-3 py-2 text-muted-foreground">{r.rep}</td>
</tr>
))}
</TableShell>
)}
</CardContent>
</Card>
);
}
// ─── 3. Closing this month ───────────────────────────────────────────────────
function ClosingThisMonthTable({ rows }: { rows: ClosingThisMonthRow[] }) {
const portSlug = useUIStore((s) => s.currentPortSlug) ?? '';
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Closing soon</CardTitle>
<p className="text-xs text-muted-foreground">
Late-stage active deals (reservation / deposit paid / contract) sorted by value. The
&quot;don&apos;t drop these&quot; list.
</p>
</CardHeader>
<CardContent>
{rows.length === 0 ? (
<EmptyRow>No deals in late stages yet.</EmptyRow>
) : (
<TableShell
headers={['Client', 'Stage', 'Days in stage', 'Value', 'Rep']}
rightAligned={[2, 3]}
>
{rows.map((r) => (
<tr key={r.id} className="border-t border-border hover:bg-muted/40 transition-colors">
<td className="px-3 py-2">
<Link
href={`/${portSlug}/interests/${r.id}` as Route}
className="text-foreground hover:text-primary transition-colors"
>
{r.clientName}
{r.primaryBerth ? (
<span className="text-muted-foreground"> · {r.primaryBerth}</span>
) : null}
</Link>
</td>
<td className="px-3 py-2">
<Badge variant="outline" className="text-[11px]">
{STAGE_LABELS[r.stage]}
</Badge>
</td>
<td className="px-3 py-2 text-right tabular-nums text-muted-foreground">
{r.daysInStage ?? '—'}
</td>
<td className="px-3 py-2 text-right tabular-nums font-medium">
{r.stageValue > 0 ? formatMoney(r.stageValue, r.stageValueCurrency) : '—'}
</td>
<td className="px-3 py-2 text-muted-foreground">{r.rep}</td>
</tr>
))}
</TableShell>
)}
</CardContent>
</Card>
);
}
// ─── 4. Recent wins ──────────────────────────────────────────────────────────
function RecentWinsTable({ rows }: { rows: RecentWinRow[] }) {
const portSlug = useUIStore((s) => s.currentPortSlug) ?? '';
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Recent wins</CardTitle>
<p className="text-xs text-muted-foreground">
The 5 most recently closed-won deals small celebratory strip.
</p>
</CardHeader>
<CardContent>
{rows.length === 0 ? (
<EmptyRow>No wins yet. The next one will appear here.</EmptyRow>
) : (
<ul className="divide-y divide-border">
{rows.map((r) => (
<li key={r.id} className="py-2.5 flex items-center gap-3">
<Link
href={`/${portSlug}/interests/${r.id}` as Route}
className="font-medium text-foreground hover:text-primary transition-colors flex-1 truncate"
>
{r.clientName}
{r.primaryBerth ? (
<span className="text-muted-foreground font-normal"> · {r.primaryBerth}</span>
) : null}
</Link>
<span className="text-sm tabular-nums text-emerald-700 font-medium w-24 text-right">
{formatMoney(r.finalValue, r.currency)}
</span>
<span className="text-xs text-muted-foreground w-24 text-right tabular-nums">
{r.daysToClose !== null ? `${r.daysToClose}d to close` : '—'}
</span>
<span className="text-xs text-muted-foreground w-28 text-right hidden sm:inline">
{r.rep}
</span>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}
// ─── 5. Lost reason breakdown ────────────────────────────────────────────────
function LostReasonTable({ rows }: { rows: LostReasonRow[] }) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Lost reason breakdown</CardTitle>
<p className="text-xs text-muted-foreground">
Where the losses went, what they cost us, and how long they took to die. Post-mortem fuel.
</p>
</CardHeader>
<CardContent>
{rows.length === 0 ? (
<EmptyRow>No losses in the period.</EmptyRow>
) : (
<TableShell
headers={['Reason', 'Count', 'Total value lost', 'Avg days to loss']}
rightAligned={[1, 2, 3]}
>
{rows.map((r) => (
<tr
key={r.outcome}
className="border-t border-border hover:bg-muted/40 transition-colors"
>
<td className="px-3 py-2 font-medium text-foreground">
{LOSS_LABEL[r.outcome] ?? r.outcome}
</td>
<td className="px-3 py-2 text-right tabular-nums">{r.count}</td>
<td className="px-3 py-2 text-right tabular-nums">
{r.totalValueLost > 0 ? formatMoney(r.totalValueLost, r.currency) : '—'}
</td>
<td className="px-3 py-2 text-right tabular-nums text-muted-foreground">
{r.avgDaysFromFirstContactToLoss === null
? '—'
: `${r.avgDaysFromFirstContactToLoss.toFixed(0)}d`}
</td>
</tr>
))}
</TableShell>
)}
</CardContent>
</Card>
);
}
// ─── Primitives ──────────────────────────────────────────────────────────────
function TableShell({
headers,
rightAligned = [],
children,
}: {
headers: string[];
rightAligned?: number[];
children: React.ReactNode;
}) {
return (
<div className="overflow-x-auto -mx-2">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
{headers.map((h, i) => (
<th
key={h}
className={cn(
'px-3 py-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground',
rightAligned.includes(i) ? 'text-right' : 'text-left',
)}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>{children}</tbody>
</table>
</div>
);
}
function EmptyRow({ children }: { children: React.ReactNode }) {
return <p className="py-6 text-sm text-muted-foreground text-center">{children}</p>;
}

View File

@@ -0,0 +1,133 @@
'use client';
/**
* Sales Performance — Pipeline funnel (Report 01 Chart 1).
*
* Originally rendered as an echarts funnel, which assumes monotonically
* decreasing counts. Real pipeline data is often non-monotonic (more
* deals in Contract than in Reservation, etc.) which made the funnel
* render as a broken bowtie. Replaced with a horizontal-bar list:
* one row per canonical stage, bar length proportional to the stage's
* count relative to the max, drop-off vs the prior stage annotated on
* the right. Same data, far more honest at a glance.
*/
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
import { cn } from '@/lib/utils';
import { STAGE_LABELS, type PipelineStage } from '@/lib/constants';
interface PipelineFunnelRow {
stage: PipelineStage;
count: number;
dropoffFromPrior: number | null;
}
interface Props {
rows: PipelineFunnelRow[];
}
// Brand palette graded across the 7 stages - earlier stages lighter
// (top of funnel = wide / lower-intent), later stages darker brand-blue
// (bottom of funnel = narrow / high-intent). Matches the existing
// STAGE_DOT palette in spirit while sticking to the brand ramp.
const STAGE_BAR_COLOR: Record<PipelineStage, string> = {
enquiry: 'bg-slate-400',
qualified: 'bg-brand-300',
nurturing: 'bg-brand-300/70',
eoi: 'bg-brand-400',
reservation: 'bg-brand-500',
deposit_paid: 'bg-brand-600',
contract: 'bg-brand-700',
};
export function SalesPipelineFunnel({ rows }: Props) {
const max = Math.max(1, ...rows.map((r) => r.count));
return (
<div className="space-y-2.5">
{rows.map((row) => {
const widthPct = (row.count / max) * 100;
const isZero = row.count === 0;
return (
<div
key={row.stage}
className="grid items-center gap-3"
// Inline style guarantees the 3-column track (label | bar |
// drop-off badge) on Tailwind v4 - the arbitrary
// `grid-cols-[...]` utility's underscore-to-space
// conversion was silently dropping the class in some
// builds, collapsing the row to stacked.
style={{ gridTemplateColumns: '140px 1fr 120px' }}
>
{/* Stage label */}
<div className="text-sm font-medium text-foreground tabular-nums">
{STAGE_LABELS[row.stage]}
</div>
{/* Bar */}
<div className="relative h-6 rounded-sm bg-muted/40 overflow-hidden">
<div
className={cn(
'h-full rounded-sm transition-[width] duration-500 ease-out',
STAGE_BAR_COLOR[row.stage],
isZero && 'opacity-30',
)}
style={{ width: `${Math.max(widthPct, isZero ? 0 : 1.5)}%` }}
aria-hidden
/>
<div
className={cn(
'absolute inset-y-0 left-2 flex items-center text-xs font-semibold tabular-nums',
// Place the count inside the bar when there's room, else outside (right of bar)
widthPct > 15 ? 'text-white' : 'text-foreground',
)}
style={widthPct > 15 ? undefined : { left: `calc(${widthPct}% + 8px)` }}
>
{row.count}
</div>
</div>
{/* Drop-off vs prior */}
<DropoffBadge dropoff={row.dropoffFromPrior} />
</div>
);
})}
</div>
);
}
function DropoffBadge({ dropoff }: { dropoff: number | null }) {
if (dropoff === null) {
return <span className="text-[11px] text-muted-foreground"></span>;
}
const pct = Math.round(dropoff * 100);
if (pct === 0) {
return (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Minus className="h-3 w-3" aria-hidden />
no change
</span>
);
}
// Negative drop-off (the typical case in a funnel) is shown in slate
// not red - it's normal for stages to shrink. Positive drop-off
// (rare; means more in this stage than the prior) gets emerald.
const isPositive = pct > 0;
return (
<span
className={cn(
'inline-flex items-center gap-1 text-[11px] font-medium tabular-nums',
isPositive ? 'text-emerald-700' : 'text-muted-foreground',
)}
>
{isPositive ? (
<ArrowUpRight className="h-3 w-3" aria-hidden />
) : (
<ArrowDownRight className="h-3 w-3" aria-hidden />
)}
{isPositive ? '+' : ''}
{pct}%
</span>
);
}

View File

@@ -0,0 +1,140 @@
'use client';
/**
* Sales Performance — Rep leaderboard (Report 01 Chart 5).
*
* Table with per-rep summary stats. Single-rep collapse: when there's
* only one rep with deals, the parent component renders the Rep
* performance detail block instead (Task #32). This component
* itself only renders the leaderboard view.
*
* Pipeline-value column carries a small bar fill so the visual
* comparison is fast — bigger bar = more $$ in their pipeline. Other
* columns are pure numerics with tabular-nums alignment.
*/
import { cn } from '@/lib/utils';
import { formatMoneyCompact as formatMoney } from '@/lib/reports/format-currency';
interface RepLeaderboardRow {
userId: string | null;
displayName: string;
newDeals: number;
won: number;
lost: number;
inFlight: number;
pipelineValue: number;
pipelineValueCurrency: string;
winRate: number | null;
medianTimeToCloseDays: number | null;
}
interface Props {
rows: RepLeaderboardRow[];
}
export function SalesRepLeaderboard({ rows }: Props) {
if (rows.length === 0) {
return (
<div className="py-12 flex flex-col items-center justify-center text-center space-y-2">
<p className="text-sm text-muted-foreground max-w-xs">
No rep activity in the selected period.
</p>
</div>
);
}
const maxPipeline = Math.max(1, ...rows.map((r) => r.pipelineValue));
return (
<div className="overflow-x-auto -mx-2">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="px-2 py-2 text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Rep
</th>
<th className="px-2 py-2 text-right text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
New
</th>
<th className="px-2 py-2 text-right text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Won
</th>
<th className="px-2 py-2 text-right text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Lost
</th>
<th className="px-2 py-2 text-right text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
In flight
</th>
<th className="px-2 py-2 text-right text-[11px] font-medium uppercase tracking-wider text-muted-foreground min-w-[160px]">
Pipeline value
</th>
<th className="px-2 py-2 text-right text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Win rate
</th>
<th className="px-2 py-2 text-right text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Median close
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const pct = (row.pipelineValue / maxPipeline) * 100;
return (
<tr
key={row.userId ?? 'unassigned'}
className="border-b border-border last:border-b-0 hover:bg-muted/40 transition-colors"
>
<td className="px-2 py-2.5 font-medium text-foreground">{row.displayName}</td>
<td className="px-2 py-2.5 text-right tabular-nums text-foreground">
{row.newDeals}
</td>
<td
className={cn(
'px-2 py-2.5 text-right tabular-nums',
row.won > 0 ? 'text-emerald-700 font-medium' : 'text-foreground',
)}
>
{row.won}
</td>
<td
className={cn(
'px-2 py-2.5 text-right tabular-nums',
row.lost > 0 ? 'text-rose-700' : 'text-muted-foreground',
)}
>
{row.lost}
</td>
<td className="px-2 py-2.5 text-right tabular-nums text-foreground">
{row.inFlight}
</td>
<td className="px-2 py-2.5">
<div className="flex items-center justify-end gap-2">
<div className="relative h-2 w-20 rounded-full bg-muted/60 overflow-hidden">
<div
className="absolute inset-y-0 left-0 bg-brand-500 rounded-full transition-[width] duration-500 ease-out"
style={{ width: `${Math.max(pct, row.pipelineValue > 0 ? 4 : 0)}%` }}
aria-hidden
/>
</div>
<span className="tabular-nums text-foreground min-w-[90px] text-right">
{formatMoney(row.pipelineValue, row.pipelineValueCurrency)}
</span>
</div>
</td>
<td className="px-2 py-2.5 text-right tabular-nums text-foreground">
{row.winRate === null ? '—' : `${(row.winRate * 100).toFixed(0)}%`}
</td>
<td className="px-2 py-2.5 text-right tabular-nums text-muted-foreground">
{row.medianTimeToCloseDays === null
? '—'
: `${row.medianTimeToCloseDays.toFixed(0)}d`}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,846 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { TrendingDown, TrendingUp } from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { DateRangePicker } from '@/components/dashboard/date-range-picker';
import { ReportExportButton } from '@/components/reports/shared/report-export-button';
import { ReportTemplatesButton } from '@/components/reports/shared/report-templates-button';
import {
FilterBar,
type FilterDefinition,
type FilterValues,
} from '@/components/shared/filter-bar';
import { rangeToBounds, type DateRange } from '@/lib/analytics/range';
import { apiFetch } from '@/lib/api/client';
import { PIPELINE_STAGES, STAGE_LABELS, OUTCOME_LABELS, type PipelineStage } from '@/lib/constants';
import { formatMoney } from '@/lib/reports/format-currency';
import type { ReportPayload } from '@/lib/reports/types';
import { SalesPipelineFunnel } from './sales-pipeline-funnel';
import { SalesStageVelocity } from './sales-stage-velocity';
import { SalesWinRateOverTime } from './sales-win-rate-over-time';
import { SalesSourceConversion } from './sales-source-conversion';
import { SalesRepLeaderboard } from './sales-rep-leaderboard';
import { SalesDealHeat } from './sales-deal-heat';
import { SalesDetailTables } from './sales-detail-tables';
interface SalesKpis {
activeInterests: number;
wonInWindow: number;
lostInWindow: number;
lossBreakdown: Array<{ outcome: string; count: number }>;
winRate: number | null;
pipelineValue: number;
pipelineValueCurrency: string;
pipelineValueExcludedCount: number;
pipelineValueTotalActiveCount: number;
medianTimeToCloseDays: number | null;
timeToCloseSampleSize: number;
newLeadsInWindow: number;
newLeadsBySource: Array<{ source: string; count: number }>;
}
interface FunnelRow {
stage: PipelineStage;
count: number;
dropoffFromPrior: number | null;
}
interface StageVelocityRow {
stage: PipelineStage;
medianDays: number | null;
p90Days: number | null;
transitions: number;
}
interface WinRatePoint {
bucket: string;
won: number;
lost: number;
winRate: number | null;
}
interface WinRateOverTime {
granularity: 'week' | 'month' | 'quarter';
points: WinRatePoint[];
}
type SourceOutcome = 'won' | 'lost' | 'cancelled' | 'in_flight';
interface SourceConversionRow {
source: string;
counts: Record<SourceOutcome, number>;
total: number;
}
interface RepLeaderboardRow {
userId: string | null;
displayName: string;
newDeals: number;
won: number;
lost: number;
inFlight: number;
pipelineValue: number;
pipelineValueCurrency: string;
winRate: number | null;
medianTimeToCloseDays: number | null;
}
type HeatBucket = 'hot' | 'warm' | 'cold';
interface DealHeatSummary {
distribution: Record<HeatBucket, number>;
topDeals: Array<{
id: string;
clientName: string;
mooringNumber: string | null;
stage: PipelineStage;
bucket: HeatBucket;
daysSinceLastContact: number | null;
pipelineValue: number;
pipelineValueCurrency: string;
}>;
}
interface OpenDealRow {
id: string;
clientName: string;
primaryBerth: string | null;
stage: PipelineStage;
stageValue: number;
stageValueCurrency: string;
daysInStage: number | null;
lastContact: string | null;
}
interface RepPerformanceDetailRow extends RepLeaderboardRow {
openDeals: OpenDealRow[];
}
interface StalledDealRow {
id: string;
clientName: string;
stage: PipelineStage;
daysSinceLastContact: number | null;
daysInStage: number | null;
stageValue: number;
stageValueCurrency: string;
rep: string;
primaryBerth: string | null;
}
interface ClosingThisMonthRow {
id: string;
clientName: string;
stage: PipelineStage;
stageValue: number;
stageValueCurrency: string;
daysInStage: number | null;
rep: string;
primaryBerth: string | null;
}
interface RecentWinRow {
id: string;
clientName: string;
primaryBerth: string | null;
finalValue: number;
currency: string;
daysToClose: number | null;
rep: string;
outcomeAt: string;
}
interface LostReasonRow {
outcome: string;
count: number;
totalValueLost: number;
currency: string;
avgDaysFromFirstContactToLoss: number | null;
}
interface SalesReportPayload {
data: {
kpis: SalesKpis;
funnel: FunnelRow[];
stageVelocity: StageVelocityRow[];
winRateOverTime: WinRateOverTime;
sourceConversion: SourceConversionRow[];
repLeaderboard: RepLeaderboardRow[];
dealHeat: DealHeatSummary;
repPerformanceDetail: RepPerformanceDetailRow[];
stalledDeals: StalledDealRow[];
closingThisMonth: ClosingThisMonthRow[];
recentWins: RecentWinRow[];
lostReasonBreakdown: LostReasonRow[];
range: { from: string; to: string };
};
}
const LOSS_LABELS: Record<string, string> = {
lost_other_marina: 'to competitor',
lost_unqualified: 'unqualified',
lost_no_response: 'no response',
cancelled: 'cancelled',
};
const SOURCE_LABELS: Record<string, string> = {
website: 'website',
referral: 'referral',
broker: 'broker',
manual: 'manual',
unknown: 'unknown',
};
const FILTER_DEFS: FilterDefinition[] = [
{
key: 'stage',
label: 'Stage',
type: 'multi-select',
options: PIPELINE_STAGES.map((s) => ({ value: s, label: STAGE_LABELS[s] })),
},
{
key: 'leadCategory',
label: 'Lead category',
type: 'multi-select',
options: [
{ value: 'hot_lead', label: 'Hot lead' },
{ value: 'specific_qualified', label: 'Specific qualified' },
{ value: 'general_interest', label: 'General interest' },
],
},
{
key: 'outcome',
label: 'Outcome',
type: 'multi-select',
options: Object.entries(OUTCOME_LABELS).map(([value, label]) => ({ value, label })),
},
];
interface SalesTemplateConfig extends Record<string, unknown> {
kind: 'sales';
range: DateRange;
filters: FilterValues;
}
export function SalesReportClient({ portSlug: _portSlug }: { portSlug: string }) {
const searchParams = useSearchParams();
const initialTemplateId = searchParams?.get('templateId') ?? null;
const [range, setRange] = useState<DateRange>('30d');
const [filterValues, setFilterValues] = useState<FilterValues>({});
const [activeTemplateId, setActiveTemplateId] = useState<string | null>(initialTemplateId);
// Wrap the user-driven setters so any view-state change clears the
// "Using template X" badge. Template-apply goes through the raw
// setters via handleApplyTemplate, so loading a template doesn't
// immediately clear its own badge.
const handleRangeChange = useCallback((next: DateRange) => {
setRange(next);
setActiveTemplateId(null);
}, []);
const handleFilterChange = useCallback((key: string, value: unknown) => {
setFilterValues((prev) => ({ ...prev, [key]: value }));
setActiveTemplateId(null);
}, []);
const handleFiltersClear = useCallback(() => {
setFilterValues({});
setActiveTemplateId(null);
}, []);
const currentConfig: SalesTemplateConfig = useMemo(
() => ({ kind: 'sales', range, filters: filterValues }),
[range, filterValues],
);
const handleApplyTemplate = useCallback((config: SalesTemplateConfig) => {
// Raw setters here: applying a template MUST NOT clear the
// active-template badge, which the user-facing setters above do.
if (config.range) setRange(config.range);
setFilterValues(config.filters ?? {});
}, []);
const bounds = useMemo(() => rangeToBounds(range), [range]);
const filterQs = useMemo(() => {
const parts: string[] = [];
for (const def of FILTER_DEFS) {
const v = filterValues[def.key];
if (Array.isArray(v) && v.length > 0) {
parts.push(`${def.key}=${encodeURIComponent(v.join(','))}`);
}
}
return parts.length > 0 ? `&${parts.join('&')}` : '';
}, [filterValues]);
const query = useQuery<SalesReportPayload>({
queryKey: ['reports', 'sales', bounds.from.toISOString(), bounds.to.toISOString(), filterQs],
queryFn: () =>
apiFetch<SalesReportPayload>(
`/api/v1/reports/sales?from=${encodeURIComponent(bounds.from.toISOString())}&to=${encodeURIComponent(bounds.to.toISOString())}${filterQs}`,
),
staleTime: 30_000,
});
const kpis = query.data?.data.kpis;
const funnel = query.data?.data.funnel ?? [];
const stageVelocity = query.data?.data.stageVelocity ?? [];
const winRateOverTime = query.data?.data.winRateOverTime ?? {
granularity: 'week' as const,
points: [],
};
const sourceConversion = query.data?.data.sourceConversion ?? [];
const repLeaderboard = query.data?.data.repLeaderboard ?? [];
// Locked decision: when only ONE rep has activity in window, the
// leaderboard table is awkward (1-row scoreboard). Hide it; the Rep
// performance detail (Task #32) will pick up the slack.
const showLeaderboard = repLeaderboard.length > 1;
const dealHeat = query.data?.data.dealHeat;
const repPerformanceDetail = query.data?.data.repPerformanceDetail ?? [];
const stalledDeals = query.data?.data.stalledDeals ?? [];
const closingThisMonth = query.data?.data.closingThisMonth ?? [];
const recentWins = query.data?.data.recentWins ?? [];
const lostReasonBreakdown = query.data?.data.lostReasonBreakdown ?? [];
/**
* Build the export payload at click time. Closed over the current
* `kpis` / `funnel` / `bounds` so the user gets the report they're
* looking at, not whatever the page state was at first render.
*/
function buildExportPayload(): ReportPayload {
if (!kpis) {
throw new Error('Report still loading');
}
// Every money figure in the payload is already in the port's
// reporting currency (service converts on read). Money rows below
// are pre-formatted into strings so the export-pdf route (which
// strips column.format callbacks at the JSON boundary) and the
// CSV / XLSX exporters (which keep them) all render the same
// currency-formatted text.
return {
title: 'Sales performance',
description: 'Rep performance, win rates, pipeline value, stalled deals, deal heat.',
filenameSlug: 'sales-performance',
range: bounds,
kpis: [
{ label: 'Active interests', value: kpis.activeInterests },
{ label: 'Won in period', value: kpis.wonInWindow },
{
label: 'Lost in period',
value: kpis.lostInWindow,
hint: kpis.lossBreakdown
.map((b) => `${b.count} ${b.outcome.replace(/^lost_/, '')}`)
.join(', '),
},
{
label: 'Win rate',
value: kpis.winRate === null ? '—' : `${(kpis.winRate * 100).toFixed(1)}%`,
},
{
label: 'Pipeline value',
value: formatMoney(kpis.pipelineValue, kpis.pipelineValueCurrency),
hint: `${kpis.pipelineValueTotalActiveCount} active interests`,
},
{
label: 'Avg time to close',
value:
kpis.medianTimeToCloseDays === null
? '—'
: `${kpis.medianTimeToCloseDays.toFixed(1)} days`,
hint:
kpis.medianTimeToCloseDays !== null
? `based on ${kpis.timeToCloseSampleSize} won deals`
: 'need ≥3 won deals',
},
{
label: 'New leads',
value: kpis.newLeadsInWindow,
hint: kpis.newLeadsBySource.map((s) => `${s.count} ${s.source}`).join(', '),
},
],
sections: [
{
title: 'Pipeline funnel',
columns: [
{ key: 'stage', label: 'Stage' },
{ key: 'count', label: 'Active deals', align: 'right' },
{
key: 'dropoffFromPrior',
label: 'Drop-off vs prior',
align: 'right',
format: (v) =>
v === null || v === undefined ? '' : `${((v as number) * 100).toFixed(0)}%`,
},
],
rows: funnel.map((r) => ({
stage: STAGE_LABELS[r.stage],
count: r.count,
dropoffFromPrior: r.dropoffFromPrior,
})),
},
{
title: 'Stage velocity',
columns: [
{ key: 'stage', label: 'Stage' },
{
key: 'medianDays',
label: 'Median days in stage',
align: 'right',
format: (v) => (v === null || v === undefined ? '—' : (v as number).toFixed(1)),
},
{
key: 'p90Days',
label: 'p90 days',
align: 'right',
format: (v) => (v === null || v === undefined ? '—' : (v as number).toFixed(1)),
},
{ key: 'transitions', label: 'Sample size', align: 'right' },
],
rows: stageVelocity.map((r) => ({
stage: STAGE_LABELS[r.stage],
medianDays: r.medianDays,
p90Days: r.p90Days,
transitions: r.transitions,
})),
},
{
title: `Win rate over time (${winRateOverTime.granularity})`,
columns: [
{ key: 'bucket', label: 'Period' },
{ key: 'won', label: 'Won', align: 'right' },
{ key: 'lost', label: 'Lost', align: 'right' },
{
key: 'winRate',
label: 'Win rate',
align: 'right',
format: (v) =>
v === null || v === undefined ? '—' : `${((v as number) * 100).toFixed(1)}%`,
},
],
rows: winRateOverTime.points.map((p) => ({ ...p })),
},
{
title: 'Source → win conversion',
columns: [
{ key: 'source', label: 'Source' },
{ key: 'won', label: 'Won', align: 'right' },
{ key: 'lost', label: 'Lost', align: 'right' },
{ key: 'cancelled', label: 'Cancelled', align: 'right' },
{ key: 'in_flight', label: 'In flight', align: 'right' },
{ key: 'total', label: 'Total', align: 'right' },
],
rows: sourceConversion.map((r) => ({
source: r.source,
won: r.counts.won,
lost: r.counts.lost,
cancelled: r.counts.cancelled,
in_flight: r.counts.in_flight,
total: r.total,
})),
},
{
title: 'Rep leaderboard',
columns: [
{ key: 'displayName', label: 'Rep' },
{ key: 'newDeals', label: 'New', align: 'right' },
{ key: 'won', label: 'Won', align: 'right' },
{ key: 'lost', label: 'Lost', align: 'right' },
{ key: 'inFlight', label: 'In flight', align: 'right' },
{
key: 'pipelineValue',
label: 'Pipeline value',
align: 'right',
},
{
key: 'winRate',
label: 'Win rate',
align: 'right',
format: (v) =>
v === null || v === undefined ? '' : `${((v as number) * 100).toFixed(0)}%`,
},
{
key: 'medianTimeToCloseDays',
label: 'Median close (days)',
align: 'right',
format: (v) => (v === null || v === undefined ? '' : (v as number).toFixed(1)),
},
],
// Pre-format `pipelineValue` per row so PDF (which strips the
// column.format callback at the server boundary) and CSV / XLSX
// (which keep it) all render the same currency-formatted
// string.
rows: repLeaderboard.map((r) => ({
...r,
pipelineValue: formatMoney(r.pipelineValue, r.pipelineValueCurrency),
})),
},
...(dealHeat
? [
{
title: 'Deal heat — hottest deals',
columns: [
{ key: 'clientName', label: 'Client' },
{ key: 'mooringNumber', label: 'Berth' },
{
key: 'stage',
label: 'Stage',
format: (v: unknown) => STAGE_LABELS[v as PipelineStage] ?? '',
},
{ key: 'bucket', label: 'Heat' },
{
key: 'daysSinceLastContact',
label: 'Days since contact',
align: 'right' as const,
format: (v: unknown) => (v === null || v === undefined ? 'never' : String(v)),
},
{
key: 'pipelineValue',
label: 'Value',
align: 'right' as const,
},
],
// Same pre-format treatment as the leaderboard above —
// closure-format here so the PDF render path sees a
// ready-to-print string.
rows: dealHeat.topDeals.map((d) => ({
...d,
pipelineValue: formatMoney(d.pipelineValue, d.pipelineValueCurrency),
})),
},
]
: []),
],
};
}
return (
<div className="space-y-6">
<PageHeader
eyebrow="Reports"
title="Sales performance"
description="Rep performance, win rates, pipeline value, stalled deals, and deal heat."
actions={
<div className="flex items-center gap-2">
<DateRangePicker value={range} onChange={handleRangeChange} />
<ReportTemplatesButton<SalesTemplateConfig>
kind="sales"
currentConfig={currentConfig}
onApply={handleApplyTemplate}
activeTemplateId={activeTemplateId}
onActiveTemplateChange={setActiveTemplateId}
initialTemplateId={initialTemplateId}
/>
<ReportExportButton buildPayload={buildExportPayload} disabled={!kpis} />
</div>
}
/>
{/* KPI STRIP - 7 tiles. Grid scales from 2-up on mobile to 4-up
on lg; the 7th tile wraps naturally to a second row. */}
<section
aria-label="Sales KPIs"
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"
>
{query.isLoading || !kpis ? (
Array.from({ length: 7 }).map((_, i) => <KpiSkeleton key={i} />)
) : (
<>
<KpiCard
label="Active interests"
value={formatInt(kpis.activeInterests)}
hint="Not archived, no outcome set"
/>
<KpiCard
label="Won in period"
value={formatInt(kpis.wonInWindow)}
valueTrend={kpis.wonInWindow > 0 ? 'positive' : 'neutral'}
/>
<KpiCard
label="Lost in period"
value={formatInt(kpis.lostInWindow)}
valueTrend={kpis.lostInWindow > 0 ? 'negative' : 'neutral'}
hint={
kpis.lossBreakdown.length > 0
? kpis.lossBreakdown
.map((b) => `${b.count} ${LOSS_LABELS[b.outcome] ?? b.outcome}`)
.join(' · ')
: undefined
}
/>
<KpiCard
label="Win rate"
value={kpis.winRate === null ? '—' : formatPercent(kpis.winRate)}
hint={kpis.winRate === null ? 'No closed deals in period' : 'Excludes cancellations'}
/>
<KpiCard
label="Pipeline value"
value={formatMoney(kpis.pipelineValue, kpis.pipelineValueCurrency)}
hint={
kpis.pipelineValueExcludedCount > 0
? `${kpis.pipelineValueExcludedCount} of ${kpis.pipelineValueTotalActiveCount} interests have no value`
: `${kpis.pipelineValueTotalActiveCount} active interests · weighted by stage`
}
/>
<KpiCard
label="Avg time to close"
value={
kpis.medianTimeToCloseDays === null
? '—'
: formatDurationFromDays(kpis.medianTimeToCloseDays)
}
hint={
kpis.medianTimeToCloseDays === null
? 'Need ≥3 won deals for a meaningful median'
: `Based on ${kpis.timeToCloseSampleSize} won deals`
}
/>
<KpiCard
label="New leads"
value={formatInt(kpis.newLeadsInWindow)}
hint={
kpis.newLeadsBySource.length > 0
? kpis.newLeadsBySource
.map((s) => `${s.count} ${SOURCE_LABELS[s.source] ?? s.source}`)
.join(' · ')
: undefined
}
/>
</>
)}
</section>
{/* CHART 1 - Pipeline funnel */}
<Card>
<CardHeader>
<CardTitle className="text-base">Pipeline funnel</CardTitle>
<p className="text-xs text-muted-foreground">
Active interests grouped by stage. Drop-off rate shown between consecutive stages.
</p>
</CardHeader>
<CardContent>
{query.isLoading ? (
<Skeleton className="h-[360px] w-full" />
) : funnel.every((r) => r.count === 0) ? (
<EmptyState>
No active interests yet. New deals appear here as they enter the pipeline.
</EmptyState>
) : (
<SalesPipelineFunnel rows={funnel} />
)}
</CardContent>
</Card>
{/* CHART 2 - Stage velocity */}
<Card>
<CardHeader>
<CardTitle className="text-base">Stage velocity</CardTitle>
<p className="text-xs text-muted-foreground">
Median days deals spend in each stage before moving on, with the p90 marker on each bar.
Derived from the stage-change audit log.
</p>
</CardHeader>
<CardContent>
{query.isLoading ? (
<Skeleton className="h-[280px] w-full" />
) : (
<SalesStageVelocity rows={stageVelocity} />
)}
</CardContent>
</Card>
{/* CHART 3 - Win rate over time */}
<Card>
<CardHeader>
<CardTitle className="text-base">Win rate over time</CardTitle>
<p className="text-xs text-muted-foreground">
Win rate per {winRateOverTime.granularity}. Faint area underlay is the total deals
closed in each bucket so 100% on 1 deal doesn&apos;t read as 100% on 50.
</p>
</CardHeader>
<CardContent>
{query.isLoading ? (
<Skeleton className="h-[280px] w-full" />
) : (
<SalesWinRateOverTime
granularity={winRateOverTime.granularity}
points={winRateOverTime.points}
/>
)}
</CardContent>
</Card>
{/* CHART 4 - Source → win conversion */}
<Card>
<CardHeader>
<CardTitle className="text-base">Source win conversion</CardTitle>
<p className="text-xs text-muted-foreground">
For each lead source, the share of deals that ended up won, lost, cancelled, or are
still in flight. PDF-friendly stacked bars (not sankey).
</p>
</CardHeader>
<CardContent>
{query.isLoading ? (
<Skeleton className="h-[200px] w-full" />
) : (
<SalesSourceConversion rows={sourceConversion} />
)}
</CardContent>
</Card>
{/* CHART 5 - Rep leaderboard (auto-hidden when only one rep
has activity; the Rep performance detail block ships as
Task #32 and will fill that slot). */}
{showLeaderboard ? (
<Card>
<CardHeader>
<CardTitle className="text-base">Rep leaderboard</CardTitle>
<p className="text-xs text-muted-foreground">
Per-rep activity in the period. Pipeline value is the rep&apos;s slice of the
port-wide stage-weighted forecast, normalised to port currency.
</p>
</CardHeader>
<CardContent>
{query.isLoading ? (
<Skeleton className="h-[200px] w-full" />
) : (
<SalesRepLeaderboard rows={repLeaderboard} />
)}
</CardContent>
</Card>
) : null}
{/* DEAL HEAT SECTION - sits between leaderboard + detail tables
per the locked spec. Hot deals count + heat distribution +
hottest 5 deals (linkable). */}
{query.isLoading || !dealHeat ? (
<Skeleton className="h-[180px] w-full" />
) : (
<SalesDealHeat data={dealHeat} />
)}
{/* DETAIL-TABLE FILTERS — narrow the next 5 tables by stage / lead
category / outcome. KPIs + charts above intentionally stay
unfiltered (macro view). */}
<div className="flex items-center justify-between gap-2 pt-2">
<h2 className="text-sm font-semibold text-foreground">Deal detail</h2>
<FilterBar
filters={FILTER_DEFS}
values={filterValues}
onChange={handleFilterChange}
onClear={handleFiltersClear}
/>
</div>
{/* 5 DETAIL TABLES - Rep performance detail (single-rep only) /
Stalled deals / Closing soon / Recent wins / Lost-reason
breakdown. */}
{query.isLoading ? (
<Skeleton className="h-[400px] w-full" />
) : (
<SalesDetailTables
repPerformanceDetail={repPerformanceDetail}
stalledDeals={stalledDeals}
closingThisMonth={closingThisMonth}
recentWins={recentWins}
lostReasonBreakdown={lostReasonBreakdown}
showRepPerformanceDetail={!showLeaderboard}
/>
)}
</div>
);
}
// ─── KPI tile primitives ─────────────────────────────────────────────────────
interface KpiCardProps {
label: string;
value: string;
hint?: string;
valueTrend?: 'positive' | 'negative' | 'neutral';
}
function KpiCard({ label, value, hint, valueTrend = 'neutral' }: KpiCardProps) {
// Padding goes directly on the bare Card (skipping CardContent)
// because CardContent ships with `p-4 pt-0 sm:p-6 sm:pt-0` for
// use-with-CardHeader contexts. KPI tiles have no header, so any
// `pt-*` override gets stripped or stomped by tailwind-merge +
// breakpoint specificity. Cleaner to skip CardContent entirely.
return (
<Card className="h-full p-4 space-y-1.5">
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{label}
</p>
<div className="flex items-baseline gap-1.5">
<p className="text-2xl font-semibold tracking-tight text-foreground tabular-nums">
{value}
</p>
{valueTrend === 'positive' ? (
<TrendingUp className="h-3.5 w-3.5 text-emerald-600" aria-hidden />
) : valueTrend === 'negative' ? (
<TrendingDown className="h-3.5 w-3.5 text-rose-600" aria-hidden />
) : null}
</div>
{hint ? (
<p className="text-[11px] text-muted-foreground leading-snug line-clamp-2">{hint}</p>
) : null}
</Card>
);
}
function KpiSkeleton() {
return (
<Card className="h-full p-4 space-y-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-7 w-16" />
<Skeleton className="h-3 w-32" />
</Card>
);
}
function EmptyState({ children }: { children: React.ReactNode }) {
return (
<div className="py-16 flex flex-col items-center justify-center text-center space-y-2">
<Badge variant="outline" className="text-muted-foreground">
No data
</Badge>
<p className="text-sm text-muted-foreground max-w-xs">{children}</p>
</div>
);
}
// ─── Formatting helpers ──────────────────────────────────────────────────────
function formatInt(n: number): string {
return new Intl.NumberFormat(undefined).format(n);
}
function formatPercent(fraction: number): string {
return `${Math.round(fraction * 1000) / 10}%`;
}
// Money helpers come from the shared module — `formatMoney` for KPI
// tile readability, `formatMoneyCompact` for tight dense tables.
/**
* Adaptive duration string per locked decision: days under 60, weeks
* under 24 weeks, otherwise months. Single-decimal rounding keeps the
* tile compact.
*/
function formatDurationFromDays(days: number): string {
if (days < 60) return `${Math.round(days)}d`;
const weeks = days / 7;
if (weeks < 24) return `${Math.round(weeks)}w`;
const months = days / 30.44;
return `${months.toFixed(1)}mo`;
}
// Reference the stage labels import so it stays load-bearing across
// later phases (used by the funnel + leaderboard component imports).
void STAGE_LABELS;

View File

@@ -0,0 +1,126 @@
'use client';
/**
* Sales Performance — Source → win conversion (Report 01 Chart 4).
*
* Stacked horizontal bar per lead source, segments coloured by
* outcome (won / lost / cancelled / in-flight). PDF-safe (we picked
* stacked-bar over sankey for that exact reason — locked decision).
*
* Each bar normalises to 100% width so source-to-source comparison
* shows MIX of outcomes regardless of absolute volume. Bar's total
* count is shown on the right so a 50% win rate on 2 deals doesn't
* read the same as 50% on 50.
*/
import { cn } from '@/lib/utils';
type Outcome = 'won' | 'lost' | 'cancelled' | 'in_flight';
interface SourceConversionRow {
source: string;
counts: Record<Outcome, number>;
total: number;
}
interface Props {
rows: SourceConversionRow[];
}
const SOURCE_LABEL: Record<string, string> = {
website: 'Website',
referral: 'Referral',
broker: 'Broker',
manual: 'Manual',
unknown: 'Unknown',
};
const OUTCOME_LABEL: Record<Outcome, string> = {
won: 'Won',
lost: 'Lost',
cancelled: 'Cancelled',
in_flight: 'In flight',
};
// Reuse brand palette. Won = brand-blue (primary success in this app's
// language); Lost = warm rose; Cancelled = muted slate; In-flight =
// soft sage tint so it reads as "still moving" without competing.
const OUTCOME_COLOR: Record<Outcome, string> = {
won: 'bg-brand-600',
lost: 'bg-rose-500',
cancelled: 'bg-slate-400',
in_flight: 'bg-amber-400',
};
export function SalesSourceConversion({ rows }: Props) {
if (rows.length === 0) {
return (
<div className="py-12 flex flex-col items-center justify-center text-center space-y-2">
<p className="text-sm text-muted-foreground max-w-xs">
No leads yet. Source-to-win attribution appears as deals start landing in the pipeline.
</p>
</div>
);
}
return (
<div className="space-y-3">
{/* Legend */}
<div className="flex flex-wrap gap-4 text-[11px] text-muted-foreground">
{(Object.keys(OUTCOME_LABEL) as Outcome[]).map((o) => (
<span key={o} className="inline-flex items-center gap-1.5">
<span className={cn('h-2 w-2 rounded-sm', OUTCOME_COLOR[o])} aria-hidden />
{OUTCOME_LABEL[o]}
</span>
))}
</div>
{/* Rows */}
<div className="space-y-2.5">
{rows.map((row) => (
<div
key={row.source}
className="grid items-center gap-3"
style={{ gridTemplateColumns: '120px 1fr 70px' }}
>
<div className="text-sm font-medium text-foreground">
{SOURCE_LABEL[row.source] ?? row.source}
</div>
<div
className="relative h-6 rounded-sm bg-muted/40 overflow-hidden flex"
role="img"
aria-label={`${row.source}: ${describeRow(row)}`}
>
{(Object.keys(OUTCOME_COLOR) as Outcome[]).map((outcome) => {
const count = row.counts[outcome];
if (count === 0) return null;
const pct = (count / row.total) * 100;
return (
<div
key={outcome}
className={cn(OUTCOME_COLOR[outcome], 'h-full')}
style={{ width: `${pct}%` }}
title={`${OUTCOME_LABEL[outcome]}: ${count} (${pct.toFixed(0)}%)`}
aria-hidden
/>
);
})}
</div>
<span className="text-[11px] text-muted-foreground tabular-nums text-right">
{row.total} {row.total === 1 ? 'lead' : 'leads'}
</span>
</div>
))}
</div>
</div>
);
}
function describeRow(row: SourceConversionRow): string {
return (Object.keys(OUTCOME_LABEL) as Outcome[])
.filter((o) => row.counts[o] > 0)
.map((o) => `${row.counts[o]} ${OUTCOME_LABEL[o].toLowerCase()}`)
.join(', ');
}

View File

@@ -0,0 +1,132 @@
'use client';
/**
* Sales Performance — Stage velocity (Report 01 Chart 2).
*
* Median days spent in each pipeline stage with a faint p90 marker.
* Same horizontal-bar pattern as the Pipeline funnel so the two charts
* read as a pair on the page.
*/
import { cn } from '@/lib/utils';
import { STAGE_LABELS, type PipelineStage } from '@/lib/constants';
interface StageVelocityRow {
stage: PipelineStage;
medianDays: number | null;
p90Days: number | null;
transitions: number;
}
interface Props {
rows: StageVelocityRow[];
}
const STAGE_BAR_COLOR: Record<PipelineStage, string> = {
enquiry: 'bg-slate-400',
qualified: 'bg-brand-300',
nurturing: 'bg-brand-300/70',
eoi: 'bg-brand-400',
reservation: 'bg-brand-500',
deposit_paid: 'bg-brand-600',
contract: 'bg-brand-700',
};
export function SalesStageVelocity({ rows }: Props) {
const hasData = rows.some((r) => r.medianDays !== null);
if (!hasData) {
return (
<div className="py-12 flex flex-col items-center justify-center text-center space-y-2">
<p className="text-sm text-muted-foreground max-w-xs">
No stage transitions captured yet. Velocity appears here once deals start moving between
stages.
</p>
</div>
);
}
// Scale all bars + p90 markers against the highest p90 we observed so
// a tail outlier doesn't crush the rest of the bars to nothing.
const max = Math.max(1, ...rows.map((r) => r.p90Days ?? r.medianDays ?? 0));
return (
<div className="space-y-2.5">
{rows.map((row) => {
const median = row.medianDays;
const p90 = row.p90Days;
const medianPct = median !== null ? (median / max) * 100 : 0;
const p90Pct = p90 !== null ? (p90 / max) * 100 : 0;
const isMissing = median === null;
return (
<div
key={row.stage}
className="grid items-center gap-3"
style={{ gridTemplateColumns: '140px 1fr 120px' }}
>
<div className="text-sm font-medium text-foreground">{STAGE_LABELS[row.stage]}</div>
<div className="relative h-6 rounded-sm bg-muted/40 overflow-hidden">
{/* Median bar */}
{!isMissing && (
<div
className={cn(
'h-full rounded-sm transition-[width] duration-500 ease-out',
STAGE_BAR_COLOR[row.stage],
)}
style={{ width: `${Math.max(medianPct, 1.5)}%` }}
aria-hidden
/>
)}
{/* p90 marker (vertical line) */}
{p90 !== null && p90 > 0 && p90Pct > 0 && (
<div
className="absolute top-0 bottom-0 w-px bg-foreground/60"
style={{ left: `calc(${p90Pct}% - 0.5px)` }}
title={`p90: ${formatDays(p90)}`}
aria-hidden
/>
)}
{/* Label inside or outside the bar */}
<div
className={cn(
'absolute inset-y-0 left-2 flex items-center text-xs font-semibold tabular-nums',
isMissing
? 'text-muted-foreground'
: medianPct > 18
? 'text-white'
: 'text-foreground',
)}
style={
isMissing || medianPct > 18 ? undefined : { left: `calc(${medianPct}% + 8px)` }
}
>
{isMissing ? '—' : formatDays(median!)}
</div>
</div>
{/* Sample size + p90 chip on the right */}
<span className="text-[11px] text-muted-foreground tabular-nums">
{isMissing ? (
'no data'
) : (
<>
{row.transitions} {row.transitions === 1 ? 'transition' : 'transitions'}
{p90 !== null && p90 > 0 ? ` · p90 ${formatDays(p90)}` : ''}
</>
)}
</span>
</div>
);
})}
</div>
);
}
function formatDays(days: number): string {
if (days < 1) return `<1d`;
if (days < 10) return `${days.toFixed(1)}d`;
if (days < 60) return `${Math.round(days)}d`;
const weeks = days / 7;
if (weeks < 24) return `${Math.round(weeks)}w`;
return `${(days / 30.44).toFixed(1)}mo`;
}

View File

@@ -0,0 +1,145 @@
'use client';
/**
* Sales Performance — Win rate over time (Report 01 Chart 3).
*
* Line: win rate per bucket. Faint area underlay: total deals closed
* per bucket so a 100% win rate on 1 deal doesn't read the same as
* 80% on 50 deals. Auto-bucket granularity (weekly / monthly /
* quarterly) is decided server-side and labelled in the chart caption.
*
* Recharts (matches the dashboard convention).
*/
import {
Area,
CartesianGrid,
ComposedChart,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
type Granularity = 'week' | 'month' | 'quarter';
interface WinRatePoint {
bucket: string;
won: number;
lost: number;
winRate: number | null;
}
interface Props {
granularity: Granularity;
points: WinRatePoint[];
}
export function SalesWinRateOverTime({ granularity, points }: Props) {
const allEmpty = points.every((p) => p.winRate === null);
if (allEmpty) {
return (
<div className="py-12 flex flex-col items-center justify-center text-center space-y-2">
<p className="text-sm text-muted-foreground max-w-xs">
No deals closed yet in the selected period. Win-rate trend appears here as wins and losses
accumulate.
</p>
</div>
);
}
// Build the chart series. Render win rate as a percentage so the
// tooltip + axis read naturally; preserve the null gaps by passing
// `null` for winRatePct on empty buckets (recharts skips them).
const data = points.map((p) => ({
bucket: formatBucket(p.bucket, granularity),
winRatePct: p.winRate === null ? null : Math.round(p.winRate * 100 * 10) / 10,
closed: p.won + p.lost,
}));
// p90 for the volume underlay scale - we want the area to feel like
// ambient context, not dominate. Capping at p90 trims spike weeks.
const closedSorted = data.map((d) => d.closed).sort((a, b) => a - b);
const p90Closed = closedSorted[Math.floor(closedSorted.length * 0.9)] ?? 1;
const maxClosed = Math.max(p90Closed, 1);
return (
<ResponsiveContainer width="100%" height={280}>
<ComposedChart data={data} margin={{ top: 8, right: 8, left: -16, bottom: 24 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
dataKey="bucket"
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
interval="preserveStartEnd"
/>
{/* Left axis: win rate %, fixed 0-100 scale so deltas read true */}
<YAxis
yAxisId="rate"
domain={[0, 100]}
tickFormatter={(v) => `${v}%`}
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
/>
{/* Right axis: deals closed (volume underlay). Hidden but used
so the area can scale independently of the line. */}
<YAxis yAxisId="volume" orientation="right" domain={[0, maxClosed * 1.2]} hide />
<Tooltip
contentStyle={{
background: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: 12,
}}
formatter={(value, name) => {
if (name === 'winRatePct') {
return [value === null ? '—' : `${value}%`, 'Win rate'];
}
if (name === 'closed') {
return [value, 'Deals closed'];
}
return [value, String(name)];
}}
/>
<Area
yAxisId="volume"
type="monotone"
dataKey="closed"
stroke="none"
fill="hsl(var(--muted))"
fillOpacity={0.55}
isAnimationActive={false}
/>
<Line
yAxisId="rate"
type="monotone"
dataKey="winRatePct"
stroke="hsl(var(--primary))"
strokeWidth={2}
dot={{ r: 3, fill: 'hsl(var(--primary))' }}
activeDot={{ r: 5 }}
// Recharts renders gaps where the value is null.
connectNulls={false}
/>
</ComposedChart>
</ResponsiveContainer>
);
}
function formatBucket(bucket: string, granularity: Granularity): string {
if (granularity === 'week') {
// "2026-W18" → "W18"
const m = bucket.match(/-W(\d+)/);
return m ? `W${m[1]}` : bucket;
}
if (granularity === 'month') {
// "2026-04" → "Apr"
const [year, month] = bucket.split('-');
if (!year || !month) return bucket;
const date = new Date(parseInt(year), parseInt(month) - 1, 1);
return date.toLocaleDateString(undefined, { month: 'short' });
}
// "2026-Q2" → "Q2 '26"
const m = bucket.match(/(\d{4})-Q(\d)/);
if (!m) return bucket;
return `Q${m[2]} '${m[1]!.slice(-2)}`;
}

View File

@@ -30,7 +30,7 @@ export interface SavedTemplate {
}
interface Props {
kind: 'dashboard' | 'clients' | 'berths' | 'interests';
kind: 'dashboard' | 'clients' | 'berths' | 'interests' | 'sales' | 'operational';
/** Called when the rep picks a template from the dropdown - the
* parent hydrates its form from the returned config. */
onApply: (template: SavedTemplate) => void;

View File

@@ -0,0 +1,357 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Plus, Save, X } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import type { ReportSchedule, ReportTemplate } from '@/lib/db/schema/reports';
type Cadence = 'weekly_monday_9' | 'monthly_first_9' | 'quarterly_first_9';
type OutputFormat = 'pdf' | 'csv' | 'png';
const CADENCE_OPTIONS: ReadonlyArray<{ value: Cadence; label: string }> = [
{ value: 'weekly_monday_9', label: 'Weekly · Monday 9:00 UTC' },
{ value: 'monthly_first_9', label: 'Monthly · 1st of month 9:00 UTC' },
{ value: 'quarterly_first_9', label: 'Quarterly · 1st of quarter 9:00 UTC' },
];
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
/** When set, the dialog edits an existing schedule. Otherwise create. */
schedule?: ReportSchedule;
/** Pre-select a template for the create flow (e.g. when the user
* triggered the dialog from a specific template detail page). */
initialTemplateId?: string;
}
interface Recipient {
name: string;
email: string;
}
/**
* Outer dialog shell — owns the open/close state and re-mounts the
* form body whenever the user switches between create / edit-N. The
* `key` on `<ScheduleDialogForm>` resets every useState initializer
* naturally when the schedule prop changes, sidestepping the
* "setState in useEffect" anti-pattern an explicit reset effect
* would otherwise need.
*/
export function ScheduleDialog({ open, onOpenChange, schedule, initialTemplateId }: Props) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{open ? (
<ScheduleDialogForm
key={schedule?.id ?? 'new'}
schedule={schedule}
initialTemplateId={initialTemplateId}
onClose={() => onOpenChange(false)}
/>
) : null}
</Dialog>
);
}
interface FormProps {
schedule?: ReportSchedule;
initialTemplateId?: string;
onClose: () => void;
}
function ScheduleDialogForm({ schedule, initialTemplateId, onClose }: FormProps) {
const qc = useQueryClient();
const isEdit = !!schedule;
const [templateId, setTemplateId] = useState<string>(
schedule?.templateId ?? initialTemplateId ?? '',
);
const [cadence, setCadence] = useState<Cadence>(
(schedule?.cadence as Cadence) ?? 'weekly_monday_9',
);
const [outputFormat, setOutputFormat] = useState<OutputFormat>(
(schedule?.outputFormat as OutputFormat) ?? 'pdf',
);
const [enabled, setEnabled] = useState<boolean>(schedule?.enabled ?? true);
const [recipients, setRecipients] = useState<Recipient[]>(
schedule?.recipients?.map((r) => ({ name: r.name ?? '', email: r.email })) ?? [],
);
const [newName, setNewName] = useState('');
const [newEmail, setNewEmail] = useState('');
// No `enabled` gate needed — the outer ScheduleDialog only mounts
// this form when `open=true`, so the query is implicitly off until
// the dialog actually appears.
const templatesQuery = useQuery<{ data: ReportTemplate[] }>({
queryKey: ['report-templates', 'all'],
queryFn: () => apiFetch<{ data: ReportTemplate[] }>(`/api/v1/reports/templates`),
staleTime: 30_000,
});
const createMutation = useMutation({
mutationFn: async () =>
apiFetch<{ data: ReportSchedule }>(`/api/v1/reports/schedules`, {
method: 'POST',
body: {
templateId,
cadence,
outputFormat,
enabled,
recipients: recipients.map((r) => ({
name: r.name.trim() || undefined,
email: r.email.trim(),
})),
},
}),
onSuccess: () => {
toast.success('Schedule created');
void qc.invalidateQueries({ queryKey: ['report-schedules'] });
onClose();
},
onError: (err) => toastError(err),
});
const updateMutation = useMutation({
mutationFn: async () => {
if (!schedule) throw new Error('No schedule to update');
return apiFetch<{ data: ReportSchedule }>(`/api/v1/reports/schedules/${schedule.id}`, {
method: 'PATCH',
body: {
cadence,
outputFormat,
enabled,
recipients: recipients.map((r) => ({
name: r.name.trim() || undefined,
email: r.email.trim(),
})),
},
});
},
onSuccess: () => {
toast.success('Schedule updated');
void qc.invalidateQueries({ queryKey: ['report-schedules'] });
onClose();
},
onError: (err) => toastError(err),
});
function addRecipient() {
const email = newEmail.trim();
if (!email) return;
setRecipients((prev) => [...prev, { name: newName.trim(), email }]);
setNewName('');
setNewEmail('');
}
function removeRecipient(idx: number) {
setRecipients((prev) => prev.filter((_, i) => i !== idx));
}
const submitting = createMutation.isPending || updateMutation.isPending;
const canSubmit = templateId !== '' && !submitting;
const templates = templatesQuery.data?.data ?? [];
return (
<>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit schedule' : 'New schedule'}</DialogTitle>
<DialogDescription>
Recurring report. Recipients are optional schedules with no recipients still run and
appear in the runs history, they just skip the email step.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="schedule-template" className="text-xs">
Template
</Label>
<Select
value={templateId}
onValueChange={setTemplateId}
disabled={isEdit || templatesQuery.isLoading}
>
<SelectTrigger id="schedule-template">
<SelectValue
placeholder={
templatesQuery.isLoading
? 'Loading templates…'
: templates.length === 0
? 'No templates available — save one first'
: 'Pick a template'
}
/>
</SelectTrigger>
<SelectContent>
{templates.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} <span className="text-muted-foreground">· {t.kind}</span>
</SelectItem>
))}
</SelectContent>
</Select>
{isEdit ? (
<p className="text-[11px] text-muted-foreground">
Template can&apos;t be changed on an existing schedule. Delete + recreate to
re-bind.
</p>
) : null}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="schedule-cadence" className="text-xs">
Cadence
</Label>
<Select value={cadence} onValueChange={(v) => setCadence(v as Cadence)}>
<SelectTrigger id="schedule-cadence">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CADENCE_OPTIONS.map((c) => (
<SelectItem key={c.value} value={c.value}>
{c.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="schedule-format" className="text-xs">
Output
</Label>
<Select
value={outputFormat}
onValueChange={(v) => setOutputFormat(v as OutputFormat)}
>
<SelectTrigger id="schedule-format">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="pdf">PDF</SelectItem>
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
CSV/XLSX coming for scheduled runs use Export for those formats now.
</p>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs">Recipients (optional)</Label>
<div className="space-y-1.5">
{recipients.length === 0 ? (
<p className="text-[11px] text-muted-foreground">
No recipients yet runs will be archived but not emailed.
</p>
) : (
recipients.map((r, idx) => (
<div
key={`${r.email}-${idx}`}
className="flex items-center gap-2 rounded-md border bg-muted/30 px-2 py-1 text-sm"
>
<div className="flex-1">
<span className="font-medium">{r.name || r.email}</span>
{r.name ? (
<span className="ml-2 text-xs text-muted-foreground">{r.email}</span>
) : null}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => removeRecipient(idx)}
aria-label="Remove recipient"
>
<X className="h-3.5 w-3.5" aria-hidden />
</Button>
</div>
))
)}
</div>
<div className="grid grid-cols-[1fr_1.4fr_auto] gap-2">
<Input
placeholder="Name (optional)"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="h-9"
/>
<Input
type="email"
placeholder="email@example.com"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addRecipient();
}
}}
className="h-9"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={addRecipient}
disabled={!newEmail.trim()}
>
<Plus className="mr-1.5 h-4 w-4" aria-hidden />
Add
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<Switch id="schedule-enabled" checked={enabled} onCheckedChange={setEnabled} />
<Label htmlFor="schedule-enabled" className="cursor-pointer text-sm">
Enabled
</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={onClose} disabled={submitting}>
Cancel
</Button>
<Button
size="sm"
onClick={() => (isEdit ? updateMutation.mutate() : createMutation.mutate())}
disabled={!canSubmit}
>
{submitting ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />
) : (
<Save className="mr-1.5 h-4 w-4" aria-hidden />
)}
{isEdit ? 'Save changes' : 'Create schedule'}
</Button>
</DialogFooter>
</DialogContent>
</>
);
}

View File

@@ -0,0 +1,262 @@
'use client';
import { useState } from 'react';
import { Download, FileSpreadsheet, FileText, Sheet } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { defaultCsvFilename, exportReportAsCsv } from '@/lib/reports/exporters/csv';
import { defaultPdfFilename, exportReportAsPdf } from '@/lib/reports/exporters/pdf';
import { defaultXlsxFilename, exportReportAsXlsx } from '@/lib/reports/exporters/xlsx';
import type { ExportResult, ReportPayload } from '@/lib/reports/types';
/** Supported formats. Excel + PDF are scaffolded UI; only CSV is wired. */
type ExportFormat = 'csv' | 'xlsx' | 'pdf';
interface ReportExportButtonProps {
/** Function that produces the ReportPayload at click time.
* Lazy: only invoked when the user picks a format, so building the
* payload (which may involve formatting numbers + dates from the
* live report state) doesn't run on every render. */
buildPayload: () => ReportPayload;
/** Disable the button (e.g. while the report query is loading). */
disabled?: boolean;
}
/**
* Shared export dropdown for every report. Three format options:
*
* - CSV: working today via `papaparse`. Multi-section flat file.
* - Excel: scaffolded — wires through to the same payload but the
* `exportReportAsXlsx` implementation lands as Task #35.
* - PDF: scaffolded — same payload, branded shell wraps the output.
* Lands as Task #34.
*
* Format-disabled items render as disabled menu items with a "coming
* soon" caption rather than being hidden, so the affordance is
* discoverable across the platform from day one.
*/
export function ReportExportButton({ buildPayload, disabled }: ReportExportButtonProps) {
const [exporting, setExporting] = useState(false);
// Pending-format dialog state: when the user picks a format from the
// dropdown, we capture that intent + open a rename dialog so they
// can override the title (which is baked into both the filename AND
// the document's header). The actual export fires from the dialog's
// confirm button.
const [pendingFormat, setPendingFormat] = useState<ExportFormat | null>(null);
const [customTitle, setCustomTitle] = useState<string>('');
function openRenameDialog(format: ExportFormat) {
// Pre-fill with the current report's title so the user only types
// when they want to override.
try {
const payload = buildPayload();
setCustomTitle(payload.title);
setPendingFormat(format);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Could not prepare export');
}
}
async function handleConfirm() {
if (!pendingFormat) return;
setExporting(true);
try {
// Rebuild payload at export time so any background-state changes
// (e.g. the rep just picked a different date range) are reflected.
const basePayload = buildPayload();
const trimmedTitle = customTitle.trim();
const titleChanged = trimmedTitle && trimmedTitle !== basePayload.title;
const titledPayload: ReportPayload = {
...basePayload,
title: trimmedTitle || basePayload.title,
};
// When the user has CUSTOMISED the title, use it verbatim as the
// filename (no auto-appended date suffix — they typed a meaningful
// name, respect it). When they kept the default, fall back to the
// exporter's standard `slug-fromdate_todate.<ext>` pattern so
// historical downloads stay disambiguated.
const filenameOverride = titleChanged
? `${slugify(trimmedTitle)}.${pendingFormat}`
: undefined;
let result: ExportResult;
if (pendingFormat === 'csv') {
result = exportReportAsCsv(titledPayload, { filenameOverride });
} else if (pendingFormat === 'xlsx') {
result = await exportReportAsXlsx(titledPayload, { filenameOverride });
} else if (pendingFormat === 'pdf') {
result = await exportReportAsPdf(titledPayload, { filenameOverride });
} else {
throw new Error(`${String(pendingFormat).toUpperCase()} export is not wired`);
}
downloadResult(result);
toast.success(`Downloaded ${result.filename}`);
setPendingFormat(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Export failed');
} finally {
setExporting(false);
}
}
/**
* Live filename preview for the dialog. Mirrors the same branching as
* `handleConfirm` so what you see is what you get — custom title →
* verbatim slug, default title → date-suffixed standard.
*/
function previewFilename(): string {
try {
const base = buildPayload();
const trimmed = customTitle.trim();
const changed = trimmed && trimmed !== base.title;
const ext = pendingFormat ?? 'csv';
if (changed) {
return `${slugify(trimmed) || 'report'}.${ext}`;
}
// Default pattern is exporter-specific.
if (ext === 'csv') return defaultCsvFilename(base);
if (ext === 'xlsx') return defaultXlsxFilename(base);
if (ext === 'pdf') return defaultPdfFilename(base);
return `${base.filenameSlug}-${todaySlug()}.${ext}`;
} catch {
return `report.${pendingFormat ?? 'csv'}`;
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={disabled || exporting}>
<Download className="mr-1.5 h-4 w-4" aria-hidden />
Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="text-xs text-muted-foreground">
Download report
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => openRenameDialog('csv')}>
<FileText className="mr-2 h-4 w-4 text-muted-foreground" aria-hidden />
<span className="flex-1">CSV</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openRenameDialog('xlsx')}>
<FileSpreadsheet className="mr-2 h-4 w-4 text-muted-foreground" aria-hidden />
<span className="flex-1">Excel</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openRenameDialog('pdf')}>
<Sheet className="mr-2 h-4 w-4 text-muted-foreground" aria-hidden />
<span className="flex-1">PDF</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog
open={pendingFormat !== null}
onOpenChange={(open) => {
if (!open) setPendingFormat(null);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Name your export</DialogTitle>
<DialogDescription>
This title appears at the top of the file and is used as the filename. Leave it as- is
for the default report name.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="export-title-input" className="text-xs text-muted-foreground">
Title
</Label>
<Input
id="export-title-input"
autoFocus
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !exporting) {
e.preventDefault();
handleConfirm();
}
}}
placeholder="e.g. Q2 sales review for board"
/>
<p className="text-[11px] text-muted-foreground">
Filename: <code className="font-mono">{previewFilename()}</code>
</p>
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setPendingFormat(null)}
disabled={exporting}
>
Cancel
</Button>
<Button size="sm" onClick={handleConfirm} disabled={exporting}>
<Download className="mr-1.5 h-4 w-4" aria-hidden />
{exporting ? 'Downloading…' : 'Download'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
/**
* File-safe slug from an arbitrary title. Lowercases, replaces runs
* of non-alphanumerics with single hyphens, trims leading/trailing
* hyphens. Cap at 80 chars so OS file dialogs don't get an essay.
*/
function slugify(s: string): string {
return s
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
}
function todaySlug(): string {
return new Date().toISOString().slice(0, 10);
}
/**
* Trigger a browser download for an ExportResult. The blob URL is
* revoked after the click so we don't leak object URLs on long-lived
* sessions where the user exports many reports.
*/
function downloadResult(result: ExportResult): void {
const url = URL.createObjectURL(result.body);
const a = document.createElement('a');
a.href = url;
a.download = result.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}

View File

@@ -0,0 +1,338 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Bookmark, Check, Loader2, Save, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import type { ReportTemplate } from '@/lib/db/schema/reports';
type StandaloneReportKind = 'sales' | 'operational' | 'custom';
interface ListResponse {
data: ReportTemplate[];
}
interface ReportTemplatesButtonProps<TConfig extends Record<string, unknown>> {
/** Discriminator on the saved template row. Must match the report
* page; cross-kind templates are filtered out of the dropdown. */
kind: StandaloneReportKind;
/** Snapshot of the report's current view state. Save flows persist
* this verbatim; Load flows hand it back via onApply. */
currentConfig: TConfig;
/** Apply a loaded config to the report's local state. The component
* passes the entire `config` object back; the report client picks
* off whatever keys it knows about. */
onApply: (config: TConfig) => void;
/** Set after a load so the UI can show "Using template X". When the
* user changes any view-state (range, filter, etc.) downstream of
* load, the parent should null this back out so the badge clears. */
activeTemplateId?: string | null;
/** Optional callback so the parent can reflect template-load /
* template-clear in URL state. */
onActiveTemplateChange?: (id: string | null) => void;
/** Optional pre-selection: if the URL carried a `?templateId=…`,
* pass it in here and the component will hydrate + apply on mount. */
initialTemplateId?: string | null;
}
/**
* Combined Save + Load + Delete control for the standalone Sales and
* Operational reports. One trigger button (with a "Using template X"
* indicator), opens a popover that lists saved templates and offers
* "Save as new template…".
*
* Schema: report_templates rows with kind ∈ {sales, operational}.
* Config payload shape is owner-defined per report.
*/
export function ReportTemplatesButton<TConfig extends Record<string, unknown>>({
kind,
currentConfig,
onApply,
activeTemplateId,
onActiveTemplateChange,
initialTemplateId,
}: ReportTemplatesButtonProps<TConfig>) {
const qc = useQueryClient();
const [popoverOpen, setPopoverOpen] = useState(false);
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [saveName, setSaveName] = useState('');
const [saveDescription, setSaveDescription] = useState('');
// Ref instead of state for the one-time hydration guard so we can
// update it without triggering a re-render (and without tripping
// react-hooks/set-state-in-effect on the surrounding useEffect).
const hydratedRef = useRef(false);
const listQuery = useQuery<ListResponse>({
queryKey: ['report-templates', kind],
queryFn: () =>
apiFetch<ListResponse>(`/api/v1/reports/templates?kind=${encodeURIComponent(kind)}`),
staleTime: 30_000,
});
// Hydrate from ?templateId=… on first render once the list lands.
useEffect(() => {
if (hydratedRef.current) return;
if (!initialTemplateId) return;
if (!listQuery.data) return;
const found = listQuery.data.data.find((t) => t.id === initialTemplateId);
if (found) {
onApply(found.config as TConfig);
onActiveTemplateChange?.(found.id);
}
hydratedRef.current = true;
}, [initialTemplateId, listQuery.data, onApply, onActiveTemplateChange]);
const saveMutation = useMutation({
mutationFn: async (input: { name: string; description: string | null }) => {
const body = {
kind,
name: input.name,
description: input.description,
// The schema-level `config.kind` cross-check on the API requires
// the discriminator to live on the payload itself.
config: { ...currentConfig, kind },
};
return apiFetch<{ data: ReportTemplate }>(`/api/v1/reports/templates`, {
method: 'POST',
body,
});
},
onSuccess: ({ data }) => {
toast.success(`Template "${data.name}" saved`);
setSaveDialogOpen(false);
setSaveName('');
setSaveDescription('');
onActiveTemplateChange?.(data.id);
void qc.invalidateQueries({ queryKey: ['report-templates', kind] });
},
onError: (err) => toastError(err),
});
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
await apiFetch(`/api/v1/reports/templates/${id}`, { method: 'DELETE' });
},
onSuccess: (_, id) => {
toast.success('Template deleted');
if (activeTemplateId === id) onActiveTemplateChange?.(null);
void qc.invalidateQueries({ queryKey: ['report-templates', kind] });
},
onError: (err) => toastError(err),
});
const updateMutation = useMutation({
mutationFn: async (id: string) => {
return apiFetch<{ data: ReportTemplate }>(`/api/v1/reports/templates/${id}`, {
method: 'PATCH',
body: { config: { ...currentConfig, kind } },
});
},
onSuccess: ({ data }) => {
toast.success(`Template "${data.name}" updated`);
void qc.invalidateQueries({ queryKey: ['report-templates', kind] });
},
onError: (err) => toastError(err),
});
function handleApply(template: ReportTemplate) {
onApply(template.config as TConfig);
onActiveTemplateChange?.(template.id);
setPopoverOpen(false);
}
const templates = listQuery.data?.data ?? [];
const activeTemplate = activeTemplateId
? templates.find((t) => t.id === activeTemplateId)
: undefined;
return (
<>
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<Bookmark className="mr-1.5 h-4 w-4" aria-hidden />
{activeTemplate ? `Template: ${activeTemplate.name}` : 'Templates'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-3" align="end">
<div className="space-y-3">
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Saved templates
</p>
{listQuery.isLoading ? (
<p className="mt-1 text-xs text-muted-foreground">Loading</p>
) : templates.length === 0 ? (
<p className="mt-1 text-xs text-muted-foreground">
No saved templates yet. Save your current view below.
</p>
) : (
<ul className="mt-1 max-h-56 overflow-y-auto space-y-0.5">
{templates.map((t) => {
const isActive = t.id === activeTemplateId;
return (
<li
key={t.id}
className="group flex items-center gap-1 rounded-sm px-1 py-0.5 hover:bg-muted/50"
>
<button
type="button"
onClick={() => handleApply(t)}
className="flex-1 text-left text-sm"
>
<span className="flex items-center gap-1.5">
{isActive ? (
<Check className="h-3.5 w-3.5 text-primary" aria-hidden />
) : (
<span className="h-3.5 w-3.5" aria-hidden />
)}
<span>{t.name}</span>
</span>
{t.description ? (
<p className="pl-5 text-[11px] text-muted-foreground line-clamp-1">
{t.description}
</p>
) : null}
</button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100"
onClick={() => deleteMutation.mutate(t.id)}
disabled={deleteMutation.isPending}
aria-label={`Delete template ${t.name}`}
title="Delete this template"
>
<Trash2 className="h-3.5 w-3.5 text-destructive" aria-hidden />
</Button>
</li>
);
})}
</ul>
)}
</div>
<Separator />
<div className="space-y-1.5">
<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={() => {
setPopoverOpen(false);
setSaveDialogOpen(true);
}}
>
<Save className="mr-1.5 h-4 w-4" aria-hidden />
Save current view as template
</Button>
{activeTemplate ? (
<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={() => {
updateMutation.mutate(activeTemplate.id);
setPopoverOpen(false);
}}
disabled={updateMutation.isPending}
>
{updateMutation.isPending ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />
) : (
<Save className="mr-1.5 h-4 w-4" aria-hidden />
)}
Update &quot;{activeTemplate.name}&quot;
</Button>
) : null}
</div>
</div>
</PopoverContent>
</Popover>
<Dialog open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Save report as template</DialogTitle>
<DialogDescription>
The current date range and filter selection are captured. Re-run the report from this
template in one click from the Reports landing page or the Templates list.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<Label htmlFor="template-name" className="text-xs">
Name
</Label>
<Input
id="template-name"
autoFocus
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
placeholder="e.g. Monthly board sales view"
/>
</div>
<div className="space-y-1">
<Label htmlFor="template-description" className="text-xs">
Description (optional)
</Label>
<Textarea
id="template-description"
value={saveDescription}
onChange={(e) => setSaveDescription(e.target.value)}
placeholder="Helpful note about what this template is for"
rows={2}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setSaveDialogOpen(false)}
disabled={saveMutation.isPending}
>
Cancel
</Button>
<Button
size="sm"
onClick={() =>
saveMutation.mutate({
name: saveName.trim(),
description: saveDescription.trim() || null,
})
}
disabled={!saveName.trim() || saveMutation.isPending}
>
{saveMutation.isPending ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />
) : (
<Save className="mr-1.5 h-4 w-4" aria-hidden />
)}
Save template
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,9 +1,10 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import type { Route } from 'next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Calendar } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Calendar, Pencil, Play, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { PageHeader } from '@/components/shared/page-header';
@@ -22,11 +23,16 @@ import {
} from '@/components/ui/table';
import { EmptyState } from '@/components/shared/empty-state';
import { apiFetch } from '@/lib/api/client';
import type { ReportSchedule } from '@/lib/db/schema/reports';
import { toastError } from '@/lib/api/toast-error';
import { ScheduleDialog } from '@/components/reports/schedule-dialog';
import type { ReportSchedule, ReportTemplate } from '@/lib/db/schema/reports';
interface ListResponse {
interface SchedulesResponse {
data: ReportSchedule[];
}
interface TemplatesResponse {
data: ReportTemplate[];
}
const CADENCE_LABELS: Record<string, string> = {
weekly_monday_9: 'Weekly · Monday 9am',
@@ -36,9 +42,20 @@ const CADENCE_LABELS: Record<string, string> = {
export function ReportSchedulesPageClient({ portSlug }: { portSlug: string }) {
const qc = useQueryClient();
const { data, isLoading } = useQuery<ListResponse>({
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<ReportSchedule | undefined>(undefined);
const schedulesQuery = useQuery<SchedulesResponse>({
queryKey: ['report-schedules'],
queryFn: () => apiFetch<ListResponse>('/api/v1/reports/schedules?limit=50'),
queryFn: () => apiFetch<SchedulesResponse>('/api/v1/reports/schedules?pageSize=50'),
});
// Pull all templates so we can resolve template_id → name in the
// table without N round-trips. One extra query, cheap, port-scoped.
const templatesQuery = useQuery<TemplatesResponse>({
queryKey: ['report-templates', 'all'],
queryFn: () => apiFetch<TemplatesResponse>('/api/v1/reports/templates'),
staleTime: 30_000,
});
const toggleMutation = useMutation({
@@ -50,36 +67,83 @@ export function ReportSchedulesPageClient({ portSlug }: { portSlug: string }) {
},
onSuccess: () => {
toast.success('Schedule updated');
qc.invalidateQueries({ queryKey: ['report-schedules'] });
void qc.invalidateQueries({ queryKey: ['report-schedules'] });
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Update failed'),
onError: (err) => toastError(err),
});
const rows = data?.data ?? [];
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
return apiFetch(`/api/v1/reports/schedules/${id}`, { method: 'DELETE' });
},
onSuccess: () => {
toast.success('Schedule deleted');
void qc.invalidateQueries({ queryKey: ['report-schedules'] });
},
onError: (err) => toastError(err),
});
const runNowMutation = useMutation({
mutationFn: async (schedule: ReportSchedule) => {
const tmpl = templatesQuery.data?.data.find((t) => t.id === schedule.templateId);
if (!tmpl) throw new Error('Template no longer exists; cannot run.');
return apiFetch(`/api/v1/reports/runs`, {
method: 'POST',
body: {
kind: tmpl.kind,
templateId: tmpl.id,
// Re-stamp the discriminator onto config — the run-create
// route's same cross-check requires config.kind === kind.
config: { ...(tmpl.config as Record<string, unknown>), kind: tmpl.kind },
outputFormat: schedule.outputFormat,
},
});
},
onSuccess: () => {
toast.success('Run queued — check Runs tab in a few seconds');
void qc.invalidateQueries({ queryKey: ['report-runs'] });
},
onError: (err) => toastError(err),
});
const templateById = new Map(templatesQuery.data?.data?.map((t) => [t.id, t]) ?? []);
const rows = schedulesQuery.data?.data ?? [];
return (
<div className="space-y-4">
<PageHeader
eyebrow="Reports"
title="Schedules"
description="Recurring reports auto-emailed to your recipient list."
description="Recurring reports that auto-run and (optionally) email a recipient list."
actions={
<Button asChild variant="outline" size="sm">
<Link href={`/${portSlug}/reports` as Route}>
<ArrowLeft className="mr-1.5 h-4 w-4" aria-hidden />
All reports
</Link>
</Button>
<div className="flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href={`/${portSlug}/reports` as Route}>
<ArrowLeft className="mr-1.5 h-4 w-4" aria-hidden />
All reports
</Link>
</Button>
<Button
size="sm"
onClick={() => {
setEditing(undefined);
setDialogOpen(true);
}}
>
<Plus className="mr-1.5 h-4 w-4" aria-hidden />
New schedule
</Button>
</div>
}
/>
{isLoading ? (
{schedulesQuery.isLoading ? (
<Skeleton className="h-[200px] w-full" aria-hidden />
) : rows.length === 0 ? (
<EmptyState
icon={Calendar}
title="No schedules yet"
description="Save a template, then schedule it from the template detail page."
description="Create a schedule against a saved template. Recipients are optional — runs are archived even without an email blast."
/>
) : (
<Card>
@@ -87,48 +151,119 @@ export function ReportSchedulesPageClient({ portSlug }: { portSlug: string }) {
<Table>
<TableHeader>
<TableRow>
<TableHead>Template</TableHead>
<TableHead>Cadence</TableHead>
<TableHead>Recipients</TableHead>
<TableHead>Last run</TableHead>
<TableHead>Next run</TableHead>
<TableHead>Output</TableHead>
<TableHead className="w-20 text-right">Enabled</TableHead>
<TableHead className="w-32 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">
{CADENCE_LABELS[s.cadence] ?? s.cadence}
</TableCell>
<TableCell>
<Badge variant="outline">
{Array.isArray(s.recipients) ? s.recipients.length : 0}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{s.lastRunAt ? new Date(s.lastRunAt).toLocaleString() : '—'}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{new Date(s.nextRunAt).toLocaleString()}
</TableCell>
<TableCell className="text-xs uppercase tracking-wide text-muted-foreground">
{s.outputFormat}
</TableCell>
<TableCell className="text-right">
<Switch
checked={s.enabled}
onCheckedChange={(enabled) => toggleMutation.mutate({ id: s.id, enabled })}
disabled={toggleMutation.isPending}
/>
</TableCell>
</TableRow>
))}
{rows.map((s) => {
const tmpl = templateById.get(s.templateId);
const recipientCount = Array.isArray(s.recipients) ? s.recipients.length : 0;
return (
<TableRow key={s.id}>
<TableCell className="font-medium">
{tmpl ? (
<>
{tmpl.name}
<span className="ml-1.5 text-xs text-muted-foreground capitalize">
· {tmpl.kind}
</span>
</>
) : (
<span className="text-muted-foreground italic">template missing</span>
)}
</TableCell>
<TableCell>{CADENCE_LABELS[s.cadence] ?? s.cadence}</TableCell>
<TableCell>
{recipientCount === 0 ? (
<Badge variant="outline" className="text-muted-foreground">
archive only
</Badge>
) : (
<Badge variant="outline">{recipientCount}</Badge>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{s.lastRunAt ? new Date(s.lastRunAt).toLocaleString() : '—'}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{new Date(s.nextRunAt).toLocaleString()}
</TableCell>
<TableCell className="text-xs uppercase tracking-wide text-muted-foreground">
{s.outputFormat}
</TableCell>
<TableCell className="text-right">
<Switch
checked={s.enabled}
onCheckedChange={(enabled) =>
toggleMutation.mutate({ id: s.id, enabled })
}
disabled={toggleMutation.isPending}
/>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-0.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => runNowMutation.mutate(s)}
disabled={runNowMutation.isPending || !tmpl}
aria-label="Run now"
title="Run this schedule now (one-off)"
>
<Play className="h-3.5 w-3.5" aria-hidden />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
setEditing(s);
setDialogOpen(true);
}}
aria-label="Edit schedule"
title="Edit schedule"
>
<Pencil className="h-3.5 w-3.5" aria-hidden />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
if (
confirm(
`Delete schedule? This stops the recurring run; existing runs in the history stay.`,
)
) {
deleteMutation.mutate(s.id);
}
}}
disabled={deleteMutation.isPending}
aria-label="Delete schedule"
title="Delete schedule"
>
<Trash2 className="h-3.5 w-3.5 text-destructive" aria-hidden />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
<ScheduleDialog open={dialogOpen} onOpenChange={setDialogOpen} schedule={editing} />
</div>
);
}

View File

@@ -0,0 +1,266 @@
import { Document, Image, Page, StyleSheet, Text, View } from '@react-pdf/renderer';
import type { ReportPayload, ReportSection } from '@/lib/reports/types';
import type { ReportBranding } from './types';
/**
* Generic payload-driven PDF document. Takes a `ReportPayload` from any
* report (Sales / Operational / Custom / future) and renders it in a
* branded shell — cover with port logo + title + period + generated-at
* stamp, KPI grid below, then one section per ReportSection with a
* tabular layout.
*
* This is the "v2" PDF surface; the legacy per-kind documents
* (DashboardReport, ClientListReport, etc.) under this folder remain
* for the original `/api/v1/reports/[id]` route and aren't touched.
*/
interface Props {
payload: ReportPayload;
branding: ReportBranding;
generatedAt: string;
}
export function PayloadReportDocument({ payload, branding, generatedAt }: Props) {
const styles = makeStyles(branding);
return (
<Document
title={payload.title}
author={branding.portName}
subject={payload.description ?? `${branding.portName} report`}
creator="Port Nimara CRM"
producer="Port Nimara CRM"
>
<Page size="A4" style={styles.page} wrap>
{/* Cover header — logo + title + period */}
<View style={styles.coverHeader}>
{branding.logoUrl ? (
<Image src={branding.logoUrl} style={styles.logo} cache />
) : (
<View style={{ width: 36, height: 36 }} />
)}
<View style={styles.coverHeaderText}>
<Text style={styles.title}>{payload.title}</Text>
{payload.description ? (
<Text style={styles.subtitle}>{payload.description}</Text>
) : null}
<Text style={styles.period}>
{formatDate(payload.range.from)} {formatDate(payload.range.to)}
</Text>
</View>
</View>
{/* KPI grid — three per row */}
{payload.kpis.length > 0 ? (
<View style={styles.kpiGrid}>
{payload.kpis.map((kpi, i) => (
<View key={i} style={styles.kpiTile}>
<Text style={styles.kpiLabel}>{String(kpi.label).toUpperCase()}</Text>
<Text style={styles.kpiValue}>{String(kpi.value)}</Text>
{kpi.hint ? <Text style={styles.kpiHint}>{kpi.hint}</Text> : null}
</View>
))}
</View>
) : null}
{/* Sections */}
{payload.sections.map((section, i) => (
<SectionBlock key={i} section={section} styles={styles} />
))}
{/* Footer (fixed across pages) */}
<View style={styles.footer} fixed>
<Text style={styles.footerLeft}>{branding.portName}</Text>
<Text style={styles.footerRight}>Generated {formatDateTime(generatedAt)}</Text>
<Text
style={styles.footerCenter}
render={({ pageNumber, totalPages }) => `${pageNumber} / ${totalPages}`}
fixed
/>
</View>
</Page>
</Document>
);
}
function SectionBlock({
section,
styles,
}: {
section: ReportSection;
styles: ReturnType<typeof makeStyles>;
}) {
return (
<View style={styles.section} wrap>
<Text style={styles.sectionTitle}>{section.title}</Text>
{section.rows.length === 0 ? (
<Text style={styles.empty}>No data in this section.</Text>
) : (
<View style={styles.table}>
{/* Header */}
<View style={[styles.tableRow, styles.tableHeader]}>
{section.columns.map((col, i) => {
const cellStyle =
col.align === 'right'
? [styles.tableCell, styles.tableHeaderCell, styles.tableCellRight]
: [styles.tableCell, styles.tableHeaderCell];
return (
<Text key={i} style={cellStyle}>
{col.label}
</Text>
);
})}
</View>
{/* Body */}
{section.rows.map((row, ri) => {
const rowStyle =
ri % 2 === 1 ? [styles.tableRow, styles.tableRowZebra] : styles.tableRow;
return (
<View key={ri} style={rowStyle}>
{section.columns.map((col, ci) => {
const v = row[col.key];
const text = col.format ? col.format(v) : formatPlain(v);
const cellStyle =
col.align === 'right'
? [styles.tableCell, styles.tableCellRight]
: styles.tableCell;
return (
<Text key={ci} style={cellStyle}>
{text}
</Text>
);
})}
</View>
);
})}
</View>
)}
</View>
);
}
function formatPlain(v: unknown): string {
if (v === null || v === undefined) return '';
if (v instanceof Date) return v.toISOString().slice(0, 10);
return String(v);
}
function formatDate(d: Date): string {
return d.toISOString().slice(0, 10);
}
function formatDateTime(iso: string): string {
return iso.replace('T', ' ').slice(0, 16) + ' UTC';
}
function makeStyles(branding: ReportBranding) {
return StyleSheet.create({
page: {
paddingTop: 40,
paddingBottom: 56,
paddingHorizontal: 36,
fontSize: 9.5,
fontFamily: 'Helvetica',
color: '#1e2844',
backgroundColor: '#ffffff',
},
coverHeader: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 14,
borderBottomWidth: 2,
borderBottomColor: branding.primaryColor,
paddingBottom: 14,
marginBottom: 18,
},
logo: { width: 36, height: 36, objectFit: 'contain' },
coverHeaderText: { flexDirection: 'column', flex: 1 },
title: {
fontSize: 18,
fontFamily: 'Helvetica-Bold',
color: branding.primaryColor,
marginBottom: 3,
},
subtitle: { fontSize: 10, color: '#475569', marginBottom: 4 },
period: { fontSize: 9, color: '#64748b', fontFamily: 'Helvetica' },
kpiGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginBottom: 18,
},
kpiTile: {
width: '32%',
borderWidth: 0.5,
borderColor: '#e2e8f0',
borderRadius: 3,
padding: 8,
backgroundColor: '#f8fafc',
},
kpiLabel: {
fontSize: 7,
color: '#64748b',
letterSpacing: 1,
fontFamily: 'Helvetica-Bold',
marginBottom: 3,
},
kpiValue: {
fontSize: 14,
fontFamily: 'Helvetica-Bold',
color: '#0f172a',
marginBottom: 2,
},
kpiHint: { fontSize: 7.5, color: '#94a3b8' },
section: { marginBottom: 16 },
sectionTitle: {
fontSize: 11,
fontFamily: 'Helvetica-Bold',
color: branding.primaryColor,
marginBottom: 6,
paddingBottom: 3,
borderBottomWidth: 0.5,
borderBottomColor: '#cbd5e1',
},
empty: { fontSize: 9, color: '#94a3b8', fontStyle: 'italic', paddingVertical: 6 },
table: { borderTopWidth: 0.5, borderTopColor: '#e2e8f0' },
tableRow: {
flexDirection: 'row',
borderBottomWidth: 0.5,
borderBottomColor: '#e2e8f0',
paddingVertical: 4,
},
tableRowZebra: { backgroundColor: '#f8fafc' },
tableHeader: { backgroundColor: branding.primaryColor },
tableHeaderCell: { color: '#ffffff', fontFamily: 'Helvetica-Bold', fontSize: 8 },
tableCell: {
flex: 1,
paddingHorizontal: 6,
fontSize: 8.5,
color: '#1e293b',
},
tableCellRight: { textAlign: 'right' },
footer: {
position: 'absolute',
bottom: 20,
left: 36,
right: 36,
borderTopWidth: 0.5,
borderTopColor: '#e2e8f0',
paddingTop: 6,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
footerLeft: { fontSize: 8, color: '#64748b' },
footerRight: { fontSize: 8, color: '#94a3b8' },
footerCenter: {
position: 'absolute',
left: 0,
right: 0,
top: 6,
textAlign: 'center',
fontSize: 8,
color: '#64748b',
},
});
}

View File

@@ -143,9 +143,20 @@ export const reportsWorker = new Worker(
.where(eq(reportSchedules.id, schedule.id));
try {
const { REPORT_KINDS } = await import('@/lib/validators/reports');
const kindNarrowed = (REPORT_KINDS as readonly string[]).includes(template.kind)
? (template.kind as (typeof REPORT_KINDS)[number])
: null;
if (!kindNarrowed) {
logger.warn(
{ scheduleId: schedule.id, templateId: schedule.templateId, kind: template.kind },
'Skipping schedule: template kind not in REPORT_KINDS allowlist',
);
continue;
}
const run = await createReportRun(
{
kind: template.kind as 'dashboard' | 'clients' | 'berths' | 'interests',
kind: kindNarrowed,
config: template.config,
outputFormat: schedule.outputFormat as 'pdf' | 'csv' | 'png',
templateId: template.id,
@@ -183,15 +194,38 @@ export const reportsWorker = new Worker(
const { renderReportRun } = await import('@/lib/services/report-render.service');
const run = await renderReportRun(reportRunId);
// Schedule-driven runs auto-cascade into the email job. User-
// triggered runs are inert — the rep downloads via the UI.
if (run.triggeredBy === 'schedule' && run.status === 'complete') {
const { getQueue: enqueue } = await import('@/lib/queue');
await enqueue('reports').add(
'report-run-email',
{ reportRunId: run.id },
{ jobId: `report-run-email:${run.id}` },
);
// Schedule-driven runs auto-cascade into the email job ONLY when
// the schedule has recipients configured. Email is optional per
// locked decision (2026-05-27): an admin can schedule a run that
// just appears in /reports/runs without forcing a blast.
// User-triggered runs are inert — the rep downloads via the UI.
if (
run.triggeredBy === 'schedule' &&
run.status === 'complete' &&
run.scheduleId !== null
) {
const { db: dbForSched } = await import('@/lib/db');
const { reportSchedules: schedTbl } = await import('@/lib/db/schema/reports');
const { eq: eqOp } = await import('drizzle-orm');
const sched = await dbForSched.query.reportSchedules.findFirst({
where: eqOp(schedTbl.id, run.scheduleId),
columns: { recipients: true },
});
const hasRecipients =
Array.isArray(sched?.recipients) && (sched?.recipients?.length ?? 0) > 0;
if (hasRecipients) {
const { getQueue: enqueue } = await import('@/lib/queue');
await enqueue('reports').add(
'report-run-email',
{ reportRunId: run.id },
{ jobId: `report-run-email:${run.id}` },
);
} else {
logger.info(
{ reportRunId: run.id, scheduleId: run.scheduleId },
'Schedule has no recipients; skipping email cascade (run archived only)',
);
}
}
break;
}

View File

@@ -0,0 +1,303 @@
/**
* Custom-report entity registry.
*
* The custom builder is the catch-all for slices the four canonical
* reports don't cover — pick an entity, pick columns, optionally
* filter by date, get a CSV. v1 ships with the four highest-value
* entities (clients, interests, berths, tenancies); the remaining six
* from the launch-readiness scope (companies, yachts, invoices,
* payments, deals, sends) layer in as their schemas are wired.
*
* Each entity defines:
* - `columns`: an allowlist of column keys + human labels + a
* resolver that extracts the value from a fetched row. The
* allowlist matters: it gates which fields a rep can pull into a
* CSV, so PII columns can be opt-in per role later.
* - `runQuery`: a Drizzle select that joins whatever the columns
* need, applies the port filter + optional date range, and
* returns raw rows.
*
* Adding a new entity:
* 1. Append it to ENTITY_KEYS.
* 2. Add a CustomEntityDefinition entry to ENTITY_REGISTRY.
* 3. Update the UI's entity-picker (it reads ENTITY_REGISTRY directly).
*/
import { and, asc, desc, eq, gte, lte, sql, type SQL } from 'drizzle-orm';
import { db } from '@/lib/db';
import { berths } from '@/lib/db/schema/berths';
import { clients } from '@/lib/db/schema/clients';
import { interests, interestBerths } from '@/lib/db/schema/interests';
import { berthTenancies as tenancies } from '@/lib/db/schema/tenancies';
import { STAGE_LABELS, type PipelineStage } from '@/lib/constants';
export const ENTITY_KEYS = ['clients', 'interests', 'berths', 'tenancies'] as const;
export type EntityKey = (typeof ENTITY_KEYS)[number];
export interface CustomFilter {
/** ISO 8601 — inclusive lower bound on the entity's "date" column
* (createdAt or equivalent — see entity definition). */
from?: Date;
/** ISO 8601 — inclusive upper bound. */
to?: Date;
}
export interface ColumnDefinition {
/** Stable key. Persisted in saved-template configs. */
key: string;
/** Human-readable column header used in CSV/PDF output + the UI
* multi-select. */
label: string;
/** Default selection in the UI. Reps can uncheck. */
defaultSelected?: boolean;
}
export interface CustomEntityDefinition {
key: EntityKey;
label: string;
description: string;
/** Friendly name for the date filter — different entities anchor
* the date range to different timestamps. */
dateAxis: string;
columns: ColumnDefinition[];
/** Execute the underlying query and return raw rows keyed by column
* key. The runner is responsible for the joins + port scoping;
* callers only pass which columns they want + the filter. */
runQuery: (input: {
portId: string;
columns: string[];
filter: CustomFilter;
}) => Promise<Array<Record<string, unknown>>>;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function applyDateRange(column: ReturnType<typeof sql<Date>>, filter: CustomFilter): SQL[] {
const conds: SQL[] = [];
if (filter.from) conds.push(gte(column as never, filter.from));
if (filter.to) conds.push(lte(column as never, filter.to));
return conds;
}
// ─── Clients ─────────────────────────────────────────────────────────────────
const CLIENTS_COLUMNS: ColumnDefinition[] = [
{ key: 'fullName', label: 'Full name', defaultSelected: true },
{ key: 'nationalityIso', label: 'Nationality', defaultSelected: false },
{ key: 'preferredLanguage', label: 'Preferred language' },
{ key: 'preferredContactMethod', label: 'Preferred contact', defaultSelected: false },
{ key: 'source', label: 'Source', defaultSelected: true },
{ key: 'createdAt', label: 'Created', defaultSelected: true },
{ key: 'archivedAt', label: 'Archived at' },
];
async function runClientsQuery({
portId,
filter,
}: {
portId: string;
columns: string[];
filter: CustomFilter;
}): Promise<Array<Record<string, unknown>>> {
const conds = [eq(clients.portId, portId), ...applyDateRange(clients.createdAt as never, filter)];
const rows = await db
.select({
fullName: clients.fullName,
nationalityIso: clients.nationalityIso,
preferredLanguage: clients.preferredLanguage,
preferredContactMethod: clients.preferredContactMethod,
source: clients.source,
createdAt: clients.createdAt,
archivedAt: clients.archivedAt,
})
.from(clients)
.where(and(...conds))
.orderBy(asc(clients.fullName))
.limit(10_000);
return rows.map((r) => ({ ...r }));
}
// ─── Interests ───────────────────────────────────────────────────────────────
const INTERESTS_COLUMNS: ColumnDefinition[] = [
{ key: 'clientName', label: 'Client', defaultSelected: true },
{ key: 'primaryBerth', label: 'Primary berth', defaultSelected: true },
{ key: 'pipelineStage', label: 'Stage', defaultSelected: true },
{ key: 'leadCategory', label: 'Lead category' },
{ key: 'outcome', label: 'Outcome', defaultSelected: true },
{ key: 'source', label: 'Source', defaultSelected: false },
{ key: 'depositExpectedAmount', label: 'Deposit expected (amt)', defaultSelected: false },
{ key: 'depositExpectedCurrency', label: 'Deposit expected (ccy)' },
{ key: 'dateFirstContact', label: 'First contact', defaultSelected: false },
{ key: 'dateLastContact', label: 'Last contact', defaultSelected: false },
{ key: 'createdAt', label: 'Created', defaultSelected: true },
];
async function runInterestsQuery({
portId,
filter,
}: {
portId: string;
columns: string[];
filter: CustomFilter;
}): Promise<Array<Record<string, unknown>>> {
const conds = [
eq(interests.portId, portId),
...applyDateRange(interests.createdAt as never, filter),
];
const rows = await db
.select({
clientName: clients.fullName,
primaryBerth: berths.mooringNumber,
pipelineStage: interests.pipelineStage,
leadCategory: interests.leadCategory,
outcome: interests.outcome,
source: interests.source,
depositExpectedAmount: interests.depositExpectedAmount,
depositExpectedCurrency: interests.depositExpectedCurrency,
dateFirstContact: interests.dateFirstContact,
dateLastContact: interests.dateLastContact,
createdAt: interests.createdAt,
})
.from(interests)
.innerJoin(clients, eq(interests.clientId, clients.id))
.leftJoin(
interestBerths,
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
)
.leftJoin(berths, eq(interestBerths.berthId, berths.id))
.where(and(...conds))
.orderBy(desc(interests.createdAt))
.limit(10_000);
return rows.map((r) => ({
...r,
// Re-label stage to the human form so the CSV is readable;
// analysts can still join back via the raw enum on display.
pipelineStage: r.pipelineStage
? (STAGE_LABELS[r.pipelineStage as PipelineStage] ?? r.pipelineStage)
: null,
}));
}
// ─── Berths ──────────────────────────────────────────────────────────────────
const BERTHS_COLUMNS: ColumnDefinition[] = [
{ key: 'mooringNumber', label: 'Mooring', defaultSelected: true },
{ key: 'area', label: 'Area' },
{ key: 'status', label: 'Status', defaultSelected: true },
{ key: 'length', label: 'Length (m)' },
{ key: 'width', label: 'Width (m)' },
{ key: 'draft', label: 'Draft (m)' },
{ key: 'price', label: 'Price', defaultSelected: true },
{ key: 'priceCurrency', label: 'Currency' },
{ key: 'createdAt', label: 'Created' },
];
async function runBerthsQuery({
portId,
filter,
}: {
portId: string;
columns: string[];
filter: CustomFilter;
}): Promise<Array<Record<string, unknown>>> {
const conds = [eq(berths.portId, portId), ...applyDateRange(berths.createdAt as never, filter)];
const rows = await db
.select({
mooringNumber: berths.mooringNumber,
area: berths.area,
status: berths.status,
length: berths.lengthM,
width: berths.widthM,
draft: berths.draftM,
price: berths.price,
priceCurrency: berths.priceCurrency,
createdAt: berths.createdAt,
})
.from(berths)
.where(and(...conds))
.orderBy(asc(berths.mooringNumber))
.limit(10_000);
return rows.map((r) => ({ ...r }));
}
// ─── Tenancies ───────────────────────────────────────────────────────────────
const TENANCIES_COLUMNS: ColumnDefinition[] = [
{ key: 'clientName', label: 'Client', defaultSelected: true },
{ key: 'mooringNumber', label: 'Berth', defaultSelected: true },
{ key: 'tenureType', label: 'Tenure type', defaultSelected: true },
{ key: 'startDate', label: 'Start', defaultSelected: true },
{ key: 'endDate', label: 'End', defaultSelected: true },
{ key: 'status', label: 'Status', defaultSelected: true },
{ key: 'createdAt', label: 'Created' },
];
async function runTenanciesQuery({
portId,
filter,
}: {
portId: string;
columns: string[];
filter: CustomFilter;
}): Promise<Array<Record<string, unknown>>> {
const conds = [
eq(tenancies.portId, portId),
...applyDateRange(tenancies.createdAt as never, filter),
];
const rows = await db
.select({
clientName: clients.fullName,
mooringNumber: berths.mooringNumber,
tenureType: tenancies.tenureType,
startDate: tenancies.startDate,
endDate: tenancies.endDate,
status: tenancies.status,
createdAt: tenancies.createdAt,
})
.from(tenancies)
.leftJoin(clients, eq(tenancies.clientId, clients.id))
.leftJoin(berths, eq(tenancies.berthId, berths.id))
.where(and(...conds))
.orderBy(desc(tenancies.startDate))
.limit(10_000);
return rows.map((r) => ({ ...r }));
}
// ─── Registry ────────────────────────────────────────────────────────────────
export const ENTITY_REGISTRY: Record<EntityKey, CustomEntityDefinition> = {
clients: {
key: 'clients',
label: 'Clients',
description: 'People in your CRM: name, source, contact preferences.',
dateAxis: 'Created',
columns: CLIENTS_COLUMNS,
runQuery: runClientsQuery,
},
interests: {
key: 'interests',
label: 'Interests / deals',
description: 'Sales pipeline: stage, outcome, value, deposit details.',
dateAxis: 'Created',
columns: INTERESTS_COLUMNS,
runQuery: runInterestsQuery,
},
berths: {
key: 'berths',
label: 'Berths',
description: 'Mooring inventory: dimensions, status, price.',
dateAxis: 'Created',
columns: BERTHS_COLUMNS,
runQuery: runBerthsQuery,
},
tenancies: {
key: 'tenancies',
label: 'Tenancies',
description: 'Berth leases / annual contracts: dates, tenure type, status.',
dateAxis: 'Created',
columns: TENANCIES_COLUMNS,
runQuery: runTenanciesQuery,
},
};

View File

@@ -0,0 +1,104 @@
import Papa from 'papaparse';
import type { ExportResult, ReportPayload, ReportSection } from '@/lib/reports/types';
/**
* Serialise a ReportPayload as CSV. The single-file output contains:
*
* 1. A title row + period row + generated-at row (header)
* 2. A "KPIs" section with two columns (label, value)
* 3. Each ReportSection's title, header row, data rows
* 4. Blank lines between sections so Excel/Numbers can detect them
* when "Convert Text to Columns" is run after a paste.
*
* The output is plain UTF-8 with a leading BOM so Excel correctly
* decodes non-ASCII characters (€ symbols, accented names, etc.)
* without the user having to manually pick an encoding.
*/
interface CsvExportOptions {
/** Override the auto-derived filename (which is
* `${payload.filenameSlug}-${date-range}.csv`). When the user has
* given the export a custom title, pass `${slugify(title)}.csv`
* here so the filename matches their intent without the verbose
* date suffix. */
filenameOverride?: string;
}
export function exportReportAsCsv(
payload: ReportPayload,
options: CsvExportOptions = {},
): ExportResult {
const lines: string[] = [];
// Header
lines.push(`"${escape(payload.title)}"`);
lines.push(
`"Period","${payload.range.from.toISOString().slice(0, 10)}","to","${payload.range.to
.toISOString()
.slice(0, 10)}"`,
);
lines.push(`"Generated","${new Date().toISOString()}"`);
lines.push('');
// KPI section
if (payload.kpis.length > 0) {
lines.push('"KPIs"');
lines.push(
Papa.unparse({
fields: ['Metric', 'Value', 'Hint'],
data: payload.kpis.map((k) => [k.label, formatValue(k.value), k.hint ?? '']),
}),
);
lines.push('');
}
// Per-section blocks
for (const section of payload.sections) {
lines.push(`"${escape(section.title)}"`);
lines.push(sectionToCsv(section));
lines.push('');
}
// BOM + UTF-8 so Excel decodes correctly without prompting.
const bom = '';
const body = new Blob([bom + lines.join('\n')], { type: 'text/csv;charset=utf-8' });
return {
filename: options.filenameOverride ?? `${payload.filenameSlug}-${dateSlug(payload.range)}.csv`,
mimeType: 'text/csv;charset=utf-8',
body,
};
}
/** Reusable filename derivation so the UI's "filename preview" matches
* what the exporter will actually emit. */
export function defaultCsvFilename(payload: ReportPayload): string {
return `${payload.filenameSlug}-${dateSlug(payload.range)}.csv`;
}
function sectionToCsv(section: ReportSection): string {
return Papa.unparse({
fields: section.columns.map((c) => c.label),
data: section.rows.map((row) =>
section.columns.map((c) => {
const v = row[c.key];
return c.format ? c.format(v) : formatValue(v);
}),
),
});
}
function formatValue(v: unknown): string {
if (v === null || v === undefined) return '';
if (v instanceof Date) return v.toISOString();
if (typeof v === 'number') return String(v);
return String(v);
}
function escape(s: string): string {
return s.replace(/"/g, '""');
}
function dateSlug(range: { from: Date; to: Date }): string {
return `${range.from.toISOString().slice(0, 10)}_${range.to.toISOString().slice(0, 10)}`;
}

View File

@@ -0,0 +1,86 @@
import type { ExportResult, ReportPayload } from '@/lib/reports/types';
/**
* PDF export. Unlike CSV + Excel (which can serialise in the browser),
* PDF generation runs server-side via `@react-pdf/renderer` so the
* client posts the payload to `/api/v1/reports/export-pdf` and receives
* the rendered bytes back.
*
* The server resolves the active port's branding (logo + primary
* color + name) so per-port theming flows through automatically — the
* client doesn't need to send branding fields.
*/
interface PdfExportOptions {
/** Filename override mirroring the CSV / Excel exporters. */
filenameOverride?: string;
}
export async function exportReportAsPdf(
payload: ReportPayload,
options: PdfExportOptions = {},
): Promise<ExportResult> {
// Serialise dates to ISO so they survive the JSON trip.
const wireBody = {
title: payload.title,
description: payload.description,
filenameSlug: payload.filenameSlug,
range: {
from: payload.range.from.toISOString(),
to: payload.range.to.toISOString(),
},
kpis: payload.kpis,
sections: payload.sections.map((s) => ({
title: s.title,
columns: s.columns.map((c) => ({
key: c.key,
label: c.label,
align: c.align,
// `format` is a function and isn't serialisable; the server
// falls back to plain stringification, which matches the CSV
// exporter's default behaviour when no format is set.
})),
// Apply client-side format functions BEFORE serialising so the
// server sees pre-formatted strings. This preserves money /
// percentage / date formatting that the original ReportPayload
// declared.
rows: s.rows.map((row) => {
const out: Record<string, unknown> = {};
for (const col of s.columns) {
out[col.key] = col.format ? col.format(row[col.key]) : row[col.key];
}
return out;
}),
})),
filenameOverride: options.filenameOverride,
};
const res = await fetch('/api/v1/reports/export-pdf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(wireBody),
credentials: 'include',
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(text || `PDF generation failed (${res.status})`);
}
const blob = await res.blob();
const cdHeader = res.headers.get('content-disposition') ?? '';
const match = cdHeader.match(/filename="([^"]+)"/);
const filename = match?.[1] ?? options.filenameOverride ?? defaultPdfFilename(payload);
return {
filename,
mimeType: 'application/pdf',
body: blob,
};
}
export function defaultPdfFilename(payload: ReportPayload): string {
const fromIso = payload.range.from.toISOString().slice(0, 10);
const toIso = payload.range.to.toISOString().slice(0, 10);
return `${payload.filenameSlug}-${fromIso}_${toIso}.pdf`;
}

View File

@@ -0,0 +1,169 @@
import ExcelJS from 'exceljs';
import type { ExportResult, ReportPayload, ReportSection } from '@/lib/reports/types';
/**
* Multi-sheet Excel export. Sheet layout:
* - "Summary" — title + period + each KPI as a labelled row
* - One sheet per ReportSection — header row + data rows
*
* Excel sheet names are capped at 31 chars + can't contain certain
* characters (\\/?*[]:); we sanitise + truncate accordingly.
*/
interface XlsxExportOptions {
/** Filename without extension. Defaults to the payload's filenameSlug
* + the date range, matching the CSV exporter's pattern. */
filenameOverride?: string;
}
export async function exportReportAsXlsx(
payload: ReportPayload,
options: XlsxExportOptions = {},
): Promise<ExportResult> {
const workbook = new ExcelJS.Workbook();
workbook.creator = 'Port Nimara CRM';
workbook.created = new Date();
workbook.modified = new Date();
// ─── Summary sheet ─────────────────────────────────────────────────────
const summary = workbook.addWorksheet('Summary', {
properties: { tabColor: { argb: 'FF3A7BC8' } },
});
// Title block
summary.mergeCells('A1:C1');
const titleCell = summary.getCell('A1');
titleCell.value = payload.title;
titleCell.font = { name: 'Arial', size: 16, bold: true, color: { argb: 'FF0A1628' } };
titleCell.alignment = { vertical: 'middle' };
summary.mergeCells('A2:C2');
summary.getCell('A2').value = payload.description ?? '';
summary.getCell('A2').font = {
name: 'Arial',
size: 10,
italic: true,
color: { argb: 'FF6B6557' },
};
summary.mergeCells('A3:C3');
summary.getCell('A3').value = `Period: ${formatDate(payload.range.from)} ${formatDate(
payload.range.to,
)}`;
summary.getCell('A3').font = { name: 'Arial', size: 10, color: { argb: 'FF6B6557' } };
summary.mergeCells('A4:C4');
summary.getCell('A4').value = `Generated: ${new Date().toISOString()}`;
summary.getCell('A4').font = { name: 'Arial', size: 9, color: { argb: 'FF94A3B8' } };
// KPI rows
summary.addRow([]);
const kpiHeader = summary.addRow(['Metric', 'Value', 'Hint']);
kpiHeader.font = { name: 'Arial', bold: true, color: { argb: 'FFFFFFFF' } };
kpiHeader.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1E2844' } };
for (const kpi of payload.kpis) {
summary.addRow([kpi.label, kpi.value, kpi.hint ?? '']);
}
// Column widths
summary.getColumn(1).width = 28;
summary.getColumn(2).width = 22;
summary.getColumn(3).width = 40;
// Freeze the title block + KPI header
summary.views = [{ state: 'frozen', ySplit: 6 }];
// ─── One sheet per section ─────────────────────────────────────────────
for (const section of payload.sections) {
const sheetName = sanitizeSheetName(section.title);
const sheet = workbook.addWorksheet(sheetName);
// Header row from section columns
const headerValues = section.columns.map((c) => c.label);
const header = sheet.addRow(headerValues);
header.font = { name: 'Arial', bold: true, color: { argb: 'FFFFFFFF' } };
header.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1E2844' } };
header.alignment = { horizontal: 'left', vertical: 'middle' };
header.height = 22;
// Data rows
addSectionRows(sheet, section);
// Column widths — set based on header length plus content peek
section.columns.forEach((col, i) => {
const headerLen = col.label.length;
const sampleLen = section.rows
.slice(0, 20)
.reduce((max, r) => Math.max(max, formatCell(col, r[col.key]).length), 0);
sheet.getColumn(i + 1).width = Math.min(Math.max(headerLen, sampleLen) + 4, 50);
if (col.align === 'right') {
sheet.getColumn(i + 1).alignment = { horizontal: 'right' };
}
});
// Freeze the header row
sheet.views = [{ state: 'frozen', ySplit: 1 }];
// Auto-filter on header
sheet.autoFilter = {
from: { row: 1, column: 1 },
to: { row: 1, column: section.columns.length },
};
}
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
return {
filename:
options.filenameOverride ?? `${payload.filenameSlug}-${dateRangeSlug(payload.range)}.xlsx`,
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
body: blob,
};
}
/** Reusable filename derivation for UI previews. */
export function defaultXlsxFilename(payload: ReportPayload): string {
return `${payload.filenameSlug}-${dateRangeSlug(payload.range)}.xlsx`;
}
function addSectionRows(sheet: ExcelJS.Worksheet, section: ReportSection): void {
for (const row of section.rows) {
const values = section.columns.map((col) => {
const v = row[col.key];
// Excel does best with native numbers / dates / strings; let
// the column.format hint take precedence for display, fall back
// to raw value for native typing.
if (col.format) {
return col.format(v);
}
if (v instanceof Date) return v;
if (typeof v === 'number') return v;
if (v === null || v === undefined) return '';
return String(v);
});
sheet.addRow(values);
}
}
function formatCell(col: { format?: (v: unknown) => string }, value: unknown): string {
if (col.format) return col.format(value);
if (value === null || value === undefined) return '';
return String(value);
}
/** Excel sheet name constraints: max 31 chars, no \\/?*[]:. */
function sanitizeSheetName(raw: string): string {
return raw.replace(/[\\/?*[\]:]/g, '-').slice(0, 31);
}
function formatDate(d: Date): string {
return d.toISOString().slice(0, 10);
}
function dateRangeSlug(range: { from: Date; to: Date }): string {
return `${formatDate(range.from)}_${formatDate(range.to)}`;
}

View File

@@ -0,0 +1,67 @@
/**
* Shared currency formatting for the reports surfaces. Three duplicated
* `formatMoney` helpers used to live in sales-report-client,
* sales-detail-tables, sales-deal-heat, sales-rep-leaderboard, and a
* fourth shape buried inside the operational report — all variations of
* the same Intl.NumberFormat call. Consolidated here so a single change
* (e.g. switching to compact / showing decimals for tiny values)
* propagates everywhere.
*
* Locked decisions (2026-05-27 currency-formatting sweep):
* - Use `style: 'currency'` with the row's / report's currency code so
* the locale's native glyph appears (€, $, £, zł). Falls back to
* `<rounded number> <code>` when the runtime doesn't know the code.
* - `maximumFractionDigits: 0` — marina deals are six figures+, the
* decimals add noise.
* - `undefined` locale → browser / Node default (en-US in CI; user's
* locale on the client). The Intl behaviour matches what every
* other money render site in the app already does.
*/
export function formatMoney(amount: number, currency: string): string {
const safeCurrency = (currency || 'USD').toUpperCase();
try {
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency: safeCurrency,
maximumFractionDigits: 0,
}).format(amount);
} catch {
return `${Math.round(amount).toLocaleString()} ${safeCurrency}`;
}
}
/**
* Compact form for KPI tiles when space is tight — `€1.2M` instead of
* `€1,234,567`. Only fires above the threshold so small portfolios still
* read literal.
*/
export function formatMoneyCompact(amount: number, currency: string): string {
const safeCurrency = (currency || 'USD').toUpperCase();
if (Math.abs(amount) < 100_000) return formatMoney(amount, safeCurrency);
try {
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency: safeCurrency,
notation: 'compact',
maximumFractionDigits: 1,
}).format(amount);
} catch {
return formatMoney(amount, safeCurrency);
}
}
/**
* Plain-number formatter with thousand separators. Use for amount
* columns where the currency is shown in an adjacent column (the
* custom builder's "Deposit expected" + "Currency" pair, for instance) —
* keeping the value parseable as a number for spreadsheet analysis while
* still being readable on screen.
*/
export function formatNumber(amount: number): string {
try {
return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(amount);
} catch {
return Math.round(amount).toLocaleString();
}
}

69
src/lib/reports/types.ts Normal file
View File

@@ -0,0 +1,69 @@
/**
* Normalised report payload shared by every report and every export
* format. Builders produce a ReportPayload; exporters (csv/xlsx/pdf)
* consume it. Keeping one shape decouples report content from output
* format — adding a new report doesn't require touching any exporter
* and vice-versa.
*/
export interface ReportPayload {
/** Display title (e.g. "Sales performance"). Used as the PDF cover
* title + xlsx workbook name + CSV filename root. */
title: string;
/** Period the report covers. Rendered on the PDF cover; baked into
* the CSV/xlsx filename. */
range: { from: Date; to: Date };
/** Optional one-line subtitle. */
description?: string;
/** Filename slug (kebab/snake). Used as the basis for the downloaded
* file's name; the format extension is appended by the exporter. */
filenameSlug: string;
/** Single-number KPI cards rendered at the top of every output. The
* CSV exporter emits these as the first section; xlsx puts them on
* a "Summary" sheet; PDF renders them as a banner. */
kpis: ReportKpi[];
/** Tabular sections. Each section becomes a CSV block (with a blank
* line between sections), an xlsx sheet, and a PDF table. */
sections: ReportSection[];
}
export interface ReportKpi {
label: string;
value: string | number;
/** Optional secondary line under the value (e.g. "based on 12 won deals"). */
hint?: string;
}
export interface ReportSection {
/** Human-readable section title. xlsx uses it as the sheet name
* (truncated to 31 chars per Excel's limit); CSV writes it as a
* comment row. */
title: string;
/** Ordered column definitions. The CSV header row + xlsx column
* headers + PDF table columns come from this. */
columns: ReportColumn[];
/** Row data. Each row is an object keyed by column.key. */
rows: Array<Record<string, unknown>>;
}
export interface ReportColumn {
/** Object key used to extract the cell value from a row. */
key: string;
/** Display header. */
label: string;
/** Optional formatter applied per cell at export time. Default is
* String(value). */
format?: (value: unknown) => string;
/** Alignment hint for xlsx + PDF (CSV ignores). */
align?: 'left' | 'right' | 'center';
}
/** What a sender produces; what an exporter returns. */
export interface ExportResult {
filename: string;
/** MIME type appropriate to the format. */
mimeType: string;
/** Raw bytes (xlsx/pdf) or UTF-8 string (csv). The caller serialises
* to a download via createObjectURL / Blob. */
body: Blob;
}

View File

@@ -46,6 +46,16 @@ import { ActivityReportPdf } from '@/lib/pdf/templates/reports/activity-report';
import { OccupancyReportPdf } from '@/lib/pdf/templates/reports/occupancy-report';
import { PipelineReportPdf } from '@/lib/pdf/templates/reports/pipeline-report';
import { RevenueReportPdf } from '@/lib/pdf/templates/reports/revenue-report';
import { PayloadReportDocument } from '@/lib/pdf/reports/payload-report';
import { absolutizeBrandingUrl } from '@/lib/branding/url';
import { getPortBrandingConfig } from '@/lib/services/port-config';
import {
buildSalesReportPayload,
buildOperationalReportPayload,
} from '@/lib/services/reports/build-payload';
import { renderToBuffer } from '@react-pdf/renderer';
import { createElement } from 'react';
import type { ReportPayload } from '@/lib/reports/types';
interface RenderCtx {
portName: string;
@@ -190,6 +200,14 @@ export async function renderReportRun(reportRunId: string): Promise<ReportRun> {
let putStoragePath: string | null = null;
try {
// Standalone report kinds (sales, operational) take a different
// render path: they build a generic ReportPayload from saved-template
// config + live data, then feed it through PayloadReportDocument.
// The legacy 4 kinds still flow through REPORT_RENDER_MAP below.
if (run.kind === 'sales' || run.kind === 'operational') {
return await renderStandaloneReportRun(run);
}
const renderer = REPORT_RENDER_MAP[run.kind];
if (!renderer) {
throw new CodedError('VALIDATION_ERROR', {
@@ -361,3 +379,104 @@ export async function emailReportRun(reportRunId: string): Promise<void> {
emailedAt: new Date(),
});
}
/**
* Render path for the standalone Sales / Operational reports. Builds the
* shared `ReportPayload` from the saved-template config + live data, then
* routes through `PayloadReportDocument` — same path the interactive
* Export PDF button uses. Output format is PDF; CSV/XLSX for scheduled
* runs is not yet wired (use the interactive Export for those formats).
*/
async function renderStandaloneReportRun(run: ReportRun): Promise<ReportRun> {
let putStoragePath: string | null = null;
try {
const port = await db.query.ports.findFirst({ where: eq(ports.id, run.portId) });
if (!port) {
throw new Error(`Cannot render report ${run.id}: port ${run.portId} not found`);
}
let payload: ReportPayload;
if (run.kind === 'sales') {
payload = await buildSalesReportPayload(
run.portId,
run.config as Parameters<typeof buildSalesReportPayload>[1],
);
} else {
payload = await buildOperationalReportPayload(
run.portId,
run.config as Parameters<typeof buildOperationalReportPayload>[1],
);
}
// CSV / XLSX rendering on the worker is deferred — PDF only for v1.
// The interactive Export button covers CSV + XLSX client-side.
if (run.outputFormat !== 'pdf') {
throw new CodedError('VALIDATION_ERROR', {
internalMessage: `Scheduled ${run.kind} reports currently support PDF only (got ${run.outputFormat}).`,
});
}
const cfg = await getPortBrandingConfig(run.portId);
const branding = {
logoUrl: absolutizeBrandingUrl(cfg.logoUrl),
primaryColor: cfg.primaryColor,
portName: port.name,
};
const generatedAt = new Date().toISOString();
const element = createElement(PayloadReportDocument, {
payload,
branding,
generatedAt,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const bytes = (await renderToBuffer(element as any)) as Buffer;
const fileId = crypto.randomUUID();
const storagePath = buildStoragePath(port.slug, 'reports', run.id, fileId, 'pdf');
const backend = await getStorageBackend();
await backend.put(storagePath, bytes, {
contentType: 'application/pdf',
sizeBytes: bytes.length,
});
putStoragePath = storagePath;
await db.insert(files).values({
id: fileId,
portId: run.portId,
filename: `${run.kind}-${run.id.slice(0, 8)}.pdf`,
originalName: `${run.kind}-report.pdf`,
mimeType: 'application/pdf',
sizeBytes: String(bytes.length),
storagePath,
storageBucket: env.MINIO_BUCKET,
category: 'misc',
uploadedBy: run.triggeredByUserId ?? 'system',
});
const updated = await updateReportRunStatus(run.id, run.portId, {
status: 'complete',
storageKey: fileId,
sizeBytes: bytes.length,
});
putStoragePath = null;
return updated;
} catch (err) {
logger.error({ err, reportRunId: run.id }, 'renderStandaloneReportRun failed');
await updateReportRunStatus(run.id, run.portId, {
status: 'failed',
errorMessage: err instanceof Error ? err.message : String(err),
}).catch(() => undefined);
if (putStoragePath) {
try {
await (await getStorageBackend()).delete(putStoragePath);
} catch (compErr) {
logger.error(
{ compErr, putStoragePath },
'Compensating storage.delete failed after render error',
);
}
}
throw err;
}
}

View File

@@ -0,0 +1,458 @@
/**
* Server-side payload builders for the standalone Sales + Operational
* reports. The interactive Export button builds the same payload in the
* browser via the report client's local state — but scheduled runs
* execute in a worker context with no browser state, so we replicate
* the same shape from saved-template configs here.
*
* Output is a `ReportPayload` ready to feed `PayloadReportDocument`
* (PDF) or any other format-agnostic exporter.
*/
import { STAGE_LABELS, OUTCOME_LABELS, type PipelineStage } from '@/lib/constants';
import { rangeToBounds, type DateRange } from '@/lib/analytics/range';
import { formatMoney } from '@/lib/reports/format-currency';
import type { ReportPayload } from '@/lib/reports/types';
import {
getSalesKpis,
getPipelineFunnel,
getStageVelocity,
getWinRateOverTime,
getSourceConversion,
getRepLeaderboard,
getDealHeat,
getRepPerformanceDetail,
getStalledDeals,
getClosingThisMonth,
getRecentWins,
getLostReasonBreakdown,
type SalesFilters,
} from '@/lib/services/reports/sales.service';
import {
getOperationalKpis,
getOccupancyByArea,
getTenanciesEndingSoon,
getVacantBerths,
getStuckSigning,
getHighestValueVacant,
} from '@/lib/services/reports/operational.service';
/** Shape of a stored template `config` for the Sales report. */
interface SalesTemplateConfig {
kind: 'sales';
range?: DateRange;
filters?: {
stage?: string[];
leadCategory?: string[];
outcome?: string[];
};
}
/** Shape of a stored template `config` for the Operational report. */
interface OperationalTemplateConfig {
kind: 'operational';
range?: DateRange;
statusMixMode?: 'absolute' | 'proportional';
}
export async function buildSalesReportPayload(
portId: string,
config: SalesTemplateConfig,
): Promise<ReportPayload> {
const range = config.range ?? '30d';
const bounds = rangeToBounds(range);
const filters: SalesFilters | undefined = config.filters
? {
stages: config.filters.stage as PipelineStage[] | undefined,
leadCategories: config.filters.leadCategory,
outcomes: config.filters.outcome,
}
: undefined;
const [
kpis,
funnel,
stageVelocity,
winRateOverTime,
sourceConversion,
repLeaderboard,
dealHeat,
stalledDeals,
closingThisMonth,
recentWins,
lostReasonBreakdown,
] = await Promise.all([
getSalesKpis(portId, bounds),
getPipelineFunnel(portId),
getStageVelocity(portId),
getWinRateOverTime(portId, bounds),
getSourceConversion(portId),
getRepLeaderboard(portId, bounds),
getDealHeat(portId),
getStalledDeals(portId, filters),
getClosingThisMonth(portId, filters),
getRecentWins(portId, filters),
getLostReasonBreakdown(portId, bounds, filters),
]);
// RepPerformanceDetail is unused in the scheduled-output payload —
// the leaderboard table covers the same ground; adding it on a PDF
// page just duplicates the data.
void getRepPerformanceDetail;
// All money values returned by the sales service are already in the
// port's reporting currency (service converts on read). Money rows
// are pre-formatted into strings below so the column emits a ready-
// to-render value regardless of whether the downstream renderer keeps
// the column.format callback (XLSX / on-page CSV) or drops it (server
// PDF over a JSON boundary).
const portCurrency = kpis.pipelineValueCurrency;
const fmtAmount = (v: number | null | undefined): string =>
v === null || v === undefined ? '—' : formatMoney(v, portCurrency);
return {
title: 'Sales performance',
description: 'Rep performance, win rates, pipeline value, stalled deals, deal heat.',
filenameSlug: 'sales-performance',
range: bounds,
kpis: [
{ label: 'Active interests', value: kpis.activeInterests },
{ label: 'Won in period', value: kpis.wonInWindow },
{
label: 'Lost in period',
value: kpis.lostInWindow,
hint: kpis.lossBreakdown
.map((b) => `${b.count} ${b.outcome.replace(/^lost_/, '')}`)
.join(', '),
},
{
label: 'Win rate',
value: kpis.winRate === null ? '—' : `${(kpis.winRate * 100).toFixed(1)}%`,
},
{
label: 'Pipeline value',
value: formatMoney(kpis.pipelineValue, kpis.pipelineValueCurrency),
hint: `${kpis.pipelineValueTotalActiveCount} active interests`,
},
{
label: 'Avg time to close',
value:
kpis.medianTimeToCloseDays === null
? '—'
: `${kpis.medianTimeToCloseDays.toFixed(1)} days`,
hint:
kpis.medianTimeToCloseDays !== null
? `based on ${kpis.timeToCloseSampleSize} won deals`
: 'need ≥3 won deals',
},
{
label: 'New leads',
value: kpis.newLeadsInWindow,
hint: kpis.newLeadsBySource.map((s) => `${s.count} ${s.source}`).join(', '),
},
],
sections: [
{
title: 'Pipeline funnel',
columns: [
{ key: 'stage', label: 'Stage' },
{ key: 'count', label: 'Active deals', align: 'right' },
{
key: 'dropoffFromPrior',
label: 'Drop-off vs prior',
align: 'right',
format: (v) =>
v === null || v === undefined ? '' : `${((v as number) * 100).toFixed(0)}%`,
},
],
rows: funnel.map((r) => ({
stage: STAGE_LABELS[r.stage],
count: r.count,
dropoffFromPrior: r.dropoffFromPrior,
})),
},
{
title: 'Stage velocity',
columns: [
{ key: 'stage', label: 'Stage' },
{
key: 'medianDays',
label: 'Median days in stage',
align: 'right',
format: (v) => (v === null || v === undefined ? '—' : (v as number).toFixed(1)),
},
{
key: 'p90Days',
label: 'p90 days',
align: 'right',
format: (v) => (v === null || v === undefined ? '—' : (v as number).toFixed(1)),
},
{ key: 'transitions', label: 'Sample size', align: 'right' },
],
rows: stageVelocity.map((r) => ({
stage: STAGE_LABELS[r.stage],
medianDays: r.medianDays,
p90Days: r.p90Days,
transitions: r.transitions,
})),
},
{
title: `Win rate over time (${winRateOverTime.granularity})`,
columns: [
{ key: 'bucket', label: 'Period' },
{ key: 'won', label: 'Won', align: 'right' },
{ key: 'lost', label: 'Lost', align: 'right' },
{
key: 'winRate',
label: 'Win rate',
align: 'right',
format: (v) =>
v === null || v === undefined ? '—' : `${((v as number) * 100).toFixed(1)}%`,
},
],
rows: winRateOverTime.points.map((p) => ({ ...p })),
},
{
title: 'Source → win conversion',
columns: [
{ key: 'source', label: 'Source' },
{ key: 'won', label: 'Won', align: 'right' },
{ key: 'lost', label: 'Lost', align: 'right' },
{ key: 'cancelled', label: 'Cancelled', align: 'right' },
{ key: 'in_flight', label: 'In flight', align: 'right' },
{ key: 'total', label: 'Total', align: 'right' },
],
rows: sourceConversion.map((r) => ({
source: r.source,
won: r.counts.won,
lost: r.counts.lost,
cancelled: r.counts.cancelled,
in_flight: r.counts.in_flight,
total: r.total,
})),
},
{
title: 'Rep leaderboard',
columns: [
{ key: 'displayName', label: 'Rep' },
{ key: 'newDeals', label: 'New', align: 'right' },
{ key: 'won', label: 'Won', align: 'right' },
{ key: 'lost', label: 'Lost', align: 'right' },
{ key: 'inFlight', label: 'In flight', align: 'right' },
{ key: 'pipelineValue', label: 'Pipeline value', align: 'right' },
{
key: 'winRate',
label: 'Win rate',
align: 'right',
format: (v) =>
v === null || v === undefined ? '' : `${((v as number) * 100).toFixed(0)}%`,
},
],
rows: repLeaderboard.map((r) => ({
...r,
pipelineValue: formatMoney(r.pipelineValue, r.pipelineValueCurrency),
})),
},
{
title: 'Deal heat — hottest deals',
columns: [
{ key: 'clientName', label: 'Client' },
{ key: 'mooringNumber', label: 'Berth' },
{
key: 'stage',
label: 'Stage',
format: (v) => STAGE_LABELS[v as PipelineStage] ?? '',
},
{ key: 'bucket', label: 'Heat' },
{
key: 'daysSinceLastContact',
label: 'Days since contact',
align: 'right',
format: (v) => (v === null || v === undefined ? 'never' : String(v)),
},
{ key: 'pipelineValue', label: 'Value', align: 'right' },
],
rows: dealHeat.topDeals.map((d) => ({
...d,
pipelineValue: formatMoney(d.pipelineValue, d.pipelineValueCurrency),
})),
},
{
title: 'Stalled deals',
columns: [
{ key: 'clientName', label: 'Client' },
{ key: 'primaryBerth', label: 'Berth' },
{ key: 'stage', label: 'Stage', format: (v) => STAGE_LABELS[v as PipelineStage] ?? '' },
{ key: 'rep', label: 'Rep' },
{ key: 'daysSinceLastContact', label: 'Days since contact', align: 'right' },
{ key: 'stageValue', label: 'Value', align: 'right' },
],
rows: stalledDeals.map((r) => ({
...r,
stageValue: fmtAmount(r.stageValue),
})),
},
{
title: 'Closing this month',
columns: [
{ key: 'clientName', label: 'Client' },
{ key: 'primaryBerth', label: 'Berth' },
{ key: 'stage', label: 'Stage', format: (v) => STAGE_LABELS[v as PipelineStage] ?? '' },
{ key: 'rep', label: 'Rep' },
{ key: 'daysInStage', label: 'Days in stage', align: 'right' },
{ key: 'stageValue', label: 'Value', align: 'right' },
],
rows: closingThisMonth.map((r) => ({
...r,
stageValue: fmtAmount(r.stageValue),
})),
},
{
title: 'Recent wins',
columns: [
{ key: 'clientName', label: 'Client' },
{ key: 'primaryBerth', label: 'Berth' },
{ key: 'rep', label: 'Rep' },
{ key: 'outcomeAt', label: 'Closed at', format: (v) => String(v).slice(0, 10) },
{ key: 'finalValue', label: 'Value', align: 'right' },
{ key: 'daysToClose', label: 'Days to close', align: 'right' },
],
rows: recentWins.map((r) => ({
...r,
finalValue: formatMoney(r.finalValue, r.currency),
})),
},
{
title: 'Lost-reason breakdown',
columns: [
{
key: 'outcome',
label: 'Outcome',
format: (v) => OUTCOME_LABELS[v as string] ?? String(v),
},
{ key: 'count', label: 'Count', align: 'right' },
{ key: 'totalValueLost', label: 'Value lost', align: 'right' },
{
key: 'avgDaysFromFirstContactToLoss',
label: 'Avg days to loss',
align: 'right',
format: (v) => (v === null || v === undefined ? '—' : (v as number).toFixed(1)),
},
],
rows: lostReasonBreakdown.map((r) => ({
...r,
totalValueLost: formatMoney(r.totalValueLost, r.currency),
})),
},
],
};
}
export async function buildOperationalReportPayload(
portId: string,
config: OperationalTemplateConfig,
): Promise<ReportPayload> {
const range = config.range ?? '30d';
const bounds = rangeToBounds(range);
const [kpis, occupancyByArea, endingSoon, vacantBerths, stuckSigning, highestValueVacant] =
await Promise.all([
getOperationalKpis(portId, bounds),
getOccupancyByArea(portId),
getTenanciesEndingSoon(portId),
getVacantBerths(portId),
getStuckSigning(portId),
getHighestValueVacant(portId),
]);
const tenanciesOn = kpis.tenanciesModuleEnabled;
return {
title: 'Operational',
description:
'Berth utilisation, tenancy lifecycle, signing turnaround, and operational bottlenecks.',
filenameSlug: 'operational',
range: bounds,
kpis: [
{ label: 'Total berths', value: kpis.totalBerths },
{ label: 'Sold %', value: `${kpis.soldPct.toFixed(1)}%` },
{ label: 'Under offer %', value: `${kpis.underOfferPct.toFixed(1)}%` },
{
label: 'Active tenancies',
value: kpis.activeTenancies ?? '—',
hint: tenanciesOn ? undefined : 'Tenancies module disabled',
},
{
label: 'Avg tenancy length',
value:
kpis.avgTenancyLengthYears !== null
? `${kpis.avgTenancyLengthYears.toFixed(1)} years`
: '—',
},
{ label: 'Berths in conflict', value: kpis.berthsInConflict },
],
sections: [
{
title: 'Occupancy by area',
columns: [
{ key: 'area', label: 'Area' },
{ key: 'available', label: 'Available', align: 'right' },
{ key: 'underOffer', label: 'Under offer', align: 'right' },
{ key: 'sold', label: 'Sold', align: 'right' },
{ key: 'total', label: 'Total', align: 'right' },
],
rows: occupancyByArea.map((r) => ({ ...r })),
},
{
title: 'Tenancies ending soon (next 6 months)',
columns: [
{ key: 'clientName', label: 'Client' },
{ key: 'primaryBerth', label: 'Berth' },
{ key: 'tenureType', label: 'Tenure type' },
{ key: 'endDate', label: 'End date', format: (v) => String(v).slice(0, 10) },
{ key: 'daysUntilEnd', label: 'Days until end', align: 'right' },
],
rows: endingSoon.map((r) => ({ ...r })),
},
{
title: 'Vacant berths (>60 days)',
columns: [
{ key: 'mooring', label: 'Mooring' },
{ key: 'area', label: 'Area' },
{ key: 'dimensions', label: 'Dimensions' },
{ key: 'price', label: 'Price', align: 'right' },
{ key: 'daysAvailable', label: 'Days available', align: 'right' },
],
// Pre-format `price` per row using each row's currency so the
// column emits a single ready-to-render string (the shared
// format callback can't see the row).
rows: vacantBerths.map((r) => ({
...r,
price: r.price !== null ? formatMoney(r.price, r.currency) : '—',
})),
},
{
title: 'Stuck signing',
columns: [
{ key: 'documentType', label: 'Document type' },
{ key: 'title', label: 'Title' },
{ key: 'clientName', label: 'Client' },
{ key: 'sentAt', label: 'Sent at', format: (v) => String(v).slice(0, 10) },
{ key: 'daysOutstanding', label: 'Days outstanding', align: 'right' },
],
rows: stuckSigning.map((r) => ({ ...r })),
},
{
title: 'Highest-value vacant berths',
columns: [
{ key: 'mooring', label: 'Mooring' },
{ key: 'price', label: 'Price', align: 'right' },
],
rows: highestValueVacant.map((r) => ({
...r,
price: formatMoney(r.price, r.currency),
})),
},
],
};
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,15 @@ export type ListReportsInput = z.infer<typeof listReportsSchema>;
// adds the CRUD layer on the new tables. The legacy `generatedReports` flow
// above stays for the existing dashboard-export button until it migrates.
export const REPORT_KINDS = ['dashboard', 'clients', 'berths', 'interests'] as const;
export const REPORT_KINDS = [
'dashboard',
'clients',
'berths',
'interests',
'sales',
'operational',
'custom',
] as const;
export type ReportKind = (typeof REPORT_KINDS)[number];
export const REPORT_OUTPUT_FORMATS = ['pdf', 'csv', 'png'] as const;
@@ -87,7 +95,11 @@ export type ListReportSchedulesInput = z.infer<typeof listReportSchedulesSchema>
export const createReportScheduleSchema = z.object({
templateId: z.string().min(1),
cadence: z.enum(REPORT_SCHEDULE_CADENCES),
recipients: z.array(recipientSchema).min(1).max(50),
// Empty recipients list = "run + archive, don't email". Per locked
// decision (2026-05-27): auto-email is OPTIONAL — an admin can
// schedule a run that just appears in /reports/runs without
// forcing an email blast.
recipients: z.array(recipientSchema).max(50).default([]),
outputFormat: z.enum(REPORT_OUTPUT_FORMATS).default('pdf'),
enabled: z.boolean().default(true),
});
@@ -95,7 +107,7 @@ export type CreateReportScheduleInput = z.infer<typeof createReportScheduleSchem
export const updateReportScheduleSchema = z.object({
cadence: z.enum(REPORT_SCHEDULE_CADENCES).optional(),
recipients: z.array(recipientSchema).min(1).max(50).optional(),
recipients: z.array(recipientSchema).max(50).optional(),
outputFormat: z.enum(REPORT_OUTPUT_FORMATS).optional(),
enabled: z.boolean().optional(),
});