diff --git a/docs/superpowers/audits/alpha-uat-master.md b/docs/superpowers/audits/alpha-uat-master.md
index 914819a2..c9c97537 100644
--- a/docs/superpowers/audits/alpha-uat-master.md
+++ b/docs/superpowers/audits/alpha-uat-master.md
@@ -138,6 +138,17 @@ _Copy tweaks, alignment, single-prop edits, obvious typos._
> - **Q10.** Output formats beyond PDF β CSV export of the underlying data, Excel workbook with one sheet per section, PNG/JPEG snapshots of each chart, public share-link to a hosted HTML version?
> - **Q11.** Customisable report metadata β title + subtitle + cover-page copy + footer note. Today the PDF header is hardcoded "Dashboard summary Β· {date-range-line}" at `src/lib/pdf/reports/dashboard-report.tsx:195`; the render path already accepts a `subtitle` prop override but the dialog never exposes it. The dedicated-page builder should expose: report title, optional subtitle, optional intro paragraph, optional sign-off / footer (e.g. "Prepared for Board Meeting Q1 2026"). Saved-templates inherit these.
> - **Action:** schedule a design session covering Q1-Q10 with the operator stakeholder. Output a short design doc (`docs/reports-page-design.md`) covering routing, data shape, scheduler, permissions, then scope into discrete Bucket 3 items. Until then, keep iterating the dialog (badges, data-availability, currency etc.). Captured 2026-05-24 from UAT.
+> - **π’ DECISIONS LOCKED 2026-05-24 (via AskUserQuestion):**
+> - **Q1 + Q2 surface:** Dedicated `/{portSlug}/reports` page with full-page builder (left = sections grouped by domain + availability pills; right = live PDF preview). Keep adopting Q2's two-panel layout.
+> - **Q3 templates:** Rename + archive, share with team (port-scoped), duplicate from another template. **Skip** "set default-for-port" at v1.
+> - **Q4 run history:** Yes β ship in v1. New `report_runs` table; list view on /reports; "Re-run" CTA reproduces with same sections + range.
+> - **Q5 scheduling:** Yes β v1 must include scheduling. Saved template + cadence (weekly / monthly / quarterly) + recipients via BullMQ.
+> - **Q6 delivery:** Yes β "Generate & email" alongside "Generate & download." Recipients + optional message; same shape as scheduled delivery.
+> - **Q7 permissions:** Two perms β `reports.export` (generate + download; everyone with current access) and `reports.admin` (manages BOTH templates AND schedules; super_admin only by default).
+> - **Q8 dashboard button:** Keep the dashboard's "Export as PDF" as a quick-path that pre-selects the Dashboard report kind at /reports with the current date range pre-filled. One-click access preserved.
+> - **Q10 output formats:** PDF (primary) + CSV export of underlying data + PNG/JPEG chart snapshots. **Skip** Excel workbook and public hosted-HTML share-link for v1.
+> - **Q11 metadata:** Override report title + subtitle; cover-page logo / branding swap (use another port's branding on the cover). **Skip** cover-page intro paragraph and footer/sign-off note for v1.
+> - **Action:** scope into Bucket 3 items. Next step: write `docs/reports-page-design.md` covering routing, table shape (`report_runs`, `report_templates_shared`), scheduler queue + cron handler, permission seed, then split into discrete PRs.
> - **Dashboard PDF export dialog: surface per-section data availability + don't render uninformative "n/a" rows** β _src/lib/services/dashboard-report-data.service.ts_ (per-widget resolvers) + _src/components/reports/export-dashboard-pdf-button.tsx_ (sections checklist) + _src/lib/pdf/reports/dashboard-report.tsx_ (render-time empty-state handling). Today on a fresh port (e.g. Port Nimara), the Average Sales Cycle section renders "Median: n/a Β· Mean: n/a" because there are 0 signed contracts to compute against. Same risk for: stage_conversion_rates (needs deals that have progressed AND won), berth_demand_ranking (needs interests on berths), reminders_summary (needs reminders in window), recent_activity (needs audit-log entries), new_clients_period / new_interests_period (window-dependent), etc. The "n/a" output is noisy + the rep wasn't warned that the section would be empty.
> - **Two-tier fix:**
> - **(a) Cheap baseline (~30-45 min):** server-side omit-when-empty. Each resolver returns `null` (or sets `data[widget] = undefined`) when the resulting payload has no meaningful content. The PDF render path already gates on `data.X ?` so the section disappears entirely. Concrete sections to add the gate to: avg_sales_cycle (sampleSize === 0 β omit), reminders_summary (no reminders β omit or render the empty state with copy), stage_conversion_rates (no advanced deals β omit), recent_activity (no events β omit), every period-cohort resolver (count === 0 β omit). When omitted, the section just doesn't appear in the PDF.
@@ -647,6 +658,21 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._
> - **Possible rename for clarity:** "Reservation Agreement" β "Tenancy Agreement" (signed doc); "Reservation" β "Tenancy" (occupancy record). Aligns with marina-industry vocabulary and removes the agreement/record confusion.
> - **Why this is the A-Z final piece:** the CRM today covers lead β qualification β EOI β reservation agreement β contract β handover. Reservations are the canonical record of what the sale produced β without them filled in, the system has no answer to "which berths are taken right now and by whom?" beyond ad-hoc interest-state inference. Everything downstream (renewals, occupancy reporting, transfer flows, revenue recognition timing) hangs off this. **Worth a dedicated session to design before any implementation.**
> - **Action:** schedule a design session covering Q1-Q10 with stakeholders who care about the operational side (rep workflow + ops/leadership reporting). Output should be a short design doc (`docs/reservations-design.md` or similar) covering data-model decisions (split or unify), workflow entry points, automation rules, reporting surfaces, and a phased rollout. THEN scope into discrete Bucket 3 items. Captured 2026-05-24 from UAT.
+> - **π’ DECISIONS LOCKED 2026-05-24 (via AskUserQuestion):**
+> - **Vocabulary split (key decision):** The pipeline-stage "reservation" + signed "Reservation Agreement" KEEP their names (they describe the right being reserved, not the occupancy). The occupancy record (the `berth_reservations` table + sidebar + client/yacht entity tabs + top-level page) is renamed **"Tenancy."** A Reservation Agreement gets signed β results in a Tenancy.
+> - **Q1 data model:** Unify β keep `berth_reservations` (to be renamed `tenancies`) as one table with `tenure_type` + `status` as discriminators. The problem isn't the model β it's that flows / nav / reporting aren't wired. Schema rename pass + the workflow fixes below.
+> - **Q2 entry points:** All four creation surfaces β berth detail (existing), interest detail (new, at reservation stage+), top-level `/tenancies` page (new sidebar entry), client detail (new). Each pre-fills from its parent context.
+> - **Q3 auto-create:** Auto-create as `pending` on signed reservation_agreement via the existing idempotent `handleDocumentCompleted` webhook. Rep confirms `startDate` + `tenureType` in a follow-up modal before `pending β active`. Default `startDate` = signed date if not on the doc.
+> - **Q4 multi-berth:** One tenancy per in-bundle berth (loop `interest_berths WHERE is_in_eoi_bundle=true`). Each gets its own lifecycle (renewals, ends independently).
+> - **Q5 renewals:** Configurable per tenure type β `permanent` / `fee_simple` / `strata_lot` β mutate the existing row (one record forever). `seasonal` / `fixed_term` β new row each cycle, linked via `previous_tenancy_id` self-FK.
+> - **Q5 transfers:** End old tenancy (`status='ended'`, `endDate=transfer date`) + mint new tenancy for new client with `transferred_from_tenancy_id` FK back to the old one. Preserves history.
+> - **Q6 public map:** Active tenancy auto-flips `berths.status='sold'` ONLY when `tenure_type IN ('permanent', 'fee_simple', 'strata_lot')`. `seasonal` / `fixed_term` don't (they're temporary). Reversed when tenancy ends + no replacement is active.
+> - **Q7 reporting (locked):** All four widgets ship in v1 β Occupancy heatmap by month, Renewals at risk (next 90 days), Revenue forecast by tenure expiry, Tenancy by tenure type breakdown. All four are gated by the platform-wide module-enabled rule below (don't render when the Tenancies module is dormant).
+> - **Q8 permissions:** Three perms β `tenancies.view` (read), `tenancies.manage` (create + mutate + transfer; default super_admin + sales_manager + sales_agent), `tenancies.cancel` (cancel only; default super_admin + sales_manager). Cancel gets its own perm because of revenue implications.
+> - **Q9 empty-state UX:** Always show the tab on Client / Yacht detail **when the Tenancies module is enabled** (see platform-wide rule below). When empty, render a friendly empty-state (icon + "No tenancies yet" + a "Create tenancy" button if user has `tenancies.manage`). Discoverable + drives the creation flow.
+> - **Q10 invoicing:** v1 ships READ-ONLY β no auto-invoice generation on tenancy lifecycle. Decouple invoicing; revisit once we see how ports actually use the tenancy data.
+> - **Platform-wide module-enabled rule (locked 2026-05-25):** the entire Tenancies module surface area is hidden by default. **A sold berth stays sold without any tenancy data** β the platform does not assume tenancies exist for sold berths. The Tenancies 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 a new `system_settings.tenancies_module_enabled` boolean (default `false`). When disabled: hide sidebar entry, hide Client/Yacht/Berth `Tenancies` tab, hide all four reporting widgets from dashboard registry, hide top-level `/{portSlug}/tenancies` page (404), skip auto-create branch in `handleDocumentCompleted` (signed reservation_agreement still progresses the interest stage and flips `reservationDocStatus`, but does NOT mint a `tenancies` row). Admin Settings β Operations gets a "Tenancies module" toggle with helper copy explaining what enabling/disabling does + a warning when disabling with existing rows ("This will hide N existing tenancies β data is preserved but invisible until re-enabled"). Module auto-flips to enabled on first row insert; never auto-disables.
+> - **Action:** scope into Bucket 3 items. Next steps: write `docs/tenancies-design.md` covering (1) table rename migration (`berth_reservations` β `tenancies` + `previous_tenancy_id` + `transferred_from_tenancy_id` self-FKs), (2) webhook auto-create branch (gated on module-enabled), (3) status-flip rules for public map, (4) sidebar entry + new permissions + module-enabled gating, (5) reporting widgets (all four, module-gated), (6) entity-tab empty-state CTAs (module-gated), (7) admin Operations toggle + auto-enable-on-first-insert behavior. Then split into PRs.
> - **Interest create: duplicate-detection warning (overlap with existing open interest for same client)** β _src/lib/services/interests.service.ts_ (new helper `findOverlappingOpenInterests(portId, clientId, berthIds, { excludeInterestId? })`) + _src/lib/services/interest-berths.service.ts_ (read-side helper) + _src/components/interests/interest-form.tsx_ (new pre-submit warning panel) + _new route_ `GET /api/v1/interests/duplicate-check?clientId=...&berthIds=...`. Today a rep can create an "A1" interest for a client and then a second "A1-A10" interest for the same client without any signal β silent data quality erosion that compounds across pipeline reports, the public map ("Under Offer" precedence), recommender tier ladder, EOI bundles. Decision: **warn, do not block** β legitimate cases exist (re-opening a lost deal, parallel scenarios, renewal alongside existing).
> - **Detection scope:** match on `client_id` + ANY berth-set intersection. "Open" = `outcome IS NULL` (or non-terminal); closed/lost/won interests are excluded from the warning. Use `interest_berths` as the source of truth (per CLAUDE.md β `interests.berth_id` does not exist post-0029); intersection is any shared `berth_id` across the candidate's berth list and any open sibling interest's berth list. Multi-tenant scope enforced via `port_id` on both sides of the join.
> - **Service shape:** `findOverlappingOpenInterests(portId, clientId, berthIds, { excludeInterestId? })` returns `Array<{ id, displayLabel, pipelineStage, currentBerths: string[], overlappingBerths: string[], updatedAt }>` β sorted most-recent first. `displayLabel` reuses the same derivation used for the document-detail Interest sub-label (berth-range via `formatBerthRange()`).
diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx
index fb01c4cf..415e1d48 100644
--- a/src/app/(dashboard)/layout.tsx
+++ b/src/app/(dashboard)/layout.tsx
@@ -11,6 +11,7 @@ import { SocketProvider } from '@/providers/socket-provider';
import { PortProvider } from '@/providers/port-provider';
import { PermissionsProvider } from '@/providers/permissions-provider';
import { AppShell } from '@/components/layout/app-shell';
+import { OnboardingBanner } from '@/components/admin/onboarding-banner';
import { DevModeBanner } from '@/components/shared/dev-mode-banner';
import { RealtimeToasts } from '@/components/shared/realtime-toasts';
import { WebVitalsReporter } from '@/components/shared/web-vitals-reporter';
@@ -84,6 +85,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
rerouted. Production hides itself (env.ts forbids the
flag in prod) so the banner is dev/staging-only. */}
+
{/* #26: AppShell mounts ONE responsive tree (desktop OR
* mobile) per render - never both - so pages don't pay the
* double-state, double-fetch, double-Tabs-provider tax. */}
diff --git a/src/app/api/v1/admin/onboarding/status/route.ts b/src/app/api/v1/admin/onboarding/status/route.ts
new file mode 100644
index 00000000..a007dfd1
--- /dev/null
+++ b/src/app/api/v1/admin/onboarding/status/route.ts
@@ -0,0 +1,24 @@
+import { NextResponse } from 'next/server';
+
+import { withAuth, withPermission } from '@/lib/api/helpers';
+import { errorResponse } from '@/lib/errors';
+import { resolveOnboardingStatus } from '@/lib/services/onboarding.service';
+
+/**
+ * GET /api/v1/admin/onboarding/status β returns the resolved checklist
+ * state for the caller's port. Drives the topbar discoverability banner
+ * + dashboard onboarding tile + the admin checklist page summary.
+ *
+ * Permission-gated on `admin.manage_settings` to mirror the rest of the
+ * admin surface; non-admin users never see the banner / tile anyway.
+ */
+export const GET = withAuth(
+ withPermission('admin', 'manage_settings', async (_req, ctx) => {
+ try {
+ const status = await resolveOnboardingStatus(ctx.portId);
+ return NextResponse.json({ data: status });
+ } catch (error) {
+ return errorResponse(error);
+ }
+ }),
+);
diff --git a/src/app/public/supplemental-info/[token]/page.tsx b/src/app/public/supplemental-info/[token]/page.tsx
index bca0a78d..e5bcb86b 100644
--- a/src/app/public/supplemental-info/[token]/page.tsx
+++ b/src/app/public/supplemental-info/[token]/page.tsx
@@ -151,7 +151,7 @@ export default function SupplementalInfoPage({ params }: PageProps) {
if (error) {
return (
-
+
{error}
diff --git a/src/components/admin/document-templates/template-list.tsx b/src/components/admin/document-templates/template-list.tsx
index 820cace1..15f951df 100644
--- a/src/components/admin/document-templates/template-list.tsx
+++ b/src/components/admin/document-templates/template-list.tsx
@@ -129,7 +129,7 @@ export function TemplateList() {
accessorKey: 'updatedAt',
header: 'Last Updated',
cell: ({ row }) =>
- new Date(row.original.updatedAt).toLocaleDateString('en-GB', {
+ new Date(row.original.updatedAt).toLocaleDateString(undefined, {
day: '2-digit',
month: 'short',
year: 'numeric',
@@ -221,7 +221,7 @@ export function TemplateList() {
Β·
Updated{' '}
- {new Date(original.updatedAt).toLocaleDateString('en-GB', {
+ {new Date(original.updatedAt).toLocaleDateString(undefined, {
day: '2-digit',
month: 'short',
year: 'numeric',
diff --git a/src/components/admin/onboarding-banner.tsx b/src/components/admin/onboarding-banner.tsx
new file mode 100644
index 00000000..1fd15205
--- /dev/null
+++ b/src/components/admin/onboarding-banner.tsx
@@ -0,0 +1,95 @@
+'use client';
+
+import { useState } from 'react';
+import Link from 'next/link';
+import { useParams } from 'next/navigation';
+import { X, Sparkles, ChevronRight } from 'lucide-react';
+
+import { Button } from '@/components/ui/button';
+import { usePermissions } from '@/hooks/use-permissions';
+import { useOnboardingStatus } from '@/hooks/use-onboarding-status';
+import { cn } from '@/lib/utils';
+
+const DISMISS_STORAGE_KEY = 'pn-crm.onboarding-banner-dismissed';
+
+function getInitialDismissed(): boolean {
+ if (typeof window === 'undefined') return false;
+ return sessionStorage.getItem(DISMISS_STORAGE_KEY) === '1';
+}
+
+/**
+ * Topbar banner nudging super_admins to finish onboarding while the
+ * checklist is incomplete. Renders nothing for non-super-admin roles and
+ * disappears for everyone once the checklist hits 100%.
+ *
+ * Dismissible per browser session β flag stored in sessionStorage so it
+ * comes back on next sign-in (we want it visible until they actually
+ * finish, not just clicked-away forever).
+ */
+export function OnboardingBanner() {
+ const params = useParams<{ portSlug: string }>();
+ const portSlug = params?.portSlug ?? '';
+ const { isSuperAdmin } = usePermissions();
+ const { data, isLoading } = useOnboardingStatus({ enabled: isSuperAdmin });
+ const [dismissed, setDismissed] = useState(getInitialDismissed);
+
+ if (!isSuperAdmin || isLoading || !data) return null;
+ if (data.isComplete) return null;
+ if (dismissed) return null;
+ if (!portSlug) return null;
+
+ const next = data.nextStep;
+ return (
+