From 3bdf59e917c5ec4b1df2ef331c7c8aaa32cd09fe Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 27 May 2026 22:41:53 +0200 Subject: [PATCH] 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) --- docs/reports-content-spec.md | 524 ++++++ .../[portSlug]/reports/[kind]/page.tsx | 147 +- .../[portSlug]/reports/custom/page.tsx | 21 + .../[portSlug]/reports/operational/page.tsx | 19 + .../(dashboard)/[portSlug]/reports/page.tsx | 269 +-- .../[portSlug]/reports/sales/page.tsx | 19 + src/app/api/v1/reports/custom/run/route.ts | 97 + src/app/api/v1/reports/export-pdf/route.ts | 123 ++ src/app/api/v1/reports/operational/route.ts | 98 + src/app/api/v1/reports/sales/route.ts | 161 ++ src/app/api/v1/reports/templates/route.ts | 7 +- .../reports/custom/custom-report-builder.tsx | 408 +++++ .../operational/operational-heatmap.tsx | 129 ++ .../operational/operational-report-client.tsx | 1047 +++++++++++ .../operational-signing-box-plot.tsx | 129 ++ .../reports/sales/sales-deal-heat.tsx | 160 ++ .../reports/sales/sales-detail-tables.tsx | 488 +++++ .../reports/sales/sales-pipeline-funnel.tsx | 133 ++ .../reports/sales/sales-rep-leaderboard.tsx | 140 ++ .../reports/sales/sales-report-client.tsx | 846 +++++++++ .../reports/sales/sales-source-conversion.tsx | 126 ++ .../reports/sales/sales-stage-velocity.tsx | 132 ++ .../sales/sales-win-rate-over-time.tsx | 145 ++ .../reports/saved-templates-picker.tsx | 2 +- src/components/reports/schedule-dialog.tsx | 357 ++++ .../reports/shared/report-export-button.tsx | 262 +++ .../shared/report-templates-button.tsx | 338 ++++ .../report-schedules-page-client.tsx | 227 ++- src/lib/pdf/reports/payload-report.tsx | 266 +++ src/lib/queue/workers/reports.ts | 54 +- src/lib/reports/custom/registry.ts | 303 +++ src/lib/reports/exporters/csv.ts | 104 ++ src/lib/reports/exporters/pdf.ts | 86 + src/lib/reports/exporters/xlsx.ts | 169 ++ src/lib/reports/format-currency.ts | 67 + src/lib/reports/types.ts | 69 + src/lib/services/report-render.service.ts | 119 ++ src/lib/services/reports/build-payload.ts | 458 +++++ .../services/reports/operational.service.ts | 1023 +++++++++++ src/lib/services/reports/sales.service.ts | 1617 +++++++++++++++++ src/lib/validators/reports.ts | 18 +- 41 files changed, 10704 insertions(+), 203 deletions(-) create mode 100644 docs/reports-content-spec.md create mode 100644 src/app/(dashboard)/[portSlug]/reports/custom/page.tsx create mode 100644 src/app/(dashboard)/[portSlug]/reports/operational/page.tsx create mode 100644 src/app/(dashboard)/[portSlug]/reports/sales/page.tsx create mode 100644 src/app/api/v1/reports/custom/run/route.ts create mode 100644 src/app/api/v1/reports/export-pdf/route.ts create mode 100644 src/app/api/v1/reports/operational/route.ts create mode 100644 src/app/api/v1/reports/sales/route.ts create mode 100644 src/components/reports/custom/custom-report-builder.tsx create mode 100644 src/components/reports/operational/operational-heatmap.tsx create mode 100644 src/components/reports/operational/operational-report-client.tsx create mode 100644 src/components/reports/operational/operational-signing-box-plot.tsx create mode 100644 src/components/reports/sales/sales-deal-heat.tsx create mode 100644 src/components/reports/sales/sales-detail-tables.tsx create mode 100644 src/components/reports/sales/sales-pipeline-funnel.tsx create mode 100644 src/components/reports/sales/sales-rep-leaderboard.tsx create mode 100644 src/components/reports/sales/sales-report-client.tsx create mode 100644 src/components/reports/sales/sales-source-conversion.tsx create mode 100644 src/components/reports/sales/sales-stage-velocity.tsx create mode 100644 src/components/reports/sales/sales-win-rate-over-time.tsx create mode 100644 src/components/reports/schedule-dialog.tsx create mode 100644 src/components/reports/shared/report-export-button.tsx create mode 100644 src/components/reports/shared/report-templates-button.tsx create mode 100644 src/lib/pdf/reports/payload-report.tsx create mode 100644 src/lib/reports/custom/registry.ts create mode 100644 src/lib/reports/exporters/csv.ts create mode 100644 src/lib/reports/exporters/pdf.ts create mode 100644 src/lib/reports/exporters/xlsx.ts create mode 100644 src/lib/reports/format-currency.ts create mode 100644 src/lib/reports/types.ts create mode 100644 src/lib/services/reports/build-payload.ts create mode 100644 src/lib/services/reports/operational.service.ts create mode 100644 src/lib/services/reports/sales.service.ts diff --git a/docs/reports-content-spec.md b/docs/reports-content-spec.md new file mode 100644 index 00000000..8a9087b1 --- /dev/null +++ b/docs/reports-content-spec.md @@ -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? diff --git a/src/app/(dashboard)/[portSlug]/reports/[kind]/page.tsx b/src/app/(dashboard)/[portSlug]/reports/[kind]/page.tsx index f0c33f60..5588a6c7 100644 --- a/src/app/(dashboard)/[portSlug]/reports/[kind]/page.tsx +++ b/src/app/(dashboard)/[portSlug]/reports/[kind]/page.tsx @@ -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 = { +const LEGACY_LABELS: Record = { 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 = { + 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 ( +
+ + {typedKind === 'dashboard' ? ( + + ) : ( + + )} +
+ ); + } + + // 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 (
- - - - All reports - - - } - /> + - {typedKind === 'dashboard' ? ( - - ) : ( - - )} + + + +
+ Builder in development + + 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. + +
+
+ +
+ + +
+
+
+ +
+
+

+ Legacy builders +

+

+ Available now while the new category builders are filled in. +

+
+
+ {(LEGACY_KINDS as readonly LegacyKind[]).map((k) => ( + + + + {LEGACY_LABELS[k].title} + + + {LEGACY_LABELS[k].description} + + + + ))} +
+
); } diff --git a/src/app/(dashboard)/[portSlug]/reports/custom/page.tsx b/src/app/(dashboard)/[portSlug]/reports/custom/page.tsx new file mode 100644 index 00000000..0129c855 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/reports/custom/page.tsx @@ -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 ; +} diff --git a/src/app/(dashboard)/[portSlug]/reports/operational/page.tsx b/src/app/(dashboard)/[portSlug]/reports/operational/page.tsx new file mode 100644 index 00000000..709df3db --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/reports/operational/page.tsx @@ -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 ; +} diff --git a/src/app/(dashboard)/[portSlug]/reports/page.tsx b/src/app/(dashboard)/[portSlug]/reports/page.tsx index 3a0120cf..6ff99c5f 100644 --- a/src/app/(dashboard)/[portSlug]/reports/page.tsx +++ b/src/app/(dashboard)/[portSlug]/reports/page.tsx @@ -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 ( + + + + +
+ {label} + {eyebrow ? ( +

{eyebrow}

+ ) : null} +
+
+ + {description} + +
+ + ); +} + export default async function ReportsLandingPage({ params }: PageProps) { const { portSlug } = await params; @@ -78,85 +149,51 @@ export default async function ReportsLandingPage({ params }: PageProps) {
-
-

- Build a new report -

-
- {KINDS.map((k) => { - const Icon = k.icon; - return ( - -
- -
-
-

{k.title}

- -
-

{k.description}

- - ); - })} +
+
+

+ Compose a report +

+

+ Four canonical categories plus an ad-hoc composer for anything else. +

+
+
+ {KIND_CARDS.map((k) => ( + + ))}
-
-

- Library -

-
- {SUB_PAGES.map((s) => { - const Icon = s.icon; - return ( - -
- -
-
-

{s.label}

- -
-

{s.description}

- - ); - })} +
+
+

+ Library +

+

+ Saved templates, generated runs, and recurring schedules. Re-run anything in one click. +

+
+
+ {LIBRARY_CARDS.map((l) => ( + + ))}
-
- -
-

- Legacy library -

- - - Older reports + ad-hoc generator - - Pre-P4 reports surface. Stays available so historical PDFs are still downloadable - while the new template / run / schedule surfaces fill in. - - - - - -
); diff --git a/src/app/(dashboard)/[portSlug]/reports/sales/page.tsx b/src/app/(dashboard)/[portSlug]/reports/sales/page.tsx new file mode 100644 index 00000000..f7f094e0 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/reports/sales/page.tsx @@ -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 ; +} diff --git a/src/app/api/v1/reports/custom/run/route.ts b/src/app/api/v1/reports/custom/run/route.ts new file mode 100644 index 00000000..74e308de --- /dev/null +++ b/src/app/api/v1/reports/custom/run/route.ts @@ -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 = { + price: 'priceCurrency', + depositExpectedAmount: 'depositExpectedCurrency', + }; + const siblingsToAttach = new Set(); + 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 = {}; + 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); + } + }), +); diff --git a/src/app/api/v1/reports/export-pdf/route.ts b/src/app/api/v1/reports/export-pdf/route.ts new file mode 100644 index 00000000..d0926344 --- /dev/null +++ b/src/app/api/v1/reports/export-pdf/route.ts @@ -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); + } + }), +); diff --git a/src/app/api/v1/reports/operational/route.ts b/src/app/api/v1/reports/operational/route.ts new file mode 100644 index 00000000..43849a83 --- /dev/null +++ b/src/app/api/v1/reports/operational/route.ts @@ -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); + } + }), +); diff --git a/src/app/api/v1/reports/sales/route.ts b/src/app/api/v1/reports/sales/route.ts new file mode 100644 index 00000000..1165510e --- /dev/null +++ b/src/app/api/v1/reports/sales/route.ts @@ -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( + raw: string | undefined, + allowed: ReadonlyArray, +): T[] | undefined { + if (!raw) return undefined; + const parts = raw + .split(',') + .map((s) => s.trim()) + .filter((s): s is T => (allowed as ReadonlyArray).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(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); + } + }), +); diff --git a/src/app/api/v1/reports/templates/route.ts b/src/app/api/v1/reports/templates/route.ts index d1228753..ff06e014 100644 --- a/src/app/api/v1/reports/templates/route.ts +++ b/src/app/api/v1/reports/templates/route.ts @@ -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 diff --git a/src/components/reports/custom/custom-report-builder.tsx b/src/components/reports/custom/custom-report-builder.tsx new file mode 100644 index 00000000..69ac7389 --- /dev/null +++ b/src/components/reports/custom/custom-report-builder.tsx @@ -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 = { + price: 'priceCurrency', + depositExpectedAmount: 'depositExpectedCurrency', +}; + +function isMoneyColumnKey(key: string): boolean { + return key in MONEY_COLUMN_PAIRS; +} + +interface RunResponse { + data: Array>; + meta: { + entity: EntityKey; + columns: Array<{ key: string; label: string }>; + rowCount: number; + }; +} + +interface CustomTemplateConfig extends Record { + 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('clients'); + const [columns, setColumns] = useState(defaultColumnsFor('clients')); + const [from, setFrom] = useState(''); + const [to, setTo] = useState(''); + const [activeTemplateId, setActiveTemplateId] = useState(initialTemplateId); + const [rows, setRows] = useState>>([]); + const [columnLabels, setColumnLabels] = useState>([]); + + // 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(`/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 ( +
+ + + kind="custom" + currentConfig={currentConfig} + onApply={handleApplyTemplate} + activeTemplateId={activeTemplateId} + onActiveTemplateChange={setActiveTemplateId} + initialTemplateId={initialTemplateId} + /> +
+ } + /> + + + +
+
+
+ + +

