1577 lines
50 KiB
TypeScript
1577 lines
50 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import { acotService } from "@/services/dashboard/acotService";
|
||
import { Card, CardContent } from "@/components/ui/card";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogTrigger,
|
||
} from "@/components/ui/dialog";
|
||
import { Separator } from "@/components/ui/separator";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import {
|
||
Area,
|
||
CartesianGrid,
|
||
ComposedChart,
|
||
Line,
|
||
ResponsiveContainer,
|
||
Tooltip,
|
||
XAxis,
|
||
YAxis,
|
||
} from "recharts";
|
||
import type { TooltipProps } from "recharts";
|
||
import { TrendingUp, DollarSign, Package, PiggyBank, Percent } from "lucide-react";
|
||
import PeriodSelectionPopover, {
|
||
type QuickPreset,
|
||
} from "@/components/dashboard/PeriodSelectionPopover";
|
||
import type { CustomPeriod, NaturalLanguagePeriodResult } from "@/utils/naturalLanguagePeriod";
|
||
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
||
import { toDateOnly } from "@/utils/businessTime";
|
||
import {
|
||
DashboardSectionHeader,
|
||
DashboardStatCard,
|
||
DashboardStatCardSkeleton,
|
||
ChartSkeleton,
|
||
DashboardEmptyState,
|
||
DashboardErrorState,
|
||
TOOLTIP_STYLES,
|
||
FINANCIAL_COLORS,
|
||
} from "@/components/dashboard/shared";
|
||
|
||
type ComparisonValue = {
|
||
absolute: number | null;
|
||
percentage: number | null;
|
||
};
|
||
|
||
type FinancialTotals = {
|
||
grossSales?: number;
|
||
refunds?: number;
|
||
taxCollected?: number;
|
||
shippingFees?: number;
|
||
discounts?: number;
|
||
cogs: number;
|
||
income?: number;
|
||
profit: number;
|
||
margin: number;
|
||
};
|
||
|
||
type FinancialTrendPoint = {
|
||
date: string | Date | null;
|
||
income: number;
|
||
cogs: number;
|
||
profit: number;
|
||
margin: number;
|
||
grossSales?: number;
|
||
refunds?: number;
|
||
shippingFees?: number;
|
||
taxCollected?: number;
|
||
discounts?: number;
|
||
timestamp: string | null;
|
||
};
|
||
|
||
type FinancialComparison = {
|
||
grossSales?: ComparisonValue;
|
||
refunds?: ComparisonValue;
|
||
taxCollected?: ComparisonValue;
|
||
discounts?: ComparisonValue;
|
||
cogs?: ComparisonValue;
|
||
income?: ComparisonValue;
|
||
profit?: ComparisonValue;
|
||
margin?: ComparisonValue;
|
||
[key: string]: ComparisonValue | undefined;
|
||
};
|
||
|
||
type FinancialDateRange = {
|
||
label?: string;
|
||
};
|
||
|
||
type FinancialResponse = {
|
||
dateRange?: FinancialDateRange;
|
||
totals: FinancialTotals;
|
||
previousTotals?: FinancialTotals | null;
|
||
comparison?: FinancialComparison | null;
|
||
trend: FinancialTrendPoint[];
|
||
};
|
||
|
||
type ChartSeriesKey = "income" | "cogs" | "cogsPercentage" | "profit" | "margin";
|
||
|
||
type GroupByOption = "day" | "month" | "quarter" | "year";
|
||
|
||
type ChartPoint = {
|
||
label: string;
|
||
timestamp: string | null;
|
||
income: number | null;
|
||
cogs: number | null;
|
||
cogsPercentage: number | null;
|
||
profit: number | null;
|
||
margin: number | null;
|
||
tooltipLabel: string;
|
||
isFuture: boolean;
|
||
};
|
||
|
||
// Chart colors from the semantic FINANCIAL_COLORS tokens (Sorbet Studio)
|
||
const chartColors: Record<ChartSeriesKey, string> = {
|
||
income: FINANCIAL_COLORS.income, // Coral - revenue/income streams
|
||
cogs: FINANCIAL_COLORS.expense, // Studio amber - costs/expenses
|
||
cogsPercentage: FINANCIAL_COLORS.expense, // Same amber — the dashed stroke carries the distinction
|
||
profit: FINANCIAL_COLORS.profit, // Teal - profit metrics
|
||
margin: FINANCIAL_COLORS.margin, // Violet - percentage/derived metrics
|
||
};
|
||
|
||
const SERIES_LABELS: Record<ChartSeriesKey, string> = {
|
||
income: "Total Income",
|
||
cogs: "COGS",
|
||
cogsPercentage: "COGS % of Income",
|
||
profit: "Gross Profit",
|
||
margin: "Profit Margin",
|
||
};
|
||
|
||
const SERIES_DEFINITIONS: Array<{
|
||
key: ChartSeriesKey;
|
||
label: string;
|
||
type: "currency" | "percentage";
|
||
}> = [
|
||
{ key: "income", label: SERIES_LABELS.income, type: "currency" },
|
||
{ key: "cogs", label: SERIES_LABELS.cogs, type: "currency" },
|
||
{ key: "cogsPercentage", label: SERIES_LABELS.cogsPercentage, type: "percentage" },
|
||
{ key: "profit", label: SERIES_LABELS.profit, type: "currency" },
|
||
{ key: "margin", label: SERIES_LABELS.margin, type: "percentage" },
|
||
];
|
||
|
||
const GROUP_BY_CHOICES: Array<{ value: GroupByOption; label: string }> = [
|
||
{ value: "day", label: "Days" },
|
||
{ value: "month", label: "Months" },
|
||
{ value: "quarter", label: "Quarters" },
|
||
{ value: "year", label: "Years" },
|
||
];
|
||
|
||
const MONTHS = [
|
||
"January",
|
||
"February",
|
||
"March",
|
||
"April",
|
||
"May",
|
||
"June",
|
||
"July",
|
||
"August",
|
||
"September",
|
||
"October",
|
||
"November",
|
||
"December",
|
||
];
|
||
|
||
const QUARTERS = ["Q1", "Q2", "Q3", "Q4"];
|
||
|
||
const MONTH_COUNT_LIMIT = 999;
|
||
const QUARTER_COUNT_LIMIT = 999;
|
||
const YEAR_COUNT_LIMIT = 999;
|
||
|
||
const formatMonthLabel = (year: number, monthIndex: number) => `${MONTHS[monthIndex]} ${year}`;
|
||
|
||
const formatQuarterLabel = (year: number, quarterIndex: number) => `${QUARTERS[quarterIndex]} ${year}`;
|
||
|
||
function formatPeriodRangeLabel(period: CustomPeriod): string {
|
||
const range = computePeriodRange(period);
|
||
if (!range) {
|
||
return "";
|
||
}
|
||
|
||
const start = range.start;
|
||
const end = range.end;
|
||
|
||
if (period.type === "month") {
|
||
const startLabel = formatMonthLabel(start.getFullYear(), start.getMonth());
|
||
const endLabel = formatMonthLabel(end.getFullYear(), end.getMonth());
|
||
return period.count === 1 ? startLabel : `${startLabel} – ${endLabel}`;
|
||
}
|
||
|
||
if (period.type === "quarter") {
|
||
const startQuarter = Math.floor(start.getMonth() / 3);
|
||
const endQuarter = Math.floor(end.getMonth() / 3);
|
||
const startLabel = formatQuarterLabel(start.getFullYear(), startQuarter);
|
||
const endLabel = formatQuarterLabel(end.getFullYear(), endQuarter);
|
||
return period.count === 1 ? startLabel : `${startLabel} – ${endLabel}`;
|
||
}
|
||
|
||
const startYear = start.getFullYear();
|
||
const endYear = end.getFullYear();
|
||
return period.count === 1 ? `${startYear}` : `${startYear} – ${endYear}`;
|
||
}
|
||
|
||
|
||
const formatCurrency = (value: number, minimumFractionDigits = 0) => {
|
||
if (!Number.isFinite(value)) {
|
||
return "$0";
|
||
}
|
||
|
||
return new Intl.NumberFormat("en-US", {
|
||
style: "currency",
|
||
currency: "USD",
|
||
minimumFractionDigits,
|
||
maximumFractionDigits: minimumFractionDigits,
|
||
}).format(value);
|
||
};
|
||
|
||
const formatPercentage = (value: number, digits = 1, suffix = "%") => {
|
||
if (!Number.isFinite(value)) {
|
||
return `0${suffix}`;
|
||
}
|
||
|
||
return `${value.toFixed(digits)}${suffix}`;
|
||
};
|
||
|
||
type RawTrendPoint = {
|
||
date: Date;
|
||
income: number;
|
||
cogs: number;
|
||
shippingFees: number;
|
||
taxCollected: number;
|
||
grossSales: number;
|
||
discounts: number;
|
||
};
|
||
|
||
type AggregatedTrendPoint = {
|
||
id: string;
|
||
label: string;
|
||
tooltipLabel: string;
|
||
detailLabel: string;
|
||
timestamp: string;
|
||
income: number;
|
||
cogs: number;
|
||
cogsPercentage: number;
|
||
profit: number;
|
||
margin: number;
|
||
isFuture: boolean;
|
||
};
|
||
|
||
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;
|
||
income: number;
|
||
cogs: 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,
|
||
income: point.income,
|
||
cogs: point.cogs,
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (point.date < bucket.start) {
|
||
bucket.start = point.date;
|
||
}
|
||
if (point.date > bucket.end) {
|
||
bucket.end = point.date;
|
||
}
|
||
|
||
bucket.income += point.income;
|
||
bucket.cogs += point.cogs;
|
||
});
|
||
|
||
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 income = bucket.income;
|
||
const profit = income - bucket.cogs;
|
||
const margin = income !== 0 ? (profit / income) * 100 : 0;
|
||
const cogsPercentage = income !== 0 ? (bucket.cogs / income) * 100 : 0;
|
||
|
||
return {
|
||
id: bucket.key,
|
||
label: axisLabel,
|
||
tooltipLabel,
|
||
detailLabel,
|
||
timestamp: bucket.start.toISOString(),
|
||
income,
|
||
cogs: bucket.cogs,
|
||
cogsPercentage,
|
||
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(),
|
||
income: 0,
|
||
cogs: 0,
|
||
cogsPercentage: 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);
|
||
return monthApprox;
|
||
};
|
||
|
||
const safeNumeric = (value: number | null | undefined) =>
|
||
typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||
|
||
const computeTotalIncome = (totals?: FinancialTotals | null) => {
|
||
if (!totals || typeof totals.income !== "number" || !Number.isFinite(totals.income)) {
|
||
return 0;
|
||
}
|
||
|
||
return totals.income;
|
||
};
|
||
|
||
const computeProfitFrom = (income: number, cogs?: number | null | undefined) => income - safeNumeric(cogs);
|
||
|
||
const computeMarginFrom = (profit: number, income: number) => (income !== 0 ? (profit / income) * 100 : 0);
|
||
|
||
const buildComparisonFromValues = (current?: number | null, previous?: number | null): ComparisonValue | null => {
|
||
if (current == null || previous == null) {
|
||
return null;
|
||
}
|
||
|
||
if (!Number.isFinite(current) || !Number.isFinite(previous)) {
|
||
return null;
|
||
}
|
||
|
||
const absolute = current - previous;
|
||
const percentage = previous !== 0 ? (absolute / Math.abs(previous)) * 100 : null;
|
||
|
||
return {
|
||
absolute,
|
||
percentage,
|
||
};
|
||
};
|
||
|
||
|
||
const ensureValidCustomPeriod = (period: CustomPeriod): CustomPeriod => {
|
||
if (period.count < 1) {
|
||
return { ...period, count: 1 };
|
||
}
|
||
|
||
switch (period.type) {
|
||
case "month":
|
||
return {
|
||
...period,
|
||
startMonth: Math.min(Math.max(period.startMonth, 0), 11),
|
||
count: Math.min(period.count, MONTH_COUNT_LIMIT),
|
||
};
|
||
case "quarter":
|
||
return {
|
||
...period,
|
||
startQuarter: Math.min(Math.max(period.startQuarter, 0), 3),
|
||
count: Math.min(period.count, QUARTER_COUNT_LIMIT),
|
||
};
|
||
case "year":
|
||
default:
|
||
return {
|
||
...period,
|
||
count: Math.min(period.count, YEAR_COUNT_LIMIT),
|
||
};
|
||
}
|
||
};
|
||
|
||
function computePeriodRange(period: CustomPeriod): { start: Date; end: Date } | null {
|
||
const safePeriod = ensureValidCustomPeriod(period);
|
||
let start: Date;
|
||
|
||
if (safePeriod.type === "month") {
|
||
start = new Date(safePeriod.startYear, safePeriod.startMonth, 1, 0, 0, 0, 0);
|
||
const endExclusive = new Date(start);
|
||
endExclusive.setMonth(endExclusive.getMonth() + safePeriod.count);
|
||
endExclusive.setMilliseconds(endExclusive.getMilliseconds() - 1);
|
||
return { start, end: endExclusive };
|
||
}
|
||
|
||
if (safePeriod.type === "quarter") {
|
||
const startMonth = safePeriod.startQuarter * 3;
|
||
start = new Date(safePeriod.startYear, startMonth, 1, 0, 0, 0, 0);
|
||
const endExclusive = new Date(start);
|
||
endExclusive.setMonth(endExclusive.getMonth() + safePeriod.count * 3);
|
||
endExclusive.setMilliseconds(endExclusive.getMilliseconds() - 1);
|
||
return { start, end: endExclusive };
|
||
}
|
||
|
||
start = new Date(safePeriod.startYear, 0, 1, 0, 0, 0, 0);
|
||
const endExclusive = new Date(start);
|
||
endExclusive.setFullYear(endExclusive.getFullYear() + safePeriod.count);
|
||
endExclusive.setMilliseconds(endExclusive.getMilliseconds() - 1);
|
||
return { start, end: endExclusive };
|
||
}
|
||
|
||
const FinancialOverview = () => {
|
||
const currentDate = useMemo(() => new Date(), []);
|
||
const currentYear = currentDate.getFullYear();
|
||
|
||
const [customPeriod, setCustomPeriod] = useState<CustomPeriod>({
|
||
type: "month",
|
||
startYear: currentYear,
|
||
startMonth: currentDate.getMonth(),
|
||
count: 1,
|
||
});
|
||
const [isLast30DaysMode, setIsLast30DaysMode] = useState<boolean>(true);
|
||
const [isPeriodPopoverOpen, setIsPeriodPopoverOpen] = useState<boolean>(false);
|
||
const [metrics, setMetrics] = useState<Record<ChartSeriesKey, boolean>>({
|
||
income: true,
|
||
cogs: true,
|
||
cogsPercentage: true,
|
||
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 selectedRange = useMemo(() => {
|
||
if (isLast30DaysMode) {
|
||
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);
|
||
}, [isLast30DaysMode, 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 rawTrendPoints = useMemo<RawTrendPoint[]>(() => {
|
||
if (!data?.trend?.length) {
|
||
return [];
|
||
}
|
||
|
||
const rangeBoundary = effectiveRangeEnd?.getTime() ?? null;
|
||
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;
|
||
}
|
||
|
||
if (typeof point.income !== "number" || !Number.isFinite(point.income)) {
|
||
return null;
|
||
}
|
||
|
||
const grossSalesValue = toNumber((point as { grossSales?: number }).grossSales);
|
||
const shippingFeesValue = toNumber((point as { shippingFees?: number }).shippingFees);
|
||
const taxCollectedValue = toNumber((point as { taxCollected?: number }).taxCollected);
|
||
const discountsValue = toNumber((point as { discounts?: number }).discounts);
|
||
const incomeValue = point.income;
|
||
|
||
return {
|
||
date,
|
||
income: incomeValue,
|
||
shippingFees: shippingFeesValue,
|
||
taxCollected: taxCollectedValue,
|
||
grossSales: grossSalesValue,
|
||
discounts: discountsValue,
|
||
cogs: toNumber(point.cogs),
|
||
};
|
||
})
|
||
.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, effectiveRangeEnd]);
|
||
|
||
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 (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) {
|
||
return;
|
||
}
|
||
|
||
const spanInMonths = monthsBetween(range.start, range.end);
|
||
const suggested = spanInMonths > 6 ? "month" : "day";
|
||
|
||
if (suggested !== groupBy) {
|
||
setGroupBy(suggested);
|
||
}
|
||
}, [groupByAuto, rawTrendPoints, requestRange, selectedRange, groupBy]);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
|
||
const fetchData = async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const params: Record<string, string> = {};
|
||
|
||
if (isLast30DaysMode) {
|
||
params.timeRange = "last30days";
|
||
} else {
|
||
if (!selectedRange || !requestRange) {
|
||
setData(null);
|
||
return;
|
||
}
|
||
params.timeRange = "custom";
|
||
// Date-only strings = business dates; the server resolves them to
|
||
// business-day bounds (1am ET / Chicago midnight). Never send
|
||
// browser-local-midnight instants.
|
||
params.startDate = toDateOnly(requestRange.start);
|
||
params.endDate = toDateOnly(requestRange.end);
|
||
}
|
||
|
||
const response = (await acotService.getFinancials(params)) as FinancialResponse;
|
||
if (!cancelled) {
|
||
setData(response);
|
||
}
|
||
} catch (err: unknown) {
|
||
if (!cancelled) {
|
||
let message = "Failed to load financial data";
|
||
|
||
if (typeof err === "object" && err !== null) {
|
||
const maybeAxiosError = err as {
|
||
response?: { data?: { error?: unknown } };
|
||
message?: unknown;
|
||
};
|
||
|
||
const responseError = maybeAxiosError.response?.data?.error;
|
||
if (typeof responseError === "string" && responseError.trim().length > 0) {
|
||
message = responseError;
|
||
} else if (typeof maybeAxiosError.message === "string") {
|
||
message = maybeAxiosError.message;
|
||
}
|
||
}
|
||
|
||
setError(message);
|
||
}
|
||
} finally {
|
||
if (!cancelled) {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
void fetchData();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [isLast30DaysMode, selectedRange, requestRange]);
|
||
|
||
const cards = useMemo(
|
||
() => {
|
||
if (!data?.totals) {
|
||
return [] as FinancialStatCardConfig[];
|
||
}
|
||
|
||
const totals = data.totals;
|
||
const previous = data.previousTotals ?? undefined;
|
||
const safeCurrency = (value: number | undefined | null, digits = 0) =>
|
||
typeof value === "number" && Number.isFinite(value) ? formatCurrency(value, digits) : "—";
|
||
|
||
const safePercentage = (value: number | undefined | null, digits = 1) =>
|
||
typeof value === "number" && Number.isFinite(value) ? formatPercentage(value, digits) : "—";
|
||
|
||
const comparison = data.comparison ?? {};
|
||
|
||
const totalIncome = computeTotalIncome(totals);
|
||
const previousIncome = previous ? computeTotalIncome(previous) : null;
|
||
|
||
const cogsValue = safeNumeric(totals.cogs);
|
||
const previousCogs = previous?.cogs != null ? safeNumeric(previous.cogs) : null;
|
||
|
||
const profitValue = Number.isFinite(totals.profit)
|
||
? safeNumeric(totals.profit)
|
||
: computeProfitFrom(totalIncome, totals.cogs);
|
||
const previousProfitValue = previousIncome != null
|
||
? Number.isFinite(previous?.profit)
|
||
? safeNumeric(previous?.profit)
|
||
: computeProfitFrom(previousIncome, previous?.cogs)
|
||
: null;
|
||
|
||
const marginValue = Number.isFinite(totals.margin)
|
||
? safeNumeric(totals.margin)
|
||
: computeMarginFrom(profitValue, totalIncome);
|
||
const previousMarginValue = previousIncome != null
|
||
? Number.isFinite(previous?.margin)
|
||
? safeNumeric(previous?.margin)
|
||
: computeMarginFrom(previousProfitValue ?? 0, previousIncome)
|
||
: null;
|
||
|
||
const incomeDescription = previousIncome != null ? `Previous: ${safeCurrency(previousIncome, 0)}` : undefined;
|
||
const cogsDescription = previousCogs != null ? `Previous: ${safeCurrency(previousCogs, 0)}` : undefined;
|
||
const profitDescription = previousProfitValue != null ? `Previous: ${safeCurrency(previousProfitValue, 0)}` : undefined;
|
||
const marginDescription = previousMarginValue != null ? `Previous: ${safePercentage(previousMarginValue, 1)}` : undefined;
|
||
|
||
const incomeComparison = comparison?.income ?? buildComparisonFromValues(totalIncome, previousIncome ?? null);
|
||
const cogsComparison = comparison?.cogs ?? buildComparisonFromValues(cogsValue, previousCogs ?? null);
|
||
const profitComparison = comparison?.profit ?? buildComparisonFromValues(profitValue, previousProfitValue ?? null);
|
||
const marginComparison = comparison?.margin ?? buildComparisonFromValues(marginValue, previousMarginValue ?? null);
|
||
|
||
return [
|
||
{
|
||
key: "income",
|
||
title: "Total Income",
|
||
value: safeCurrency(totalIncome, 0),
|
||
description: incomeDescription,
|
||
trendValue: incomeComparison?.percentage,
|
||
iconColor: "blue" as const,
|
||
tooltip:
|
||
"Gross sales minus refunds and discounts, plus shipping fees collected (shipping, small-order, and rush fees). Taxes are excluded.",
|
||
},
|
||
{
|
||
key: "cogs",
|
||
title: "COGS",
|
||
value: safeCurrency(cogsValue, 0),
|
||
description: cogsDescription,
|
||
trendValue: cogsComparison?.percentage,
|
||
trendInverted: true,
|
||
iconColor: "orange" as const,
|
||
tooltip: "Sum of reported product cost of goods sold (cogs_amount) for completed sales actions in the period.",
|
||
},
|
||
{
|
||
key: "profit",
|
||
title: "Gross Profit",
|
||
value: safeCurrency(profitValue, 0),
|
||
description: profitDescription,
|
||
trendValue: profitComparison?.percentage,
|
||
iconColor: "emerald" as const,
|
||
tooltip: "Total Income minus COGS.",
|
||
},
|
||
{
|
||
key: "margin",
|
||
title: "Profit Margin",
|
||
value: safePercentage(marginValue, 1),
|
||
description: marginDescription,
|
||
trendValue: marginComparison?.absolute,
|
||
iconColor: "purple" as const,
|
||
tooltip: "Gross Profit divided by Total Income, expressed as a percentage.",
|
||
},
|
||
];
|
||
},
|
||
[data]
|
||
);
|
||
|
||
const aggregatedPoints = useMemo<AggregatedTrendPoint[]>(() => {
|
||
const aggregated = aggregateTrendPoints(rawTrendPoints, groupBy);
|
||
return extendAggregatedTrendPoints(aggregated, groupBy, selectedRange, effectiveRangeEnd);
|
||
}, [rawTrendPoints, groupBy, selectedRange, effectiveRangeEnd]);
|
||
|
||
const chartData = useMemo<ChartPoint[]>(() => {
|
||
if (!aggregatedPoints.length) {
|
||
return [];
|
||
}
|
||
|
||
return aggregatedPoints.map((point) => ({
|
||
label: point.label,
|
||
timestamp: point.timestamp,
|
||
income: point.isFuture ? null : point.income,
|
||
cogs: point.isFuture ? null : point.cogs,
|
||
cogsPercentage: point.isFuture ? null : point.cogsPercentage,
|
||
profit: point.isFuture ? null : point.profit,
|
||
margin: point.isFuture ? null : point.margin,
|
||
tooltipLabel: point.tooltipLabel,
|
||
isFuture: point.isFuture,
|
||
}));
|
||
}, [aggregatedPoints]);
|
||
|
||
const selectedRangeLabel = useMemo(() => {
|
||
if (isLast30DaysMode) {
|
||
return "Last 30 Days";
|
||
}
|
||
const label = formatPeriodRangeLabel(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",
|
||
|
||
});
|
||
|
||
return `${label} (through ${partialLabel})`;
|
||
}, [isLast30DaysMode, customPeriod, selectedRange, effectiveRangeEnd]);
|
||
|
||
const percentageDomain = useMemo<[number, number]>(() => {
|
||
if ((!metrics.margin && !metrics.cogsPercentage) || !chartData.length) {
|
||
return [0, 100];
|
||
}
|
||
|
||
const values: number[] = [];
|
||
|
||
if (metrics.margin) {
|
||
values.push(
|
||
...chartData
|
||
.map((point) => point.margin)
|
||
.filter((value): value is number => typeof value === "number" && Number.isFinite(value))
|
||
);
|
||
}
|
||
|
||
if (metrics.cogsPercentage) {
|
||
values.push(
|
||
...chartData
|
||
.map((point) => point.cogsPercentage)
|
||
.filter((value): value is number => typeof value === "number" && Number.isFinite(value))
|
||
);
|
||
}
|
||
|
||
if (!values.length) {
|
||
return [0, 100];
|
||
}
|
||
|
||
const min = Math.min(...values);
|
||
const max = Math.max(...values);
|
||
|
||
if (min === max) {
|
||
const padding = Math.max(Math.abs(min) * 0.1, 5);
|
||
return [min - padding, max + padding];
|
||
}
|
||
|
||
const padding = (max - min) * 0.1;
|
||
return [min - padding, max + padding];
|
||
}, [chartData, metrics.margin, metrics.cogsPercentage]);
|
||
|
||
const hasActiveMetrics = useMemo(() => Object.values(metrics).some(Boolean), [metrics]);
|
||
const showPercentageAxis = metrics.margin || metrics.cogsPercentage;
|
||
|
||
const detailRows = useMemo(
|
||
() =>
|
||
aggregatedPoints.map((point) => ({
|
||
id: point.id,
|
||
label: point.detailLabel,
|
||
timestamp: point.timestamp,
|
||
income: point.income,
|
||
cogs: point.cogs,
|
||
cogsPercentage: point.cogsPercentage,
|
||
profit: point.profit,
|
||
margin: point.margin,
|
||
isFuture: point.isFuture,
|
||
})),
|
||
[aggregatedPoints]
|
||
);
|
||
|
||
const hasData = chartData.length > 0;
|
||
|
||
const enableAutoGrouping = () => setGroupByAuto(true);
|
||
|
||
const handleGroupByChange = (value: string) => {
|
||
setGroupBy(value as GroupByOption);
|
||
setGroupByAuto(false);
|
||
};
|
||
|
||
|
||
|
||
const toggleMetric = (series: ChartSeriesKey) => {
|
||
setMetrics((prev) => ({
|
||
...prev,
|
||
[series]: !prev[series],
|
||
}));
|
||
};
|
||
const handleNaturalLanguageResult = (result: NaturalLanguagePeriodResult) => {
|
||
if (result === "last30days") {
|
||
setIsLast30DaysMode(true);
|
||
return;
|
||
}
|
||
|
||
if (result) {
|
||
setIsLast30DaysMode(false);
|
||
setCustomPeriod(result);
|
||
}
|
||
};
|
||
|
||
const handleQuickPeriod = (preset: QuickPreset) => {
|
||
const now = new Date();
|
||
const currentYear = now.getFullYear();
|
||
const currentMonth = now.getMonth();
|
||
const currentQuarter = Math.floor(currentMonth / 3);
|
||
|
||
enableAutoGrouping();
|
||
|
||
switch (preset) {
|
||
case "last30days":
|
||
// For Last 30 Days, we keep the special mode but this is temporary
|
||
// The UI will show this as selected but the period inputs won't reflect it
|
||
setIsLast30DaysMode(true);
|
||
break;
|
||
case "thisMonth":
|
||
setIsLast30DaysMode(false);
|
||
setCustomPeriod({
|
||
type: "month",
|
||
startYear: currentYear,
|
||
startMonth: currentMonth,
|
||
count: 1,
|
||
});
|
||
break;
|
||
case "lastMonth":
|
||
setIsLast30DaysMode(false);
|
||
const lastMonth = currentMonth === 0 ? 11 : currentMonth - 1;
|
||
const lastMonthYear = currentMonth === 0 ? currentYear - 1 : currentYear;
|
||
setCustomPeriod({
|
||
type: "month",
|
||
startYear: lastMonthYear,
|
||
startMonth: lastMonth,
|
||
count: 1,
|
||
});
|
||
break;
|
||
case "thisQuarter":
|
||
setIsLast30DaysMode(false);
|
||
setCustomPeriod({
|
||
type: "quarter",
|
||
startYear: currentYear,
|
||
startQuarter: currentQuarter,
|
||
count: 1,
|
||
});
|
||
break;
|
||
case "lastQuarter":
|
||
setIsLast30DaysMode(false);
|
||
const lastQuarter = currentQuarter === 0 ? 3 : currentQuarter - 1;
|
||
const lastQuarterYear = currentQuarter === 0 ? currentYear - 1 : currentYear;
|
||
setCustomPeriod({
|
||
type: "quarter",
|
||
startYear: lastQuarterYear,
|
||
startQuarter: lastQuarter,
|
||
count: 1,
|
||
});
|
||
break;
|
||
case "thisYear":
|
||
setIsLast30DaysMode(false);
|
||
setCustomPeriod({
|
||
type: "year",
|
||
startYear: currentYear,
|
||
count: 1,
|
||
});
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
};
|
||
|
||
|
||
// Header actions: Details dialog and Period selector
|
||
const headerActions = !error ? (
|
||
<>
|
||
<Dialog>
|
||
<DialogTrigger asChild>
|
||
<Button variant="outline" size="sm" className="h-8 rounded-full border-[#e7e5e1] text-xs font-semibold text-[#2b2925] shadow-none hover:bg-[#faf9f7]" disabled={loading || !detailRows.length}>
|
||
Details
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent className={`p-4 max-w-[95vw] w-fit max-h-[85vh] overflow-hidden flex flex-col ${CARD_STYLES.base}`}>
|
||
<DialogHeader className="flex-none">
|
||
<DialogTitle className="text-foreground">
|
||
Financial Details
|
||
</DialogTitle>
|
||
<div className="flex items-center justify-center gap-2 pt-4">
|
||
<div className="flex flex-wrap gap-1">
|
||
{SERIES_DEFINITIONS.map((series) => (
|
||
<Button
|
||
key={series.key}
|
||
variant="ghost"
|
||
size="sm"
|
||
className={`h-7 rounded-full px-3 text-xs font-medium ${metrics[series.key] ? "bg-[#f0eeea] text-[#2b2925] hover:bg-[#eae7e2]" : "text-[#7c7870] hover:bg-[#f5f3f0] hover:text-[#2b2925]"}`}
|
||
onClick={() => toggleMetric(series.key)}
|
||
>
|
||
{series.label}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
<Separator orientation="vertical" className="h-6" />
|
||
<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>
|
||
</DialogHeader>
|
||
<div className="flex-1 overflow-auto mt-6">
|
||
<div className={`rounded-lg border ${CARD_STYLES.base} w-full`}>
|
||
<Table className="w-full">
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="text-center whitespace-nowrap px-6">
|
||
Date
|
||
</TableHead>
|
||
{metrics.income && (
|
||
<TableHead className="text-center whitespace-nowrap px-6">
|
||
Total Income
|
||
</TableHead>
|
||
)}
|
||
{metrics.cogs && (
|
||
<TableHead className="text-center whitespace-nowrap px-6">
|
||
COGS
|
||
</TableHead>
|
||
)}
|
||
{metrics.cogsPercentage && (
|
||
<TableHead className="text-center whitespace-nowrap px-6">
|
||
COGS % of Income
|
||
</TableHead>
|
||
)}
|
||
{metrics.profit && (
|
||
<TableHead className="text-center whitespace-nowrap px-6">
|
||
Gross Profit
|
||
</TableHead>
|
||
)}
|
||
{metrics.margin && (
|
||
<TableHead className="text-center whitespace-nowrap px-6">
|
||
Margin
|
||
</TableHead>
|
||
)}
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{detailRows.map((row) => (
|
||
<TableRow key={row.id}>
|
||
<TableCell className="text-center whitespace-nowrap px-6">
|
||
{row.label || "—"}
|
||
</TableCell>
|
||
{metrics.income && (
|
||
<TableCell className="text-center whitespace-nowrap px-6">
|
||
{row.isFuture ? "—" : formatCurrency(row.income, 0)}
|
||
</TableCell>
|
||
)}
|
||
{metrics.cogs && (
|
||
<TableCell className="text-center whitespace-nowrap px-6">
|
||
{row.isFuture ? "—" : formatCurrency(row.cogs, 0)}
|
||
</TableCell>
|
||
)}
|
||
{metrics.cogsPercentage && (
|
||
<TableCell className="text-center whitespace-nowrap px-6">
|
||
{row.isFuture ? "—" : formatPercentage(row.cogsPercentage, 1)}
|
||
</TableCell>
|
||
)}
|
||
{metrics.profit && (
|
||
<TableCell className="text-center whitespace-nowrap px-6">
|
||
{row.isFuture ? "—" : formatCurrency(row.profit, 0)}
|
||
</TableCell>
|
||
)}
|
||
{metrics.margin && (
|
||
<TableCell className="text-center whitespace-nowrap px-6">
|
||
{row.isFuture ? "—" : formatPercentage(row.margin, 1)}
|
||
</TableCell>
|
||
)}
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<PeriodSelectionPopover
|
||
open={isPeriodPopoverOpen}
|
||
onOpenChange={setIsPeriodPopoverOpen}
|
||
selectedLabel={selectedRangeLabel}
|
||
referenceDate={currentDate}
|
||
isLast30DaysActive={isLast30DaysMode}
|
||
onQuickSelect={handleQuickPeriod}
|
||
onApplyResult={handleNaturalLanguageResult}
|
||
/>
|
||
</>
|
||
) : null;
|
||
|
||
return (
|
||
<Card className={`w-full ${CARD_STYLES.elevated}`}>
|
||
<DashboardSectionHeader
|
||
title="Profit & Loss Overview"
|
||
size="large"
|
||
actions={headerActions}
|
||
/>
|
||
|
||
<CardContent className="p-4 pt-3 space-y-3">
|
||
{/* Show stats only if not in error state */}
|
||
{!error && (
|
||
loading ? (
|
||
<SkeletonStats />
|
||
) : (
|
||
cards.length > 0 && <FinancialStatGrid cards={cards} />
|
||
)
|
||
)}
|
||
|
||
{/* Metric toggle pills — the pill dot doubles as the series legend */}
|
||
{!error && (
|
||
<div className="flex flex-wrap items-center gap-0.5">
|
||
{SERIES_DEFINITIONS.map((series) => (
|
||
<button
|
||
key={series.key}
|
||
type="button"
|
||
onClick={() => toggleMetric(series.key)}
|
||
className={
|
||
"flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium transition-colors " +
|
||
(metrics[series.key]
|
||
? "bg-[#f0eeea] text-[#2b2925] hover:bg-[#eae7e2]"
|
||
: "text-[#7c7870] hover:bg-[#f5f3f0] hover:text-[#2b2925]")
|
||
}
|
||
>
|
||
<span
|
||
className="inline-block h-2 w-2 rounded-full"
|
||
style={
|
||
series.key === "cogsPercentage"
|
||
? {
|
||
background: "transparent",
|
||
border: `1.5px dashed ${metrics[series.key] ? chartColors.cogsPercentage : "#d8d4cd"}`,
|
||
}
|
||
: {
|
||
background: metrics[series.key]
|
||
? chartColors[series.key as ChartSeriesKey]
|
||
: "#d8d4cd",
|
||
}
|
||
}
|
||
/>
|
||
{series.label}
|
||
</button>
|
||
))}
|
||
<span className="mx-1.5 h-4 w-px bg-[#eceae5]" />
|
||
<Select value={groupBy} onValueChange={handleGroupByChange}>
|
||
<SelectTrigger className="h-8 w-auto gap-1.5 rounded-full border-[#e7e5e1] bg-white px-3.5 text-xs font-semibold text-[#2b2925] shadow-none hover:bg-[#faf9f7]">
|
||
<SelectValue placeholder="Group by" />
|
||
</SelectTrigger>
|
||
<SelectContent className="rounded-xl">
|
||
{GROUP_BY_CHOICES.map((option) => (
|
||
<SelectItem key={option.value} value={option.value}>
|
||
{option.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
{loading ? (
|
||
<div className="space-y-6">
|
||
<SkeletonChartSection />
|
||
</div>
|
||
) : error ? (
|
||
<DashboardErrorState error={`Failed to load financial data: ${error}`} className="mx-0 my-0" />
|
||
) : !hasData ? (
|
||
<DashboardEmptyState
|
||
icon={TrendingUp}
|
||
title="No financial data available"
|
||
description="Try selecting a different time range"
|
||
/>
|
||
) : (
|
||
<>
|
||
<div className={`h-[340px] ${CARD_STYLES.subtle} p-0 relative`}>
|
||
{!hasActiveMetrics ? (
|
||
<DashboardEmptyState
|
||
icon={TrendingUp}
|
||
title="No metrics selected"
|
||
description="Select at least one metric to visualize."
|
||
/>
|
||
) : (
|
||
<ResponsiveContainer width="100%" height="100%">
|
||
<ComposedChart data={chartData} margin={{ top: 8, right: 0, left: -8, bottom: 0 }}>
|
||
<defs>
|
||
<linearGradient id="financialCogs" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="5%" stopColor={chartColors.cogs} stopOpacity={0.8} />
|
||
<stop offset="95%" stopColor={chartColors.cogs} stopOpacity={0.6} />
|
||
</linearGradient>
|
||
<linearGradient id="financialProfit" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="5%" stopColor={chartColors.profit} stopOpacity={0.8} />
|
||
<stop offset="95%" stopColor={chartColors.profit} stopOpacity={0.6} />
|
||
</linearGradient>
|
||
</defs>
|
||
<CartesianGrid
|
||
strokeDasharray="3 3"
|
||
className="stroke-muted"
|
||
/>
|
||
<XAxis
|
||
dataKey="label"
|
||
className="text-xs text-muted-foreground"
|
||
tick={{ fill: "currentColor" }}
|
||
/>
|
||
<YAxis
|
||
yAxisId="left"
|
||
tickFormatter={(value: number) => formatCurrency(value, 0)}
|
||
className="text-xs text-muted-foreground"
|
||
tick={{ fill: "currentColor" }}
|
||
/>
|
||
{showPercentageAxis && (
|
||
<YAxis
|
||
yAxisId="right"
|
||
orientation="right"
|
||
tickFormatter={(value: number) => formatPercentage(value, 0)}
|
||
domain={percentageDomain}
|
||
className="text-xs text-muted-foreground"
|
||
tick={{ fill: "currentColor" }}
|
||
/>
|
||
)}
|
||
<Tooltip content={<FinancialTooltip />} />
|
||
{/* Stacked areas showing revenue breakdown */}
|
||
{metrics.cogs ? (
|
||
<Area
|
||
yAxisId="left"
|
||
type="monotone"
|
||
dataKey="cogs"
|
||
stackId="revenue"
|
||
name={SERIES_LABELS.cogs}
|
||
stroke={chartColors.cogs}
|
||
fill="url(#financialCogs)"
|
||
strokeWidth={1}
|
||
/>
|
||
) : null}
|
||
{metrics.profit ? (
|
||
<Area
|
||
yAxisId="left"
|
||
type="monotone"
|
||
dataKey="profit"
|
||
stackId="revenue"
|
||
name={SERIES_LABELS.profit}
|
||
stroke={chartColors.profit}
|
||
fill="url(#financialProfit)"
|
||
strokeWidth={1}
|
||
/>
|
||
) : null}
|
||
{/* Show total income as a line for reference if selected */}
|
||
{metrics.income ? (
|
||
<Line
|
||
yAxisId="left"
|
||
type="monotone"
|
||
dataKey="income"
|
||
name={SERIES_LABELS.income}
|
||
stroke={chartColors.income}
|
||
strokeWidth={2}
|
||
dot={false}
|
||
activeDot={{ r: 4 }}
|
||
connectNulls
|
||
/>
|
||
) : null}
|
||
{metrics.cogsPercentage ? (
|
||
<Line
|
||
yAxisId="right"
|
||
type="monotone"
|
||
dataKey="cogsPercentage"
|
||
name={SERIES_LABELS.cogsPercentage}
|
||
stroke={chartColors.cogsPercentage}
|
||
strokeWidth={2}
|
||
strokeDasharray="6 3"
|
||
dot={false}
|
||
activeDot={{ r: 4 }}
|
||
connectNulls
|
||
/>
|
||
) : null}
|
||
{metrics.margin ? (
|
||
<Line
|
||
yAxisId="right"
|
||
type="monotone"
|
||
dataKey="margin"
|
||
name={SERIES_LABELS.margin}
|
||
stroke={chartColors.margin}
|
||
strokeWidth={3}
|
||
dot={false}
|
||
activeDot={{ r: 4 }}
|
||
connectNulls
|
||
/>
|
||
) : null}
|
||
</ComposedChart>
|
||
</ResponsiveContainer>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
type FinancialStatCardConfig = {
|
||
key: string;
|
||
title: string;
|
||
value: string;
|
||
description?: string;
|
||
trendValue?: number | null;
|
||
trendInverted?: boolean;
|
||
iconColor: "blue" | "orange" | "emerald" | "purple";
|
||
tooltip?: string;
|
||
};
|
||
|
||
const ICON_MAP = {
|
||
income: DollarSign,
|
||
cogs: Package,
|
||
profit: PiggyBank,
|
||
margin: Percent,
|
||
} as const;
|
||
|
||
function FinancialStatGrid({
|
||
cards,
|
||
}: {
|
||
cards: FinancialStatCardConfig[];
|
||
}) {
|
||
return (
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 w-full dashboard-stagger">
|
||
{cards.map((card) => (
|
||
<DashboardStatCard
|
||
key={card.key}
|
||
title={card.title}
|
||
value={card.value}
|
||
subtitle={card.description}
|
||
trend={
|
||
card.trendValue != null && Number.isFinite(card.trendValue)
|
||
? {
|
||
value: card.trendValue,
|
||
moreIsBetter: !card.trendInverted,
|
||
}
|
||
: undefined
|
||
}
|
||
icon={ICON_MAP[card.key as keyof typeof ICON_MAP]}
|
||
iconColor={card.iconColor}
|
||
tooltip={card.tooltip}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SkeletonStats() {
|
||
return (
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 w-full">
|
||
{Array.from({ length: 4 }).map((_, index) => (
|
||
<DashboardStatCardSkeleton key={index} hasIcon hasSubtitle />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SkeletonChartSection() {
|
||
return (
|
||
<ChartSkeleton type="area" height="default" withCard={false} />
|
||
);
|
||
}
|
||
|
||
|
||
const FinancialTooltip = ({ active, payload, label }: TooltipProps<number, string>) => {
|
||
if (!active || !payload?.length) {
|
||
return null;
|
||
}
|
||
|
||
const basePoint = payload[0]?.payload as ChartPoint | undefined;
|
||
const resolvedLabel = basePoint?.tooltipLabel ?? label;
|
||
const isFuturePoint = basePoint?.isFuture ?? false;
|
||
|
||
// Calculate total income for percentage calculations
|
||
const income = basePoint?.income ?? 0;
|
||
|
||
// Define the desired order: income, cogs, cogs %, profit, margin
|
||
const desiredOrder: ChartSeriesKey[] = ["income", "cogs", "cogsPercentage", "profit", "margin"];
|
||
|
||
// Create a map of payload entries by dataKey for easy lookup
|
||
const payloadMap = new Map(payload.map(entry => [entry.dataKey as ChartSeriesKey, entry]));
|
||
|
||
// Sort payload according to desired order
|
||
const orderedPayload = desiredOrder
|
||
.map(key => payloadMap.get(key))
|
||
.filter((entry): entry is typeof payload[0] => entry !== undefined);
|
||
|
||
return (
|
||
<div className={TOOLTIP_STYLES.container}>
|
||
<p className={TOOLTIP_STYLES.header}>{resolvedLabel}</p>
|
||
<div className={TOOLTIP_STYLES.content}>
|
||
{orderedPayload.map((entry, index) => {
|
||
const key = (entry.dataKey ?? "") as ChartSeriesKey;
|
||
const rawValue = entry.value;
|
||
let formattedValue: string;
|
||
let percentageOfRevenue = "";
|
||
|
||
if (isFuturePoint || rawValue == null) {
|
||
formattedValue = "—";
|
||
} else if (typeof rawValue === "number") {
|
||
const isPercentageSeries = key === "margin" || key === "cogsPercentage";
|
||
formattedValue = isPercentageSeries ? formatPercentage(rawValue, 1) : formatCurrency(rawValue, 0);
|
||
|
||
// Add percentage of revenue for COGS only
|
||
if (key === "cogs" && income > 0 && !isFuturePoint) {
|
||
const percentage = (rawValue / income) * 100;
|
||
percentageOfRevenue = ` (${formatPercentage(percentage, 1)})`;
|
||
}
|
||
} else if (typeof rawValue === "string") {
|
||
formattedValue = rawValue;
|
||
} else {
|
||
formattedValue = "—";
|
||
}
|
||
|
||
return (
|
||
<div key={`${key}-${index}`} className={TOOLTIP_STYLES.row}>
|
||
<div className={TOOLTIP_STYLES.rowLabel}>
|
||
<span
|
||
className={TOOLTIP_STYLES.dot}
|
||
style={{ backgroundColor: entry.stroke || entry.color || "#888" }}
|
||
/>
|
||
<span className={TOOLTIP_STYLES.name}>
|
||
{SERIES_LABELS[key] ?? entry.name ?? key}
|
||
</span>
|
||
</div>
|
||
<span className={TOOLTIP_STYLES.value}>
|
||
{formattedValue}{percentageOfRevenue}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default FinancialOverview;
|