diff --git a/docs/reports-page-design.md b/docs/reports-page-design.md new file mode 100644 index 00000000..37617deb --- /dev/null +++ b/docs/reports-page-design.md @@ -0,0 +1,278 @@ +# 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 `` (`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. diff --git a/docs/tenancies-design.md b/docs/tenancies-design.md new file mode 100644 index 00000000..4a2719c8 --- /dev/null +++ b/docs/tenancies-design.md @@ -0,0 +1,302 @@ +# Tenancies Module Design + +> **Status:** Design doc. All Q-block decisions locked 2026-05-24 via AskUserQuestion + a follow-up platform-wide module-enabled rule locked 2026-05-25 in the alpha UAT master doc. Implementation phased into discrete PRs at the end. + +## Vocabulary split (the foundational decision) + +The pipeline-stage `reservation` + the signed `Reservation Agreement` **keep their names** — they describe the _right being reserved_, not the _occupancy that results_. + +The occupancy record (`berth_reservations` table + sidebar + entity tabs + top-level page) is **renamed Tenancy**: + +| Concept | Lives in | Name (post-rename) | +| -------------------------------------------- | ----------------------------------------------------- | ----------------------------------- | +| Pipeline stage where the rep targets a berth | `interests.pipelineStage` | `reservation` (unchanged) | +| The signed legal document | `documents` w/ `documentType='reservation_agreement'` | `Reservation Agreement` (unchanged) | +| The record of who's tied up at a berth | `tenancies` (was `berth_reservations`) | **Tenancy** | + +A signed Reservation Agreement → results in a Tenancy. + +--- + +## Platform-wide module-enabled rule + +The entire Tenancies module surface is **hidden by default**. + +A sold berth stays sold without any tenancy data — the platform does not assume tenancies exist for sold berths. The module only surfaces when EITHER: + +- **(a) at least one `tenancies` row exists** for the port (lazy auto-enable on first creation, including auto-create from a signed Reservation Agreement), OR +- **(b) an admin has explicitly enabled it** via `system_settings.tenancies_module_enabled` (default `false`). + +### When disabled + +- Sidebar entry hidden +- Client / Yacht / Berth `Tenancies` tab hidden +- All four reporting widgets hidden from dashboard registry +- Top-level `/{portSlug}/tenancies` page returns 404 +- `handleDocumentCompleted` skips the auto-create branch on signed `reservation_agreement` (the document still progresses the interest stage and flips `reservationDocStatus` — only the tenancy mint is gated) + +### When enabled + +Full module surfaces. + +### Admin toggle + +Admin → Operations → "Tenancies module" Switch: + +- **Helper copy:** "When enabled, the platform tracks who occupies each berth (Tenancies). Without it, sold berths stay sold but the platform doesn't model the occupancy record." +- **Warning on disable with rows:** Modal — "This will hide N existing tenancies. Data is preserved but invisible until re-enabled. Continue?" +- **Auto-enable on first insert:** The first row INSERT on `tenancies` flips `tenancies_module_enabled=true` in the same transaction (`pg_advisory_xact_lock` per port to avoid races). +- **Never auto-disables.** + +--- + +## Data model + +### Rename migration + +```sql +-- 008X_rename_reservations_to_tenancies.sql +ALTER TABLE berth_reservations RENAME TO tenancies; + +-- Self-FKs for renewals + transfers. +ALTER TABLE tenancies + ADD COLUMN previous_tenancy_id text REFERENCES tenancies(id) ON DELETE SET NULL, + ADD COLUMN transferred_from_tenancy_id text REFERENCES tenancies(id) ON DELETE SET NULL; + +CREATE INDEX tenancies_previous_id_idx ON tenancies(previous_tenancy_id) WHERE previous_tenancy_id IS NOT NULL; +CREATE INDEX tenancies_transferred_from_id_idx ON tenancies(transferred_from_tenancy_id) WHERE transferred_from_tenancy_id IS NOT NULL; +``` + +Schema TypeScript also renames: `src/lib/db/schema/reservations.ts` → `tenancies.ts`, `berthReservations` → `tenancies`. Adjust all imports. + +### `tenure_type` discriminator (unchanged from existing union) + +`permanent | fee_simple | strata_lot | seasonal | fixed_term` + +Behaviour by type: + +| `tenure_type` | Renewals | Public map flip | +| ------------- | ----------------------------------------------- | --------------------------- | +| `permanent` | Mutate existing row (one record forever) | Sets `berths.status='sold'` | +| `fee_simple` | Mutate existing row | Sets `berths.status='sold'` | +| `strata_lot` | Mutate existing row | Sets `berths.status='sold'` | +| `seasonal` | New row each cycle, `previous_tenancy_id` links | No status flip — temporary | +| `fixed_term` | New row each cycle, `previous_tenancy_id` links | No status flip — temporary | + +### Transfers + +Two-step operation: + +1. End old tenancy: `UPDATE tenancies SET status='ended', end_date=transfer_date WHERE id=:old`. +2. Mint new tenancy: `INSERT INTO tenancies (..., transferred_from_tenancy_id=:old) VALUES (...)` for the new client. + +Both steps in one transaction; same berth, different client. Preserves history. + +### Module-enabled setting + +Add to `src/lib/settings/registry.ts`: + +```ts +{ + key: 'tenancies_module_enabled', + section: 'operations.tenancies', + label: 'Tenancies module', + description: 'When enabled, the platform tracks who occupies each berth (Tenancies). Without it, sold berths stay sold but the platform does not model the occupancy record.', + type: 'boolean', + defaultValue: false, + scope: 'port', +} +``` + +### Permissions + +Three new perms in `src/lib/db/seed-permissions.ts`: + +| Perm | Default ON for | Notes | +| ------------------ | -------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| `tenancies.view` | super_admin, director, sales_manager, sales_agent, finance_manager, viewer | Read access. | +| `tenancies.manage` | super_admin, sales_manager, sales_agent | Create / mutate / transfer. | +| `tenancies.cancel` | super_admin, sales_manager | Cancel only. Carved out because cancellation has revenue implications. | + +Every Tenancies surface respects both `tenancies.view` AND `tenancies_module_enabled` — the module-enabled gate is checked first. + +--- + +## Webhook auto-create branch + +Inside `handleDocumentCompleted` (`src/lib/services/documents.service.ts`): + +```ts +// After signedFileId is committed + post-completion email queues, branch: +if (doc.documentType === 'reservation_agreement') { + const moduleEnabled = await isTenanciesModuleEnabled(doc.portId); + if (moduleEnabled) { + await autoCreatePendingTenancies(doc.portId, doc.interestId, { + signedAt: completedAt, + sourceDocumentId: doc.id, + userId: 'system', + }); + } + // Stage advance + reservationDocStatus flip happen regardless. +} +``` + +`autoCreatePendingTenancies` loops over `interest_berths WHERE interest_id = :interestId AND is_in_eoi_bundle = TRUE` and inserts ONE tenancy row per in-bundle berth (locked Q4 decision: "one tenancy per in-bundle berth"). Status `pending`; rep confirms `startDate` + `tenureType` in a follow-up modal before `pending → active`. Default `startDate = signed date` when not on the doc. + +The first insert in a port flips `tenancies_module_enabled=true` (lazy auto-enable). + +--- + +## Public map status flip + +`src/lib/services/berths.service.ts` (status precedence resolver): + +``` +sold > under_offer > available + +Sold can come from: + 1. berths.status = 'sold' (explicit admin set) + 2. An active tenancy with tenure_type IN ('permanent', 'fee_simple', 'strata_lot') exists for this berth +``` + +The new branch (2) only fires when `tenancies_module_enabled = true`. When disabled OR the only active tenancies are `seasonal` / `fixed_term`, fall through to existing precedence (under_offer / available based on interest links). + +Reversal: when an active permanent-class tenancy ends + no replacement is active for the same berth, the auto-derived `sold` lifts. Explicit `berths.status='sold'` (admin-set) stays sold. + +--- + +## Sidebar entry + +`src/components/layout/sidebar.tsx`: add `Tenancies` entry below `Berths`, gated by: + +- `tenancies.view` permission +- `tenancies_module_enabled = true` (resolved server-side; SSR'd into the sidebar so it never flickers in) + +Icon: `KeyRound` from lucide. + +--- + +## Top-level page — `/{portSlug}/tenancies` + +Returns 404 when module disabled. When enabled: + +- Filters: status (active / pending / ended / cancelled), tenure_type, berth-area, client search. +- Columns: Berth · Client · Yacht · Tenure type · Status · Start · End · Last renewal. +- Row actions: Open detail · Edit · Renew (tenure-type aware) · Transfer · End / Cancel. +- Bulk actions: End multiple (with `tenancies.cancel`). +- "+ New tenancy" CTA top-right (gated on `tenancies.manage`). + +--- + +## Entity-tab CTAs + +On Client / Yacht / Berth detail pages, the existing read-only tenancies tab gets a refreshed empty state when module is enabled but no rows exist: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ [icon] No tenancies yet │ +│ │ +│ This doesn't have any tenancies on file. │ +│ │ +│ [ Create tenancy ] (only when user has tenancies.manage) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +The "Create tenancy" button opens a pre-filled `` with the parent entity already selected. Berth context pre-fills berth_id, Client pre-fills client_id, Yacht pre-fills yacht_id. + +When `tenancies_module_enabled = false`: the whole tab is hidden (entity tabs registry gates). + +--- + +## Reporting widgets (all four, all module-gated) + +Locked Q7: ship all four in v1, every one gated by `tenancies_module_enabled`. + +1. **Occupancy heatmap by month** — Per-berth-area grid: rows = berth areas, columns = months for the active date range, cell shade = % months occupied. Data from `tenancies.startDate / endDate` overlap with each month. +2. **Renewals at risk (next 90 days)** — Table of active tenancies whose `endDate IS NOT NULL AND endDate <= now() + 90d AND` no successor row exists yet. Click-through opens the tenancy with "Renew" CTA pre-focused. +3. **Revenue forecast by tenure expiry** — Forward projection per quarter: sum of berth-price × remaining-tenure for active rows; bucketed by quarter ending date. Highlights revenue cliffs. +4. **Tenancy by tenure type breakdown** — Donut + table of active tenancies grouped by `tenure_type`. Operational mix at a glance. + +Each widget registers in `src/components/dashboard/widget-registry.tsx` with: + +```ts +{ + id: 'tenancy_occupancy_heatmap', + label: 'Occupancy heatmap', + render: (range) => , + group: 'chart', + defaultVisible: true, + selfGates: true, + requires: 'tenancies_module', // new gating channel +} +``` + +The `tenancies_module` integration check resolves to `tenancies_module_enabled === true`. When false → widget filtered out of both the dashboard render AND the customize picker. + +--- + +## Service layer additions + +`src/lib/services/tenancies.service.ts` (renamed from `berth-reservations.service.ts`): + +- `listTenancies({ portId, filters, page })` — gated read. +- `createTenancy(portId, data, meta)` — mints a row; also triggers the module-enable flip on first insert. +- `updateTenancy(portId, id, data, meta)`. +- `renewTenancy(portId, id, data, meta)` — picks mutate-in-place vs new-row branch based on `tenure_type`. +- `transferTenancy(portId, id, newClientId, transferDate, meta)`. +- `cancelTenancy(portId, id, reason, meta)` — gated on `tenancies.cancel`. +- `endTenancy(portId, id, endDate, meta)`. +- `autoCreatePendingTenancies(portId, interestId, opts)` — webhook auto-create branch. + +`src/lib/services/tenancies-module.service.ts` (new): + +- `isTenanciesModuleEnabled(portId)` — checks setting OR `EXISTS (SELECT 1 FROM tenancies WHERE port_id = $1)` to surface the lazy state. +- `enableTenanciesModule(portId, meta)` — admin-driven enable. +- `disableTenanciesModule(portId, meta)` — admin-driven disable; the warning copy lives in the admin UI. + +--- + +## API surface (`/api/v1/tenancies/*`) + +All routes gated on `tenancies.view` (read) or `tenancies.manage` / `tenancies.cancel` (write). Each handler additionally calls `assertTenanciesModuleEnabled(portId)` first — returns 404 when off (matches the sidebar/top-level page behaviour). + +| Verb | Path | Permission | +| ----- | ---------------------------------------- | ----------------------- | +| GET | `/api/v1/tenancies` | `tenancies.view` | +| GET | `/api/v1/tenancies/[id]` | `tenancies.view` | +| POST | `/api/v1/tenancies` | `tenancies.manage` | +| PATCH | `/api/v1/tenancies/[id]` | `tenancies.manage` | +| POST | `/api/v1/tenancies/[id]/renew` | `tenancies.manage` | +| POST | `/api/v1/tenancies/[id]/transfer` | `tenancies.manage` | +| POST | `/api/v1/tenancies/[id]/end` | `tenancies.manage` | +| POST | `/api/v1/tenancies/[id]/cancel` | `tenancies.cancel` | +| GET | `/api/v1/admin/tenancies-module/status` | `admin.manage_settings` | +| POST | `/api/v1/admin/tenancies-module/enable` | `admin.manage_settings` | +| POST | `/api/v1/admin/tenancies-module/disable` | `admin.manage_settings` | + +--- + +## Phased PR plan + +| PR | Scope | Effort | Ships independently | +| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------ | +| **P1: Rename migration + perms + setting** | `008X_rename_reservations_to_tenancies.sql` + self-FKs + seed `tenancies.view`/`.manage`/`.cancel` + `tenancies_module_enabled` registry entry. Schema files renamed. ALL imports updated. **No behaviour change** — module starts disabled, so reps don't see anything new. | ~6 h | Yes (silent rename; existing consumers keep working through the renamed table) | +| **P2: Module-enabled gating infra** | `tenancies-module.service.ts` + admin Operations page Switch + lazy-flip logic + permission helper that combines `tenancies.view` AND module-enabled. | ~4 h | Yes (admin can toggle; rest of app honors the flag) | +| **P3: Webhook auto-create branch** | `autoCreatePendingTenancies` + branch in `handleDocumentCompleted` + first-insert flip. Vitest covering: module on → row created; module off → no row + stage still advances. | ~5 h | Yes (back-compat — pre-existing reservation flows keep working) | +| **P4: Public-map status flip rules** | Status resolver in `berths.service.ts` honors active permanent-class tenancies. Vitest for precedence + module-off behaviour. | ~3 h | Yes | +| **P5: Sidebar entry + top-level page** | Sidebar mounts the Tenancies entry behind both gates. New `/{portSlug}/tenancies/page.tsx` with the listing table + filters. 404 when module disabled. | ~6 h | Yes (visible to super_admin first; sales reps see it once perms seed) | +| **P6: Entity tab refresh + Create dialog** | Friendly empty state + "Create tenancy" CTA on Client / Yacht / Berth tabs. `` pre-fills from parent context. Edit / Renew / Transfer / End dialogs follow the same idiom. | ~8 h | Yes | +| **P7: Reporting widgets** | All four widgets — occupancy heatmap, renewals at risk, revenue forecast, tenure type breakdown — all module-gated via `selfGates: true` + `requires: 'tenancies_module'`. | ~10 h | Yes | + +Total: ~42 h spread across 7 PRs. + +--- + +## Open follow-ups (intentionally deferred past v1) + +- **Auto-invoicing on tenancy lifecycle.** Locked: v1 ships READ-ONLY — no auto-invoice on tenancy create / renew / end. Revisit once we see how ports actually use the tenancy data. +- **Strict-block duplicate-tenancy toggle.** Locked: out of scope. No admin-configurable "block creating a tenancy if one already exists for this berth." Keep dead-simple now. +- **Warning for closed-outcome siblings.** Out of scope. +- **Cross-tenant warnings.** Out of scope (already enforced by `port_id` constraints). + +Capture in `docs/BACKLOG.md` after P5 ships.