216 lines
8.0 KiB
TypeScript
216 lines
8.0 KiB
TypeScript
'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}
|
||
/>
|
||
);
|
||
}
|