update the adminpage ui

This commit is contained in:
2026-09-25 16:29:41 +05:30
parent 91e4db8217
commit b36a30385c
168 changed files with 10535 additions and 1008 deletions

View 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}
/>
);
}