{def.description}

+
+ +
+ +
+ + +
+

+ Optional. Leave blank for all-time. +

+
+ +
+ + +
+
+ +
+ +
+ {def.columns.map((c) => { + const checked = columns.includes(c.key); + return ( + + ); + })} +
+ {columns.length === 0 ? ( +

Select at least one column to run.

+ ) : ( +

+ {columns.length} of {def.columns.length} columns selected. +

+ )} +
+
+
+
+ + {/* Results table — only shows after Run query. Caps the visible + rows; CSV export gives the full set. */} + {rows.length > 0 ? ( + + +
+ {rows.length} rows + + Showing first {Math.min(rows.length, 50)} · download CSV for full set + +
+
+ + + + {columnLabels.map((c) => ( + {c.label} + ))} + + + + {rows.slice(0, 50).map((row, idx) => ( + + {columnLabels.map((c) => ( + + {formatCellValue(c.key, row)} + + ))} + + ))} + +
+
+
+
+ ) : runMutation.isSuccess ? ( + + + +

No rows match this query

+

+ Try widening the date range, picking a different entity, or removing filters. +

+
+
+ ) : ( + + + +

Configure your query above, then Run.

+

+ Results appear here. Save the configuration as a template to schedule recurring runs + or share it with the team. +

+
+
+ )} +
+ ); +} + +/** + * 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 { + 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, '""')}"`; +} diff --git a/src/components/reports/operational/operational-heatmap.tsx b/src/components/reports/operational/operational-heatmap.tsx new file mode 100644 index 00000000..755322c3 --- /dev/null +++ b/src/components/reports/operational/operational-heatmap.tsx @@ -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 ( +
+ No berth history captured yet. The heatmap fills in as status changes accumulate. +
+ ); + } + + // 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(); + for (const c of cells) byKey.set(`${c.area}|${c.month}`, c); + + return ( +
+
+
+ {/* Header row: month labels */} +
+ {months.map((m) => ( +
18 ? 'vertical-rl' : undefined }} + > + {formatMonth(m)} +
+ ))} + + {/* Body */} + {areas.map((area) => ( + + ))} +
+ + {/* Legend */} +
+ Occupancy: +
+ {[0, 20, 40, 60, 80, 100].map((pct) => ( +
+ ))} +
+ 0% → 100% +
+
+
+ ); +} + +function FragmentRow({ + area, + months, + byKey, +}: { + area: string; + months: string[]; + byKey: Map; +}) { + return ( + <> +
{area}
+ {months.map((month) => { + const cell = byKey.get(`${area}|${month}`); + const pct = cell?.occupancyPct ?? 0; + return ( +
+ ); + })} + + ); +} + +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' }); +} diff --git a/src/components/reports/operational/operational-report-client.tsx b/src/components/reports/operational/operational-report-client.tsx new file mode 100644 index 00000000..63f58a73 --- /dev/null +++ b/src/components/reports/operational/operational-report-client.tsx @@ -0,0 +1,1047 @@ +'use client'; + +import { useCallback, useMemo, useState } from 'react'; +import Link from 'next/link'; +import type { Route } from 'next'; +import { useSearchParams } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, + Cell, +} from 'recharts'; +import { Anchor, FileWarning, AlertTriangle } 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 { Button } from '@/components/ui/button'; +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 { formatMoneyCompact as formatMoney } from '@/lib/reports/format-currency'; +import { rangeToBounds, type DateRange } from '@/lib/analytics/range'; +import { apiFetch } from '@/lib/api/client'; +import { cn } from '@/lib/utils'; +import { useUIStore } from '@/stores/ui-store'; +import type { ReportPayload } from '@/lib/reports/types'; + +import { OperationalHeatmap } from './operational-heatmap'; +import { OperationalSigningBoxPlot } from './operational-signing-box-plot'; + +// ─── Types mirroring the service ───────────────────────────────────────────── + +interface OperationalKpis { + totalBerths: number; + soldPct: number; + soldPctDelta: number | null; + underOfferPct: number; + underOfferPctDelta: number | null; + tenanciesModuleEnabled: boolean; + activeTenancies: number | null; + avgTenancyLengthYears: number | null; + signingTurnaround: Array<{ + documentType: string; + medianDays: number | null; + sampleSize: number; + }>; + berthsInConflict: number; +} + +interface UtilisationCell { + area: string; + month: string; + occupancyPct: number; +} + +interface StatusMixPoint { + month: string; + available: number; + underOffer: number; + sold: number; +} + +interface TenancyChurnPoint { + bucket: string; + newActive: number; + ended: number; + netDelta: number; +} + +interface TenureBucket { + label: string; + count: number; +} + +interface SigningBoxPlot { + documentType: string; + min: number; + q1: number; + median: number; + q3: number; + max: number; + sampleSize: number; +} + +interface AreaOccupancyRow { + area: string; + available: number; + underOffer: number; + sold: number; + total: number; +} + +interface DocsPipelineRow { + documentType: string; + draft: number; + sent: number; + partiallySigned: number; + completed: number; + declined: number; + voided: number; +} + +interface EndingSoonRow { + id: string; + clientName: string; + primaryBerth: string | null; + tenureType: string; + endDate: string; + daysUntilEnd: number; +} + +interface VacantBerthRow { + id: string; + mooring: string; + area: string | null; + dimensions: string; + price: number | null; + currency: string; + daysAvailable: number | null; +} + +interface StuckSigningRow { + id: string; + documentType: string; + title: string; + clientName: string | null; + sentAt: string; + daysOutstanding: number; +} + +interface HighestValueVacantRow { + id: string; + mooring: string; + area: string | null; + dimensions: string; + price: number; + currency: string; + daysAvailable: number | null; +} + +interface OperationalReportPayload { + data: { + kpis: OperationalKpis; + utilisationHeatmap: UtilisationCell[]; + statusMix: StatusMixPoint[]; + tenancyChurn: { granularity: 'month' | 'quarter'; points: TenancyChurnPoint[] }; + tenureDistribution: TenureBucket[]; + signingBoxPlot: SigningBoxPlot[]; + occupancyByArea: AreaOccupancyRow[]; + docsInPipeline: DocsPipelineRow[]; + endingSoon: EndingSoonRow[]; + vacantBerths: VacantBerthRow[]; + stuckSigning: StuckSigningRow[]; + highestValueVacant: HighestValueVacantRow[]; + range: { from: string; to: string }; + }; +} + +interface OperationalTemplateConfig extends Record { + kind: 'operational'; + range: DateRange; + statusMixMode: 'absolute' | 'proportional'; +} + +export function OperationalReportClient({ portSlug }: { portSlug: string }) { + const searchParams = useSearchParams(); + const initialTemplateId = searchParams?.get('templateId') ?? null; + + const [range, setRange] = useState('30d'); + const [statusMixMode, setStatusMixMode] = useState<'absolute' | 'proportional'>('proportional'); + const [activeTemplateId, setActiveTemplateId] = useState(initialTemplateId); + + // User-driven setters clear the active-template badge; template + // apply uses the raw setters so it doesn't immediately clear its + // own badge. + const handleRangeChange = useCallback((next: DateRange) => { + setRange(next); + setActiveTemplateId(null); + }, []); + + const handleStatusMixChange = useCallback((next: 'absolute' | 'proportional') => { + setStatusMixMode(next); + setActiveTemplateId(null); + }, []); + + const currentConfig: OperationalTemplateConfig = useMemo( + () => ({ kind: 'operational', range, statusMixMode }), + [range, statusMixMode], + ); + + const handleApplyTemplate = useCallback((config: OperationalTemplateConfig) => { + if (config.range) setRange(config.range); + if (config.statusMixMode) setStatusMixMode(config.statusMixMode); + }, []); + + const bounds = useMemo(() => rangeToBounds(range), [range]); + + const query = useQuery({ + queryKey: ['reports', 'operational', bounds.from.toISOString(), bounds.to.toISOString()], + queryFn: () => + apiFetch( + `/api/v1/reports/operational?from=${encodeURIComponent(bounds.from.toISOString())}&to=${encodeURIComponent(bounds.to.toISOString())}`, + ), + staleTime: 30_000, + }); + + const data = query.data?.data; + const tenanciesOn = data?.kpis.tenanciesModuleEnabled ?? false; + + function buildExportPayload(): ReportPayload { + if (!data) throw new Error('Report still loading'); + return { + title: 'Operational', + description: + 'Berth utilisation, tenancy lifecycle, signing turnaround, and operational bottlenecks.', + filenameSlug: 'operational', + range: bounds, + kpis: [ + { label: 'Total berths', value: data.kpis.totalBerths }, + { label: 'Sold %', value: `${data.kpis.soldPct.toFixed(1)}%` }, + { label: 'Under offer %', value: `${data.kpis.underOfferPct.toFixed(1)}%` }, + { + label: 'Active tenancies', + value: data.kpis.activeTenancies ?? '—', + hint: tenanciesOn ? undefined : 'Tenancies module disabled', + }, + { + label: 'Avg tenancy length', + value: + data.kpis.avgTenancyLengthYears !== null + ? `${data.kpis.avgTenancyLengthYears.toFixed(1)} years` + : '—', + }, + { label: 'Berths in conflict', value: data.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: data.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: data.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 so each cell uses its row's + // currency. The shared `format` callback only sees the value, + // not the row, so we resolve the currency at row-build time + // and emit a string. Null prices show "—". + rows: data.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: data.stuckSigning.map((r) => ({ ...r })), + }, + { + title: 'Highest-value vacant berths', + 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' }, + ], + rows: data.highestValueVacant.map((r) => ({ + ...r, + price: formatMoney(r.price, r.currency), + })), + }, + ], + }; + } + + return ( +
+ + + + kind="operational" + currentConfig={currentConfig} + onApply={handleApplyTemplate} + activeTemplateId={activeTemplateId} + onActiveTemplateChange={setActiveTemplateId} + initialTemplateId={initialTemplateId} + /> + +
+ } + /> + + {/* KPI strip */} +
+ {query.isLoading || !data ? ( + Array.from({ length: 7 }).map((_, i) => ) + ) : ( + <> + + + + + + + `${formatType(t.documentType)} ${ + t.medianDays !== null ? `${t.medianDays.toFixed(1)}d` : '—' + }`, + ) + .join(' · ') + } + hint={ + data.kpis.signingTurnaround.length === 0 + ? 'No completed documents yet' + : `${data.kpis.signingTurnaround.reduce((acc, t) => acc + t.sampleSize, 0)} documents` + } + valueSmall + /> + {data.kpis.berthsInConflict > 0 ? ( + + ) : null} + + )} +
+ + {/* Module-OFF banner for tenancies-disabled ports */} + {!query.isLoading && data && !tenanciesOn ? ( +
+ +
+

Tenancies module is disabled for this port.

+

+ Tenancy-related KPIs, the tenancy churn waterfall, tenure distribution, and the + ending-soon table are hidden. Enable in{' '} + + System Settings + + . +

+
+
+ ) : null} + + {/* CHART 1 - Utilisation heatmap */} + + + Berth utilisation timeline +

