feat(dashboard): custom date range + KPI port-hydration gate
DateRangePicker grows a "Custom range" mode (From/To inputs capped at today, mutually-bounded so From <= To). dashboard-shell threads the range through to /api/v1/analytics, which validates calendar dates via ISO round-trip and enforces a 365-day cap as a backstop against the occupancy timeline N+1. KpiCards now gates its query on currentPortId so the early unhydrated-store fetch can't cache a zeroed/error response and display "-" until staleTime expires. MyRemindersRail drops xl:h-full so the rail no longer stretches past its grid row and overlaps ActivityFeed below. useRealtimeInvalidation switches to partial-prefix queryKeys so a realtime mutation invalidates every cached range bucket at once instead of just the one currently visible. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
|||||||
getRevenueBreakdown,
|
getRevenueBreakdown,
|
||||||
type DateRange,
|
type DateRange,
|
||||||
type MetricBase,
|
type MetricBase,
|
||||||
|
type PresetDateRange,
|
||||||
} from '@/lib/services/analytics.service';
|
} from '@/lib/services/analytics.service';
|
||||||
|
|
||||||
const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<unknown>> = {
|
const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<unknown>> = {
|
||||||
@@ -18,17 +19,69 @@ const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<
|
|||||||
lead_source_attribution: getLeadSourceAttribution,
|
lead_source_attribution: getLeadSourceAttribution,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
export const GET = withAuth(
|
export const GET = withAuth(
|
||||||
withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => {
|
withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const metric = url.searchParams.get('metric') as MetricBase | null;
|
const metric = url.searchParams.get('metric') as MetricBase | null;
|
||||||
const range = (url.searchParams.get('range') ?? '30d') as DateRange;
|
const rawRange = url.searchParams.get('range') ?? '30d';
|
||||||
|
const fromParam = url.searchParams.get('from');
|
||||||
|
const toParam = url.searchParams.get('to');
|
||||||
|
|
||||||
if (!metric || !(metric in METRICS)) {
|
if (!metric || !(metric in METRICS)) {
|
||||||
return NextResponse.json({ error: 'Invalid or missing metric' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid or missing metric' }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (!ALL_RANGES.includes(range)) {
|
|
||||||
return NextResponse.json({ error: 'Invalid range' }, { status: 400 });
|
let range: DateRange;
|
||||||
|
if (rawRange === 'custom') {
|
||||||
|
if (!fromParam || !toParam) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Custom range requires `from` and `to` (YYYY-MM-DD)' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!ISO_DATE_RX.test(fromParam) || !ISO_DATE_RX.test(toParam)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '`from`/`to` must be ISO date strings (YYYY-MM-DD)' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (fromParam > toParam) {
|
||||||
|
return NextResponse.json({ error: '`from` must be on or before `to`' }, { status: 400 });
|
||||||
|
}
|
||||||
|
// Round-trip date check: regex passes "9999-13-99" or "2026-02-31"
|
||||||
|
// (rolls over silently when handed to `new Date`). Re-serialize and
|
||||||
|
// confirm it matches the input to catch invalid calendar values.
|
||||||
|
for (const [label, raw] of [
|
||||||
|
['from', fromParam],
|
||||||
|
['to', toParam],
|
||||||
|
] as const) {
|
||||||
|
const d = new Date(`${raw}T00:00:00.000Z`);
|
||||||
|
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== raw) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `\`${label}\` is not a valid calendar date` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Backstop against the occupancy-timeline N+1 query loop. Each day
|
||||||
|
// in the range issues its own DB query, so a multi-year custom
|
||||||
|
// range would saturate the connection pool. 365 days is a generous
|
||||||
|
// ceiling for analytical queries; if a longer span is needed, the
|
||||||
|
// service should be restructured to use `generate_series` instead
|
||||||
|
// of a JS loop.
|
||||||
|
const fromMs = new Date(`${fromParam}T00:00:00.000Z`).getTime();
|
||||||
|
const toMs = new Date(`${toParam}T23:59:59.999Z`).getTime();
|
||||||
|
if ((toMs - fromMs) / 86_400_000 > 365) {
|
||||||
|
return NextResponse.json({ error: 'Custom range cannot exceed 365 days' }, { status: 400 });
|
||||||
|
}
|
||||||
|
range = { kind: 'custom', from: fromParam, to: toParam };
|
||||||
|
} else {
|
||||||
|
if (!ALL_RANGES.includes(rawRange as PresetDateRange)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid range' }, { status: 400 });
|
||||||
|
}
|
||||||
|
range = rawRange as PresetDateRange;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await METRICS[metric](ctx.portId, range);
|
const data = await METRICS[metric](ctx.portId, range);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
|
import { usePortContext } from '@/providers/port-provider';
|
||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
import { KpiCardsWithBoundary } from './kpi-cards';
|
import { KpiCardsWithBoundary } from './kpi-cards';
|
||||||
import { ActivityFeed } from './activity-feed';
|
import { ActivityFeed } from './activity-feed';
|
||||||
@@ -12,29 +13,53 @@ import { OccupancyTimelineChart } from './occupancy-timeline-chart';
|
|||||||
import { RevenueBreakdownChart } from './revenue-breakdown-chart';
|
import { RevenueBreakdownChart } from './revenue-breakdown-chart';
|
||||||
import { LeadSourceChart } from './lead-source-chart';
|
import { LeadSourceChart } from './lead-source-chart';
|
||||||
import { MyRemindersRail } from './my-reminders-rail';
|
import { MyRemindersRail } from './my-reminders-rail';
|
||||||
|
import { WebsiteGlanceTile } from './website-glance-tile';
|
||||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||||
import { AlertRail } from '@/components/alerts/alert-rail';
|
import { AlertRail } from '@/components/alerts/alert-rail';
|
||||||
import type { DateRange } from '@/lib/services/analytics.service';
|
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
|
||||||
|
|
||||||
const RANGE_LABELS: Record<DateRange, string> = {
|
const PRESET_LABELS: Record<'today' | '7d' | '30d' | '90d', string> = {
|
||||||
today: 'Today',
|
today: 'Today',
|
||||||
'7d': 'Last 7 days',
|
'7d': 'Last 7 days',
|
||||||
'30d': 'Last 30 days',
|
'30d': 'Last 30 days',
|
||||||
'90d': 'Last 90 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];
|
||||||
|
}
|
||||||
|
|
||||||
export function DashboardShell() {
|
export function DashboardShell() {
|
||||||
const [range, setRange] = useState<DateRange>('30d');
|
const [range, setRange] = useState<DateRange>('30d');
|
||||||
|
const { currentPort } = usePortContext();
|
||||||
|
const portName = currentPort?.name ?? 'this port';
|
||||||
|
|
||||||
|
// 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({
|
useRealtimeInvalidation({
|
||||||
'interest:stageChanged': [
|
'interest:stageChanged': [
|
||||||
['analytics', 'pipeline_funnel', range],
|
['analytics', 'pipeline_funnel'],
|
||||||
['analytics', 'lead_source_attribution', range],
|
['analytics', 'lead_source_attribution'],
|
||||||
['dashboard', 'kpis'],
|
['dashboard', 'kpis'],
|
||||||
],
|
],
|
||||||
'client:created': [['dashboard', 'kpis']],
|
'client:created': [['dashboard', 'kpis']],
|
||||||
'berth:statusChanged': [
|
'berth:statusChanged': [
|
||||||
['analytics', 'occupancy_timeline', range],
|
['analytics', 'occupancy_timeline'],
|
||||||
['dashboard', 'kpis'],
|
['dashboard', 'kpis'],
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -44,8 +69,8 @@ export function DashboardShell() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="Dashboard"
|
title="Dashboard"
|
||||||
eyebrow="Overview"
|
eyebrow="Overview"
|
||||||
description="Live snapshot of your marina activity"
|
description={`Live snapshot of ${portName} activity`}
|
||||||
kpiLine={<span>{RANGE_LABELS[range]}</span>}
|
kpiLine={<span>{rangeLabel(range)}</span>}
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
actions={<DateRangePicker value={range} onChange={setRange} />}
|
actions={<DateRangePicker value={range} onChange={setRange} />}
|
||||||
/>
|
/>
|
||||||
@@ -54,7 +79,12 @@ export function DashboardShell() {
|
|||||||
<KpiCardsWithBoundary />
|
<KpiCardsWithBoundary />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px]">
|
{/* `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 and into the rows below where
|
||||||
|
ActivityFeed renders. */}
|
||||||
|
<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-2">
|
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
|
||||||
<WidgetErrorBoundary>
|
<WidgetErrorBoundary>
|
||||||
<PipelineFunnelChart range={range} />
|
<PipelineFunnelChart range={range} />
|
||||||
@@ -70,6 +100,11 @@ export function DashboardShell() {
|
|||||||
</WidgetErrorBoundary>
|
</WidgetErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
<aside className="min-w-0 space-y-4">
|
<aside className="min-w-0 space-y-4">
|
||||||
|
{/* Soft-fail tile linking to /website-analytics. Hidden if Umami
|
||||||
|
isn't configured for this port. */}
|
||||||
|
<WidgetErrorBoundary>
|
||||||
|
<WebsiteGlanceTile />
|
||||||
|
</WidgetErrorBoundary>
|
||||||
<WidgetErrorBoundary>
|
<WidgetErrorBoundary>
|
||||||
<MyRemindersRail />
|
<MyRemindersRail />
|
||||||
</WidgetErrorBoundary>
|
</WidgetErrorBoundary>
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Calendar } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { DateRange } from '@/lib/services/analytics.service';
|
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
|
||||||
|
|
||||||
interface DateRangePickerProps {
|
interface DateRangePickerProps {
|
||||||
value: DateRange;
|
value: DateRange;
|
||||||
@@ -10,14 +14,64 @@ interface DateRangePickerProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const OPTIONS: Array<{ value: DateRange; label: string }> = [
|
const PRESETS: Array<{ value: 'today' | '7d' | '30d' | '90d'; label: string }> = [
|
||||||
{ value: 'today', label: 'Today' },
|
{ value: 'today', label: 'Today' },
|
||||||
{ value: '7d', label: '7d' },
|
{ value: '7d', label: '7d' },
|
||||||
{ value: '30d', label: '30d' },
|
{ value: '30d', label: '30d' },
|
||||||
{ value: '90d', label: '90d' },
|
{ value: '90d', label: '90d' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a custom range as a compact button label, e.g. "Apr 14 – May 4".
|
||||||
|
* Same year omits the year on both sides; different years includes both.
|
||||||
|
*/
|
||||||
|
function formatCustom(range: { from: string; to: string }): string {
|
||||||
|
const from = new Date(`${range.from}T00:00:00.000Z`);
|
||||||
|
const to = new Date(`${range.to}T00:00:00.000Z`);
|
||||||
|
const sameYear = from.getUTCFullYear() === to.getUTCFullYear();
|
||||||
|
const fmt: Intl.DateTimeFormatOptions = sameYear
|
||||||
|
? { month: 'short', day: 'numeric', timeZone: 'UTC' }
|
||||||
|
: { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' };
|
||||||
|
return `${from.toLocaleDateString('en-US', fmt)} – ${to.toLocaleDateString('en-US', fmt)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Today's date as a YYYY-MM-DD string in UTC. Used as the default for the
|
||||||
|
* "to" picker so users can't accidentally pick a future date by leaving the
|
||||||
|
* field empty.
|
||||||
|
*/
|
||||||
|
function todayIso(): string {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
export function DateRangePicker({ value, onChange, className }: DateRangePickerProps) {
|
export function DateRangePicker({ value, onChange, className }: DateRangePickerProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const isCustom = isCustomRange(value);
|
||||||
|
|
||||||
|
// Local state for the popover form. Seeded from the current value if it's
|
||||||
|
// already custom, otherwise defaults to a 14-day window ending today.
|
||||||
|
const [draftFrom, setDraftFrom] = useState<string>(() => {
|
||||||
|
if (isCustom) return value.from;
|
||||||
|
const d = new Date();
|
||||||
|
d.setUTCDate(d.getUTCDate() - 14);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
});
|
||||||
|
const [draftTo, setDraftTo] = useState<string>(() => (isCustom ? value.to : todayIso()));
|
||||||
|
|
||||||
|
const today = todayIso();
|
||||||
|
const draftValid =
|
||||||
|
draftFrom !== '' &&
|
||||||
|
draftTo !== '' &&
|
||||||
|
draftFrom <= draftTo &&
|
||||||
|
draftFrom <= today &&
|
||||||
|
draftTo <= today;
|
||||||
|
|
||||||
|
function applyCustom() {
|
||||||
|
if (!draftValid) return;
|
||||||
|
onChange({ kind: 'custom', from: draftFrom, to: draftTo });
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="tablist"
|
role="tablist"
|
||||||
@@ -27,8 +81,8 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{OPTIONS.map((opt) => {
|
{PRESETS.map((opt) => {
|
||||||
const active = opt.value === value;
|
const active = !isCustom && opt.value === value;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
@@ -50,6 +104,68 @@ export function DateRangePicker({ value, onChange, className }: DateRangePickerP
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Custom range - popover with two date inputs and an Apply button */}
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isCustom}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
'h-7 px-3 text-xs font-medium transition-all duration-base ease-spring inline-flex items-center gap-1',
|
||||||
|
isCustom
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
data-testid="range-custom"
|
||||||
|
>
|
||||||
|
<Calendar className="h-3 w-3" aria-hidden />
|
||||||
|
{isCustom ? formatCustom(value) : 'Custom'}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" className="w-[260px] p-3">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Custom range
|
||||||
|
</div>
|
||||||
|
<label className="block text-xs">
|
||||||
|
<span className="block text-muted-foreground mb-1">From</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={draftFrom}
|
||||||
|
/* `max` capped at min(draftTo, today). Without the today
|
||||||
|
cap, users could pick a future From, end up with an
|
||||||
|
empty result, and not understand why. */
|
||||||
|
max={draftTo && draftTo < today ? draftTo : today}
|
||||||
|
onChange={(e) => setDraftFrom(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none focus:border-brand/60 focus:ring-2 focus:ring-brand/15"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-xs">
|
||||||
|
<span className="block text-muted-foreground mb-1">To</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={draftTo}
|
||||||
|
min={draftFrom || undefined}
|
||||||
|
max={today}
|
||||||
|
onChange={(e) => setDraftTo(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none focus:border-brand/60 focus:ring-2 focus:ring-brand/15"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-1">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={applyCustom} disabled={!draftValid}>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { useUIStore } from '@/stores/ui-store';
|
||||||
import { KPITile } from '@/components/ui/kpi-tile';
|
import { KPITile } from '@/components/ui/kpi-tile';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { WidgetErrorBoundary } from './widget-error-boundary';
|
import { WidgetErrorBoundary } from './widget-error-boundary';
|
||||||
@@ -37,11 +38,19 @@ function KpiTileSkeleton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function KpiCards() {
|
export function KpiCards() {
|
||||||
|
// Keying on currentPortId ensures React Query treats a port-resolved fetch
|
||||||
|
// as a different query than the one that fires on first paint when the
|
||||||
|
// store hasn't yet hydrated. Without this, an early null-port fetch could
|
||||||
|
// cache an error and display "-" indefinitely until the staleTime expires.
|
||||||
|
const portId = useUIStore((s) => s.currentPortId);
|
||||||
const { data, isLoading, isError } = useQuery<KpiData>({
|
const { data, isLoading, isError } = useQuery<KpiData>({
|
||||||
queryKey: ['dashboard', 'kpis'],
|
queryKey: ['dashboard', 'kpis', portId],
|
||||||
queryFn: () => apiFetch<KpiData>('/api/v1/dashboard/kpis'),
|
queryFn: () => apiFetch<KpiData>('/api/v1/dashboard/kpis'),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
retry: 2,
|
retry: 2,
|
||||||
|
// Avoid running until we have a port id - gates against the early
|
||||||
|
// unauth/no-port window where the API would return zeroes/errors.
|
||||||
|
enabled: !!portId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -62,22 +71,22 @@ export function KpiCards() {
|
|||||||
}> = [
|
}> = [
|
||||||
{
|
{
|
||||||
label: 'Total Clients',
|
label: 'Total Clients',
|
||||||
value: isError ? '—' : String(data?.totalClients ?? 0),
|
value: isError ? '-' : String(data?.totalClients ?? 0),
|
||||||
accent: 'brand',
|
accent: 'brand',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Active Interests',
|
label: 'Active Interests',
|
||||||
value: isError ? '—' : String(data?.activeInterests ?? 0),
|
value: isError ? '-' : String(data?.activeInterests ?? 0),
|
||||||
accent: 'teal',
|
accent: 'teal',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Pipeline Value',
|
label: 'Pipeline Value',
|
||||||
value: isError ? '—' : formatCurrency(data?.pipelineValueUsd ?? 0),
|
value: isError ? '-' : formatCurrency(data?.pipelineValueUsd ?? 0),
|
||||||
accent: 'success',
|
accent: 'success',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Occupancy Rate',
|
label: 'Occupancy Rate',
|
||||||
value: isError ? '—' : formatPercent(data?.occupancyRate ?? 0),
|
value: isError ? '-' : formatPercent(data?.occupancyRate ?? 0),
|
||||||
accent: 'purple',
|
accent: 'purple',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const PRIORITY_BADGE: Record<string, string> = {
|
|||||||
/**
|
/**
|
||||||
* Compact reminders rail for the dashboard sidebar. Lists reminders assigned
|
* Compact reminders rail for the dashboard sidebar. Lists reminders assigned
|
||||||
* to the current user (overdue first, then upcoming). Each item links to its
|
* to the current user (overdue first, then upcoming). Each item links to its
|
||||||
* subject — interest preferred, then client, then the generic entity ref.
|
* subject - interest preferred, then client, then the generic entity ref.
|
||||||
*
|
*
|
||||||
* Limited to 6 items; "View all" routes to /reminders.
|
* Limited to 6 items; "View all" routes to /reminders.
|
||||||
*/
|
*/
|
||||||
@@ -67,11 +67,13 @@ export function MyRemindersRail() {
|
|||||||
return `/${portSlug}/reminders`;
|
return `/${portSlug}/reminders`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `h-full` only at xl: where the dashboard grid pairs this rail with
|
// Natural height - the parent dashboard grid uses `items-start` so the
|
||||||
// a sibling chart column. On mobile (stacked) it produced a weirdly
|
// aside column no longer forces this rail to fill the chart column's
|
||||||
// tall empty card.
|
// height. Stretching here pushed AlertRail out of the aside's box and
|
||||||
|
// into the territory below where ActivityFeed renders, producing a
|
||||||
|
// visible overlap on tall viewports.
|
||||||
return (
|
return (
|
||||||
<Card className="xl:h-full">
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3">
|
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<CardTitle className="flex items-center gap-1.5 text-base">
|
<CardTitle className="flex items-center gap-1.5 text-base">
|
||||||
@@ -100,7 +102,7 @@ export function MyRemindersRail() {
|
|||||||
</div>
|
</div>
|
||||||
) : sorted.length === 0 ? (
|
) : sorted.length === 0 ? (
|
||||||
<p className="py-3 text-center text-sm text-muted-foreground">
|
<p className="py-3 text-center text-sm text-muted-foreground">
|
||||||
All caught up — no reminders.
|
All caught up - no reminders.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
|
||||||
import type {
|
import type {
|
||||||
DateRange,
|
|
||||||
LeadSourceAttributionData,
|
LeadSourceAttributionData,
|
||||||
MetricBase,
|
MetricBase,
|
||||||
OccupancyTimelineData,
|
OccupancyTimelineData,
|
||||||
@@ -18,12 +18,27 @@ interface MetricResponse<T> {
|
|||||||
data: T;
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a DateRange (preset or custom) into the URL query params the
|
||||||
|
* /api/v1/analytics route expects: `range=30d` for presets, or
|
||||||
|
* `range=custom&from=YYYY-MM-DD&to=YYYY-MM-DD` for custom.
|
||||||
|
*/
|
||||||
|
function rangeToQuery(range: DateRange): string {
|
||||||
|
if (isCustomRange(range)) {
|
||||||
|
return `range=custom&from=${range.from}&to=${range.to}`;
|
||||||
|
}
|
||||||
|
return `range=${range}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function useAnalyticsMetric<T>(metric: MetricBase, range: DateRange) {
|
export function useAnalyticsMetric<T>(metric: MetricBase, range: DateRange) {
|
||||||
return useQuery<T>({
|
return useQuery<T>({
|
||||||
|
// Stringify custom ranges into the cache key so React Query treats
|
||||||
|
// each {from,to} pair as its own query - otherwise switching dates
|
||||||
|
// would never refetch.
|
||||||
queryKey: ['analytics', metric, range],
|
queryKey: ['analytics', metric, range],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await apiFetch<MetricResponse<T>>(
|
const res = await apiFetch<MetricResponse<T>>(
|
||||||
`/api/v1/analytics?metric=${metric}&range=${range}`,
|
`/api/v1/analytics?metric=${metric}&${rangeToQuery(range)}`,
|
||||||
);
|
);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export type { EventMap, SocketLike } from '@/hooks/realtime-invalidation-core';
|
|||||||
/**
|
/**
|
||||||
* Subscribes to socket events and invalidates React Query caches.
|
* Subscribes to socket events and invalidates React Query caches.
|
||||||
*
|
*
|
||||||
* Safe to call with an inline-literal `eventMap` — the hook only re-subscribes
|
* Safe to call with an inline-literal `eventMap` - the hook only re-subscribes
|
||||||
* when the SET of event keys actually changes (not when the object identity
|
* when the SET of event keys actually changes (not when the object identity
|
||||||
* changes). The latest query-key list is read at event fire-time via a ref.
|
* changes). The latest query-key list is read at event fire-time via a ref.
|
||||||
*
|
*
|
||||||
@@ -39,7 +39,7 @@ export function useRealtimeInvalidation(eventMap: EventMap) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!socket) return;
|
if (!socket) return;
|
||||||
// eventMapRef is intentionally not in deps — it's a ref; we only want to
|
// eventMapRef is intentionally not in deps - it's a ref; we only want to
|
||||||
// re-run when the socket, queryClient, or the event-key SET changes.
|
// re-run when the socket, queryClient, or the event-key SET changes.
|
||||||
return subscribeRealtimeInvalidations(
|
return subscribeRealtimeInvalidations(
|
||||||
socket,
|
socket,
|
||||||
|
|||||||
Reference in New Issue
Block a user