feat(ui): broad consistency sweep — sources, dates, comboboxes, milestones
Mobile + responsive - berth-form full-width on phones (was 480px fixed → overflowed iPhone) - currency-input switched to inputMode=decimal with live thousands separator - client-form Country/Timezone/Source/Preferred-Contact full-width <sm - contacts row restructured so Primary toggle + Remove get their own strip - customize-dashboard footer stacks vertically on mobile; Done full-width - interest-form client/berth pickers no longer cmdk-filter on UUID (typing "Carlos" now returns Carlos Vega instead of "No clients found") Data + consistency - SOURCES + SOURCE_LABELS + formatSource() in lib/constants; 9 surfaces now resolve interest/client source from one place - INTEREST_OUTCOMES adds lost_other (picker, badge, timeline) - Berth options natural-sort A1 → A2 → … → A10 via lib/utils/mooring-sort - archiver downgraded ^8 → ^7.0.1 so the GDPR export route compiles - TableBody last-row uses border-b-0 (not border-0); colored left-accent on the bottom berth row now renders - Hide Invite-to-Portal until port setting === true (was !== false default-show) - OwnerPicker primer query resolves entity name on first paint (no more UUID flash before the popover opens) Terminology - Replaced user-facing "Documenso" with "signing service" / "Generated EOI" / "Manual EOI" in 8 components (admin/internal references kept) - Plainer status-change copy on berth-detail-header Forms + editing - InlineEditableField gained a `date` variant (native picker); applied to company incorporation date and ready for other YYYY-MM-DD plaintext fields - Inline source picker on interest-tabs detail (was free text) - TagPicker self-hides when port has no tags AND nothing is selected - New ReminderDaysInput with preset chips (1d / 3d / 1wk / 2wk / 1mo / custom) - Compose dialog follow-up is now a toggle that reveals datetime picker Pipeline milestones - changeStageSchema accepts optional milestoneDate; service stamps it on the matching date column instead of always using now - MilestoneAdvanceButton popover collects a back-date before stage advance - Applied to every "Mark X manually" surface on the interest overview EOI / linked-berths polish - Add-bypass row aligned inline with toggle descriptions - Tooltips on "Specifically pitching" / "Mark in EOI bundle" explain their legal vs. public-map consequences Surfaces - Companies list now has the column picker + persisted hidden-column prefs - NotesList aggregate flag enabled on clients, companies, residential_clients (yachts already aggregated) ft/m unit toggle (interim, before drift fix) - "Berth size desired" gets a section-level ft/m toggle; per-field hint shows the converted value. Storage stays canonical-ft for now; the drift-safe persistence migration is the next step. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
198
src/components/dashboard/widget-registry.tsx
Normal file
198
src/components/dashboard/widget-registry.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* 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 { ActiveDealsTile } from './active-deals-tile';
|
||||
import { ActivityFeed } from './activity-feed';
|
||||
import { BerthStatusChart } from './berth-status-chart';
|
||||
import { HotDealsCard } from './hot-deals-card';
|
||||
import { LeadSourceChart } from './lead-source-chart';
|
||||
import { OccupancyTimelineChart } from './occupancy-timeline-chart';
|
||||
import { PipelineFunnelChart } from './pipeline-funnel-chart';
|
||||
import { PipelineValueTile } from './pipeline-value-tile';
|
||||
import { RevenueBreakdownChart } from './revenue-breakdown-chart';
|
||||
import { SourceConversionChart } from './source-conversion-chart';
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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: 'Compact tile: total berth value of active deals (USD).',
|
||||
render: () => <PipelineValueTile />,
|
||||
group: 'rail',
|
||||
defaultVisible: false,
|
||||
},
|
||||
|
||||
// ── 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: 'revenue_breakdown',
|
||||
label: 'Revenue Breakdown',
|
||||
description: 'Invoice totals grouped by status and currency.',
|
||||
render: (range) => <RevenueBreakdownChart 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',
|
||||
defaultVisible: false,
|
||||
},
|
||||
{
|
||||
id: 'website_analytics',
|
||||
label: 'Website Analytics',
|
||||
description: 'Quick glance at marketing site traffic. Requires Umami.',
|
||||
render: () => <WebsiteGlanceTile />,
|
||||
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]),
|
||||
);
|
||||
Reference in New Issue
Block a user