+ Area × month occupancy (sold + under-offer share of berths). Trailing 24 months. +

+
+ + {query.isLoading || !data ? ( + + ) : ( + + )} + +
+ + {/* CHART 2 - Status mix over time */} + + +
+
+ Status mix over time +

+ Port-wide available / under-offer / sold by month. +

+
+
+ + +
+
+
+ + {query.isLoading || !data ? ( + + ) : ( + + )} + +
+ + {/* CHARTS 3 + 4 - Tenancy charts (gated on module ON) */} + {tenanciesOn && data ? ( +
+ + + Tenancy churn +

+ New active vs ended per {data.tenancyChurn.granularity}. +

+
+ + + +
+ + + Tenure distribution +

+ Ended tenancies by length. Marina-tuned buckets. +

+
+ + + +
+
+ ) : null} + + {/* CHART 5 - Signing box plot */} + + + Signing turnaround +

+ Distribution of days from sent → completed per document type. Box = median + Q1/Q3; + whiskers = min/max. +

+
+ + {query.isLoading || !data ? ( + + ) : ( + + )} + +
+ + {/* CHART 6 + 7 - Occupancy by area + Documents in pipeline */} + {data ? ( +
+ + + Occupancy by area +

Current status mix per port area.

+
+ + + +
+ + + Documents in pipeline +

Current state per document type.

+
+ + + +
+
+ ) : null} + + {/* TABLES */} + {data ? ( +
+ {tenanciesOn ? : null} + + + +
+ ) : null} +
+ ); +} + +// ─── KPI primitives ────────────────────────────────────────────────────────── + +function KpiCard({ + label, + value, + hint, + delta, + tone = 'neutral', + valueSmall, +}: { + label: string; + value: string; + hint?: string; + delta?: number | null; + tone?: 'neutral' | 'danger'; + valueSmall?: boolean; +}) { + return ( + +

