Files
pn-new-crm/src/components/dashboard/dashboard-shell.tsx
Matt 660553c074 feat(admin+search): user-mgmt polish, role labels, search keyword index
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>
2026-05-12 16:14:12 +02:00

244 lines
10 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.
'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>;
}