Add ability to group by different periods to financial overview
This commit is contained in:
@@ -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<ChartSeriesKey, string> = {
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
const parsed = new Date(timestamp);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return fallback || timestamp;
|
||||
}
|
||||
type AggregatedTrendPoint = {
|
||||
id: string;
|
||||
label: string;
|
||||
tooltipLabel: string;
|
||||
detailLabel: string;
|
||||
timestamp: string;
|
||||
grossSales: number;
|
||||
netRevenue: number;
|
||||
cogs: number;
|
||||
profit: number;
|
||||
margin: number;
|
||||
};
|
||||
|
||||
return parsed.toLocaleDateString("en-US", {
|
||||
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 bucketMap = new Map<
|
||||
string,
|
||||
{
|
||||
key: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
grossSales: number;
|
||||
netRevenue: number;
|
||||
cogs: number;
|
||||
profit: number;
|
||||
}
|
||||
>();
|
||||
|
||||
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<GroupByOption>("day");
|
||||
const [groupByAuto, setGroupByAuto] = useState<boolean>(true);
|
||||
const [data, setData] = useState<FinancialResponse | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const yearOptions = useMemo(() => generateYearOptions(12), []);
|
||||
|
||||
const rawTrendPoints = useMemo<RawTrendPoint[]>(() => {
|
||||
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<AggregatedTrendPoint[]>(() => {
|
||||
return aggregateTrendPoints(rawTrendPoints, groupBy);
|
||||
}, [rawTrendPoints, groupBy]);
|
||||
|
||||
const chartData = useMemo<ChartPoint[]>(() => {
|
||||
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 = () => {
|
||||
<DialogHeader className="flex-none">
|
||||
<DialogTitle className="text-gray-900 dark:text-gray-100">Financial Details</DialogTitle>
|
||||
<DialogDescription>
|
||||
Explore the daily breakdown for the selected metrics.
|
||||
Explore the grouped breakdown for the selected metrics.
|
||||
</DialogDescription>
|
||||
<div className="flex flex-wrap justify-center gap-2 pt-4">
|
||||
<Select value={groupBy} onValueChange={handleGroupByChange}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Group By" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{GROUP_BY_CHOICES.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{SERIES_DEFINITIONS.map((series) => (
|
||||
<Button
|
||||
key={series.key}
|
||||
@@ -979,7 +1184,21 @@ const FinancialOverview = () => {
|
||||
{cards.length ? <FinancialStatGrid cards={cards} /> : null}
|
||||
<div className="space-y-4 mt-6">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Chart Metrics</span>
|
||||
<Select value={groupBy} onValueChange={handleGroupByChange}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Group By" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{GROUP_BY_CHOICES.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SERIES_DEFINITIONS.map((series) => (
|
||||
<Button
|
||||
@@ -1235,9 +1454,12 @@ const FinancialTooltip = ({ active, payload, label }: TooltipProps<number, strin
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedLabel =
|
||||
(payload[0]?.payload as ChartPoint | undefined)?.tooltipLabel ?? label;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border/60 bg-white dark:bg-gray-900/80 px-3 py-2 shadow-lg">
|
||||
<p className="text-xs font-semibold text-gray-900 dark:text-gray-100">{label}</p>
|
||||
<p className="text-xs font-semibold text-gray-900 dark:text-gray-100">{resolvedLabel}</p>
|
||||
<div className="mt-1 space-y-1 text-xs">
|
||||
{payload.map((entry, index) => {
|
||||
const key = (entry.dataKey ?? "") as ChartSeriesKey;
|
||||
|
||||
Reference in New Issue
Block a user