'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 ( [ {...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'}, ]} /> {(rows) => ( b.footfall - a.footfall)} xKey="area" series={[{key: 'footfall', label: 'Footfall', format: formatCount}]} yFormat={formatCount} /> )} {(rows) => ( )} {(rows) => } {(rows) => } ); } 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(); 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>(); 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 ( ({key: s, label: s, format: formatCount}))} xFormat={formatDayLabel} yFormat={formatCount} height={300} /> ); } /* ── Details table ────────────────────────────────────────────────────── */ type FootfallRow = FootfallDetail & Record & {rowId: string}; const FOOTFALL_SORTS: SortOption[] = [ {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[] = [ {key: 'date', header: 'Date', renderCell: (r) => {formatDayLabel(r.date)}}, {key: 'merchantName', header: 'Merchant'}, ...(hasField(rows, 'area') ? [{key: 'area', header: 'Area'} as TableColumn] : []), {key: 'shopName', header: 'Shop'}, {key: 'footfall', header: 'Footfall', align: 'end', renderCell: (r) => {formatCount(r.footfall)}}, ...(hasField(rows, 'entries') ? [{key: 'entries', header: 'Entries', align: 'end'} as TableColumn] : []), ...(hasField(rows, 'exits') ? [{key: 'exits', header: 'Exits', align: 'end'} as TableColumn] : []), ]; return ( ); }