diff --git a/inventory/src/components/dashboard/FinancialOverview.tsx b/inventory/src/components/dashboard/FinancialOverview.tsx index 19980ca..5a5fd7d 100644 --- a/inventory/src/components/dashboard/FinancialOverview.tsx +++ b/inventory/src/components/dashboard/FinancialOverview.tsx @@ -136,12 +136,13 @@ type GroupByOption = "day" | "month" | "quarter" | "year"; type ChartPoint = { label: string; timestamp: string | null; - grossSales: number; - netRevenue: number; - cogs: number; - profit: number; - margin: number; + grossSales: number | null; + netRevenue: number | null; + cogs: number | null; + profit: number | null; + margin: number | null; tooltipLabel: string; + isFuture: boolean; }; const chartColors: Record = { @@ -293,6 +294,7 @@ type AggregatedTrendPoint = { cogs: number; profit: number; margin: number; + isFuture: boolean; }; const pad = (value: number) => value.toString().padStart(2, "0"); @@ -419,10 +421,113 @@ const aggregateTrendPoints = (points: RawTrendPoint[], groupBy: GroupByOption): cogs: bucket.cogs, profit, margin, + isFuture: false, }; }); }; +const alignToGroupStart = (date: Date, groupBy: GroupByOption) => { + const aligned = new Date(date); + aligned.setHours(0, 0, 0, 0); + + switch (groupBy) { + case "month": + aligned.setDate(1); + break; + case "quarter": { + const quarterStartMonth = Math.floor(aligned.getMonth() / 3) * 3; + aligned.setMonth(quarterStartMonth, 1); + break; + } + case "year": + aligned.setMonth(0, 1); + break; + case "day": + default: + break; + } + + return aligned; +}; + +const advanceGroupStart = (date: Date, groupBy: GroupByOption) => { + const next = new Date(date); + + switch (groupBy) { + case "day": + next.setDate(next.getDate() + 1); + break; + case "month": + next.setMonth(next.getMonth() + 1, 1); + break; + case "quarter": + next.setMonth(next.getMonth() + 3, 1); + break; + case "year": + default: + next.setFullYear(next.getFullYear() + 1); + next.setMonth(0, 1); + break; + } + + next.setHours(0, 0, 0, 0); + return next; +}; + +const computeBucketRange = (start: Date, groupBy: GroupByOption) => { + const bucketStart = alignToGroupStart(start, groupBy); + const nextStart = advanceGroupStart(bucketStart, groupBy); + const bucketEnd = new Date(nextStart.getTime() - 1); + return { start: bucketStart, end: bucketEnd }; +}; + +const extendAggregatedTrendPoints = ( + points: AggregatedTrendPoint[], + groupBy: GroupByOption, + fullRange: { start: Date; end: Date } | null, + effectiveRangeEnd: Date | null +) => { + if (!fullRange) { + return points; + } + + const ordered = new Map(points.map((point) => [point.id, point])); + const results: AggregatedTrendPoint[] = []; + const start = alignToGroupStart(fullRange.start, groupBy); + const rangeEndMs = fullRange.end.getTime(); + const effectiveEndMs = effectiveRangeEnd?.getTime() ?? null; + + for (let cursor = start; cursor.getTime() <= rangeEndMs; cursor = advanceGroupStart(cursor, groupBy)) { + const key = getGroupKey(cursor, groupBy); + const existing = ordered.get(key); + + if (existing) { + results.push(existing); + continue; + } + + const { start: bucketStart, end: bucketEnd } = computeBucketRange(cursor, groupBy); + const { axisLabel, tooltipLabel, detailLabel } = buildGroupLabels(bucketStart, bucketEnd, groupBy); + const isFutureBucket = effectiveEndMs != null && cursor.getTime() >= effectiveEndMs; + + results.push({ + id: key, + label: axisLabel, + tooltipLabel, + detailLabel, + timestamp: bucketStart.toISOString(), + grossSales: 0, + netRevenue: 0, + cogs: 0, + profit: 0, + margin: 0, + isFuture: isFutureBucket, + }); + } + + return results; +}; + const monthsBetween = (start: Date, end: Date) => { const diffMs = Math.max(0, end.getTime() - start.getTime()); const monthApprox = diffMs / (1000 * 60 * 60 * 24 * 30); @@ -545,6 +650,45 @@ const FinancialOverview = () => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const selectedRange = useMemo(() => { + if (viewMode === "rolling") { + const end = new Date(currentDate); + const start = new Date(currentDate); + start.setHours(0, 0, 0, 0); + end.setHours(23, 59, 59, 999); + start.setDate(start.getDate() - 29); + return { start, end }; + } + + return computePeriodRange(customPeriod); + }, [viewMode, customPeriod, currentDate]); + + const effectiveRangeEnd = useMemo(() => { + if (!selectedRange) { + return null; + } + + const rangeEndMs = selectedRange.end.getTime(); + const currentMs = currentDate.getTime(); + const startMs = selectedRange.start.getTime(); + const clampedMs = Math.min(rangeEndMs, currentMs); + const safeEndMs = clampedMs < startMs ? startMs : clampedMs; + return new Date(safeEndMs); + }, [selectedRange, currentDate]); + + const requestRange = useMemo(() => { + if (!selectedRange) { + return null; + } + + const end = effectiveRangeEnd ?? selectedRange.end; + + return { + start: new Date(selectedRange.start), + end: new Date(end), + }; + }, [selectedRange, effectiveRangeEnd]); + const yearOptions = useMemo(() => generateYearOptions(12), []); const rawTrendPoints = useMemo(() => { @@ -552,6 +696,7 @@ const FinancialOverview = () => { return []; } + const rangeBoundary = effectiveRangeEnd?.getTime() ?? null; const toNumber = (value: number | null | undefined) => typeof value === "number" && Number.isFinite(value) ? value : 0; @@ -588,8 +733,9 @@ const FinancialOverview = () => { }; }) .filter((value): value is RawTrendPoint => Boolean(value)) + .filter((value) => (rangeBoundary == null ? true : value.date.getTime() <= rangeBoundary)) .sort((a, b) => a.date.getTime() - b.date.getTime()); - }, [data]); + }, [data, effectiveRangeEnd]); useEffect(() => { if (!groupByAuto) { @@ -603,13 +749,16 @@ const FinancialOverview = () => { 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 }; + } else if (requestRange) { + range = { + start: new Date(requestRange.start), + end: new Date(requestRange.end), + }; + } else if (selectedRange) { + range = { + start: new Date(selectedRange.start), + end: new Date(selectedRange.end), + }; } if (!range) { @@ -622,7 +771,7 @@ const FinancialOverview = () => { if (suggested !== groupBy) { setGroupBy(suggested); } - }, [groupByAuto, rawTrendPoints, viewMode, customPeriod, groupBy]); + }, [groupByAuto, rawTrendPoints, requestRange, selectedRange, groupBy]); useEffect(() => { let cancelled = false; @@ -637,14 +786,13 @@ const FinancialOverview = () => { if (viewMode === "rolling") { params.timeRange = "last30days"; } else { - const range = computePeriodRange(customPeriod); - if (!range) { - setLoading(false); + if (!selectedRange || !requestRange) { + setData(null); return; } params.timeRange = "custom"; - params.startDate = range.start.toISOString(); - params.endDate = range.end.toISOString(); + params.startDate = requestRange.start.toISOString(); + params.endDate = requestRange.end.toISOString(); } const response = (await acotService.getFinancials(params)) as FinancialResponse; @@ -683,7 +831,7 @@ const FinancialOverview = () => { return () => { cancelled = true; }; - }, [viewMode, customPeriod]); + }, [viewMode, selectedRange, requestRange]); const cards = useMemo( () => { @@ -747,8 +895,9 @@ const FinancialOverview = () => { ); const aggregatedPoints = useMemo(() => { - return aggregateTrendPoints(rawTrendPoints, groupBy); - }, [rawTrendPoints, groupBy]); + const aggregated = aggregateTrendPoints(rawTrendPoints, groupBy); + return extendAggregatedTrendPoints(aggregated, groupBy, selectedRange, effectiveRangeEnd); + }, [rawTrendPoints, groupBy, selectedRange, effectiveRangeEnd]); const chartData = useMemo(() => { if (!aggregatedPoints.length) { @@ -758,12 +907,13 @@ const FinancialOverview = () => { 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, + grossSales: point.isFuture ? null : point.grossSales, + netRevenue: point.isFuture ? null : point.netRevenue, + cogs: point.isFuture ? null : point.cogs, + profit: point.isFuture ? null : point.profit, + margin: point.isFuture ? null : point.margin, tooltipLabel: point.tooltipLabel, + isFuture: point.isFuture, })); }, [aggregatedPoints]); @@ -772,8 +922,27 @@ const FinancialOverview = () => { return "Last 30 Days"; } const label = formatPeriodRangeLabel(customPeriod); - return label || ""; - }, [viewMode, customPeriod]); + if (!label) { + return ""; + } + + if (!selectedRange || !effectiveRangeEnd) { + return label; + } + + const isPartial = effectiveRangeEnd.getTime() < selectedRange.end.getTime(); + if (!isPartial) { + return label; + } + + const partialLabel = effectiveRangeEnd.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + + return `${label} (through ${partialLabel})`; + }, [viewMode, customPeriod, selectedRange, effectiveRangeEnd]); const marginDomain = useMemo<[number, number]>(() => { if (!metrics.margin || !chartData.length) { @@ -782,7 +951,7 @@ const FinancialOverview = () => { const values = chartData .map((point) => point.margin) - .filter((value) => Number.isFinite(value)) as number[]; + .filter((value): value is number => typeof value === "number" && Number.isFinite(value)); if (!values.length) { return [0, 100]; @@ -813,6 +982,7 @@ const FinancialOverview = () => { cogs: point.cogs, profit: point.profit, margin: point.margin, + isFuture: point.isFuture, })), [aggregatedPoints] ); @@ -935,8 +1105,6 @@ const FinancialOverview = () => { }); }, [customPeriod]); - const isSeriesVisible = (series: ChartSeriesKey) => Boolean(metrics[series]); - const toggleMetric = (series: ChartSeriesKey) => { setMetrics((prev) => ({ ...prev, @@ -1031,27 +1199,27 @@ const FinancialOverview = () => { {metrics.grossSales && ( - {formatCurrency(row.grossSales, 0)} + {row.isFuture ? "—" : formatCurrency(row.grossSales, 0)} )} {metrics.netRevenue && ( - {formatCurrency(row.netRevenue, 0)} + {row.isFuture ? "—" : formatCurrency(row.netRevenue, 0)} )} {metrics.profit && ( - {formatCurrency(row.profit, 0)} + {row.isFuture ? "—" : formatCurrency(row.profit, 0)} )} {metrics.cogs && ( - {formatCurrency(row.cogs, 0)} + {row.isFuture ? "—" : formatCurrency(row.cogs, 0)} )} {metrics.margin && ( - {formatPercentage(row.margin, 1)} + {row.isFuture ? "—" : formatPercentage(row.margin, 1)} )} @@ -1454,8 +1622,9 @@ const FinancialTooltip = ({ active, payload, label }: TooltipProps @@ -1464,12 +1633,17 @@ const FinancialTooltip = ({ active, payload, label }: TooltipProps { const key = (entry.dataKey ?? "") as ChartSeriesKey; const rawValue = entry.value; - const formattedValue = - typeof rawValue === "number" - ? key === "margin" - ? formatPercentage(rawValue, 1) - : formatCurrency(rawValue, 0) - : rawValue; + let formattedValue: string; + + if (isFuturePoint || rawValue == null) { + formattedValue = "—"; + } else if (typeof rawValue === "number") { + formattedValue = key === "margin" ? formatPercentage(rawValue, 1) : formatCurrency(rawValue, 0); + } else if (typeof rawValue === "string") { + formattedValue = rawValue; + } else { + formattedValue = "—"; + } return (