+ {label} +

+

+ {value} +

+ {delta !== undefined && delta !== null ? ( +

0 ? 'text-emerald-700' : delta < 0 ? 'text-rose-700' : 'text-muted-foreground', + )} + > + {delta > 0 ? '+' : ''} + {delta.toFixed(1)}pp vs period start +

+ ) : null} + {hint ? ( +

{hint}

+ ) : null} +
+ ); +} + +function KpiSkeleton() { + return ( + + + + + + ); +} + +// ─── Inline chart components ───────────────────────────────────────────────── + +function StatusMixChart({ + data, + mode, +}: { + data: StatusMixPoint[]; + mode: 'absolute' | 'proportional'; +}) { + if (data.length === 0) { + return No history yet.; + } + const points = data.map((p) => { + const total = p.available + p.underOffer + p.sold; + if (mode === 'absolute') { + return { + month: formatMonth(p.month), + available: p.available, + underOffer: p.underOffer, + sold: p.sold, + }; + } + if (total === 0) { + return { month: formatMonth(p.month), available: 0, underOffer: 0, sold: 0 }; + } + return { + month: formatMonth(p.month), + available: (p.available / total) * 100, + underOffer: (p.underOffer / total) * 100, + sold: (p.sold / total) * 100, + }; + }); + + return ( + + + + + (mode === 'proportional' ? `${v}%` : String(v))} + /> + { + const v = mode === 'proportional' ? `${(value as number).toFixed(1)}%` : String(value); + return [ + v, + name === 'sold' ? 'Sold' : name === 'underOffer' ? 'Under offer' : 'Available', + ]; + }} + /> + + + + + + ); +} + +function TenancyChurnChart({ points }: { points: TenancyChurnPoint[] }) { + if (points.length === 0) { + return No tenancy events in the window.; + } + return ( + + + + + + + + + + + ); +} + +function TenureHistogram({ rows }: { rows: TenureBucket[] }) { + if (rows.length === 0 || rows.every((r) => r.count === 0)) { + return No completed tenancies yet.; + } + return ( + + + + + + + + {rows.map((_, i) => ( + + ))} + + + + ); +} + +function OccupancyByAreaChart({ rows }: { rows: AreaOccupancyRow[] }) { + if (rows.length === 0) { + return No areas yet.; + } + return ( +
+ {rows.map((r) => ( +
+
{r.area}
+
+ {r.sold > 0 && ( +
+ )} + {r.underOffer > 0 && ( +
+ )} + {r.available > 0 && ( +
+ )} +
+ {r.total} +
+ ))} +
+ ); +} + +function DocsPipelineChart({ rows }: { rows: DocsPipelineRow[] }) { + if (rows.length === 0) { + return No documents yet.; + } + return ( + + + + + formatType(String(v))} + /> + + + + + + + + + + ); +} + +// ─── Tables ────────────────────────────────────────────────────────────────── + +function EndingSoonTable({ rows, portSlug }: { rows: EndingSoonRow[]; portSlug: string }) { + return ( + + + Tenancies ending in next 6 months + + + {rows.length === 0 ? ( +

