Files
pn-new-crm/src/components/dashboard/widget-registry.tsx
Matt 449b9497ab fix(uat): batch — timeline overshoot, name-sync, reset-password, dashboard cleanup, queue/seed hygiene + alpha UAT findings doc
UAT findings landed across the last few Playwright + React Grab passes;
single grouped commit so the index doesn't fragment into 30 one-liners.

User & auth:
- `user-settings`: name now updates the avatar + topbar menu after save
  (was reading stale session).
- `me/password-reset`: 3 bugs (token validation, error response shape,
  redirect chain).
- Admin user permission-overrides route honours the same envelope as
  the rest of the admin surface.

Dashboard:
- Removed obsolete `revenue-breakdown-chart` + `dashboard-widgets-card`
  (replaced by the customisable widget grid).
- Strip `revenue_breakdown` from analytics route + use-analytics +
  service + integration test so nothing renders an empty card.
- Activity log timeline overshoot fix (`interest-timeline` +
  `entity-activity-feed`).
- Tightened tiles: active-deals, berth-heat-widget, pipeline-value, kpi-tile.
- `dev-mode-banner`: derive dismissed state synchronously instead of
  via an effect (set-state-in-effect lint rule).

Forms & lists (assorted polish):
- client / company / yacht / interest / reminder forms — validation +
  empty-state copy + tab transitions.
- companies/yachts list tweaks; berth recommender panel; qualification
  checklist; supplemental info request button.

Infra & misc:
- Queue workers (ai / email / notifications) — log shape +
  per-job timeout consistency.
- Auth / brochures / users schema small adjustments; seeds reflect
  permissions matrix changes.
- Scan shell + scanner manifest + AI admin page small fixes.
- `next.config.transpilePackages` adds `echarts`/`zrender`/`echarts-for-react`
  (recommended config from echarts-for-react inside Next).

Docs:
- `docs/superpowers/audits/alpha-uat-master.md` — single rolling
  cross-cutting UAT findings doc (per CLAUDE.md convention).
- `docs/BACKLOG.md`: dashboard stats cards (§I) + activity-log
  normalization (§J).
- 2026-05-18 audit log updated with this batch.
- `CLAUDE.md` — small manual UAT scaffold notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:56:11 +02:00

233 lines
8.4 KiB
TypeScript

/**
* 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 { BerthHeatWidget } from './berth-heat-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 },
);
/**
* 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';
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[] = [
// ── 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.',
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: '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,
},
];
/** 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]),
);