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:
524
docs/reports-content-spec.md
Normal file
524
docs/reports-content-spec.md
Normal 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` / `1–5y` / `5–10y` / `10–20y` / `20y+`
|
||||
- Ended tenancies only (active ones have no end date yet)
|
||||
|
||||
5. **Signing turnaround box plot** (echarts)
|
||||
- One box per document type (EOI / Reservation / Contract)
|
||||
- Median + quartiles + whiskers + outlier dots
|
||||
- Excludes voided + declined
|
||||
|
||||
6. **Occupancy by area** (recharts stacked horizontal bar)
|
||||
- One bar per area; segments coloured sold / under_offer / available
|
||||
- Scales cleanly to 10+ areas (vs donut-per-area which doesn't)
|
||||
|
||||
7. **Documents in pipeline** (recharts stacked bar)
|
||||
- Per document type, count by current status (`pending` / `sent` / `signed` / `declined` / `voided`)
|
||||
- Spots stuck batches at a glance
|
||||
|
||||
### Tables (4)
|
||||
|
||||
1. **Tenancies ending soon** _(module ON)_
|
||||
- Window: **next 6 months** (default)
|
||||
- Columns: client · berth · tenure type · end date · days until end · quick action (renew / end)
|
||||
- Sort: `endDate` asc
|
||||
|
||||
2. **Berths with no current owner**
|
||||
- Threshold: available for **>60 days**
|
||||
- Columns: mooring · area · dimensions · price · days available · last viewed date (from public berth-page analytics if available)
|
||||
|
||||
3. **Stuck signing**
|
||||
- **Document-type-aware thresholds:** EOI >10d / Reservation >7d / Contract >5d
|
||||
- Columns: document type · client · sent date · days outstanding · next signer · resend button
|
||||
|
||||
4. **Highest-value vacant berths**
|
||||
- Available berths sorted by `price` desc
|
||||
- Columns: mooring · area · dimensions · price · days available
|
||||
- Sales-focus list
|
||||
|
||||
### Filters
|
||||
|
||||
- **Cross-cutting** (auto-hidden when not relevant): date range + comparison toggle + rep + source
|
||||
- **Operational-specific:**
|
||||
- **Berth area** — multi-select; restricts heatmap + tables
|
||||
- **Tenure type** — permanent / fixed-term (affects tenancy charts + ending-soon table)
|
||||
- **Document type** — EOI / Reservation / Contract (affects signing chart + stuck-signing)
|
||||
- **Status filter** — for the heatmap/status-mix views: which statuses to display
|
||||
|
||||
### Currency handling
|
||||
|
||||
- All berth prices render in port-default currency
|
||||
- Underlying records can be in any currency; convert at render time
|
||||
- Render with thousand-separator + currency symbol
|
||||
|
||||
---
|
||||
|
||||
## Report 05 — Custom (ad-hoc composer)
|
||||
|
||||
**Purpose:** answer questions the canonical reports don't cover.
|
||||
|
||||
### Composition surface
|
||||
|
||||
1. **Pick an entity** (one): Clients, Yachts, Companies, Interests,
|
||||
Berths, Tenancies, Invoices, Expenses, Documents,
|
||||
Website Submissions.
|
||||
2. **Pick columns** — checkbox list of available columns for that
|
||||
entity, with sensible defaults pre-checked. Includes computed
|
||||
columns where they exist (e.g. `daysOverdue` on invoices).
|
||||
3. **Add filters** — one row per filter; each row: column → operator
|
||||
(=, ≠, in, contains, > <, between, is null) → value picker
|
||||
appropriate to the column type. AND/OR between rows.
|
||||
4. **Group by** (optional single dimension) — column from the entity.
|
||||
5. **Sort** — column + direction.
|
||||
6. **Aggregate** (when group-by is set) — count, sum, avg, min, max
|
||||
on each numeric column.
|
||||
7. **Live preview** — first 50 rows render as you build, server query
|
||||
re-runs on debounced change.
|
||||
8. **Save** — three buttons:
|
||||
- **Run once** — generate the report and add to library, no
|
||||
template saved.
|
||||
- **Save as template** — name + scope (personal / port-wide).
|
||||
- **Update existing template** — only visible if you opened from a
|
||||
template.
|
||||
|
||||
### Permissions
|
||||
|
||||
- Column whitelist per entity per role. A rep without
|
||||
`clients.view_pii` cannot pick `email` or `phone` columns. Same
|
||||
enforcement on the server-side row filter.
|
||||
- Filtering is always tenant-scoped via `port_id` (defense in depth).
|
||||
|
||||
### Output
|
||||
|
||||
- Same export buttons (PDF / CSV / Excel) as canonical reports.
|
||||
- PDF treatment uses the standard branded shell.
|
||||
|
||||
---
|
||||
|
||||
## Templates system
|
||||
|
||||
Applies to all 5 categories.
|
||||
|
||||
### Lifecycle
|
||||
|
||||
1. **Open a builder** — defaults to "Untitled" config.
|
||||
2. **Modify any filter / column / range** — header shows "Modified ●"
|
||||
indicator.
|
||||
3. **Save** — three options:
|
||||
- Overwrite the loaded template (if any).
|
||||
- Save as new (prompts for name + scope).
|
||||
- Discard changes.
|
||||
4. **Templates page** — list of all templates, per-template actions:
|
||||
open, run, schedule, share, archive.
|
||||
|
||||
### Scope
|
||||
|
||||
- **Personal** — visible only to creator. Can be promoted to port-wide
|
||||
later.
|
||||
- **Port-wide** — visible to all reps in the port; editable only by
|
||||
admins. "Owned by" name shown.
|
||||
|
||||
### Storage
|
||||
|
||||
- `report_templates` table already exists (per `schema/reports.ts`),
|
||||
audit to confirm shape matches the lifecycle above.
|
||||
|
||||
---
|
||||
|
||||
## Schedules
|
||||
|
||||
### Schedule object
|
||||
|
||||
- `templateId` — the report to run
|
||||
- `cron` expression OR friendly cadence (daily 9am, weekly Mondays,
|
||||
monthly 1st)
|
||||
- `emailEnabled` — boolean. When true, fires email; when false, only
|
||||
drops into runs library.
|
||||
- `recipients` — array of email addresses (only used when
|
||||
`emailEnabled`)
|
||||
- `format` — pdf / csv / xlsx — what to attach to the email
|
||||
- `lastRunAt`, `nextRunAt`, `lastResult` (success / failure)
|
||||
|
||||
### Worker
|
||||
|
||||
- BullMQ recurring job already exists in the stack; one queue
|
||||
`report-runs` does both on-demand and scheduled runs.
|
||||
- Failure surface: email the schedule creator on first failure (with
|
||||
short error), backoff retry once, mark `lastResult='failure'`.
|
||||
|
||||
---
|
||||
|
||||
## Open questions for the user
|
||||
|
||||
1. **AR aging buckets.** Do we use 30-day buckets or 14-day buckets?
|
||||
30 is industry standard; 14 catches issues earlier.
|
||||
2. **Currency normalisation for revenue.** USD or EUR as default? Or
|
||||
the port's `branding_default_currency`?
|
||||
3. **Sales rep visibility.** Should a rep see ONLY their own metrics
|
||||
on Sales Performance by default (with admins seeing the full
|
||||
leaderboard), or always the full team?
|
||||
4. **Inquiry → interest auto-link rule.** We've got `convertedAt` on
|
||||
`websiteSubmissions` and `sourceInquiryId` on `clients`. Is every
|
||||
conversion captured today, or are some manual links missed (which
|
||||
would skew the marketing report)?
|
||||
5. **"Pulse" / heat data.** Should the Sales report surface the deal
|
||||
pulse metric, or is that a separate "Deal Pulse" report?
|
||||
6. **Geographic chart.** The `react-simple-maps` library is approved
|
||||
(per memory). Are we OK to use it for the Marketing country chart,
|
||||
or is that scope creep?
|
||||
7. **Custom builder entity scope.** All 10 entities above, or start
|
||||
with the 4 sales-core ones (Clients, Yachts, Interests, Berths)
|
||||
and expand later?
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
21
src/app/(dashboard)/[portSlug]/reports/custom/page.tsx
Normal file
21
src/app/(dashboard)/[portSlug]/reports/custom/page.tsx
Normal 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} />;
|
||||
}
|
||||
19
src/app/(dashboard)/[portSlug]/reports/operational/page.tsx
Normal file
19
src/app/(dashboard)/[portSlug]/reports/operational/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
19
src/app/(dashboard)/[portSlug]/reports/sales/page.tsx
Normal file
19
src/app/(dashboard)/[portSlug]/reports/sales/page.tsx
Normal 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} />;
|
||||
}
|
||||
97
src/app/api/v1/reports/custom/run/route.ts
Normal file
97
src/app/api/v1/reports/custom/run/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
123
src/app/api/v1/reports/export-pdf/route.ts
Normal file
123
src/app/api/v1/reports/export-pdf/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
98
src/app/api/v1/reports/operational/route.ts
Normal file
98
src/app/api/v1/reports/operational/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
161
src/app/api/v1/reports/sales/route.ts
Normal file
161
src/app/api/v1/reports/sales/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -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
|
||||
|
||||
408
src/components/reports/custom/custom-report-builder.tsx
Normal file
408
src/components/reports/custom/custom-report-builder.tsx
Normal 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, '""')}"`;
|
||||
}
|
||||
129
src/components/reports/operational/operational-heatmap.tsx
Normal file
129
src/components/reports/operational/operational-heatmap.tsx
Normal 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' });
|
||||
}
|
||||
1047
src/components/reports/operational/operational-report-client.tsx
Normal file
1047
src/components/reports/operational/operational-report-client.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
}
|
||||
160
src/components/reports/sales/sales-deal-heat.tsx
Normal file
160
src/components/reports/sales/sales-deal-heat.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
488
src/components/reports/sales/sales-detail-tables.tsx
Normal file
488
src/components/reports/sales/sales-detail-tables.tsx
Normal 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'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'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
|
||||
"don't drop these" 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>;
|
||||
}
|
||||
133
src/components/reports/sales/sales-pipeline-funnel.tsx
Normal file
133
src/components/reports/sales/sales-pipeline-funnel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
src/components/reports/sales/sales-rep-leaderboard.tsx
Normal file
140
src/components/reports/sales/sales-rep-leaderboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
846
src/components/reports/sales/sales-report-client.tsx
Normal file
846
src/components/reports/sales/sales-report-client.tsx
Normal 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'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'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;
|
||||
126
src/components/reports/sales/sales-source-conversion.tsx
Normal file
126
src/components/reports/sales/sales-source-conversion.tsx
Normal 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(', ');
|
||||
}
|
||||
132
src/components/reports/sales/sales-stage-velocity.tsx
Normal file
132
src/components/reports/sales/sales-stage-velocity.tsx
Normal 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`;
|
||||
}
|
||||
145
src/components/reports/sales/sales-win-rate-over-time.tsx
Normal file
145
src/components/reports/sales/sales-win-rate-over-time.tsx
Normal 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)}`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
357
src/components/reports/schedule-dialog.tsx
Normal file
357
src/components/reports/schedule-dialog.tsx
Normal 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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
262
src/components/reports/shared/report-export-button.tsx
Normal file
262
src/components/reports/shared/report-export-button.tsx
Normal 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);
|
||||
}
|
||||
338
src/components/reports/shared/report-templates-button.tsx
Normal file
338
src/components/reports/shared/report-templates-button.tsx
Normal 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 "{activeTemplate.name}"
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
266
src/lib/pdf/reports/payload-report.tsx
Normal file
266
src/lib/pdf/reports/payload-report.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
303
src/lib/reports/custom/registry.ts
Normal file
303
src/lib/reports/custom/registry.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
104
src/lib/reports/exporters/csv.ts
Normal file
104
src/lib/reports/exporters/csv.ts
Normal 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)}`;
|
||||
}
|
||||
86
src/lib/reports/exporters/pdf.ts
Normal file
86
src/lib/reports/exporters/pdf.ts
Normal 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`;
|
||||
}
|
||||
169
src/lib/reports/exporters/xlsx.ts
Normal file
169
src/lib/reports/exporters/xlsx.ts
Normal 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)}`;
|
||||
}
|
||||
67
src/lib/reports/format-currency.ts
Normal file
67
src/lib/reports/format-currency.ts
Normal 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
69
src/lib/reports/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
458
src/lib/services/reports/build-payload.ts
Normal file
458
src/lib/services/reports/build-payload.ts
Normal 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),
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
1023
src/lib/services/reports/operational.service.ts
Normal file
1023
src/lib/services/reports/operational.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
1617
src/lib/services/reports/sales.service.ts
Normal file
1617
src/lib/services/reports/sales.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user