+ No tenancies ending soon. +

+ ) : ( +
    + {rows.map((r) => ( +
  • + + {r.clientName} + {r.primaryBerth ? ( + · {r.primaryBerth} + ) : null} + + + {r.tenureType} + + + {r.endDate.slice(0, 10)} + + + {r.daysUntilEnd}d + +
  • + ))} +
+ )} +
+
+ ); +} + +function VacantBerthsTable({ rows, portSlug }: { rows: VacantBerthRow[]; portSlug: string }) { + return ( + + + Vacant berths (>60 days) +

+ Available berths that haven't moved in 2+ months. The "why isn't this + selling" list. +

+
+ + {rows.length === 0 ? ( +

No stagnant inventory.

+ ) : ( +
    + {rows.map((r) => ( +
  • + + + {r.mooring} + + {r.area ?? '—'} + {r.dimensions} + + {r.price !== null ? formatMoney(r.price, r.currency) : '—'} + + + {r.daysAvailable !== null ? `${r.daysAvailable}d` : '—'} + +
  • + ))} +
+ )} +
+
+ ); +} + +function StuckSigningTable({ rows, portSlug }: { rows: StuckSigningRow[]; portSlug: string }) { + return ( + + + Stuck signing +

+ Documents sent but not completed. Thresholds: EOI > 10d / Reservation > 7d / + Contract > 5d. +

+
+ + {rows.length === 0 ? ( +

+ Nothing stuck — signings on track. +

+ ) : ( +
    + {rows.map((r) => ( +
  • + + + {r.title} + + + {formatType(r.documentType)} + + + {r.clientName ?? '—'} + + + {r.daysOutstanding}d + +
  • + ))} +
+ )} +
+
+ ); +} + +function HighestValueVacantTable({ + rows, + portSlug, +}: { + rows: HighestValueVacantRow[]; + portSlug: string; +}) { + return ( + + + Highest-value vacant berths +

+ Top {rows.length} available berths by price. Sales focus list. +

+
+ + {rows.length === 0 ? ( +

+ No priced vacant inventory. +

+ ) : ( +
    + {rows.map((r, i) => ( +
  • + + {i + 1} + + + {r.mooring} + + {r.area ?? '—'} + {r.dimensions} + + {formatMoney(r.price, r.currency)} + + +
  • + ))} +
+ )} +
+
+ ); +} + +function EmptyChartMessage({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function formatType(t: string): string { + return t + .replace(/_/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()) + .replace(/Eoi/i, 'EOI'); +} + +function formatMonth(month: string): string { + // "2026-04" → "Apr" + 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' }); +} + +// Suppress unused-import warning for hooks used only at certain breakpoints +void useUIStore; diff --git a/src/components/reports/operational/operational-signing-box-plot.tsx b/src/components/reports/operational/operational-signing-box-plot.tsx new file mode 100644 index 00000000..b5a54ddb --- /dev/null +++ b/src/components/reports/operational/operational-signing-box-plot.tsx @@ -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 = { + eoi: 'bg-brand-300', + reservation_agreement: 'bg-brand-500', + contract: 'bg-brand-700', +}; + +export function OperationalSigningBoxPlot({ rows }: Props) { + if (rows.length === 0) { + return ( +
+ No completed documents yet. The distribution fills in once documents complete their full + signing cycle. +
+ ); + } + + // Universal scale across all rows so types are visually comparable + const max = Math.max(1, ...rows.map((r) => r.max)); + + return ( +
+ {rows.map((row) => { + const color = TYPE_COLOR[row.documentType] ?? 'bg-brand-500'; + return ( +
+
+ {formatType(row.documentType)} +
+ + {/* Box plot rendered with CSS: + - whisker line: min → max (faint) + - box: Q1 → Q3 (brand color) + - median tick inside box (white) */} +
+ {/* Whisker (min to max) */} +
+ {/* Min cap */} +
+ {/* Max cap */} +
+ {/* Box (Q1 to Q3) */} +
+ {/* Median tick */} +
+
+ +
+ {row.median.toFixed(1)}d median +
+ n={row.sampleSize} +
+
+ ); + })} + + {/* X-axis tick reference */} +
+
+
+ 0d + + {max.toFixed(0)}d + +
+
+
+
+ ); +} + +function formatType(t: string): string { + return t + .replace(/_/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()) + .replace(/Eoi/i, 'EOI'); +} diff --git a/src/components/reports/sales/sales-deal-heat.tsx b/src/components/reports/sales/sales-deal-heat.tsx new file mode 100644 index 00000000..8b1d64d4 --- /dev/null +++ b/src/components/reports/sales/sales-deal-heat.tsx @@ -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; + 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 = { hot: 'Hot', warm: 'Warm', cold: 'Cold' }; +const HEAT_COLOR: Record = { + hot: 'bg-rose-500', + warm: 'bg-amber-400', + cold: 'bg-slate-400', +}; +const HEAT_BADGE: Record = { + 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 ( +
+ {/* Hot deals tile + distribution bar (lg col-span 1) */} + +
+

+ Hot deals right now +

+ +
+
+

+ {data.distribution.hot} +

+

+ of {total} active {total === 1 ? 'deal' : 'deals'} +

+
+ + {/* Distribution bar */} + {total > 0 ? ( +
+
+ {(['hot', 'warm', 'cold'] as HeatBucket[]).map((bucket) => { + const count = data.distribution[bucket]; + if (count === 0) return null; + const pct = (count / total) * 100; + return ( +
+ ); + })} +
+
+ {(['hot', 'warm', 'cold'] as HeatBucket[]).map((bucket) => ( + + + {HEAT_LABEL[bucket]} {data.distribution[bucket]} + + ))} +
+
+ ) : null} + + + {/* Hottest 5 deals (lg col-span 2) */} + +

+ Hottest deals right now +

+ {data.topDeals.length === 0 ? ( +

No active deals yet.

+ ) : ( +
    + {data.topDeals.map((deal) => ( +
  • + + {deal.clientName} + {deal.mooringNumber ? ( + + {' '} + · {deal.mooringNumber} + + ) : null} + + + {HEAT_LABEL[deal.bucket]} + + {STAGE_LABELS[deal.stage]} + + {deal.pipelineValue > 0 + ? formatMoney(deal.pipelineValue, deal.pipelineValueCurrency) + : '—'} + + + {deal.daysSinceLastContact === null + ? 'never contacted' + : `${deal.daysSinceLastContact}d ago`} + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/reports/sales/sales-detail-tables.tsx b/src/components/reports/sales/sales-detail-tables.tsx new file mode 100644 index 00000000..2cd292f1 --- /dev/null +++ b/src/components/reports/sales/sales-detail-tables.tsx @@ -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 = { + 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 ( +
+ {showRepPerformanceDetail ? : null} + + + + +
+ ); +} + +// ─── 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>( + () => 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 ( + + + Rep performance detail +

+ Per-rep summary + their open deals. Click a row to expand the open-deals list. +

+
+ + {rows.length === 0 ? ( + No rep activity in the period. + ) : ( +
+ {rows.map((row) => { + const key = row.userId ?? 'unassigned'; + const isOpen = expanded.has(key); + return ( +
+ + {isOpen && ( +
+ {row.openDeals.length === 0 ? ( +

No active deals.

+ ) : ( + + + + + + + + + + + + {row.openDeals.map((d) => ( + + + + + + + + ))} + +
ClientBerthStageValueDays in stage
+ + {d.clientName} + + + {d.primaryBerth ?? '—'} + + {STAGE_LABELS[d.stage]} + + {d.stageValue > 0 + ? formatMoney(d.stageValue, row.pipelineValueCurrency) + : '—'} + + {d.daysInStage ?? '—'} +
+ )} +
+ )} +
+ ); + })} +
+ )} +
+
+ ); +} + +// ─── 2. Stalled deals ──────────────────────────────────────────────────────── + +function StalledDealsTable({ rows }: { rows: StalledDealRow[] }) { + const portSlug = useUIStore((s) => s.currentPortSlug) ?? ''; + return ( + + + Stalled deals +

+ Active deals not contacted within their stage's threshold (enquiry 21d · qualified + 14d · nurturing 60d · eoi 10d · reservation 7d · deposit 7d · contract 5d). +

+
+ + {rows.length === 0 ? ( + Nothing stalled — everything's being worked. + ) : ( + + {rows.map((r) => ( + + + + {r.clientName} + {r.primaryBerth ? ( + · {r.primaryBerth} + ) : null} + + + {STAGE_LABELS[r.stage]} + + {r.daysSinceLastContact === null ? 'never' : `${r.daysSinceLastContact}d`} + + + {r.daysInStage ?? '—'} + + + {r.stageValue > 0 ? formatMoney(r.stageValue, r.stageValueCurrency) : '—'} + + {r.rep} + + ))} + + )} + +
+ ); +} + +// ─── 3. Closing this month ─────────────────────────────────────────────────── + +function ClosingThisMonthTable({ rows }: { rows: ClosingThisMonthRow[] }) { + const portSlug = useUIStore((s) => s.currentPortSlug) ?? ''; + return ( + + + Closing soon +

+ Late-stage active deals (reservation / deposit paid / contract) sorted by value. The + "don't drop these" list. +

+
+ + {rows.length === 0 ? ( + No deals in late stages yet. + ) : ( + + {rows.map((r) => ( + + + + {r.clientName} + {r.primaryBerth ? ( + · {r.primaryBerth} + ) : null} + + + + + {STAGE_LABELS[r.stage]} + + + + {r.daysInStage ?? '—'} + + + {r.stageValue > 0 ? formatMoney(r.stageValue, r.stageValueCurrency) : '—'} + + {r.rep} + + ))} + + )} + +
+ ); +} + +// ─── 4. Recent wins ────────────────────────────────────────────────────────── + +function RecentWinsTable({ rows }: { rows: RecentWinRow[] }) { + const portSlug = useUIStore((s) => s.currentPortSlug) ?? ''; + return ( + + + Recent wins +

+ The 5 most recently closed-won deals — small celebratory strip. +

+
+ + {rows.length === 0 ? ( + No wins yet. The next one will appear here. + ) : ( +
    + {rows.map((r) => ( +
  • + + {r.clientName} + {r.primaryBerth ? ( + · {r.primaryBerth} + ) : null} + + + {formatMoney(r.finalValue, r.currency)} + + + {r.daysToClose !== null ? `${r.daysToClose}d to close` : '—'} + + + {r.rep} + + +
  • + ))} +
+ )} +
+
+ ); +} + +// ─── 5. Lost reason breakdown ──────────────────────────────────────────────── + +function LostReasonTable({ rows }: { rows: LostReasonRow[] }) { + return ( + + + Lost reason breakdown +

+ Where the losses went, what they cost us, and how long they took to die. Post-mortem fuel. +

+
+ + {rows.length === 0 ? ( + No losses in the period. + ) : ( + + {rows.map((r) => ( + + + {LOSS_LABEL[r.outcome] ?? r.outcome} + + {r.count} + + {r.totalValueLost > 0 ? formatMoney(r.totalValueLost, r.currency) : '—'} + + + {r.avgDaysFromFirstContactToLoss === null + ? '—' + : `${r.avgDaysFromFirstContactToLoss.toFixed(0)}d`} + + + ))} + + )} + +
+ ); +} + +// ─── Primitives ────────────────────────────────────────────────────────────── + +function TableShell({ + headers, + rightAligned = [], + children, +}: { + headers: string[]; + rightAligned?: number[]; + children: React.ReactNode; +}) { + return ( +
+ + + + {headers.map((h, i) => ( + + ))} + + + {children} +
+ {h} +
+
+ ); +} + +function EmptyRow({ children }: { children: React.ReactNode }) { + return

{children}

; +} diff --git a/src/components/reports/sales/sales-pipeline-funnel.tsx b/src/components/reports/sales/sales-pipeline-funnel.tsx new file mode 100644 index 00000000..10715d0f --- /dev/null +++ b/src/components/reports/sales/sales-pipeline-funnel.tsx @@ -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 = { + 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 ( +
+ {rows.map((row) => { + const widthPct = (row.count / max) * 100; + const isZero = row.count === 0; + return ( +
+ {/* Stage label */} +
+ {STAGE_LABELS[row.stage]} +
+ + {/* Bar */} +
+
+
15 ? 'text-white' : 'text-foreground', + )} + style={widthPct > 15 ? undefined : { left: `calc(${widthPct}% + 8px)` }} + > + {row.count} +
+
+ + {/* Drop-off vs prior */} + +
+ ); + })} +
+ ); +} + +function DropoffBadge({ dropoff }: { dropoff: number | null }) { + if (dropoff === null) { + return ; + } + const pct = Math.round(dropoff * 100); + if (pct === 0) { + return ( + + + no change + + ); + } + // 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 ( + + {isPositive ? ( + + ) : ( + + )} + {isPositive ? '+' : ''} + {pct}% + + ); +} diff --git a/src/components/reports/sales/sales-rep-leaderboard.tsx b/src/components/reports/sales/sales-rep-leaderboard.tsx new file mode 100644 index 00000000..9be05491 --- /dev/null +++ b/src/components/reports/sales/sales-rep-leaderboard.tsx @@ -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 ( +
+

+ No rep activity in the selected period. +

+
+ ); + } + + const maxPipeline = Math.max(1, ...rows.map((r) => r.pipelineValue)); + + return ( +
+ + + + + + + + + + + + + + + {rows.map((row) => { + const pct = (row.pipelineValue / maxPipeline) * 100; + return ( + + + + + + + + + + + ); + })} + +
+ Rep + + New + + Won + + Lost + + In flight + + Pipeline value + + Win rate + + Median close +
{row.displayName} + {row.newDeals} + 0 ? 'text-emerald-700 font-medium' : 'text-foreground', + )} + > + {row.won} + 0 ? 'text-rose-700' : 'text-muted-foreground', + )} + > + {row.lost} + + {row.inFlight} + +
+
+
0 ? 4 : 0)}%` }} + aria-hidden + /> +
+ + {formatMoney(row.pipelineValue, row.pipelineValueCurrency)} + +
+
+ {row.winRate === null ? '—' : `${(row.winRate * 100).toFixed(0)}%`} + + {row.medianTimeToCloseDays === null + ? '—' + : `${row.medianTimeToCloseDays.toFixed(0)}d`} +
+
+ ); +} diff --git a/src/components/reports/sales/sales-report-client.tsx b/src/components/reports/sales/sales-report-client.tsx new file mode 100644 index 00000000..050ca743 --- /dev/null +++ b/src/components/reports/sales/sales-report-client.tsx @@ -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; + 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; + 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 = { + lost_other_marina: 'to competitor', + lost_unqualified: 'unqualified', + lost_no_response: 'no response', + cancelled: 'cancelled', +}; + +const SOURCE_LABELS: Record = { + 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 { + 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('30d'); + const [filterValues, setFilterValues] = useState({}); + const [activeTemplateId, setActiveTemplateId] = useState(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({ + queryKey: ['reports', 'sales', bounds.from.toISOString(), bounds.to.toISOString(), filterQs], + queryFn: () => + apiFetch( + `/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 ( +
+ + + + kind="sales" + currentConfig={currentConfig} + onApply={handleApplyTemplate} + activeTemplateId={activeTemplateId} + onActiveTemplateChange={setActiveTemplateId} + initialTemplateId={initialTemplateId} + /> + +
+ } + /> + + {/* 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. */} +
+ {query.isLoading || !kpis ? ( + Array.from({ length: 7 }).map((_, i) => ) + ) : ( + <> + + 0 ? 'positive' : 'neutral'} + /> + 0 ? 'negative' : 'neutral'} + hint={ + kpis.lossBreakdown.length > 0 + ? kpis.lossBreakdown + .map((b) => `${b.count} ${LOSS_LABELS[b.outcome] ?? b.outcome}`) + .join(' · ') + : undefined + } + /> + + 0 + ? `${kpis.pipelineValueExcludedCount} of ${kpis.pipelineValueTotalActiveCount} interests have no value` + : `${kpis.pipelineValueTotalActiveCount} active interests · weighted by stage` + } + /> + + 0 + ? kpis.newLeadsBySource + .map((s) => `${s.count} ${SOURCE_LABELS[s.source] ?? s.source}`) + .join(' · ') + : undefined + } + /> + + )} +
+ + {/* CHART 1 - Pipeline funnel */} + + + Pipeline funnel +

