/** * Dashboard widget registry - the single source of truth for which * widgets exist, what they're called, where they live, and what they * default to. The DashboardShell loops over this; the settings UI also * loops over this. Adding a new widget = adding one entry here. * * Widget visibility is persisted per-user in * `user_profiles.preferences.dashboardWidgets` as `{ [id]: boolean }`. * Missing entries default to `defaultVisible`, so a brand-new widget * surfaces for existing users automatically. */ import type { ReactNode } from 'react'; import dynamic from 'next/dynamic'; import { ActiveDealsTile } from './active-deals-tile'; import { ActivityFeed } from './activity-feed'; import { OnboardingTile } from './onboarding-tile'; import { BerthHeatWidget } from './berth-heat-widget'; import { ClientsByCountryWidget } from './clients-by-country-widget'; import { HotDealsCard } from './hot-deals-card'; import { PipelineValueTile } from './pipeline-value-tile'; import { WebsiteGlanceTile } from './website-glance-tile'; import { MyRemindersRail } from './my-reminders-rail'; import { AlertRail } from '@/components/alerts/alert-rail'; import type { DateRange } from '@/lib/analytics/range'; // Recharts-backed widgets are dynamic-imported so the recharts bundle // (~80-150KB) doesn't ship on every dashboard load when the rep has // disabled charts. perf-test-auditor HIGH H3 caught the static import. // Each one gets a placeholder loading state matching its grid slot. const ChartFallback = () => (
Loading chart…
); const BerthStatusChart = dynamic( () => import('./berth-status-chart').then((m) => ({ default: m.BerthStatusChart })), { loading: ChartFallback, ssr: false }, ); const LeadSourceChart = dynamic( () => import('./lead-source-chart').then((m) => ({ default: m.LeadSourceChart })), { loading: ChartFallback, ssr: false }, ); const OccupancyTimelineChart = dynamic( () => import('./occupancy-timeline-chart').then((m) => ({ default: m.OccupancyTimelineChart })), { loading: ChartFallback, ssr: false }, ); const PipelineFunnelChart = dynamic( () => import('./pipeline-funnel-chart').then((m) => ({ default: m.PipelineFunnelChart })), { loading: ChartFallback, ssr: false }, ); const SourceConversionChart = dynamic( () => import('./source-conversion-chart').then((m) => ({ default: m.SourceConversionChart })), { loading: ChartFallback, ssr: false }, ); const TenancyOccupancyHeatmapWidget = dynamic( () => import('./tenancy-occupancy-heatmap').then((m) => ({ default: m.TenancyOccupancyHeatmapWidget, })), { loading: ChartFallback, ssr: false }, ); const TenancyRenewalsAtRiskWidget = dynamic( () => import('./tenancy-renewals-at-risk').then((m) => ({ default: m.TenancyRenewalsAtRiskWidget, })), { loading: ChartFallback, ssr: false }, ); const TenancyRevenueForecastWidget = dynamic( () => import('./tenancy-revenue-forecast').then((m) => ({ default: m.TenancyRevenueForecastWidget, })), { loading: ChartFallback, ssr: false }, ); const TenancyByTenureTypeWidget = dynamic( () => import('./tenancy-by-tenure-type').then((m) => ({ default: m.TenancyByTenureTypeWidget, })), { loading: ChartFallback, ssr: false }, ); /** * Where a widget lives on the dashboard. The shell renders three * separate auto-fit regions so charts and rails don't compete for the * same horizontal slots (preserves the visual hierarchy the team has * gotten used to). * * - 'chart' → main analytics region (wider min-col) * - 'rail' → side-rail region (narrower min-col) * - 'feed' → full-width row underneath everything else */ export type WidgetGroup = 'chart' | 'rail' | 'feed'; /** * External integrations a widget can depend on. When the corresponding * integration isn't connected for the active port, the widget is hidden * from the picker AND from the rendered dashboard so reps can't toggle * something that would render nothing. Wire new integrations through * `useDashboardIntegrations()`. */ export type WidgetIntegration = 'umami' | 'documenso' | 'tenancies_module'; export interface DashboardWidget { /** Stable persistence key. Don't rename - old preferences would break. */ id: string; label: string; description: string; /** * Renders the widget. Receives the active date-range so chart widgets * can react; non-chart widgets simply ignore it. Keeping this a * function instead of a `ComponentType` lets each widget pick its own * prop shape without leaking the union into the registry type. */ render: (range: DateRange) => ReactNode; group: WidgetGroup; defaultVisible: boolean; /** * Some widgets self-gate (e.g. WebsiteGlanceTile renders null when * Umami isn't configured). When `true`, the settings UI still shows * the toggle so admins can enable it once the integration is wired - * but the widget itself decides whether to render content. */ selfGates?: boolean; /** * Names the external integration this widget depends on. When the * integration isn't connected for the active port, the widget is * filtered out of both the picker and the rendered dashboard. */ requires?: WidgetIntegration; } export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [ // ── Onboarding (rail, super_admin-only) ───────────────────────────── // Self-collapses when the checklist hits 100% and self-hides for // non-super-admin users so most reps never see this tile at all. { id: 'onboarding_checklist', label: 'Setup checklist', description: 'Progress + next-step nudge while the org is still going through Documenso / branding / users setup. Hidden once 100% complete.', render: () => , group: 'rail', defaultVisible: true, }, // ── KPI tiles (rail) ──────────────────────────────────────────────── // Off by default - keep the existing dashboard layout unchanged for // users on first paint after the upgrade; reps can flip them on from // the Customize menu. { id: 'kpi_active_deals', label: 'Active Deals', description: 'Compact tile: count of in-flight interests.', render: () => , group: 'rail', defaultVisible: false, }, { id: 'kpi_pipeline_value', label: 'Pipeline Value', description: 'Gross + weighted forecast, broken down by pipeline stage so leadership can see what is near-close vs speculative.', // Current-state snapshot: pipeline value = sum across ALL active deals, // not "added in the selected window". Don't thread the range (UAT // 2026-06-03 — windowing it dropped older deals + confused the headline). render: () => , // Lives in the chart grid (not the narrow rail) so the per-stage // breakdown rows have room to breathe alongside the headline numbers, // and the rail stays reserved for reminders / alerts / glance tiles. group: 'chart', defaultVisible: true, }, // ── Charts (main area) ────────────────────────────────────────────── { id: 'pipeline_funnel', label: 'Pipeline Funnel', description: 'Interests by stage with conversion-rate vs open.', render: (range) => , group: 'chart', defaultVisible: true, }, { id: 'occupancy_timeline', label: 'Occupancy Timeline', description: 'Daily berth occupancy across the range.', render: (range) => , group: 'chart', defaultVisible: true, }, { id: 'lead_source', label: 'Lead Source Attribution', description: 'Where new interests came from.', render: (range) => , group: 'chart', defaultVisible: true, }, { id: 'berth_status', label: 'Berth Status', description: 'Donut: available / under offer / sold split.', render: () => , group: 'chart', defaultVisible: false, }, { id: 'source_conversion', label: 'Source Conversion', description: 'Win rate per lead source - which channels deliver buyers, not just leads.', render: () => , group: 'chart', // Flipped on 2026-05-14 - investor-facing conversion-funnel-by-source // surface (PRE-DEPLOY-PLAN § 1.6.23). Reads inquiry → client linkage // (clients.source_inquiry_id) added in migration 0065. defaultVisible: true, }, { id: 'berth_heat', label: 'Berth Demand', description: 'Ranks berths by active interest. Surfaces the leading mooring with its runners-up.', render: () => , group: 'chart', defaultVisible: true, }, { id: 'clients_by_country', label: 'Clients by country', description: 'Per-country distribution of the active client book. Click a row to filter the clients list by country.', render: () => , // Same rail-tile idiom as BerthHeatWidget + HotDealsCard - compact // ranked list with mini-bars. Variant (a) per the master-doc design; // the world-map variant lands alongside the recharts→ECharts pass. group: 'rail', defaultVisible: true, }, { id: 'website_analytics', label: 'Website Analytics', description: 'Quick glance at marketing site traffic. Requires Umami.', render: (range) => , group: 'rail', defaultVisible: true, selfGates: true, requires: 'umami', }, { id: 'my_reminders', label: 'My Reminders', description: 'Your upcoming and overdue reminders.', render: () => , group: 'rail', defaultVisible: true, }, { id: 'alerts', label: 'Alerts', description: 'System-flagged action items.', render: () => , group: 'rail', defaultVisible: true, }, { id: 'hot_deals', label: 'Hot Deals', description: 'Top 5 active interests closest to closing.', render: () => , group: 'rail', defaultVisible: false, }, { id: 'activity_feed', label: 'Recent Activity', description: 'Audit log of changes across the port.', render: () => , group: 'feed', defaultVisible: true, }, // ── Tenancies module widgets ─────────────────────────────────────────── // All four self-gate on `tenancies_module`. Hidden from picker + render // when the module isn't enabled for the active port. { id: 'tenancy_occupancy_heatmap', label: 'Occupancy heatmap', description: 'Per-(berth area × month) occupancy across the year.', render: () => , group: 'chart', defaultVisible: true, selfGates: true, requires: 'tenancies_module', }, { id: 'tenancy_renewals_at_risk', label: 'Renewals at risk', description: 'Active tenancies expiring in the next 90 days without a successor.', render: () => , group: 'rail', defaultVisible: true, selfGates: true, requires: 'tenancies_module', }, { id: 'tenancy_revenue_forecast', label: 'Tenancy revenue forecast', description: 'Berth value tied to tenancies ending each quarter, projected forward.', render: () => , group: 'chart', defaultVisible: true, selfGates: true, requires: 'tenancies_module', }, { id: 'tenancy_by_tenure_type', label: 'Tenancies by tenure type', description: 'Active tenancy mix by tenure (permanent / fixed-term / seasonal …).', render: () => , group: 'rail', defaultVisible: true, selfGates: true, requires: 'tenancies_module', }, ]; /** Lookup helper so consumers don't have to scan the array. */ export const WIDGETS_BY_ID: Record = Object.fromEntries( DASHBOARD_WIDGETS.map((w) => [w.id, w]), );