dashboard design changes

This commit is contained in:
2026-08-05 18:34:37 +05:30
parent 2c8c394795
commit 9d7bbad32c
92 changed files with 5104 additions and 608 deletions

View File

@@ -3,7 +3,11 @@
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';
@@ -27,18 +31,51 @@ import {
} from '@/lib/format';
/**
* The dashboard answers three questions in order, top to bottom:
* 1. How is the business performing? → KPI row
* 2. What is the shape of that? → footfall / revenue / conversion
* 3. What needs attention, and when? → peak hours, rewards, stores, feed
* An analytics workspace. Charts are the subject, not evidence for a to-do list.
*
* Every panel is scoped by the same {storeId, range} from WorkspaceProvider,
* so changing either control in the top nav re-queries the whole page without
* a route change — which is what keeps the Copilot mounted alongside it.
* 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};
@@ -49,17 +86,46 @@ export default function DashboardPage() {
const rewards = useResource(endpoints.dashboardRewardUsage(scope));
const comparison = useResource(endpoints.dashboardStoreComparison(scope));
const scopeLabel = storeId === 'all' ? 'all stores' : storeName(storeId);
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={`How the business is performing across ${scopeLabel}, what needs attention, and what to do next.`}
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) => (
@@ -84,7 +150,10 @@ export default function DashboardPage() {
/>
)}
</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"
@@ -124,6 +193,7 @@ export default function DashboardPage() {
</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"
@@ -137,18 +207,27 @@ export default function DashboardPage() {
<RewardUsageChart resource={rewards} />
</Grid>
{/* 7 — period rollup. */}
<PerformancePanel
scope={scope}
granularity={granularity}
onGranularityChange={setGranularity}
/>
{/* Comparing stores is meaningless when scoped to one of them. */}
{storeId === 'all' ? (
{/* 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}
<ActivityTimeline resource={activity} />
{/* 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>
);
}