279 lines
20 KiB
Markdown
279 lines
20 KiB
Markdown
|
|
# Reports Page Design (`/{portSlug}/reports`)
|
||
|
|
|
||
|
|
> **Status:** Design doc. All Q-block decisions locked 2026-05-24 via AskUserQuestion in the alpha UAT master doc. Implementation phased into discrete PRs at the end.
|
||
|
|
|
||
|
|
## Goals & non-goals
|
||
|
|
|
||
|
|
**Goals**
|
||
|
|
|
||
|
|
- Promote PDF report generation from a cramped dashboard dialog (~25 widgets and growing) to a dedicated landing + builder page.
|
||
|
|
- Support saved-template management (rename / archive / share-with-team / duplicate).
|
||
|
|
- Add run history so reps can answer "send me the same report Sarah ran last month."
|
||
|
|
- Scheduled recurring reports (weekly / monthly / quarterly) with per-recipient email delivery.
|
||
|
|
- One-click "Generate & email" alongside "Generate & download."
|
||
|
|
- CSV + PNG/JPEG chart-snapshot outputs alongside the existing PDF.
|
||
|
|
- Per-report metadata overrides: title, subtitle, cover-page branding swap.
|
||
|
|
|
||
|
|
**Non-goals (v1)**
|
||
|
|
|
||
|
|
- Excel workbook output (`xlsx`) — defer; PDF + CSV cover the asks.
|
||
|
|
- Public hosted-HTML share-link to a report — defer.
|
||
|
|
- Cover-page intro paragraph + footer/sign-off — defer; title/subtitle is enough.
|
||
|
|
- A separate "Reports admin" page; admin controls live alongside the same `/reports` surface gated by `reports.admin`.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Routing
|
||
|
|
|
||
|
|
```
|
||
|
|
/{portSlug}/reports
|
||
|
|
├── (default view) Landing: every report kind as a card with "Generate" CTA + the port's saved templates
|
||
|
|
├── /[kind] Per-report-kind builder (two-panel: sections checklist + live preview)
|
||
|
|
├── /templates Shared-templates manager (rename / archive / duplicate / share)
|
||
|
|
├── /runs Run history (re-run / re-email)
|
||
|
|
└── /schedules Active recurring schedules (pause / edit recipients / cadence)
|
||
|
|
```
|
||
|
|
|
||
|
|
The existing dashboard "Export as PDF" button is rewired to navigate to `/{portSlug}/reports/dashboard?range=YYYY-MM-DD..YYYY-MM-DD` with the active date range pre-filled. One-click access preserved; rep lands in the full builder with everything pre-selected and the PDF preview ready.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Data model
|
||
|
|
|
||
|
|
Three new tables.
|
||
|
|
|
||
|
|
### `report_templates_shared`
|
||
|
|
|
||
|
|
Per-port, port-scoped, optionally shared with the whole team.
|
||
|
|
|
||
|
|
```sql
|
||
|
|
CREATE TABLE report_templates_shared (
|
||
|
|
id text PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||
|
|
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
|
||
|
|
name text NOT NULL,
|
||
|
|
description text,
|
||
|
|
-- The report-kind union ('dashboard' | 'website-analytics' | 'client-summary' | 'interest-summary' | 'berth-spec' | 'occupancy' | …).
|
||
|
|
-- Same vocabulary the existing PDF exporter uses.
|
||
|
|
kind text NOT NULL,
|
||
|
|
-- Widget selection + per-widget option overrides + report metadata.
|
||
|
|
config jsonb NOT NULL,
|
||
|
|
-- 'private' = creator only; 'team' = anyone with reports.export at this port.
|
||
|
|
visibility text NOT NULL DEFAULT 'private',
|
||
|
|
created_by text NOT NULL REFERENCES "user"(id) ON DELETE RESTRICT,
|
||
|
|
archived_at timestamptz,
|
||
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||
|
|
);
|
||
|
|
CREATE INDEX report_templates_shared_port_kind_idx ON report_templates_shared(port_id, kind);
|
||
|
|
CREATE INDEX report_templates_shared_port_visibility_idx ON report_templates_shared(port_id, visibility);
|
||
|
|
```
|
||
|
|
|
||
|
|
Notes:
|
||
|
|
|
||
|
|
- `config.sections: string[]` — widget ids, same shape as today's dialog.
|
||
|
|
- `config.dateRange: { from?: string, to?: string, mode?: 'last_7' | 'last_30' | 'last_90' | 'custom' }` — saved templates default to relative ranges so a "Weekly snapshot" template stays fresh.
|
||
|
|
- `config.metadata: { title?: string, subtitle?: string, brandingPortId?: string }` — `brandingPortId` lets the report use another port's logo/colour on the cover (admin-only).
|
||
|
|
- `config.kindOptions` — per-kind option bag; e.g. for `website-analytics` the country filter, for `client-summary` the client-id.
|
||
|
|
- Partial unique on `(port_id, lower(name)) where archived_at is null` — no two active templates share a name per port.
|
||
|
|
|
||
|
|
### `report_runs`
|
||
|
|
|
||
|
|
Append-only audit log of every generated report.
|
||
|
|
|
||
|
|
```sql
|
||
|
|
CREATE TABLE report_runs (
|
||
|
|
id text PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||
|
|
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
|
||
|
|
-- Nullable: ad-hoc runs (no template) still get logged.
|
||
|
|
template_id text REFERENCES report_templates_shared(id) ON DELETE SET NULL,
|
||
|
|
schedule_id text REFERENCES report_schedules(id) ON DELETE SET NULL,
|
||
|
|
kind text NOT NULL,
|
||
|
|
config jsonb NOT NULL, -- snapshotted at run time so re-runs reproduce identically
|
||
|
|
output_format text NOT NULL, -- 'pdf' | 'csv' | 'png' | 'jpg'
|
||
|
|
-- Storage key of the rendered artefact. Same backend as files (s3 or filesystem).
|
||
|
|
storage_key text,
|
||
|
|
size_bytes integer,
|
||
|
|
status text NOT NULL DEFAULT 'pending', -- 'pending' | 'rendering' | 'complete' | 'failed'
|
||
|
|
error_message text,
|
||
|
|
triggered_by text NOT NULL, -- 'user' | 'schedule'
|
||
|
|
triggered_by_user_id text REFERENCES "user"(id) ON DELETE SET NULL,
|
||
|
|
-- When non-null, this run was emailed to these recipients on completion.
|
||
|
|
emailed_to jsonb, -- Array<{ name?: string, email: string }>
|
||
|
|
emailed_at timestamptz,
|
||
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||
|
|
completed_at timestamptz
|
||
|
|
);
|
||
|
|
CREATE INDEX report_runs_port_created_idx ON report_runs(port_id, created_at DESC);
|
||
|
|
CREATE INDEX report_runs_port_user_idx ON report_runs(port_id, triggered_by_user_id);
|
||
|
|
CREATE INDEX report_runs_port_template_idx ON report_runs(port_id, template_id) WHERE template_id IS NOT NULL;
|
||
|
|
```
|
||
|
|
|
||
|
|
### `report_schedules`
|
||
|
|
|
||
|
|
```sql
|
||
|
|
CREATE TABLE report_schedules (
|
||
|
|
id text PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||
|
|
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
|
||
|
|
template_id text NOT NULL REFERENCES report_templates_shared(id) ON DELETE CASCADE,
|
||
|
|
-- 'weekly_monday_9' | 'monthly_first_9' | 'quarterly_first_9' to start; cron string optional later.
|
||
|
|
cadence text NOT NULL,
|
||
|
|
recipients jsonb NOT NULL, -- Array<{ name?: string, email: string }>
|
||
|
|
output_format text NOT NULL DEFAULT 'pdf',
|
||
|
|
enabled boolean NOT NULL DEFAULT true,
|
||
|
|
last_run_at timestamptz,
|
||
|
|
next_run_at timestamptz NOT NULL, -- pre-computed for the BullMQ scheduler
|
||
|
|
created_by text NOT NULL REFERENCES "user"(id) ON DELETE RESTRICT,
|
||
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||
|
|
);
|
||
|
|
CREATE INDEX report_schedules_port_enabled_next_idx ON report_schedules(port_id, enabled, next_run_at);
|
||
|
|
```
|
||
|
|
|
||
|
|
A schedule lifecycle:
|
||
|
|
|
||
|
|
1. Created via the builder ("Schedule recurring" panel) or `/schedules` page.
|
||
|
|
2. BullMQ cron checks every 15 min for `enabled=true AND next_run_at <= now()`.
|
||
|
|
3. For each match: create a `report_runs` row (`triggered_by='schedule'`), enqueue the rendering job, then advance `next_run_at` based on cadence.
|
||
|
|
4. Rendering job completes → email job fires with the storage key.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## API surface (`/api/v1/reports/*`)
|
||
|
|
|
||
|
|
| Verb | Path | Permission | Notes |
|
||
|
|
| ------ | ------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
|
|
| POST | `/api/v1/reports/generate` | `reports.export` | One-shot generate. Body: `{ kind, config, outputFormat?, deliverTo?: { recipients[] } }`. Returns `{ runId, downloadUrl }` (presigned) or fires email job when `deliverTo` set. |
|
||
|
|
| GET | `/api/v1/reports/templates` | `reports.export` | Lists templates visible to the caller (own private + team-shared). |
|
||
|
|
| POST | `/api/v1/reports/templates` | `reports.export` | Create a template (visibility defaults to `private`). |
|
||
|
|
| PATCH | `/api/v1/reports/templates/[id]` | `reports.export`\* | Update name / description / config. `*` Only the creator OR holders of `reports.admin` can edit team-shared templates. |
|
||
|
|
| DELETE | `/api/v1/reports/templates/[id]` | `reports.admin` | Soft-delete (sets `archived_at`). Frontend uses "Archive" copy. |
|
||
|
|
| POST | `/api/v1/reports/templates/[id]/duplicate` | `reports.export` | Returns a copy owned by caller, visibility=`private`. |
|
||
|
|
| GET | `/api/v1/reports/runs` | `reports.export` | Run history. Filter params: `templateId`, `userId`, `kind`, `from`, `to`. |
|
||
|
|
| POST | `/api/v1/reports/runs/[id]/re-run` | `reports.export` | Generates a fresh run with the original snapshotted config + same recipients (when triggered_by=schedule). |
|
||
|
|
| GET | `/api/v1/reports/runs/[id]/download` | `reports.export` | Presigned URL for the run artefact. |
|
||
|
|
| GET | `/api/v1/reports/schedules` | `reports.admin` | List scheduled jobs. |
|
||
|
|
| POST | `/api/v1/reports/schedules` | `reports.admin` | Create a schedule. |
|
||
|
|
| PATCH | `/api/v1/reports/schedules/[id]` | `reports.admin` | Pause / edit / change recipients. |
|
||
|
|
| DELETE | `/api/v1/reports/schedules/[id]` | `reports.admin` | Remove. |
|
||
|
|
| GET | `/api/v1/reports/availability?kind=...&...` | `reports.export` | Lightweight per-widget presence check (drives the empty-state pills in the builder; already speced in B2 audit). |
|
||
|
|
|
||
|
|
Existing `POST /api/v1/reports/generate` stays — it's the foundation. New endpoints layer on top.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Permissions
|
||
|
|
|
||
|
|
Two perms (locked decision):
|
||
|
|
|
||
|
|
- **`reports.export`** — generate + download + manage own private templates. Default ON for `super_admin`, `director`, `sales_manager`, `sales_agent`, `finance_manager`. OFF for `viewer`, `residential_partner`.
|
||
|
|
- **`reports.admin`** — manage BOTH team-shared templates AND schedules. Default ON for `super_admin` only.
|
||
|
|
|
||
|
|
Seed via `src/lib/db/seed-permissions.ts` in the same PR that adds the schema.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## BullMQ queue + cron handler
|
||
|
|
|
||
|
|
Two new queues:
|
||
|
|
|
||
|
|
- **`reports-render`** — per-run render job. Consumed by `src/jobs/processors/report-render.ts`. Steps:
|
||
|
|
1. Resolve the run's config + storage key.
|
||
|
|
2. Run kind-specific resolver (already wired for `dashboard` and `website-analytics`; new ones get a registry entry).
|
||
|
|
3. Render to `outputFormat` (PDF via existing `pdfme`+`pdf-lib` path; CSV via shared resolver-to-csv helper; PNG/JPEG via puppeteer-snapshot of each chart).
|
||
|
|
4. Upload to storage, update `report_runs` row with `storage_key`, `size_bytes`, `status='complete'`.
|
||
|
|
5. If `triggered_by='schedule'` (schedule has recipients) — enqueue `reports-email` follow-up.
|
||
|
|
|
||
|
|
- **`reports-email`** — fan-out email delivery. Consumed by `src/jobs/processors/report-email.ts`. Uses existing transactional-email infra (`sendBrandedEmail`) with the run artefact as an attachment OR a 7-day signed link when over the per-port attachment threshold.
|
||
|
|
|
||
|
|
A cron-style `reports-scheduler` BullMQ recurring job fires every 15 min:
|
||
|
|
|
||
|
|
1. `SELECT id FROM report_schedules WHERE enabled = TRUE AND next_run_at <= now() ORDER BY next_run_at`.
|
||
|
|
2. For each: create the `report_runs` row + enqueue `reports-render` + UPDATE `next_run_at` based on cadence (helpers in `src/lib/services/report-schedule.service.ts`).
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## UI plan
|
||
|
|
|
||
|
|
### 1. Landing — `/{portSlug}/reports`
|
||
|
|
|
||
|
|
Two-column layout:
|
||
|
|
|
||
|
|
- **Left rail**: report-kind cards (Dashboard, Website Analytics, Client Summary, Interest Summary, Berth Spec, Occupancy, …). Each card shows last-run timestamp + "Generate" CTA opening that kind's builder.
|
||
|
|
- **Right column**: tabs for "My templates" (private), "Team templates" (shared), "Recent runs" (last 10).
|
||
|
|
|
||
|
|
Filtered by `reports.export`/`reports.admin` so a `viewer` never sees the page at all.
|
||
|
|
|
||
|
|
### 2. Builder — `/{portSlug}/reports/[kind]`
|
||
|
|
|
||
|
|
Full-page two-panel layout (the locked Q2 shape):
|
||
|
|
|
||
|
|
```
|
||
|
|
┌────────────────────────────────────┬────────────────────────────────┐
|
||
|
|
│ Title + subtitle inputs │ │
|
||
|
|
│ Date range picker │ │
|
||
|
|
│ ─── Sections (grouped by domain) ──│ Live PDF preview │
|
||
|
|
│ ☑ Summary │ (re-renders on each │
|
||
|
|
│ ☐ Pipeline │ toggle, debounced 200ms)│
|
||
|
|
│ ☑ Berths │ │
|
||
|
|
│ ☑ Lead sources │ │
|
||
|
|
│ ☐ Operations │ │
|
||
|
|
│ ─── Output ─────────────────────── │ │
|
||
|
|
│ ◉ PDF │ │
|
||
|
|
│ ◯ CSV │ │
|
||
|
|
│ ◯ PNG (per chart) │ │
|
||
|
|
│ ─── Delivery ────────────────────── │ │
|
||
|
|
│ ◯ Download │ │
|
||
|
|
│ ◯ Email — recipient list │ │
|
||
|
|
│ ─── Save / Schedule ─────────────── │ │
|
||
|
|
│ [ Save as template ] [ Schedule…] │ │
|
||
|
|
└────────────────────────────────────┴────────────────────────────────┘
|
||
|
|
```
|
||
|
|
|
||
|
|
Per-section row shows the existing "data availability" pill from the B2 audit (`ok` / `no_data` / `needs_window` / `partial`) plus a drag-handle to reorder (locked Q9 polish).
|
||
|
|
|
||
|
|
### 3. Templates manager — `/{portSlug}/reports/templates`
|
||
|
|
|
||
|
|
Table of every visible template with columns: name · kind · visibility · last-used · created-by. Row actions: Open in builder · Rename · Duplicate · Share with team (gated on `reports.admin` for shared ones) · Archive.
|
||
|
|
|
||
|
|
### 4. Run history — `/{portSlug}/reports/runs`
|
||
|
|
|
||
|
|
Server-paginated table. Columns: when · who · template name · kind · format · status · size · re-run / re-email / download.
|
||
|
|
|
||
|
|
### 5. Schedules — `/{portSlug}/reports/schedules`
|
||
|
|
|
||
|
|
Table of active schedules. Columns: template · cadence · recipients · last run · next run · enabled toggle · edit.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Quick-path dashboard button
|
||
|
|
|
||
|
|
The existing `<ExportDashboardPdfButton>` (`src/components/reports/export-dashboard-pdf-button.tsx`) is rewired to navigate to `/{portSlug}/reports/dashboard?range=...` instead of opening the in-dashboard dialog. The dialog logic moves into the builder page wholesale (same checklist + same preview component). One-click access preserved; the bigger surface gives reps room to breathe.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Phased PR plan
|
||
|
|
|
||
|
|
| PR | Scope | Effort | Ships independently |
|
||
|
|
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | --------------------------------------------------------------- |
|
||
|
|
| **P1: Schema + perms** | `0084_reports_page.sql` (3 tables + indexes) + seed `reports.export` / `reports.admin` perms + service skeleton (`report-template.service.ts`, `report-run.service.ts`, `report-schedule.service.ts`). No UI changes. | ~4 h | Yes (no behavioural change) |
|
||
|
|
| **P2: Templates API** | CRUD routes for `report_templates_shared` + `report_runs` (read-only at this stage). Mount under `/api/v1/reports/templates` + `/api/v1/reports/runs`. Vitest coverage. | ~4 h | Yes |
|
||
|
|
| **P3: Schedules API + cron** | `/api/v1/reports/schedules` CRUD + BullMQ `reports-scheduler` recurring job + `reports-render` + `reports-email` queues. Renderer reuses the existing PDF path. Vitest + integration tests. | ~8 h | Yes |
|
||
|
|
| **P4: Landing + builder UI** | `/{portSlug}/reports` landing + `/[kind]` builder. Migrate the existing dialog UI into the builder; delete the dialog. Dashboard button rewires to the builder. | ~10 h | Yes (templates/runs UIs still missing — they get a placeholder) |
|
||
|
|
| **P5: Templates + Runs + Schedules pages** | Three sub-route pages, table UIs, row actions, modal forms for "Schedule…". | ~8 h | Yes |
|
||
|
|
| **P6: CSV + PNG outputs** | Add output-format renderers; wire output radio in builder. | ~6 h | Yes |
|
||
|
|
| **P7: Metadata overrides + branding swap** | Title/subtitle inputs + cover-page brand picker (admin-only). | ~3 h | Yes |
|
||
|
|
|
||
|
|
Total: ~43 h spread across 7 PRs.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Open follow-ups (intentionally deferred past v1)
|
||
|
|
|
||
|
|
- Excel workbook output.
|
||
|
|
- Public hosted-HTML share-link (write to `/api/public/reports/[id]` with a signed token).
|
||
|
|
- Cover-page intro paragraph + footer/sign-off note.
|
||
|
|
- Custom cron strings (today: enum cadence only — `weekly_monday_9` etc).
|
||
|
|
- Per-user template visibility ('shared with specific users' beyond port-wide team).
|
||
|
|
|
||
|
|
Capture in `docs/BACKLOG.md` after P5 ships.
|