update the adminpage ui
This commit is contained in:
215
src/features/admin/components/analytics/FootfallDashboard.tsx
Normal file
215
src/features/admin/components/analytics/FootfallDashboard.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo} from 'react';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {BarChartView} from '@/shared/components/charts/BarChartView';
|
||||
import {LineChartView} from '@/shared/components/charts/LineChartView';
|
||||
import {AreaChartView} from '@/shared/components/charts/AreaChartView';
|
||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import {formatCount, formatDayLabel} from '@/shared/utils/format';
|
||||
import type {IconKey} from '@/shared/utils/icons';
|
||||
import type {AnalyticsFilters, AreaDateFootfall, FootfallDetail} from '@/features/admin/types/analytics';
|
||||
import {useFootfallAnalytics} from '@/features/admin/hooks/useAnalytics';
|
||||
import {
|
||||
AnalyticsPanel,
|
||||
ChartLegend,
|
||||
DetailsTable,
|
||||
StatPanel,
|
||||
hasField,
|
||||
type SortOption,
|
||||
} from './AnalyticsPanel';
|
||||
|
||||
/**
|
||||
* Footfall: overview → area comparison + daily trend → area × date → details.
|
||||
*
|
||||
* Every panel reads its own endpoint through footfallRepository with the same
|
||||
* filters, so a merchant / area / shop / range change refetches all of them
|
||||
* and none can show another scope's rows.
|
||||
*/
|
||||
export function FootfallDashboard({filters}: {filters: AnalyticsFilters}) {
|
||||
const bp = useBreakpoint();
|
||||
const data = useFootfallAnalytics(filters);
|
||||
const twoUp = bp === 'desktop' || bp === 'ultrawide';
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<StatPanel
|
||||
title="Footfall overview"
|
||||
subtitle="Walk-ins for the selected merchant, area, shop and period."
|
||||
data={data.overview}
|
||||
placeholders={OVERVIEW_TILES}
|
||||
toStats={(o) => [
|
||||
{...OVERVIEW_TILES[0], value: formatCount(o.totalFootfall), caption: 'Walk-ins in the period'},
|
||||
{...OVERVIEW_TILES[1], value: formatCount(Math.round(o.averageDaily)), caption: 'Per day'},
|
||||
{
|
||||
...OVERVIEW_TILES[2],
|
||||
value: o.peakDay ? formatCount(o.peakDay.footfall) : undefined,
|
||||
caption: o.peakDay ? formatDayLabel(o.peakDay.date) : undefined,
|
||||
},
|
||||
{...OVERVIEW_TILES[3], value: formatCount(o.activeLocations), caption: 'Areas with walk-ins'},
|
||||
{...OVERVIEW_TILES[4], value: formatCount(o.reportingShops), caption: 'Shops that sent data'},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Grid columns={twoUp ? 2 : 1} gap={6}>
|
||||
<AnalyticsPanel
|
||||
title="Footfall by area"
|
||||
subtitle="Compare footfall across locations for the selected period."
|
||||
icon="analytics"
|
||||
data={data.byArea}
|
||||
pendingTitle="No area footfall data yet"
|
||||
>
|
||||
{(rows) => (
|
||||
<BarChartView
|
||||
data={[...rows].sort((a, b) => b.footfall - a.footfall)}
|
||||
xKey="area"
|
||||
series={[{key: 'footfall', label: 'Footfall', format: formatCount}]}
|
||||
yFormat={formatCount}
|
||||
/>
|
||||
)}
|
||||
</AnalyticsPanel>
|
||||
|
||||
<AnalyticsPanel
|
||||
title="Daily footfall trend"
|
||||
subtitle="Walk-ins per day across the selected range."
|
||||
icon="visitors"
|
||||
data={data.daily}
|
||||
pendingTitle="No daily footfall data yet"
|
||||
>
|
||||
{(rows) => (
|
||||
<AreaChartView
|
||||
data={rows}
|
||||
xKey="date"
|
||||
series={[{key: 'footfall', label: 'Footfall', format: formatCount}]}
|
||||
xFormat={formatDayLabel}
|
||||
yFormat={formatCount}
|
||||
/>
|
||||
)}
|
||||
</AnalyticsPanel>
|
||||
</Grid>
|
||||
|
||||
<AnalyticsPanel
|
||||
title="Area × date"
|
||||
subtitle="Every area across the same dates. The four busiest areas are drawn; the rest fold into Other."
|
||||
icon="compare"
|
||||
data={data.areaDate}
|
||||
pendingTitle="No area trend data yet"
|
||||
height={300}
|
||||
>
|
||||
{(rows) => <AreaDateChart rows={rows} />}
|
||||
</AnalyticsPanel>
|
||||
|
||||
<AnalyticsPanel
|
||||
title="Footfall details"
|
||||
subtitle="One row per shop per day, as the platform reports it."
|
||||
icon="events"
|
||||
data={data.details}
|
||||
pendingTitle="No footfall rows yet"
|
||||
variant="table"
|
||||
height={220}
|
||||
>
|
||||
{(rows) => <FootfallTable rows={rows} />}
|
||||
</AnalyticsPanel>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
const OVERVIEW_TILES = [
|
||||
{label: 'Total footfall', icon: 'visitors'},
|
||||
{label: 'Average daily', icon: 'analytics'},
|
||||
{label: 'Peak day', icon: 'up'},
|
||||
{label: 'Active locations', icon: 'stores'},
|
||||
{label: 'Reporting shops', icon: 'present'},
|
||||
] as const satisfies {label: string; icon: IconKey}[];
|
||||
|
||||
/* ── Area × date ──────────────────────────────────────────────────────── */
|
||||
|
||||
const MAX_SERIES = 4;
|
||||
const OTHER = 'Other';
|
||||
|
||||
/**
|
||||
* Pivots the long rows into one object per date with a key per area. Series
|
||||
* follow the AREA, ranked by total — never cycled: past four, the rest sum
|
||||
* into "Other" rather than reusing a grey another area already owns.
|
||||
*/
|
||||
function pivot(rows: AreaDateFootfall[]) {
|
||||
const totals = new Map<string, number>();
|
||||
for (const r of rows) totals.set(r.area, (totals.get(r.area) ?? 0) + r.footfall);
|
||||
const ranked = [...totals.entries()].sort((a, b) => b[1] - a[1]).map(([a]) => a);
|
||||
const shown = ranked.length > MAX_SERIES + 1 ? ranked.slice(0, MAX_SERIES) : ranked;
|
||||
const folds = ranked.length > shown.length;
|
||||
|
||||
const byDate = new Map<string, Record<string, number | string>>();
|
||||
for (const r of rows) {
|
||||
const row = byDate.get(r.date) ?? {date: r.date};
|
||||
const key = shown.includes(r.area) ? r.area : OTHER;
|
||||
row[key] = ((row[key] as number | undefined) ?? 0) + r.footfall;
|
||||
byDate.set(r.date, row);
|
||||
}
|
||||
const data = [...byDate.values()].sort((a, b) =>
|
||||
String(a.date).localeCompare(String(b.date)),
|
||||
);
|
||||
return {data, series: folds ? [...shown, OTHER] : shown};
|
||||
}
|
||||
|
||||
function AreaDateChart({rows}: {rows: AreaDateFootfall[]}) {
|
||||
const {data, series} = useMemo(() => pivot(rows), [rows]);
|
||||
return (
|
||||
<VStack gap={3} width="100%">
|
||||
<ChartLegend labels={series} />
|
||||
<LineChartView
|
||||
data={data}
|
||||
xKey="date"
|
||||
series={series.map((s) => ({key: s, label: s, format: formatCount}))}
|
||||
xFormat={formatDayLabel}
|
||||
yFormat={formatCount}
|
||||
height={300}
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Details table ────────────────────────────────────────────────────── */
|
||||
|
||||
type FootfallRow = FootfallDetail & Record<string, unknown> & {rowId: string};
|
||||
|
||||
const FOOTFALL_SORTS: SortOption<FootfallRow>[] = [
|
||||
{value: 'date-desc', label: 'Newest first', compare: (a, b) => b.date.localeCompare(a.date)},
|
||||
{value: 'date-asc', label: 'Oldest first', compare: (a, b) => a.date.localeCompare(b.date)},
|
||||
{value: 'footfall-desc', label: 'Most footfall', compare: (a, b) => b.footfall - a.footfall},
|
||||
{value: 'shop', label: 'Shop A–Z', compare: (a, b) => a.shopName.localeCompare(b.shopName)},
|
||||
];
|
||||
|
||||
function FootfallTable({rows}: {rows: FootfallDetail[]}) {
|
||||
const data: FootfallRow[] = rows.map((r) => ({
|
||||
...r,
|
||||
rowId: `${r.date}|${r.shopId}`,
|
||||
}));
|
||||
|
||||
const columns: TableColumn<FootfallRow>[] = [
|
||||
{key: 'date', header: 'Date', renderCell: (r) => <Text size="sm">{formatDayLabel(r.date)}</Text>},
|
||||
{key: 'merchantName', header: 'Merchant'},
|
||||
...(hasField(rows, 'area') ? [{key: 'area', header: 'Area'} as TableColumn<FootfallRow>] : []),
|
||||
{key: 'shopName', header: 'Shop'},
|
||||
{key: 'footfall', header: 'Footfall', align: 'end', renderCell: (r) => <Text size="sm">{formatCount(r.footfall)}</Text>},
|
||||
...(hasField(rows, 'entries')
|
||||
? [{key: 'entries', header: 'Entries', align: 'end'} as TableColumn<FootfallRow>]
|
||||
: []),
|
||||
...(hasField(rows, 'exits')
|
||||
? [{key: 'exits', header: 'Exits', align: 'end'} as TableColumn<FootfallRow>]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<DetailsTable
|
||||
rows={data}
|
||||
columns={columns}
|
||||
idKey="rowId"
|
||||
primaryKey="shopName"
|
||||
sorts={FOOTFALL_SORTS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user