+ Active interests grouped by stage. Drop-off rate shown between consecutive stages. +

+
+ + {query.isLoading ? ( + + ) : funnel.every((r) => r.count === 0) ? ( + + No active interests yet. New deals appear here as they enter the pipeline. + + ) : ( + + )} + +
+ + {/* CHART 2 - Stage velocity */} + + + Stage velocity +

+ Median days deals spend in each stage before moving on, with the p90 marker on each bar. + Derived from the stage-change audit log. +

+
+ + {query.isLoading ? ( + + ) : ( + + )} + +
+ + {/* CHART 3 - Win rate over time */} + + + Win rate over time +

+ 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. +

+
+ + {query.isLoading ? ( + + ) : ( + + )} + +
+ + {/* CHART 4 - Source → win conversion */} + + + Source → win conversion +

+ 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). +

+
+ + {query.isLoading ? ( + + ) : ( + + )} + +
+ + {/* 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 ? ( + + + Rep leaderboard +

+ Per-rep activity in the period. Pipeline value is the rep's slice of the + port-wide stage-weighted forecast, normalised to port currency. +

+
+ + {query.isLoading ? ( + + ) : ( + + )} + +
+ ) : 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 ? ( + + ) : ( + + )} + + {/* DETAIL-TABLE FILTERS — narrow the next 5 tables by stage / lead + category / outcome. KPIs + charts above intentionally stay + unfiltered (macro view). */} +
+

