Files
inventory/inventory/src/components/dashboard/FinancialOverview.tsx
T

1577 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;