Files
loyaly-merchant/src/features/admin/components/analytics/FootfallDashboard.tsx
2026-09-25 16:29:41 +05:30

216 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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}
/>
);
}