Deal detail

+ +
+ + {/* 5 DETAIL TABLES - Rep performance detail (single-rep only) / + Stalled deals / Closing soon / Recent wins / Lost-reason + breakdown. */} + {query.isLoading ? ( + + ) : ( + + )} +
+ ); +} + +// ─── 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 ( + +

+ {label} +

+
+

+ {value} +

+ {valueTrend === 'positive' ? ( + + ) : valueTrend === 'negative' ? ( + + ) : null} +
+ {hint ? ( +

{hint}

+ ) : null} +
+ ); +} + +function KpiSkeleton() { + return ( + + + + + + ); +} + +function EmptyState({ children }: { children: React.ReactNode }) { + return ( +
+ + No data + +

{children}

+
+ ); +} + +// ─── 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; diff --git a/src/components/reports/sales/sales-source-conversion.tsx b/src/components/reports/sales/sales-source-conversion.tsx new file mode 100644 index 00000000..8cb2c603 --- /dev/null +++ b/src/components/reports/sales/sales-source-conversion.tsx @@ -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; + total: number; +} + +interface Props { + rows: SourceConversionRow[]; +} + +const SOURCE_LABEL: Record = { + website: 'Website', + referral: 'Referral', + broker: 'Broker', + manual: 'Manual', + unknown: 'Unknown', +}; + +const OUTCOME_LABEL: Record = { + 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 = { + 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 ( +
+

+ No leads yet. Source-to-win attribution appears as deals start landing in the pipeline. +

