234 lines
8.9 KiB
TypeScript
234 lines
8.9 KiB
TypeScript
'use client';
|
|
|
|
import {useState} from 'react';
|
|
import {VStack} from '@astryxdesign/core/Layout';
|
|
import {Grid} from '@astryxdesign/core/Grid';
|
|
import {Button} from '@astryxdesign/core/Button';
|
|
import {Icon} from '@astryxdesign/core/Icon';
|
|
import {PageHeader} from '@/components/primitives/PageHeader';
|
|
import {ScopeControls} from '@/components/scope/ScopeControls';
|
|
import {ICONS} from '@/lib/icons';
|
|
import {ChartCard} from '@/components/charts/ChartCard';
|
|
import {AreaChartView} from '@/components/charts/AreaChartView';
|
|
import {LineChartView} from '@/components/charts/LineChartView';
|
|
import {BarChartView} from '@/components/charts/BarChartView';
|
|
import {HeatmapGrid} from '@/components/charts/HeatmapGrid';
|
|
import {KpiRow} from '@/features/dashboard/KpiRow';
|
|
import {ActivityTimeline} from '@/features/dashboard/ActivityTimeline';
|
|
import {RewardUsageChart} from '@/features/dashboard/RewardUsageChart';
|
|
import {StoreComparisonPanel} from '@/features/dashboard/StoreComparison';
|
|
import {PerformancePanel} from '@/features/dashboard/PerformancePanel';
|
|
import {useResource} from '@/lib/api/useResource';
|
|
import {endpoints} from '@/lib/api/client';
|
|
import {useWorkspace} from '@/components/shell/WorkspaceProvider';
|
|
import {storeName} from '@/lib/mock/stores';
|
|
import type {Granularity} from '@/lib/api/contracts';
|
|
import {
|
|
formatCompact,
|
|
formatDayLabel,
|
|
formatInrCompact,
|
|
formatPct,
|
|
} from '@/lib/format';
|
|
|
|
/**
|
|
* An analytics workspace. Charts are the subject, not evidence for a to-do list.
|
|
*
|
|
* The page reads top-down as one narrowing question: headline numbers → the two
|
|
* trends that drive them → the conversion story behind those → when and on what
|
|
* it happens → the period rollup → which store → what just happened.
|
|
*
|
|
* Operational widgets — quick actions, tasks, the AI briefing, top store/reward,
|
|
* staff status — deliberately do NOT live here. The briefing endpoint they were
|
|
* built on is still live and feeds the Copilot's AI tab, which is where that
|
|
* class of content belongs: a panel you open to be told what to do, beside a
|
|
* dashboard you read to work it out yourself.
|
|
*
|
|
* Recent activity keeps its compact form — six rows in a fixed box, full history
|
|
* on /activity. The uncapped version reached 784px, taller than any chart on the
|
|
* page, which is an operational log outweighing the analytics it sits among.
|
|
*
|
|
* Every panel is scoped by the same {storeId, range} from WorkspaceProvider, set
|
|
* from this page's header. `series` is fetched once and shared by the four
|
|
* charts drawn from it, so they cannot disagree.
|
|
*/
|
|
|
|
/**
|
|
* Derived from the SERVER's clock, carried on the response meta.
|
|
*
|
|
* Calling Date.now() during render is impure and disagrees between the SSR and
|
|
* hydration passes. Before meta lands there is no instant to reason about, so
|
|
* the greeting is the time-independent one; both strings are a single line, so
|
|
* the swap costs no layout shift. getHours() resolves the server's instant in
|
|
* the viewer's zone, which is the hour the merchant is actually living in.
|
|
*/
|
|
function greetingFor(generatedAt?: string): string {
|
|
if (!generatedAt) return 'Welcome back';
|
|
const hour = new Date(generatedAt).getHours();
|
|
if (hour < 12) return 'Good morning';
|
|
if (hour < 17) return 'Good afternoon';
|
|
return 'Good evening';
|
|
}
|
|
|
|
export default function DashboardPage() {
|
|
const {storeId, range} = useWorkspace();
|
|
const [granularity, setGranularity] = useState<Granularity>('weekly');
|
|
// On by default: store comparison has always been part of the all-stores
|
|
// dashboard, so Compare starts pressed and switches the panel off, rather
|
|
// than hiding a panel that used to be there until someone finds the button.
|
|
const [isComparing, setIsComparing] = useState(true);
|
|
|
|
const scope = {range, storeId};
|
|
|
|
const kpis = useResource(endpoints.dashboardKpis(scope));
|
|
const series = useResource(endpoints.dashboardTimeseries(scope));
|
|
const peak = useResource(endpoints.dashboardPeakHours(scope));
|
|
const activity = useResource(endpoints.dashboardActivity(scope));
|
|
const rewards = useResource(endpoints.dashboardRewardUsage(scope));
|
|
const comparison = useResource(endpoints.dashboardStoreComparison(scope));
|
|
|
|
const isAllStores = storeId === 'all';
|
|
const scopeLabel = isAllStores ? 'all stores' : storeName(storeId);
|
|
|
|
return (
|
|
<VStack gap={5}>
|
|
{/* 1 + 2 — greeting, title, and the store / period / compare filters. */}
|
|
<PageHeader
|
|
eyebrow={greetingFor(kpis.meta?.generatedAt)}
|
|
title="Dashboard"
|
|
description={`Business performance across ${scopeLabel}.`}
|
|
controls={
|
|
<>
|
|
<ScopeControls />
|
|
<Button
|
|
size="sm"
|
|
// Pressed state is the raised gray of `primary` in this theme —
|
|
// a toggle needs a visible on-state, and it must not be colour.
|
|
variant={isComparing ? 'primary' : 'secondary'}
|
|
label="Compare"
|
|
icon={<Icon icon={ICONS.compare} size="sm" />}
|
|
aria-pressed={isComparing}
|
|
isDisabled={!isAllStores}
|
|
// With a tooltip present, Button uses aria-disabled rather than
|
|
// the native attribute, so the reason stays reachable by keyboard
|
|
// instead of the control just going dead.
|
|
tooltip={
|
|
isAllStores
|
|
? undefined
|
|
: 'Comparing stores needs the All stores scope'
|
|
}
|
|
onClick={() => setIsComparing((v) => !v)}
|
|
/>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{/* 3 — headline numbers. */}
|
|
<KpiRow resource={kpis} />
|
|
|
|
{/* 4 — primary analytics: the two series everything else explains. */}
|
|
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
|
<ChartCard title="Footfall" subtitle="Visitors per day" resource={series}>
|
|
{(d) => (
|
|
<AreaChartView
|
|
data={d}
|
|
xKey="t"
|
|
xFormat={formatDayLabel}
|
|
yFormat={formatCompact}
|
|
series={[{key: 'visitors', label: 'Visitors'}]}
|
|
/>
|
|
)}
|
|
</ChartCard>
|
|
|
|
<ChartCard title="Revenue" subtitle="Daily takings" resource={series}>
|
|
{(d) => (
|
|
<BarChartView
|
|
data={d}
|
|
xKey="t"
|
|
xFormat={formatDayLabel}
|
|
yFormat={formatInrCompact}
|
|
series={[{key: 'revenue', label: 'Revenue'}]}
|
|
/>
|
|
)}
|
|
</ChartCard>
|
|
</Grid>
|
|
|
|
{/* 5 — secondary analytics: the conversion story, in two readings. */}
|
|
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
|
<ChartCard
|
|
title="Visitors vs purchases"
|
|
subtitle="The gap is the conversion opportunity"
|
|
resource={series}
|
|
>
|
|
{(d) => (
|
|
<LineChartView
|
|
data={d}
|
|
xKey="t"
|
|
xFormat={formatDayLabel}
|
|
yFormat={formatCompact}
|
|
series={[
|
|
{key: 'visitors', label: 'Visitors'},
|
|
{key: 'purchases', label: 'Purchases'},
|
|
]}
|
|
/>
|
|
)}
|
|
</ChartCard>
|
|
|
|
<ChartCard
|
|
title="Conversion"
|
|
subtitle="Share of visitors who bought"
|
|
resource={series}
|
|
>
|
|
{(d) => (
|
|
<LineChartView
|
|
data={d}
|
|
xKey="t"
|
|
xFormat={formatDayLabel}
|
|
yFormat={(v) => formatPct(v, 0)}
|
|
series={[{key: 'conversion', label: 'Conversion'}]}
|
|
// The only semantic colour on this chart: above the benchmark is
|
|
// good, below is not, and that is what the tint communicates.
|
|
reference={{y: 22, label: 'Network benchmark', tone: 'positive'}}
|
|
/>
|
|
)}
|
|
</ChartCard>
|
|
</Grid>
|
|
|
|
{/* 6 — operational analytics: when traffic lands, what it redeems. */}
|
|
<Grid columns={{minWidth: 360, max: 2, repeat: 'fit'}} gap={4}>
|
|
<ChartCard
|
|
title="Peak hours"
|
|
subtitle="Where the week's footfall actually lands"
|
|
resource={peak}
|
|
height={200}
|
|
>
|
|
{(d) => <HeatmapGrid data={d} />}
|
|
</ChartCard>
|
|
|
|
<RewardUsageChart resource={rewards} />
|
|
</Grid>
|
|
|
|
{/* 7 — period rollup. */}
|
|
<PerformancePanel
|
|
scope={scope}
|
|
granularity={granularity}
|
|
onGranularityChange={setGranularity}
|
|
/>
|
|
|
|
{/* 8 — comparing stores is meaningless when scoped to one of them, which
|
|
is why Compare is disabled rather than merely off in that case. */}
|
|
{isAllStores && isComparing ? (
|
|
<StoreComparisonPanel resource={comparison} />
|
|
) : null}
|
|
|
|
{/* 9 — bounded feed: six rows, fixed box, full history on /activity. */}
|
|
<ActivityTimeline
|
|
resource={activity}
|
|
subtitle="Latest events across the selected store and period"
|
|
limit={6}
|
|
height={285}
|
|
viewAllHref="/activity"
|
|
/>
|
|
</VStack>
|
|
);
|
|
}
|