Admin search now matches against per-card keyword lists so typing "client portal", "smtp", "tier ladder" lands on the System Settings card (which hosts those flags). The same keyword list extends the topbar global search (NAV_CATALOG) so any setting key resolves from the cmd-K input — settings results sort to the bottom of the dropdown beneath entity hits. User management: - Third action button (Power/PowerOff) enables/disables sign-in from the desktop list; mobile card dropdown gains the same item. Backed by the existing userProfiles.isActive flag — withAuth already refuses disabled sessions with 403. - UserForm collects first + last name (canonical) alongside displayName, with admin email-change behind a confirmation modal. On confirm we send the OLD address an automated "your admin changed your sign-in email" notice (new template at admin-email-change.ts) and rewrite the Better Auth user row. - Phone field swaps the bare tel input for the shared PhoneInput (country combobox + AsYouType formatting + E.164 storage). - "Manage permissions" link points to /admin/roles?focusUser=… as a stepping stone for the future fine-tuned-permissions UI. Role names normalize through a new ROLE_LABELS + formatRole() helper in constants.ts. Replaces the ad-hoc humanizeRole in sidebar and the prettifyRoleName in role-list; user-list and user-card now render "Sales Agent" instead of "sales_agent". Custom roles pass through unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
244 lines
10 KiB
TypeScript
244 lines
10 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useState } from 'react';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
|
||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||
import { useDashboardWidgets } from '@/hooks/use-dashboard-widgets';
|
||
import { apiFetch } from '@/lib/api/client';
|
||
import { PageHeader } from '@/components/shared/page-header';
|
||
import { CustomizeWidgetsMenu } from './customize-widgets-menu';
|
||
import { DateRangePicker } from './date-range-picker';
|
||
import { TimezoneDriftBanner } from './timezone-drift-banner';
|
||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||
import type { DashboardWidget } from './widget-registry';
|
||
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
|
||
|
||
const PRESET_LABELS: Record<'today' | '7d' | '30d' | '90d', string> = {
|
||
today: 'Today',
|
||
'7d': 'Last 7 days',
|
||
'30d': 'Last 30 days',
|
||
'90d': 'Last 90 days',
|
||
};
|
||
|
||
function rangeLabel(range: DateRange): string {
|
||
if (isCustomRange(range)) {
|
||
const fmt: Intl.DateTimeFormatOptions = {
|
||
month: 'short',
|
||
day: 'numeric',
|
||
year: 'numeric',
|
||
timeZone: 'UTC',
|
||
};
|
||
const from = new Date(`${range.from}T00:00:00.000Z`).toLocaleDateString('en-US', fmt);
|
||
const to = new Date(`${range.to}T00:00:00.000Z`).toLocaleDateString('en-US', fmt);
|
||
return `${from} – ${to}`;
|
||
}
|
||
return PRESET_LABELS[range];
|
||
}
|
||
|
||
interface MeData {
|
||
data?: {
|
||
profile?: {
|
||
firstName?: string | null;
|
||
displayName?: string | null;
|
||
};
|
||
};
|
||
}
|
||
|
||
function timeOfDayGreeting(): string {
|
||
// new Date().getHours() uses the browser's local timezone, which the OS
|
||
// updates automatically on most modern devices when the user travels (laptop
|
||
// "set timezone automatically" on macOS, phones via cell-tower / GPS). So
|
||
// this reflects the user's physical location as long as their machine clock
|
||
// does. We still defer the compute to a useEffect on mount so the rendered
|
||
// HTML can never disagree with the server's clock during hydration.
|
||
const hour = new Date().getHours();
|
||
if (hour < 5) return 'Up late';
|
||
if (hour < 12) return 'Good morning';
|
||
if (hour < 18) return 'Good afternoon';
|
||
return 'Good evening';
|
||
}
|
||
|
||
interface DashboardShellProps {
|
||
/** SSR-prefetched first name. When provided, the greeting renders with it
|
||
* on first paint instead of flickering "Welcome back" → "Hello, Matt". */
|
||
initialFirstName?: string | null;
|
||
/** SSR-prefetched widget visibility map. Seeds the preferences cache so the
|
||
* layout doesn't reflow once the client-side fetch resolves. */
|
||
initialWidgetVisibility?: Record<string, boolean> | null;
|
||
}
|
||
|
||
export function DashboardShell({
|
||
initialFirstName,
|
||
initialWidgetVisibility,
|
||
}: DashboardShellProps = {}) {
|
||
const [range, setRange] = useState<DateRange>('30d');
|
||
|
||
const { visibleWidgets } = useDashboardWidgets({
|
||
initialVisibility: initialWidgetVisibility ?? null,
|
||
});
|
||
|
||
// Bucket once so the JSX stays readable. Registry order is preserved
|
||
// inside each bucket, so reordering the registry reorders the render.
|
||
const charts = visibleWidgets.filter((w) => w.group === 'chart');
|
||
const rails = visibleWidgets.filter((w) => w.group === 'rail');
|
||
const feed = visibleWidgets.filter((w) => w.group === 'feed');
|
||
|
||
// Reuses the existing ['me'] cache (5-minute staleTime) populated by
|
||
// useTablePreferences elsewhere — usually a cache hit, so no extra
|
||
// request. When the page server-prefetches the first name we seed it
|
||
// here via `initialData` so the cache is warm before the post-mount
|
||
// fetch resolves, eliminating the "Welcome back → Hello, Matt" flash.
|
||
const me = useQuery<MeData>({
|
||
queryKey: ['me'],
|
||
queryFn: ({ signal }) => apiFetch<MeData>('/api/v1/me', { signal }),
|
||
staleTime: 5 * 60_000,
|
||
initialData:
|
||
initialFirstName !== undefined
|
||
? ({ data: { profile: { firstName: initialFirstName } } } as MeData)
|
||
: undefined,
|
||
});
|
||
const firstName = me.data?.data?.profile?.firstName?.trim();
|
||
|
||
// Greeting word is computed in a useEffect so the rendered HTML can't lock
|
||
// to the server's clock during hydration. Until the effect fires, the
|
||
// header reads "Welcome" — a neutral phrase that's correct at every hour
|
||
// and never produces a hydration warning. `clientGreeting` flips to the
|
||
// local-time-aware phrasing once the component has mounted.
|
||
const [clientGreeting, setClientGreeting] = useState<string | null>(null);
|
||
useEffect(() => {
|
||
setClientGreeting(timeOfDayGreeting());
|
||
// Re-evaluate hourly so a rep who leaves the dashboard open through a
|
||
// boundary (5am, noon, 6pm) doesn't keep stale text on screen.
|
||
const interval = window.setInterval(() => setClientGreeting(timeOfDayGreeting()), 60 * 60_000);
|
||
return () => window.clearInterval(interval);
|
||
}, []);
|
||
|
||
const greeting = firstName
|
||
? `${clientGreeting ?? 'Welcome'}, ${firstName}`
|
||
: (clientGreeting ?? 'Welcome back');
|
||
|
||
// Use a partial query-key prefix (no range segment) for invalidations.
|
||
// Reading: "any cached analytics result, regardless of range, please
|
||
// refetch on this event." This avoids any chance that a custom-range
|
||
// object literal hashes differently than the one stored in the cache,
|
||
// and keeps the invalidation surface broad enough to refresh whichever
|
||
// range the user is currently looking at.
|
||
useRealtimeInvalidation({
|
||
'interest:stageChanged': [
|
||
['analytics', 'pipeline_funnel'],
|
||
['analytics', 'lead_source_attribution'],
|
||
['dashboard', 'kpis'],
|
||
],
|
||
'client:created': [['dashboard', 'kpis']],
|
||
'berth:statusChanged': [
|
||
['analytics', 'occupancy_timeline'],
|
||
['dashboard', 'kpis'],
|
||
],
|
||
});
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Mobile-only greeting strip. The shared PageHeader is hidden
|
||
below `sm` (its title is normally duplicated by the topbar),
|
||
so we render the welcome message inline here for mobile —
|
||
keeps the personalized touch from desktop without polluting
|
||
the topbar (which stays "Dashboard" for wayfinding). */}
|
||
<div className="sm:hidden">
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-brand">Dashboard</p>
|
||
<h1 className="mt-1 text-xl font-bold tracking-tight text-foreground">{greeting}</h1>
|
||
</div>
|
||
|
||
<TimezoneDriftBanner />
|
||
|
||
<PageHeader
|
||
title={greeting}
|
||
eyebrow="Dashboard"
|
||
// The date-range subtitle only means something when at least
|
||
// one widget is on the page to consume the range; if everything
|
||
// is hidden it just reads as an orphaned line.
|
||
kpiLine={visibleWidgets.length > 0 ? <span>{rangeLabel(range)}</span> : undefined}
|
||
variant="gradient"
|
||
actions={
|
||
<div className="flex items-center gap-2">
|
||
<DateRangePicker value={range} onChange={setRange} />
|
||
<CustomizeWidgetsMenu />
|
||
</div>
|
||
}
|
||
/>
|
||
|
||
{/* Charts + rails sit side-by-side at xl+. Each side is an auto-fit
|
||
grid, so hiding a card causes the remaining ones to widen.
|
||
`items-start` is critical: without it, the right-column aside is
|
||
stretched to match the chart column's row height, which forces
|
||
MyRemindersRail (or any other child with `h-full`) to push later
|
||
children out of the aside's box. */}
|
||
{/* Charts + rails. Layout adapts to which regions have content so
|
||
we never leave a 320px stripe of dead space when only one side
|
||
is populated:
|
||
both → main 1fr column + 320px rail (the original layout)
|
||
charts only → single full-width auto-fit chart grid
|
||
rails only → rails widen into an auto-fit grid (no fixed 320)
|
||
neither → nothing renders
|
||
The chart grid uses `minmax(360px, 1fr)` so a lone chart fills
|
||
the row; the rails-only grid uses a slightly tighter `280px`
|
||
minimum so KPI tiles + rails fit 3-4 across on a wide viewport
|
||
instead of stretching to 600px+ each. */}
|
||
{charts.length > 0 && rails.length > 0 ? (
|
||
<div className="grid gap-4 grid-cols-1 items-start xl:grid-cols-[minmax(0,1fr)_320px]">
|
||
<div className="grid gap-4 grid-cols-1 lg:grid-cols-[repeat(auto-fit,minmax(360px,1fr))]">
|
||
{charts.map((w) => (
|
||
<WidgetCell key={w.id} widget={w} range={range} />
|
||
))}
|
||
</div>
|
||
<aside className="min-w-0 space-y-4">
|
||
{rails.map((w) => (
|
||
<WidgetCell key={w.id} widget={w} range={range} />
|
||
))}
|
||
</aside>
|
||
</div>
|
||
) : charts.length > 0 ? (
|
||
<div className="grid gap-4 grid-cols-1 lg:grid-cols-[repeat(auto-fit,minmax(360px,1fr))]">
|
||
{charts.map((w) => (
|
||
<WidgetCell key={w.id} widget={w} range={range} />
|
||
))}
|
||
</div>
|
||
) : rails.length > 0 ? (
|
||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(280px,1fr))]">
|
||
{rails.map((w) => (
|
||
<WidgetCell key={w.id} widget={w} range={range} />
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
{feed.map((w) => (
|
||
<WidgetCell key={w.id} widget={w} range={range} />
|
||
))}
|
||
|
||
{visibleWidgets.length === 0 ? <EmptyDashboardHint /> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Placeholder shown when the rep has hidden every widget. Without this,
|
||
* the dashboard collapses to just the gradient header strip and looks
|
||
* like a broken page — this hints at the "Customize" button to bring
|
||
* widgets back.
|
||
*/
|
||
function EmptyDashboardHint() {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border bg-card/40 px-6 py-16 text-center">
|
||
<p className="text-sm font-medium text-foreground">No widgets on your dashboard yet</p>
|
||
<p className="max-w-sm text-sm text-muted-foreground">
|
||
Click <span className="font-medium text-foreground">Customize</span> above to pick which
|
||
analytics cards appear here.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function WidgetCell({ widget, range }: { widget: DashboardWidget; range: DateRange }) {
|
||
return <WidgetErrorBoundary>{widget.render(range)}</WidgetErrorBoundary>;
|
||
}
|