+
+ ); + } + + return ( +
+ {/* Legend */} +
+ {(Object.keys(OUTCOME_LABEL) as Outcome[]).map((o) => ( + + + {OUTCOME_LABEL[o]} + + ))} +
+ + {/* Rows */} +
+ {rows.map((row) => ( +
+
+ {SOURCE_LABEL[row.source] ?? row.source} +
+ +
+ {(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 ( +
+ ); + })} +
+ + + {row.total} {row.total === 1 ? 'lead' : 'leads'} + +
+ ))} +
+
+ ); +} + +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(', '); +} diff --git a/src/components/reports/sales/sales-stage-velocity.tsx b/src/components/reports/sales/sales-stage-velocity.tsx new file mode 100644 index 00000000..bbf45ac0 --- /dev/null +++ b/src/components/reports/sales/sales-stage-velocity.tsx @@ -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 = { + 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 ( +
+

+ No stage transitions captured yet. Velocity appears here once deals start moving between + stages. +

+
+ ); + } + + // 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 ( +
+ {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 ( +
+
{STAGE_LABELS[row.stage]}
+ +
+ {/* Median bar */} + {!isMissing && ( +
+ )} + {/* p90 marker (vertical line) */} + {p90 !== null && p90 > 0 && p90Pct > 0 && ( +
+ )} + {/* Label inside or outside the bar */} +
18 + ? 'text-white' + : 'text-foreground', + )} + style={ + isMissing || medianPct > 18 ? undefined : { left: `calc(${medianPct}% + 8px)` } + } + > + {isMissing ? '—' : formatDays(median!)} +
+
+ + {/* Sample size + p90 chip on the right */} + + {isMissing ? ( + 'no data' + ) : ( + <> + {row.transitions} {row.transitions === 1 ? 'transition' : 'transitions'} + {p90 !== null && p90 > 0 ? ` · p90 ${formatDays(p90)}` : ''} + + )} + +
+ ); + })} +
+ ); +} + +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`; +} diff --git a/src/components/reports/sales/sales-win-rate-over-time.tsx b/src/components/reports/sales/sales-win-rate-over-time.tsx new file mode 100644 index 00000000..73eaffc6 --- /dev/null +++ b/src/components/reports/sales/sales-win-rate-over-time.tsx @@ -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 ( +
+

+ No deals closed yet in the selected period. Win-rate trend appears here as wins and losses + accumulate. +

+
+ ); + } + + // 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 ( + + + + + {/* Left axis: win rate %, fixed 0-100 scale so deltas read true */} + `${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. */} + + { + if (name === 'winRatePct') { + return [value === null ? '—' : `${value}%`, 'Win rate']; + } + if (name === 'closed') { + return [value, 'Deals closed']; + } + return [value, String(name)]; + }} + /> + + + + + ); +} + +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)}`; +} diff --git a/src/components/reports/saved-templates-picker.tsx b/src/components/reports/saved-templates-picker.tsx index 8c0197c6..e5202e5f 100644 --- a/src/components/reports/saved-templates-picker.tsx +++ b/src/components/reports/saved-templates-picker.tsx @@ -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; diff --git a/src/components/reports/schedule-dialog.tsx b/src/components/reports/schedule-dialog.tsx new file mode 100644 index 00000000..f6033f5d --- /dev/null +++ b/src/components/reports/schedule-dialog.tsx @@ -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 `` 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 ( + + {open ? ( + onOpenChange(false)} + /> + ) : null} + + ); +} + +interface FormProps { + schedule?: ReportSchedule; + initialTemplateId?: string; + onClose: () => void; +} + +function ScheduleDialogForm({ schedule, initialTemplateId, onClose }: FormProps) { + const qc = useQueryClient(); + const isEdit = !!schedule; + + const [templateId, setTemplateId] = useState( + schedule?.templateId ?? initialTemplateId ?? '', + ); + const [cadence, setCadence] = useState( + (schedule?.cadence as Cadence) ?? 'weekly_monday_9', + ); + const [outputFormat, setOutputFormat] = useState( + (schedule?.outputFormat as OutputFormat) ?? 'pdf', + ); + const [enabled, setEnabled] = useState(schedule?.enabled ?? true); + const [recipients, setRecipients] = useState( + 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 ( + <> + + + {isEdit ? 'Edit schedule' : 'New schedule'} + + Recurring report. Recipients are optional — schedules with no recipients still run and + appear in the runs history, they just skip the email step. + + + +
+
+ + + {isEdit ? ( +

+ Template can't be changed on an existing schedule. Delete + recreate to + re-bind. +

+ ) : null} +
+ +
+
+ + +
+
+ + +

+ CSV/XLSX coming for scheduled runs — use Export for those formats now. +

+
+
+ +
+ +
+ {recipients.length === 0 ? ( +

+ No recipients yet — runs will be archived but not emailed. +

+ ) : ( + recipients.map((r, idx) => ( +
+
+ {r.name || r.email} + {r.name ? ( + {r.email} + ) : null} +
+ +
+ )) + )} +
+
+ setNewName(e.target.value)} + className="h-9" + /> + setNewEmail(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addRecipient(); + } + }} + className="h-9" + /> + +
+
+ +
+ + +
+
+ + + + + +
+ + ); +} diff --git a/src/components/reports/shared/report-export-button.tsx b/src/components/reports/shared/report-export-button.tsx new file mode 100644 index 00000000..2b036880 --- /dev/null +++ b/src/components/reports/shared/report-export-button.tsx @@ -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(null); + const [customTitle, setCustomTitle] = useState(''); + + 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.` 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 ( + <> + + + + + + + Download report + + + openRenameDialog('csv')}> + + CSV + + openRenameDialog('xlsx')}> + + Excel + + openRenameDialog('pdf')}> + + PDF + + + + + { + if (!open) setPendingFormat(null); + }} + > + + + Name your export + + This title appears at the top of the file and is used as the filename. Leave it as- is + for the default report name. + + +
+ + setCustomTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !exporting) { + e.preventDefault(); + handleConfirm(); + } + }} + placeholder="e.g. Q2 sales review for board" + /> +

+ Filename: {previewFilename()} +

+
+ + + + +
+
+ + ); +} + +/** + * 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); +} diff --git a/src/components/reports/shared/report-templates-button.tsx b/src/components/reports/shared/report-templates-button.tsx new file mode 100644 index 00000000..20f69823 --- /dev/null +++ b/src/components/reports/shared/report-templates-button.tsx @@ -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> { + /** 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>({ + kind, + currentConfig, + onApply, + activeTemplateId, + onActiveTemplateChange, + initialTemplateId, +}: ReportTemplatesButtonProps) { + 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({ + queryKey: ['report-templates', kind], + queryFn: () => + apiFetch(`/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 ( + <> + + + + + +
+
+

+ Saved templates +

+ {listQuery.isLoading ? ( +

Loading…

+ ) : templates.length === 0 ? ( +

+ No saved templates yet. Save your current view below. +

+ ) : ( +
    + {templates.map((t) => { + const isActive = t.id === activeTemplateId; + return ( +
  • + + +
  • + ); + })} +
+ )} +
+ +
+ + {activeTemplate ? ( + + ) : null} +
+
+
+
+ + + + + Save report as template + + 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. + + +
+
+ + setSaveName(e.target.value)} + placeholder="e.g. Monthly board sales view" + /> +
+
+ +