diff --git a/inventory/src/components/dashboard/FinancialOverview.tsx b/inventory/src/components/dashboard/FinancialOverview.tsx index d703be0..19980ca 100644 --- a/inventory/src/components/dashboard/FinancialOverview.tsx +++ b/inventory/src/components/dashboard/FinancialOverview.tsx @@ -131,6 +131,8 @@ type CustomPeriod = MonthPeriod | QuarterPeriod | YearPeriod; type ChartSeriesKey = "grossSales" | "netRevenue" | "cogs" | "profit" | "margin"; +type GroupByOption = "day" | "month" | "quarter" | "year"; + type ChartPoint = { label: string; timestamp: string | null; @@ -139,6 +141,7 @@ type ChartPoint = { cogs: number; profit: number; margin: number; + tooltipLabel: string; }; const chartColors: Record = { @@ -169,6 +172,13 @@ const SERIES_DEFINITIONS: Array<{ { key: "margin", label: SERIES_LABELS.margin, type: "percentage" }, ]; +const GROUP_BY_CHOICES: Array<{ value: GroupByOption; label: string }> = [ + { value: "day", label: "Daily" }, + { value: "month", label: "Monthly" }, + { value: "quarter", label: "Quarterly" }, + { value: "year", label: "Yearly" }, +]; + const MONTHS = [ "January", "February", @@ -264,22 +274,160 @@ const formatPercentage = (value: number, digits = 1, suffix = "%") => { return `${value.toFixed(digits)}${suffix}`; }; -function formatDetailLabel(timestamp: string | null, fallback = "") { - if (!timestamp) { - return fallback; +type RawTrendPoint = { + date: Date; + grossSales: number; + netRevenue: number; + cogs: number; + profit: number; +}; + +type AggregatedTrendPoint = { + id: string; + label: string; + tooltipLabel: string; + detailLabel: string; + timestamp: string; + grossSales: number; + netRevenue: number; + cogs: number; + profit: number; + margin: number; +}; + +const pad = (value: number) => value.toString().padStart(2, "0"); + +const getGroupKey = (date: Date, groupBy: GroupByOption) => { + switch (groupBy) { + case "day": + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; + case "month": + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}`; + case "quarter": { + const quarter = Math.floor(date.getMonth() / 3) + 1; + return `${date.getFullYear()}-Q${quarter}`; + } + case "year": + default: + return `${date.getFullYear()}`; + } +}; + +const formatQuarterRange = (start: Date) => { + const quarterIndex = Math.floor(start.getMonth() / 3); + const quarterLabel = QUARTERS[quarterIndex]; + return `${quarterLabel} ${start.getFullYear()}`; +}; + +const buildGroupLabels = (start: Date, end: Date, groupBy: GroupByOption) => { + switch (groupBy) { + case "day": { + const axisLabel = start.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + const detailLabel = start.toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", + }); + return { axisLabel, tooltipLabel: detailLabel, detailLabel }; + } + case "month": { + const axisLabel = start.toLocaleDateString("en-US", { month: "short", year: "numeric" }); + const detailLabel = start.toLocaleDateString("en-US", { month: "long", year: "numeric" }); + return { axisLabel, tooltipLabel: detailLabel, detailLabel }; + } + case "quarter": { + const axisLabel = formatQuarterRange(start); + let tooltipLabel = axisLabel; + if (start && end) { + const startLabel = start.toLocaleDateString("en-US", { month: "short" }); + const endLabel = end.toLocaleDateString("en-US", { month: "short" }); + tooltipLabel = `${axisLabel} (${startLabel} – ${endLabel})`; + } + return { axisLabel, tooltipLabel, detailLabel: axisLabel }; + } + case "year": + default: { + const axisLabel = start.getFullYear().toString(); + return { axisLabel, tooltipLabel: axisLabel, detailLabel: axisLabel }; + } + } +}; + +const aggregateTrendPoints = (points: RawTrendPoint[], groupBy: GroupByOption): AggregatedTrendPoint[] => { + if (!points.length) { + return []; } - const parsed = new Date(timestamp); - if (Number.isNaN(parsed.getTime())) { - return fallback || timestamp; - } + const bucketMap = new Map< + string, + { + key: string; + start: Date; + end: Date; + grossSales: number; + netRevenue: number; + cogs: number; + profit: number; + } + >(); - return parsed.toLocaleDateString("en-US", { - weekday: "short", - month: "short", - day: "numeric", + points.forEach((point) => { + const key = getGroupKey(point.date, groupBy); + const bucket = bucketMap.get(key); + if (!bucket) { + bucketMap.set(key, { + key, + start: point.date, + end: point.date, + grossSales: point.grossSales, + netRevenue: point.netRevenue, + cogs: point.cogs, + profit: point.profit, + }); + return; + } + + if (point.date < bucket.start) { + bucket.start = point.date; + } + if (point.date > bucket.end) { + bucket.end = point.date; + } + + bucket.grossSales += point.grossSales; + bucket.netRevenue += point.netRevenue; + bucket.cogs += point.cogs; + bucket.profit += point.profit; }); -} + + return Array.from(bucketMap.values()) + .sort((a, b) => a.start.getTime() - b.start.getTime()) + .map((bucket) => { + const { axisLabel, tooltipLabel, detailLabel } = buildGroupLabels(bucket.start, bucket.end, groupBy); + const { netRevenue, profit } = bucket; + const margin = netRevenue !== 0 ? (profit / netRevenue) * 100 : 0; + + return { + id: bucket.key, + label: axisLabel, + tooltipLabel, + detailLabel, + timestamp: bucket.start.toISOString(), + grossSales: bucket.grossSales, + netRevenue, + cogs: bucket.cogs, + profit, + margin, + }; + }); +}; + +const monthsBetween = (start: Date, end: Date) => { + const diffMs = Math.max(0, end.getTime() - start.getTime()); + const monthApprox = diffMs / (1000 * 60 * 60 * 24 * 30); + return monthApprox; +}; const buildTrendLabel = ( comparison?: ComparisonValue | null, @@ -391,12 +539,91 @@ const FinancialOverview = () => { profit: true, margin: true, }); + const [groupBy, setGroupBy] = useState("day"); + const [groupByAuto, setGroupByAuto] = useState(true); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const yearOptions = useMemo(() => generateYearOptions(12), []); + const rawTrendPoints = useMemo(() => { + if (!data?.trend?.length) { + return []; + } + + const toNumber = (value: number | null | undefined) => + typeof value === "number" && Number.isFinite(value) ? value : 0; + + return data.trend + .map((point) => { + const timestamp = + point.timestamp ?? + (typeof point.date === "string" + ? point.date + : point.date instanceof Date + ? point.date.toISOString() + : null); + + let date: Date | null = null; + if (timestamp) { + const parsed = new Date(timestamp); + if (!Number.isNaN(parsed.getTime())) { + date = parsed; + } + } else if (point.date instanceof Date && !Number.isNaN(point.date.getTime())) { + date = point.date; + } + + if (!date) { + return null; + } + + return { + date, + grossSales: toNumber(point.grossSales), + netRevenue: toNumber(point.netRevenue), + cogs: toNumber(point.cogs), + profit: toNumber(point.profit), + }; + }) + .filter((value): value is RawTrendPoint => Boolean(value)) + .sort((a, b) => a.date.getTime() - b.date.getTime()); + }, [data]); + + useEffect(() => { + if (!groupByAuto) { + return; + } + + let range: { start: Date; end: Date } | null = null; + + if (rawTrendPoints.length > 1) { + range = { + start: rawTrendPoints[0].date, + end: rawTrendPoints[rawTrendPoints.length - 1].date, + }; + } else if (viewMode === "custom") { + range = computePeriodRange(customPeriod); + } else { + const end = new Date(); + const start = new Date(end); + start.setDate(end.getDate() - 29); + range = { start, end }; + } + + if (!range) { + return; + } + + const spanInMonths = monthsBetween(range.start, range.end); + const suggested = spanInMonths > 6 ? "month" : "day"; + + if (suggested !== groupBy) { + setGroupBy(suggested); + } + }, [groupByAuto, rawTrendPoints, viewMode, customPeriod, groupBy]); + useEffect(() => { let cancelled = false; @@ -519,46 +746,26 @@ const FinancialOverview = () => { [data] ); + const aggregatedPoints = useMemo(() => { + return aggregateTrendPoints(rawTrendPoints, groupBy); + }, [rawTrendPoints, groupBy]); + const chartData = useMemo(() => { - if (!data?.trend?.length) { + if (!aggregatedPoints.length) { return []; } - return data.trend.map((point) => { - const timestamp = point.timestamp - ?? (typeof point.date === "string" - ? point.date - : point.date instanceof Date - ? point.date.toISOString() - : null); - - let labelDate: Date | null = null; - if (timestamp) { - const parsed = new Date(timestamp); - if (!Number.isNaN(parsed.getTime())) { - labelDate = parsed; - } - } else if (point.date instanceof Date && !Number.isNaN(point.date.getTime())) { - labelDate = point.date; - } - - const label = labelDate - ? labelDate.toLocaleDateString("en-US", { month: "short", day: "numeric" }) - : typeof point.date === "string" - ? point.date - : ""; - - return { - label, - timestamp, - grossSales: Number.isFinite(point.grossSales) ? point.grossSales : 0, - netRevenue: Number.isFinite(point.netRevenue) ? point.netRevenue : 0, - cogs: Number.isFinite(point.cogs) ? point.cogs : 0, - profit: Number.isFinite(point.profit) ? point.profit : 0, - margin: Number.isFinite(point.margin) ? point.margin : 0, - }; - }); - }, [data]); + return aggregatedPoints.map((point) => ({ + label: point.label, + timestamp: point.timestamp, + grossSales: point.grossSales, + netRevenue: point.netRevenue, + cogs: point.cogs, + profit: point.profit, + margin: point.margin, + tooltipLabel: point.tooltipLabel, + })); + }, [aggregatedPoints]); const selectedRangeLabel = useMemo(() => { if (viewMode === "rolling") { @@ -595,57 +802,39 @@ const FinancialOverview = () => { const hasActiveMetrics = useMemo(() => Object.values(metrics).some(Boolean), [metrics]); - const detailRows = useMemo(() => { - if (!data?.trend?.length) { - return [] as Array<{ - id: string; - label: string; - timestamp: string | null; - grossSales: number; - netRevenue: number; - cogs: number; - profit: number; - margin: number; - }>; - } - - return data.trend.map((point, index) => { - const timestamp = - point.timestamp ?? - (typeof point.date === "string" - ? point.date - : point.date instanceof Date - ? point.date.toISOString() - : null); - - const fallbackLabel = chartData[index]?.label ?? (typeof point.date === "string" ? point.date : ""); - const label = formatDetailLabel(timestamp, fallbackLabel); - - const safe = (value: number | null | undefined) => - typeof value === "number" && Number.isFinite(value) ? value : 0; - - return { - id: timestamp ?? `row-${index}`, - label, - timestamp, - grossSales: safe(point.grossSales), - netRevenue: safe(point.netRevenue), - cogs: safe(point.cogs), - profit: safe(point.profit), - margin: safe(point.margin), - }; - }); - }, [data, chartData]); + const detailRows = useMemo( + () => + aggregatedPoints.map((point) => ({ + id: point.id, + label: point.detailLabel, + timestamp: point.timestamp, + grossSales: point.grossSales, + netRevenue: point.netRevenue, + cogs: point.cogs, + profit: point.profit, + margin: point.margin, + })), + [aggregatedPoints] + ); const hasData = chartData.length > 0; + const enableAutoGrouping = () => setGroupByAuto(true); + const handleViewModeChange = (mode: "rolling" | "custom") => { + enableAutoGrouping(); setViewMode(mode); }; + const handleGroupByChange = (value: string) => { + setGroupBy(value as GroupByOption); + setGroupByAuto(false); + }; + const handleCustomTypeChange = (value: string) => { const nextType = value as CustomPeriod["type"]; setViewMode("custom"); + enableAutoGrouping(); setCustomPeriod((prev) => { const safePrev = ensureValidCustomPeriod(prev); switch (nextType) { @@ -683,6 +872,7 @@ const FinancialOverview = () => { const handleStartYearChange = (value: string) => { const nextYear = Number(value); + enableAutoGrouping(); setCustomPeriod((prev) => { const safePrev = ensureValidCustomPeriod(prev); switch (safePrev.type) { @@ -699,6 +889,7 @@ const FinancialOverview = () => { const handleStartMonthChange = (value: string) => { const nextMonth = Number(value); + enableAutoGrouping(); setCustomPeriod((prev) => { if (prev.type !== "month") { return prev; @@ -709,6 +900,7 @@ const FinancialOverview = () => { const handleStartQuarterChange = (value: string) => { const nextQuarter = Number(value); + enableAutoGrouping(); setCustomPeriod((prev) => { if (prev.type !== "quarter") { return prev; @@ -719,6 +911,7 @@ const FinancialOverview = () => { const handleCountChange = (value: string) => { const nextCount = Number(value); + enableAutoGrouping(); setCustomPeriod((prev) => { const safePrev = ensureValidCustomPeriod(prev); return ensureValidCustomPeriod({ ...safePrev, count: nextCount }); @@ -778,9 +971,21 @@ const FinancialOverview = () => { Financial Details - Explore the daily breakdown for the selected metrics. + Explore the grouped breakdown for the selected metrics.
+ {SERIES_DEFINITIONS.map((series) => (