Files
pn-new-crm/src/components/dashboard/widget-registry.tsx
Matt 2a7f922a01
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m54s
Build & Push Docker Images / build-and-push (push) Successful in 8m10s
fix(uat): dashboard snapshots current-state, pulse-chip gate, phone display, chip width
- pipeline funnel: count active interests by current stage (drop created_at
  window) — backfill had collapsed it to early stages (UAT 2026-06-03)
- pipeline value tile: render current-state (don't thread the date range)
- deal pulse chip: gate on the pulse_enabled master toggle (default ON) —
  was rendering even when admin turned it off; useFeatureFlag gains a
  default arg + the feature-flag endpoint a ?default= param (default-ON safe)
- contact phone display: show international format + country flag (E164),
  not the bare national format that hid the country
- berths: remove the dead row-density toggle; widen "Under offer to" chip on
  desktop so client names aren't truncated

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:27:56 +02:00

335 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 = () => (
<div className="rounded-lg border bg-muted/30 p-8 text-center text-sm text-muted-foreground">
Loading chart
</div>
);
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: () => <OnboardingTile />,
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: () => <ActiveDealsTile />,
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: () => <PipelineValueTile />,
// 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) => <PipelineFunnelChart range={range} />,
group: 'chart',
defaultVisible: true,
},
{
id: 'occupancy_timeline',
label: 'Occupancy Timeline',
description: 'Daily berth occupancy across the range.',
render: (range) => <OccupancyTimelineChart range={range} />,
group: 'chart',
defaultVisible: true,
},
{
id: 'lead_source',
label: 'Lead Source Attribution',
description: 'Where new interests came from.',
render: (range) => <LeadSourceChart range={range} />,
group: 'chart',
defaultVisible: true,
},
{
id: 'berth_status',
label: 'Berth Status',
description: 'Donut: available / under offer / sold split.',
render: () => <BerthStatusChart />,
group: 'chart',
defaultVisible: false,
},
{
id: 'source_conversion',
label: 'Source Conversion',
description: 'Win rate per lead source - which channels deliver buyers, not just leads.',
render: () => <SourceConversionChart />,
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: () => <BerthHeatWidget />,
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: () => <ClientsByCountryWidget />,
// 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) => <WebsiteGlanceTile range={range} />,
group: 'rail',
defaultVisible: true,
selfGates: true,
requires: 'umami',
},
{
id: 'my_reminders',
label: 'My Reminders',
description: 'Your upcoming and overdue reminders.',
render: () => <MyRemindersRail />,
group: 'rail',
defaultVisible: true,
},
{
id: 'alerts',
label: 'Alerts',
description: 'System-flagged action items.',
render: () => <AlertRail />,
group: 'rail',
defaultVisible: true,
},
{
id: 'hot_deals',
label: 'Hot Deals',
description: 'Top 5 active interests closest to closing.',
render: () => <HotDealsCard />,
group: 'rail',
defaultVisible: false,
},
{
id: 'activity_feed',
label: 'Recent Activity',
description: 'Audit log of changes across the port.',
render: () => <ActivityFeed />,
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: () => <TenancyOccupancyHeatmapWidget />,
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: () => <TenancyRenewalsAtRiskWidget />,
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: () => <TenancyRevenueForecastWidget />,
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: () => <TenancyByTenureTypeWidget />,
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<string, DashboardWidget> = Object.fromEntries(
DASHBOARD_WIDGETS.map((w) => [w.id, w]),
);