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 ChartSeriesKey = "grossSales" | "netRevenue" | "cogs" | "profit" | "margin";
|
||||||
|
|
||||||
|
type GroupByOption = "day" | "month" | "quarter" | "year";
|
||||||
|
|
||||||
type ChartPoint = {
|
type ChartPoint = {
|
||||||
label: string;
|
label: string;
|
||||||
timestamp: string | null;
|
timestamp: string | null;
|
||||||
@@ -139,6 +141,7 @@ type ChartPoint = {
|
|||||||
cogs: number;
|
cogs: number;
|
||||||
profit: number;
|
profit: number;
|
||||||
margin: number;
|
margin: number;
|
||||||
|
tooltipLabel: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const chartColors: Record<ChartSeriesKey, string> = {
|
const chartColors: Record<ChartSeriesKey, string> = {
|
||||||
@@ -169,6 +172,13 @@ const SERIES_DEFINITIONS: Array<{
|
|||||||
{ key: "margin", label: SERIES_LABELS.margin, type: "percentage" },
|
{ 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 = [
|
const MONTHS = [
|
||||||
"January",
|
"January",
|
||||||
"February",
|
"February",
|
||||||
@@ -264,22 +274,160 @@ const formatPercentage = (value: number, digits = 1, suffix = "%") => {
|
|||||||
return `${value.toFixed(digits)}${suffix}`;
|
return `${value.toFixed(digits)}${suffix}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDetailLabel(timestamp: string | null, fallback = "") {
|
type RawTrendPoint = {
|
||||||
if (!timestamp) {
|
date: Date;
|
||||||
return fallback;
|
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);
|
const bucketMap = new Map<
|
||||||
if (Number.isNaN(parsed.getTime())) {
|
string,
|
||||||
return fallback || timestamp;
|
{
|
||||||
}
|
key: string;
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
grossSales: number;
|
||||||
|
netRevenue: number;
|
||||||
|
cogs: number;
|
||||||
|
profit: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
return parsed.toLocaleDateString("en-US", {
|
points.forEach((point) => {
|
||||||
weekday: "short",
|
const key = getGroupKey(point.date, groupBy);
|
||||||
month: "short",
|
const bucket = bucketMap.get(key);
|
||||||
day: "numeric",
|
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 = (
|
const buildTrendLabel = (
|
||||||
comparison?: ComparisonValue | null,
|
comparison?: ComparisonValue | null,
|
||||||
@@ -391,12 +539,91 @@ const FinancialOverview = () => {
|
|||||||
profit: true,
|
profit: true,
|
||||||
margin: true,
|
margin: true,
|
||||||
});
|
});
|
||||||
|
const [groupBy, setGroupBy] = useState<GroupByOption>("day");
|
||||||
|
const [groupByAuto, setGroupByAuto] = useState<boolean>(true);
|
||||||
const [data, setData] = useState<FinancialResponse | null>(null);
|
const [data, setData] = useState<FinancialResponse | null>(null);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const yearOptions = useMemo(() => generateYearOptions(12), []);
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -519,46 +746,26 @@ const FinancialOverview = () => {
|
|||||||
[data]
|
[data]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const aggregatedPoints = useMemo<AggregatedTrendPoint[]>(() => {
|
||||||
|
return aggregateTrendPoints(rawTrendPoints, groupBy);
|
||||||
|
}, [rawTrendPoints, groupBy]);
|
||||||
|
|
||||||
const chartData = useMemo<ChartPoint[]>(() => {
|
const chartData = useMemo<ChartPoint[]>(() => {
|
||||||
if (!data?.trend?.length) {
|
if (!aggregatedPoints.length) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return data.trend.map((point) => {
|
return aggregatedPoints.map((point) => ({
|
||||||
const timestamp = point.timestamp
|
label: point.label,
|
||||||
?? (typeof point.date === "string"
|
timestamp: point.timestamp,
|
||||||
? point.date
|
grossSales: point.grossSales,
|
||||||
: point.date instanceof Date
|
netRevenue: point.netRevenue,
|
||||||
? point.date.toISOString()
|
cogs: point.cogs,
|
||||||
: null);
|
profit: point.profit,
|
||||||
|
margin: point.margin,
|
||||||
let labelDate: Date | null = null;
|
tooltipLabel: point.tooltipLabel,
|
||||||
if (timestamp) {
|
}));
|
||||||
const parsed = new Date(timestamp);
|
}, [aggregatedPoints]);
|
||||||
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]);
|
|
||||||
|
|
||||||
const selectedRangeLabel = useMemo(() => {
|
const selectedRangeLabel = useMemo(() => {
|
||||||
if (viewMode === "rolling") {
|
if (viewMode === "rolling") {
|
||||||
@@ -595,57 +802,39 @@ const FinancialOverview = () => {
|
|||||||
|
|
||||||
const hasActiveMetrics = useMemo(() => Object.values(metrics).some(Boolean), [metrics]);
|
const hasActiveMetrics = useMemo(() => Object.values(metrics).some(Boolean), [metrics]);
|
||||||
|
|
||||||
const detailRows = useMemo(() => {
|
const detailRows = useMemo(
|
||||||
if (!data?.trend?.length) {
|
() =>
|
||||||
return [] as Array<{
|
aggregatedPoints.map((point) => ({
|
||||||
id: string;
|
id: point.id,
|
||||||
label: string;
|
label: point.detailLabel,
|
||||||
timestamp: string | null;
|
timestamp: point.timestamp,
|
||||||
grossSales: number;
|
grossSales: point.grossSales,
|
||||||
netRevenue: number;
|
netRevenue: point.netRevenue,
|
||||||
cogs: number;
|
cogs: point.cogs,
|
||||||
profit: number;
|
profit: point.profit,
|
||||||
margin: number;
|
margin: point.margin,
|
||||||
}>;
|
})),
|
||||||
}
|
[aggregatedPoints]
|
||||||
|
);
|
||||||
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 hasData = chartData.length > 0;
|
const hasData = chartData.length > 0;
|
||||||
|
|
||||||
|
const enableAutoGrouping = () => setGroupByAuto(true);
|
||||||
|
|
||||||
const handleViewModeChange = (mode: "rolling" | "custom") => {
|
const handleViewModeChange = (mode: "rolling" | "custom") => {
|
||||||
|
enableAutoGrouping();
|
||||||
setViewMode(mode);
|
setViewMode(mode);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGroupByChange = (value: string) => {
|
||||||
|
setGroupBy(value as GroupByOption);
|
||||||
|
setGroupByAuto(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleCustomTypeChange = (value: string) => {
|
const handleCustomTypeChange = (value: string) => {
|
||||||
const nextType = value as CustomPeriod["type"];
|
const nextType = value as CustomPeriod["type"];
|
||||||
setViewMode("custom");
|
setViewMode("custom");
|
||||||
|
enableAutoGrouping();
|
||||||
setCustomPeriod((prev) => {
|
setCustomPeriod((prev) => {
|
||||||
const safePrev = ensureValidCustomPeriod(prev);
|
const safePrev = ensureValidCustomPeriod(prev);
|
||||||
switch (nextType) {
|
switch (nextType) {
|
||||||
@@ -683,6 +872,7 @@ const FinancialOverview = () => {
|
|||||||
|
|
||||||
const handleStartYearChange = (value: string) => {
|
const handleStartYearChange = (value: string) => {
|
||||||
const nextYear = Number(value);
|
const nextYear = Number(value);
|
||||||
|
enableAutoGrouping();
|
||||||
setCustomPeriod((prev) => {
|
setCustomPeriod((prev) => {
|
||||||
const safePrev = ensureValidCustomPeriod(prev);
|
const safePrev = ensureValidCustomPeriod(prev);
|
||||||
switch (safePrev.type) {
|
switch (safePrev.type) {
|
||||||
@@ -699,6 +889,7 @@ const FinancialOverview = () => {
|
|||||||
|
|
||||||
const handleStartMonthChange = (value: string) => {
|
const handleStartMonthChange = (value: string) => {
|
||||||
const nextMonth = Number(value);
|
const nextMonth = Number(value);
|
||||||
|
enableAutoGrouping();
|
||||||
setCustomPeriod((prev) => {
|
setCustomPeriod((prev) => {
|
||||||
if (prev.type !== "month") {
|
if (prev.type !== "month") {
|
||||||
return prev;
|
return prev;
|
||||||
@@ -709,6 +900,7 @@ const FinancialOverview = () => {
|
|||||||
|
|
||||||
const handleStartQuarterChange = (value: string) => {
|
const handleStartQuarterChange = (value: string) => {
|
||||||
const nextQuarter = Number(value);
|
const nextQuarter = Number(value);
|
||||||
|
enableAutoGrouping();
|
||||||
setCustomPeriod((prev) => {
|
setCustomPeriod((prev) => {
|
||||||
if (prev.type !== "quarter") {
|
if (prev.type !== "quarter") {
|
||||||
return prev;
|
return prev;
|
||||||
@@ -719,6 +911,7 @@ const FinancialOverview = () => {
|
|||||||
|
|
||||||
const handleCountChange = (value: string) => {
|
const handleCountChange = (value: string) => {
|
||||||
const nextCount = Number(value);
|
const nextCount = Number(value);
|
||||||
|
enableAutoGrouping();
|
||||||
setCustomPeriod((prev) => {
|
setCustomPeriod((prev) => {
|
||||||
const safePrev = ensureValidCustomPeriod(prev);
|
const safePrev = ensureValidCustomPeriod(prev);
|
||||||
return ensureValidCustomPeriod({ ...safePrev, count: nextCount });
|
return ensureValidCustomPeriod({ ...safePrev, count: nextCount });
|
||||||
@@ -778,9 +971,21 @@ const FinancialOverview = () => {
|
|||||||
<DialogHeader className="flex-none">
|
<DialogHeader className="flex-none">
|
||||||
<DialogTitle className="text-gray-900 dark:text-gray-100">Financial Details</DialogTitle>
|
<DialogTitle className="text-gray-900 dark:text-gray-100">Financial Details</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Explore the daily breakdown for the selected metrics.
|
Explore the grouped breakdown for the selected metrics.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
<div className="flex flex-wrap justify-center gap-2 pt-4">
|
<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) => (
|
{SERIES_DEFINITIONS.map((series) => (
|
||||||
<Button
|
<Button
|
||||||
key={series.key}
|
key={series.key}
|
||||||
@@ -979,7 +1184,21 @@ const FinancialOverview = () => {
|
|||||||
{cards.length ? <FinancialStatGrid cards={cards} /> : null}
|
{cards.length ? <FinancialStatGrid cards={cards} /> : null}
|
||||||
<div className="space-y-4 mt-6">
|
<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-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||||
<span className="text-sm font-medium text-muted-foreground">Chart Metrics</span>
|
<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">
|
<div className="flex flex-wrap gap-2">
|
||||||
{SERIES_DEFINITIONS.map((series) => (
|
{SERIES_DEFINITIONS.map((series) => (
|
||||||
<Button
|
<Button
|
||||||
@@ -1235,9 +1454,12 @@ const FinancialTooltip = ({ active, payload, label }: TooltipProps<number, strin
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolvedLabel =
|
||||||
|
(payload[0]?.payload as ChartPoint | undefined)?.tooltipLabel ?? label;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-border/60 bg-white dark:bg-gray-900/80 px-3 py-2 shadow-lg">
|
<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">
|
<div className="mt-1 space-y-1 text-xs">
|
||||||
{payload.map((entry, index) => {
|
{payload.map((entry, index) => {
|
||||||
const key = (entry.dataKey ?? "") as ChartSeriesKey;
|
const key = (entry.dataKey ?? "") as ChartSeriesKey;
|
||||||
|
|||||||
Reference in New Issue
Block a user