From 90b1bbe2ba39224175468cda1d1ca5e3ba66e697 Mon Sep 17 00:00:00 2001 From: IanTheMitten Date: Fri, 10 Apr 2026 01:05:47 +0900 Subject: [PATCH] Replace average revenue table with top products aggregation --- src/App.tsx | 1 - .../statistic/ProductAverageRevenueTable.tsx | 278 ------------------ src/components/statistic/StatisticPage.tsx | 10 +- src/components/statistic/TopProductsTable.tsx | 181 ++++++++++++ 4 files changed, 185 insertions(+), 285 deletions(-) delete mode 100644 src/components/statistic/ProductAverageRevenueTable.tsx create mode 100644 src/components/statistic/TopProductsTable.tsx diff --git a/src/App.tsx b/src/App.tsx index 9cc88d2..e287874 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -646,7 +646,6 @@ export default function App() { {currentPage === 'statistic' && ( )} diff --git a/src/components/statistic/ProductAverageRevenueTable.tsx b/src/components/statistic/ProductAverageRevenueTable.tsx deleted file mode 100644 index 2a547cb..0000000 --- a/src/components/statistic/ProductAverageRevenueTable.tsx +++ /dev/null @@ -1,278 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; -import type { Product, Transaction } from '../../App'; -import { useCurrency } from '../../contexts/CurrencyContext'; -import { productsAPI } from '../../services/api'; -import { Card } from '../ui/card'; -import { getSampledTransactionDates, type StatisticSamplingOptions } from './analyticsSampling'; - -interface ProductAverageRevenueTableProps { - transactions: Transaction[]; - products: Product[]; - samplingOptions: StatisticSamplingOptions; -} - -interface InventoryAdjustment { - id: string; - productId: string; - date: string; - quantity: number; -} - -interface ProductRevenueRow { - product: Product; - sampledRevenue: number; - eligibleDays: number; - avgRevenuePerEligibleDay: number; -} - -const TOP_LIMIT = 10; - -const toDayKey = (date: Date): string => { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; -}; - -const startOfDay = (date: Date): Date => { - const normalized = new Date(date); - normalized.setHours(0, 0, 0, 0); - return normalized; -}; - -const endOfDay = (date: Date): Date => { - const normalized = startOfDay(date); - normalized.setDate(normalized.getDate() + 1); - return normalized; -}; - -function isZeroForEntireDay(stockAtDayStart: number, dayAdjustments: InventoryAdjustment[]): boolean { - if (stockAtDayStart > 0) { - return false; - } - - const sorted = [...dayAdjustments].sort( - (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), - ); - - let runningStock = stockAtDayStart; - for (const adjustment of sorted) { - runningStock += Number(adjustment.quantity || 0); - if (runningStock > 0) { - return false; - } - } - - return true; -} - -function getEligibleDaysForProduct({ - product, - adjustments, - sampledDayKeys, -}: { - product: Product; - adjustments: InventoryAdjustment[]; - sampledDayKeys: string[]; -}): number { - if (sampledDayKeys.length === 0) { - return 0; - } - - const sampledDates = sampledDayKeys.map((dayKey) => startOfDay(new Date(dayKey))); - const rangeStart = sampledDates[0]; - const rangeEnd = sampledDates[sampledDates.length - 1]; - - const stockNow = Number(product.stock || 0); - - const rangeStartMs = rangeStart.getTime(); - const afterRangeStart = adjustments.filter((adjustment) => new Date(adjustment.date).getTime() >= rangeStartMs); - - const stockAtRangeStart = afterRangeStart.reduce( - (runningStock, adjustment) => runningStock - Number(adjustment.quantity || 0), - stockNow, - ); - - const dayAdjustments = new Map(); - for (const adjustment of adjustments) { - const adjustmentDate = new Date(adjustment.date); - if (adjustmentDate < rangeStart || adjustmentDate >= endOfDay(rangeEnd)) { - continue; - } - - const dayKey = toDayKey(adjustmentDate); - const existing = dayAdjustments.get(dayKey) ?? []; - existing.push(adjustment); - dayAdjustments.set(dayKey, existing); - } - - const stockAtDayStart = new Map(); - let runningStock = stockAtRangeStart; - - for (let cursor = new Date(rangeStart); cursor <= rangeEnd; cursor.setDate(cursor.getDate() + 1)) { - const dayKey = toDayKey(cursor); - stockAtDayStart.set(dayKey, runningStock); - - const deltas = dayAdjustments.get(dayKey) ?? []; - for (const delta of deltas) { - runningStock += Number(delta.quantity || 0); - } - } - - return sampledDayKeys.reduce((count, dayKey) => { - const dayStartStock = stockAtDayStart.get(dayKey) ?? 0; - const fullDayZero = isZeroForEntireDay(dayStartStock, dayAdjustments.get(dayKey) ?? []); - return fullDayZero ? count : count + 1; - }, 0); -} - -export function ProductAverageRevenueTable({ transactions, products, samplingOptions }: ProductAverageRevenueTableProps) { - const { formatCurrency } = useCurrency(); - const [showAll, setShowAll] = useState(false); - const [adjustments, setAdjustments] = useState([]); - - useEffect(() => { - let isMounted = true; - - const loadAdjustments = async () => { - try { - if (products.length === 0) { - if (isMounted) { - setAdjustments([]); - } - return; - } - - const productIds = products.map((product) => product.id); - const rows = await productsAPI.getAdjustments({ productIds }) as InventoryAdjustment[]; - if (isMounted) { - setAdjustments(rows || []); - } - } catch (error) { - console.error('Failed to load inventory adjustments for product average revenue:', error); - if (isMounted) { - setAdjustments([]); - } - } - }; - - loadAdjustments(); - - return () => { - isMounted = false; - }; - }, [products]); - - const sampledDayKeys = useMemo(() => { - const sampledDates = getSampledTransactionDates(transactions, samplingOptions).selected; - const uniqueDayKeys = new Set(sampledDates.map(toDayKey)); - return Array.from(uniqueDayKeys).sort(); - }, [transactions, samplingOptions]); - - const sampledDaySet = useMemo(() => new Set(sampledDayKeys), [sampledDayKeys]); - - const rows = useMemo(() => { - const productRevenueOnSampledDays = new Map(); - - for (const transaction of transactions) { - const dayKey = toDayKey(transaction.timestamp); - if (!sampledDaySet.has(dayKey)) { - continue; - } - - for (const item of transaction.items) { - const runningTotal = productRevenueOnSampledDays.get(item.product.id) ?? 0; - productRevenueOnSampledDays.set( - item.product.id, - runningTotal + (item.product.price * item.quantity), - ); - } - } - - const adjustmentsByProduct = new Map(); - for (const adjustment of adjustments) { - const existing = adjustmentsByProduct.get(adjustment.productId) ?? []; - existing.push(adjustment); - adjustmentsByProduct.set(adjustment.productId, existing); - } - - const computedRows: ProductRevenueRow[] = products.map((product) => { - const sampledRevenue = productRevenueOnSampledDays.get(product.id) ?? 0; - const eligibleDaysForProduct = getEligibleDaysForProduct({ - product, - adjustments: adjustmentsByProduct.get(product.id) ?? [], - sampledDayKeys, - }); - - return { - product, - sampledRevenue, - eligibleDays: eligibleDaysForProduct, - avgRevenuePerEligibleDay: - eligibleDaysForProduct > 0 ? sampledRevenue / eligibleDaysForProduct : 0, - }; - }); - - return computedRows - .filter((row) => row.sampledRevenue > 0 || row.eligibleDays > 0) - .sort((a, b) => { - if (b.avgRevenuePerEligibleDay !== a.avgRevenuePerEligibleDay) { - return b.avgRevenuePerEligibleDay - a.avgRevenuePerEligibleDay; - } - - return b.sampledRevenue - a.sampledRevenue; - }); - }, [transactions, sampledDaySet, adjustments, products, sampledDayKeys]); - - const visibleRows = showAll ? rows : rows.slice(0, TOP_LIMIT); - - return ( - -
-
-

Average Revenue per Eligible Day by Product

-

- Sampled weekdays with transactions only. Days fully out of stock are excluded per product. -

-
- - {rows.length > TOP_LIMIT && ( - - )} -
- - {rows.length === 0 ? ( -
No sampled product revenue available.
- ) : ( -
- - - - - - - - - - - {visibleRows.map((row) => ( - - - - - - - ))} - -
ProductRevenue (sampled days)Eligible DaysAvg Revenue / Eligible Day
{row.product.name}{formatCurrency(row.sampledRevenue)}{row.eligibleDays}{formatCurrency(row.avgRevenuePerEligibleDay)}
-
- )} -
- ); -} diff --git a/src/components/statistic/StatisticPage.tsx b/src/components/statistic/StatisticPage.tsx index ee66895..866552a 100644 --- a/src/components/statistic/StatisticPage.tsx +++ b/src/components/statistic/StatisticPage.tsx @@ -1,9 +1,9 @@ import { useMemo, useState } from 'react'; -import type { Product, Transaction } from '../../App'; +import type { Transaction } from '../../App'; import { Card } from '../ui/card'; import { TimePeriodRevenueBarChart } from './TimePeriodRevenueBarChart'; import { TimePeriodCumulativeLine } from './TimePeriodCumulativeLine'; -import { ProductAverageRevenueTable } from './ProductAverageRevenueTable'; +import { TopProductsTable } from './TopProductsTable'; import { CANONICAL_TIME_PERIODS } from './timePeriodAnalytics'; import { DateRangeSelector } from './DateRangeSelector'; import { WeekdayRevenueBarChart } from './WeekdayRevenueBarChart'; @@ -11,7 +11,6 @@ import type { StatisticDateRange, StatisticSamplingOptions } from './analyticsSa interface StatisticPageProps { transactions: Transaction[]; - products: Product[]; } function formatMonthInputValue(date: Date): string { @@ -25,7 +24,7 @@ function parseMonthInputValue(value: string): Date { return new Date(year, month - 1, 1); } -export function StatisticPage({ transactions, products }: StatisticPageProps) { +export function StatisticPage({ transactions }: StatisticPageProps) { const [dateRange, setDateRange] = useState('thisMonth'); const [chosenMonth, setChosenMonth] = useState(() => formatMonthInputValue(new Date())); const [samplingSeed, setSamplingSeed] = useState(''); @@ -73,9 +72,8 @@ export function StatisticPage({ transactions, products }: StatisticPageProps) { selectedPeriodId={selectedPeriodId} /> - diff --git a/src/components/statistic/TopProductsTable.tsx b/src/components/statistic/TopProductsTable.tsx new file mode 100644 index 0000000..8ed66e3 --- /dev/null +++ b/src/components/statistic/TopProductsTable.tsx @@ -0,0 +1,181 @@ +import { useMemo, useState } from 'react'; +import type { Transaction } from '../../App'; +import { useCurrency } from '../../contexts/CurrencyContext'; +import { Card } from '../ui/card'; +import { getSampledTransactionDates, type StatisticSamplingOptions } from './analyticsSampling'; + +interface TopProductsTableProps { + transactions: Transaction[]; + samplingOptions: StatisticSamplingOptions; +} + +type SecondarySortMetric = 'none' | 'product' | 'unitsSold' | 'profit' | 'margin'; +type SortDirection = 'desc' | 'asc'; + +interface ProductAggregate { + productId: string; + productName: string; + unitsSold: number; + revenue: number; + profit: number; + margin: number; +} + +const toDayKey = (date: Date): string => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + +function compareByMetric(a: ProductAggregate, b: ProductAggregate, metric: SecondarySortMetric): number { + switch (metric) { + case 'product': + return a.productName.localeCompare(b.productName); + case 'unitsSold': + return a.unitsSold - b.unitsSold; + case 'profit': + return a.profit - b.profit; + case 'margin': + return a.margin - b.margin; + case 'none': + default: + return 0; + } +} + +export function TopProductsTable({ transactions, samplingOptions }: TopProductsTableProps) { + const { formatCurrency } = useCurrency(); + const [secondarySortMetric, setSecondarySortMetric] = useState('none'); + const [secondarySortDirection, setSecondarySortDirection] = useState('desc'); + + const sampledDaySet = useMemo(() => { + const sampledDates = getSampledTransactionDates(transactions, samplingOptions).selected; + return new Set(sampledDates.map(toDayKey)); + }, [transactions, samplingOptions]); + + const rows = useMemo(() => { + const aggregates = new Map(); + + for (const transaction of transactions) { + if (!sampledDaySet.has(toDayKey(transaction.timestamp))) { + continue; + } + + for (const item of transaction.items) { + const current = aggregates.get(item.product.id) ?? { + productId: item.product.id, + productName: item.product.name, + unitsSold: 0, + revenue: 0, + profit: 0, + margin: 0, + }; + + const quantity = Number(item.quantity) || 0; + const unitPrice = Number(item.product.price) || 0; + const unitCost = Number(item.product.unitCost) || 0; + + current.unitsSold += quantity; + current.revenue += unitPrice * quantity; + current.profit += (unitPrice - unitCost) * quantity; + + aggregates.set(item.product.id, current); + } + } + + const computedRows = Array.from(aggregates.values()).map((row) => ({ + ...row, + margin: row.revenue > 0 ? row.profit / row.revenue : 0, + })); + + return computedRows + .filter((row) => row.unitsSold > 0 || row.revenue !== 0) + .sort((a, b) => { + if (b.revenue !== a.revenue) { + return b.revenue - a.revenue; + } + + if (secondarySortMetric !== 'none') { + const secondaryResult = compareByMetric(a, b, secondarySortMetric); + if (secondaryResult !== 0) { + return secondarySortDirection === 'asc' ? secondaryResult : -secondaryResult; + } + } + + return a.productName.localeCompare(b.productName); + }); + }, [transactions, sampledDaySet, secondarySortMetric, secondarySortDirection]); + + return ( + +
+
+

Top Products

+

Direct transaction aggregation across sampled days.

+
+ +
+ + + +
+
+ + {rows.length === 0 ? ( +
No product sales available for the selected sample.
+ ) : ( +
+ + + + + + + + + + + + + {rows.map((row, index) => ( + + + + + + + + + ))} + +
RankProductUnits SoldRevenueProfitMargin %
{index + 1}{row.productName}{row.unitsSold}{formatCurrency(row.revenue)}{formatCurrency(row.profit)}{(row.margin * 100).toFixed(1)}%
+
+ )} +
+ ); +}