Layout tweaks for financial overview, add cogs % line
This commit is contained in:
@@ -44,7 +44,13 @@ import {
|
|||||||
import type { TooltipProps } from "recharts";
|
import type { TooltipProps } from "recharts";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { ArrowUpRight, ArrowDownRight, Minus, TrendingUp, AlertCircle } from "lucide-react";
|
import {
|
||||||
|
Tooltip as UITooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
TooltipProvider,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { ArrowUp, ArrowDown, Minus, TrendingUp, AlertCircle, Info } from "lucide-react";
|
||||||
import PeriodSelectionPopover, {
|
import PeriodSelectionPopover, {
|
||||||
type QuickPreset,
|
type QuickPreset,
|
||||||
} from "@/components/dashboard/PeriodSelectionPopover";
|
} from "@/components/dashboard/PeriodSelectionPopover";
|
||||||
@@ -112,7 +118,7 @@ type FinancialResponse = {
|
|||||||
trend: FinancialTrendPoint[];
|
trend: FinancialTrendPoint[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ChartSeriesKey = "income" | "cogs" | "profit" | "margin";
|
type ChartSeriesKey = "income" | "cogs" | "cogsPercentage" | "profit" | "margin";
|
||||||
|
|
||||||
type GroupByOption = "day" | "month" | "quarter" | "year";
|
type GroupByOption = "day" | "month" | "quarter" | "year";
|
||||||
|
|
||||||
@@ -121,6 +127,7 @@ type ChartPoint = {
|
|||||||
timestamp: string | null;
|
timestamp: string | null;
|
||||||
income: number | null;
|
income: number | null;
|
||||||
cogs: number | null;
|
cogs: number | null;
|
||||||
|
cogsPercentage: number | null;
|
||||||
profit: number | null;
|
profit: number | null;
|
||||||
margin: number | null;
|
margin: number | null;
|
||||||
tooltipLabel: string;
|
tooltipLabel: string;
|
||||||
@@ -128,15 +135,17 @@ type ChartPoint = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const chartColors: Record<ChartSeriesKey, string> = {
|
const chartColors: Record<ChartSeriesKey, string> = {
|
||||||
income: "#2563eb",
|
income: "#3b82f6",
|
||||||
cogs: "#f97316",
|
cogs: "#f97316",
|
||||||
|
cogsPercentage: "#fb923c",
|
||||||
profit: "#10b981",
|
profit: "#10b981",
|
||||||
margin: "#0ea5e9",
|
margin: "#8b5cf6",
|
||||||
};
|
};
|
||||||
|
|
||||||
const SERIES_LABELS: Record<ChartSeriesKey, string> = {
|
const SERIES_LABELS: Record<ChartSeriesKey, string> = {
|
||||||
income: "Total Income",
|
income: "Total Income",
|
||||||
cogs: "COGS",
|
cogs: "COGS",
|
||||||
|
cogsPercentage: "COGS % of Income",
|
||||||
profit: "Gross Profit",
|
profit: "Gross Profit",
|
||||||
margin: "Profit Margin",
|
margin: "Profit Margin",
|
||||||
};
|
};
|
||||||
@@ -148,15 +157,16 @@ const SERIES_DEFINITIONS: Array<{
|
|||||||
}> = [
|
}> = [
|
||||||
{ key: "income", label: SERIES_LABELS.income, type: "currency" },
|
{ key: "income", label: SERIES_LABELS.income, type: "currency" },
|
||||||
{ key: "cogs", label: SERIES_LABELS.cogs, 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: "profit", label: SERIES_LABELS.profit, type: "currency" },
|
||||||
{ 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 }> = [
|
const GROUP_BY_CHOICES: Array<{ value: GroupByOption; label: string }> = [
|
||||||
{ value: "day", label: "Daily" },
|
{ value: "day", label: "Days" },
|
||||||
{ value: "month", label: "Monthly" },
|
{ value: "month", label: "Months" },
|
||||||
{ value: "quarter", label: "Quarterly" },
|
{ value: "quarter", label: "Quarters" },
|
||||||
{ value: "year", label: "Yearly" },
|
{ value: "year", label: "Years" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const MONTHS = [
|
const MONTHS = [
|
||||||
@@ -176,9 +186,9 @@ const MONTHS = [
|
|||||||
|
|
||||||
const QUARTERS = ["Q1", "Q2", "Q3", "Q4"];
|
const QUARTERS = ["Q1", "Q2", "Q3", "Q4"];
|
||||||
|
|
||||||
const MONTH_COUNT_LIMIT = 12;
|
const MONTH_COUNT_LIMIT = 999;
|
||||||
const QUARTER_COUNT_LIMIT = 8;
|
const QUARTER_COUNT_LIMIT = 999;
|
||||||
const YEAR_COUNT_LIMIT = 5;
|
const YEAR_COUNT_LIMIT = 999;
|
||||||
|
|
||||||
const formatMonthLabel = (year: number, monthIndex: number) => `${MONTHS[monthIndex]} ${year}`;
|
const formatMonthLabel = (year: number, monthIndex: number) => `${MONTHS[monthIndex]} ${year}`;
|
||||||
|
|
||||||
@@ -252,6 +262,7 @@ type AggregatedTrendPoint = {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
income: number;
|
income: number;
|
||||||
cogs: number;
|
cogs: number;
|
||||||
|
cogsPercentage: number;
|
||||||
profit: number;
|
profit: number;
|
||||||
margin: number;
|
margin: number;
|
||||||
isFuture: boolean;
|
isFuture: boolean;
|
||||||
@@ -364,6 +375,7 @@ const aggregateTrendPoints = (points: RawTrendPoint[], groupBy: GroupByOption):
|
|||||||
const income = bucket.income;
|
const income = bucket.income;
|
||||||
const profit = income - bucket.cogs;
|
const profit = income - bucket.cogs;
|
||||||
const margin = income !== 0 ? (profit / income) * 100 : 0;
|
const margin = income !== 0 ? (profit / income) * 100 : 0;
|
||||||
|
const cogsPercentage = income !== 0 ? (bucket.cogs / income) * 100 : 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: bucket.key,
|
id: bucket.key,
|
||||||
@@ -373,6 +385,7 @@ const aggregateTrendPoints = (points: RawTrendPoint[], groupBy: GroupByOption):
|
|||||||
timestamp: bucket.start.toISOString(),
|
timestamp: bucket.start.toISOString(),
|
||||||
income,
|
income,
|
||||||
cogs: bucket.cogs,
|
cogs: bucket.cogs,
|
||||||
|
cogsPercentage,
|
||||||
profit,
|
profit,
|
||||||
margin,
|
margin,
|
||||||
isFuture: false,
|
isFuture: false,
|
||||||
@@ -472,6 +485,7 @@ const extendAggregatedTrendPoints = (
|
|||||||
timestamp: bucketStart.toISOString(),
|
timestamp: bucketStart.toISOString(),
|
||||||
income: 0,
|
income: 0,
|
||||||
cogs: 0,
|
cogs: 0,
|
||||||
|
cogsPercentage: 0,
|
||||||
profit: 0,
|
profit: 0,
|
||||||
margin: 0,
|
margin: 0,
|
||||||
isFuture: isFutureBucket,
|
isFuture: isFutureBucket,
|
||||||
@@ -509,14 +523,14 @@ const buildTrendLabel = (
|
|||||||
if (options?.isPercentage) {
|
if (options?.isPercentage) {
|
||||||
return {
|
return {
|
||||||
direction,
|
direction,
|
||||||
label: `${absolute > 0 ? "+" : absolute < 0 ? "-" : ""}${formatPercentage(absoluteValue, 1, "pp")} vs previous`,
|
label: `${absolute > 0 ? "+" : absolute < 0 ? "-" : ""}${formatPercentage(absoluteValue, 1, "%")}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof percentage === "number" && Number.isFinite(percentage)) {
|
if (typeof percentage === "number" && Number.isFinite(percentage)) {
|
||||||
return {
|
return {
|
||||||
direction,
|
direction,
|
||||||
label: `${absolute > 0 ? "+" : absolute < 0 ? "-" : ""}${formatPercentage(Math.abs(percentage), 1)} vs previous`,
|
label: `${absolute > 0 ? "+" : absolute < 0 ? "-" : ""}${formatPercentage(Math.abs(percentage), 1)}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -630,6 +644,7 @@ const FinancialOverview = () => {
|
|||||||
const [metrics, setMetrics] = useState<Record<ChartSeriesKey, boolean>>({
|
const [metrics, setMetrics] = useState<Record<ChartSeriesKey, boolean>>({
|
||||||
income: true,
|
income: true,
|
||||||
cogs: true,
|
cogs: true,
|
||||||
|
cogsPercentage: true,
|
||||||
profit: true,
|
profit: true,
|
||||||
margin: true,
|
margin: true,
|
||||||
});
|
});
|
||||||
@@ -836,14 +851,7 @@ const FinancialOverview = () => {
|
|||||||
const cards = useMemo(
|
const cards = useMemo(
|
||||||
() => {
|
() => {
|
||||||
if (!data?.totals) {
|
if (!data?.totals) {
|
||||||
return [] as Array<{
|
return [] as FinancialStatCardConfig[];
|
||||||
key: string;
|
|
||||||
title: string;
|
|
||||||
value: string;
|
|
||||||
description?: string;
|
|
||||||
trend: TrendSummary | null;
|
|
||||||
accentClass: string;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const totals = data.totals;
|
const totals = data.totals;
|
||||||
@@ -880,43 +888,57 @@ const FinancialOverview = () => {
|
|||||||
: computeMarginFrom(previousProfitValue ?? 0, previousIncome)
|
: computeMarginFrom(previousProfitValue ?? 0, previousIncome)
|
||||||
: null;
|
: 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;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: "income",
|
key: "income",
|
||||||
title: "Total Income",
|
title: "Total Income",
|
||||||
value: safeCurrency(totalIncome, 0),
|
value: safeCurrency(totalIncome, 0),
|
||||||
description: previousIncome != null ? `Previous: ${safeCurrency(previousIncome, 0)}` : undefined,
|
description: incomeDescription,
|
||||||
trend: buildTrendLabel(comparison?.income ?? buildComparisonFromValues(totalIncome, previousIncome ?? null)),
|
trend: buildTrendLabel(comparison?.income ?? buildComparisonFromValues(totalIncome, previousIncome ?? null)),
|
||||||
accentClass: "text-indigo-600 dark:text-indigo-400",
|
accentClass: "text-blue-500 dark:text-blue-400",
|
||||||
|
tooltip:
|
||||||
|
"Gross sales minus refunds and discounts, plus shipping fees collected (shipping, small-order, and rush fees). Taxes are excluded.",
|
||||||
|
showDescription: incomeDescription != null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "cogs",
|
key: "cogs",
|
||||||
title: "COGS",
|
title: "COGS",
|
||||||
value: safeCurrency(cogsValue, 0),
|
value: safeCurrency(cogsValue, 0),
|
||||||
description: previousCogs != null ? `Previous: ${safeCurrency(previousCogs, 0)}` : undefined,
|
description: cogsDescription,
|
||||||
trend: buildTrendLabel(comparison?.cogs ?? buildComparisonFromValues(cogsValue, previousCogs ?? null), {
|
trend: buildTrendLabel(comparison?.cogs ?? buildComparisonFromValues(cogsValue, previousCogs ?? null), {
|
||||||
invertDirection: true,
|
invertDirection: true,
|
||||||
}),
|
}),
|
||||||
accentClass: "text-amber-600 dark:text-amber-400",
|
accentClass: "text-orange-500 dark:text-orange-400",
|
||||||
|
tooltip: "Sum of reported product cost of goods sold (cogs_amount) for completed sales actions in the period.",
|
||||||
|
showDescription: cogsDescription != null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "profit",
|
key: "profit",
|
||||||
title: "Gross Profit",
|
title: "Gross Profit",
|
||||||
value: safeCurrency(profitValue, 0),
|
value: safeCurrency(profitValue, 0),
|
||||||
description: previousProfitValue != null ? `Previous: ${safeCurrency(previousProfitValue, 0)}` : undefined,
|
description: profitDescription,
|
||||||
trend: buildTrendLabel(comparison?.profit ?? buildComparisonFromValues(profitValue, previousProfitValue ?? null)),
|
trend: buildTrendLabel(comparison?.profit ?? buildComparisonFromValues(profitValue, previousProfitValue ?? null)),
|
||||||
accentClass: "text-emerald-600 dark:text-emerald-400",
|
accentClass: "text-emerald-500 dark:text-emerald-400",
|
||||||
|
tooltip: "Total Income minus COGS.",
|
||||||
|
showDescription: profitDescription != null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "margin",
|
key: "margin",
|
||||||
title: "Profit Margin",
|
title: "Profit Margin",
|
||||||
value: safePercentage(marginValue, 1),
|
value: safePercentage(marginValue, 1),
|
||||||
description: previousMarginValue != null ? `Previous: ${safePercentage(previousMarginValue, 1)}` : undefined,
|
description: marginDescription,
|
||||||
trend: buildTrendLabel(
|
trend: buildTrendLabel(
|
||||||
comparison?.margin ?? buildComparisonFromValues(marginValue, previousMarginValue ?? null),
|
comparison?.margin ?? buildComparisonFromValues(marginValue, previousMarginValue ?? null),
|
||||||
{ isPercentage: true }
|
{ isPercentage: true }
|
||||||
),
|
),
|
||||||
accentClass: "text-sky-600 dark:text-sky-400",
|
accentClass: "text-purple-500 dark:text-purple-400",
|
||||||
|
tooltip: "Gross Profit divided by Total Income, expressed as a percentage.",
|
||||||
|
showDescription: marginDescription != null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
@@ -938,6 +960,7 @@ const FinancialOverview = () => {
|
|||||||
timestamp: point.timestamp,
|
timestamp: point.timestamp,
|
||||||
income: point.isFuture ? null : point.income,
|
income: point.isFuture ? null : point.income,
|
||||||
cogs: point.isFuture ? null : point.cogs,
|
cogs: point.isFuture ? null : point.cogs,
|
||||||
|
cogsPercentage: point.isFuture ? null : point.cogsPercentage,
|
||||||
profit: point.isFuture ? null : point.profit,
|
profit: point.isFuture ? null : point.profit,
|
||||||
margin: point.isFuture ? null : point.margin,
|
margin: point.isFuture ? null : point.margin,
|
||||||
tooltipLabel: point.tooltipLabel,
|
tooltipLabel: point.tooltipLabel,
|
||||||
@@ -966,20 +989,34 @@ const FinancialOverview = () => {
|
|||||||
const partialLabel = effectiveRangeEnd.toLocaleDateString("en-US", {
|
const partialLabel = effectiveRangeEnd.toLocaleDateString("en-US", {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
year: "numeric",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return `${label} (through ${partialLabel})`;
|
return `${label} (through ${partialLabel})`;
|
||||||
}, [isLast30DaysMode, customPeriod, selectedRange, effectiveRangeEnd]);
|
}, [isLast30DaysMode, customPeriod, selectedRange, effectiveRangeEnd]);
|
||||||
|
|
||||||
const marginDomain = useMemo<[number, number]>(() => {
|
const percentageDomain = useMemo<[number, number]>(() => {
|
||||||
if (!metrics.margin || !chartData.length) {
|
if ((!metrics.margin && !metrics.cogsPercentage) || !chartData.length) {
|
||||||
return [0, 100];
|
return [0, 100];
|
||||||
}
|
}
|
||||||
|
|
||||||
const values = chartData
|
const values: number[] = [];
|
||||||
.map((point) => point.margin)
|
|
||||||
.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
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) {
|
if (!values.length) {
|
||||||
return [0, 100];
|
return [0, 100];
|
||||||
@@ -995,9 +1032,10 @@ const FinancialOverview = () => {
|
|||||||
|
|
||||||
const padding = (max - min) * 0.1;
|
const padding = (max - min) * 0.1;
|
||||||
return [min - padding, max + padding];
|
return [min - padding, max + padding];
|
||||||
}, [chartData, metrics.margin]);
|
}, [chartData, metrics.margin, metrics.cogsPercentage]);
|
||||||
|
|
||||||
const hasActiveMetrics = useMemo(() => Object.values(metrics).some(Boolean), [metrics]);
|
const hasActiveMetrics = useMemo(() => Object.values(metrics).some(Boolean), [metrics]);
|
||||||
|
const showPercentageAxis = metrics.margin || metrics.cogsPercentage;
|
||||||
|
|
||||||
const detailRows = useMemo(
|
const detailRows = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -1007,6 +1045,7 @@ const FinancialOverview = () => {
|
|||||||
timestamp: point.timestamp,
|
timestamp: point.timestamp,
|
||||||
income: point.income,
|
income: point.income,
|
||||||
cogs: point.cogs,
|
cogs: point.cogs,
|
||||||
|
cogsPercentage: point.cogsPercentage,
|
||||||
profit: point.profit,
|
profit: point.profit,
|
||||||
margin: point.margin,
|
margin: point.margin,
|
||||||
isFuture: point.isFuture,
|
isFuture: point.isFuture,
|
||||||
@@ -1124,15 +1163,6 @@ const FinancialOverview = () => {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{!error && (
|
{!error && (
|
||||||
<>
|
<>
|
||||||
<PeriodSelectionPopover
|
|
||||||
open={isPeriodPopoverOpen}
|
|
||||||
onOpenChange={setIsPeriodPopoverOpen}
|
|
||||||
selectedLabel={selectedRangeLabel}
|
|
||||||
referenceDate={currentDate}
|
|
||||||
isLast30DaysActive={isLast30DaysMode}
|
|
||||||
onQuickSelect={handleQuickPeriod}
|
|
||||||
onApplyResult={handleNaturalLanguageResult}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@@ -1191,6 +1221,11 @@ const FinancialOverview = () => {
|
|||||||
COGS
|
COGS
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)}
|
)}
|
||||||
|
{metrics.cogsPercentage && (
|
||||||
|
<TableHead className="text-center whitespace-nowrap px-6">
|
||||||
|
COGS % of Income
|
||||||
|
</TableHead>
|
||||||
|
)}
|
||||||
{metrics.profit && (
|
{metrics.profit && (
|
||||||
<TableHead className="text-center whitespace-nowrap px-6">
|
<TableHead className="text-center whitespace-nowrap px-6">
|
||||||
Gross Profit
|
Gross Profit
|
||||||
@@ -1219,6 +1254,11 @@ const FinancialOverview = () => {
|
|||||||
{row.isFuture ? "—" : formatCurrency(row.cogs, 0)}
|
{row.isFuture ? "—" : formatCurrency(row.cogs, 0)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
|
{metrics.cogsPercentage && (
|
||||||
|
<TableCell className="text-center whitespace-nowrap px-6">
|
||||||
|
{row.isFuture ? "—" : formatPercentage(row.cogsPercentage, 1)}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
{metrics.profit && (
|
{metrics.profit && (
|
||||||
<TableCell className="text-center whitespace-nowrap px-6">
|
<TableCell className="text-center whitespace-nowrap px-6">
|
||||||
{row.isFuture ? "—" : formatCurrency(row.profit, 0)}
|
{row.isFuture ? "—" : formatCurrency(row.profit, 0)}
|
||||||
@@ -1237,6 +1277,17 @@ const FinancialOverview = () => {
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<PeriodSelectionPopover
|
||||||
|
open={isPeriodPopoverOpen}
|
||||||
|
onOpenChange={setIsPeriodPopoverOpen}
|
||||||
|
selectedLabel={selectedRangeLabel}
|
||||||
|
referenceDate={currentDate}
|
||||||
|
isLast30DaysActive={isLast30DaysMode}
|
||||||
|
onQuickSelect={handleQuickPeriod}
|
||||||
|
onApplyResult={handleNaturalLanguageResult}
|
||||||
|
/>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1276,8 +1327,11 @@ const FinancialOverview = () => {
|
|||||||
className="sm:hidden w-20 my-2"
|
className="sm:hidden w-20 my-2"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-sm text-muted-foreground">Group By:</div>
|
||||||
|
|
||||||
<Select value={groupBy} onValueChange={handleGroupByChange}>
|
<Select value={groupBy} onValueChange={handleGroupByChange}>
|
||||||
<SelectTrigger className="w-[140px]">
|
<SelectTrigger className="w-[110px]">
|
||||||
<SelectValue placeholder="Group By" />
|
<SelectValue placeholder="Group By" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -1286,8 +1340,9 @@ const FinancialOverview = () => {
|
|||||||
{option.label}
|
{option.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1327,19 +1382,15 @@ const FinancialOverview = () => {
|
|||||||
<EmptyChartState message="Select at least one metric to visualize." />
|
<EmptyChartState message="Select at least one metric to visualize." />
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<ComposedChart data={chartData} margin={{ top: 5, right: -30, left: -5, bottom: 5 }}>
|
<ComposedChart data={chartData} margin={{ top: 5, right: -25, left: 15, bottom: 5 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="financialIncome" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="5%" stopColor={chartColors.income} stopOpacity={0.3} />
|
|
||||||
<stop offset="95%" stopColor={chartColors.income} stopOpacity={0.05} />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="financialCogs" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="financialCogs" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor={chartColors.cogs} stopOpacity={0.25} />
|
<stop offset="5%" stopColor={chartColors.cogs} stopOpacity={0.8} />
|
||||||
<stop offset="95%" stopColor={chartColors.cogs} stopOpacity={0.05} />
|
<stop offset="95%" stopColor={chartColors.cogs} stopOpacity={0.6} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="financialProfit" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="financialProfit" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor={chartColors.profit} stopOpacity={0.35} />
|
<stop offset="5%" stopColor={chartColors.profit} stopOpacity={0.8} />
|
||||||
<stop offset="95%" stopColor={chartColors.profit} stopOpacity={0.05} />
|
<stop offset="95%" stopColor={chartColors.profit} stopOpacity={0.6} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid
|
<CartesianGrid
|
||||||
@@ -1357,38 +1408,29 @@ const FinancialOverview = () => {
|
|||||||
className="text-xs text-muted-foreground"
|
className="text-xs text-muted-foreground"
|
||||||
tick={{ fill: "currentColor" }}
|
tick={{ fill: "currentColor" }}
|
||||||
/>
|
/>
|
||||||
{metrics.margin && (
|
{showPercentageAxis && (
|
||||||
<YAxis
|
<YAxis
|
||||||
yAxisId="right"
|
yAxisId="right"
|
||||||
orientation="right"
|
orientation="right"
|
||||||
tickFormatter={(value: number) => formatPercentage(value, 0)}
|
tickFormatter={(value: number) => formatPercentage(value, 0)}
|
||||||
domain={marginDomain}
|
domain={percentageDomain}
|
||||||
className="text-xs text-muted-foreground"
|
className="text-xs text-muted-foreground"
|
||||||
tick={{ fill: "currentColor" }}
|
tick={{ fill: "currentColor" }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Tooltip content={<FinancialTooltip />} />
|
<Tooltip content={<FinancialTooltip />} />
|
||||||
<Legend formatter={(value: string) => SERIES_LABELS[value as ChartSeriesKey] ?? value} />
|
<Legend formatter={(value: string) => SERIES_LABELS[value as ChartSeriesKey] ?? value} />
|
||||||
{metrics.income ? (
|
{/* Stacked areas showing revenue breakdown */}
|
||||||
<Area
|
|
||||||
yAxisId="left"
|
|
||||||
type="monotone"
|
|
||||||
dataKey="income"
|
|
||||||
name={SERIES_LABELS.income}
|
|
||||||
stroke={chartColors.income}
|
|
||||||
fill="url(#financialIncome)"
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{metrics.cogs ? (
|
{metrics.cogs ? (
|
||||||
<Area
|
<Area
|
||||||
yAxisId="left"
|
yAxisId="left"
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="cogs"
|
dataKey="cogs"
|
||||||
|
stackId="revenue"
|
||||||
name={SERIES_LABELS.cogs}
|
name={SERIES_LABELS.cogs}
|
||||||
stroke={chartColors.cogs}
|
stroke={chartColors.cogs}
|
||||||
fill="url(#financialCogs)"
|
fill="url(#financialCogs)"
|
||||||
strokeWidth={2}
|
strokeWidth={1}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{metrics.profit ? (
|
{metrics.profit ? (
|
||||||
@@ -1396,10 +1438,39 @@ const FinancialOverview = () => {
|
|||||||
yAxisId="left"
|
yAxisId="left"
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="profit"
|
dataKey="profit"
|
||||||
|
stackId="revenue"
|
||||||
name={SERIES_LABELS.profit}
|
name={SERIES_LABELS.profit}
|
||||||
stroke={chartColors.profit}
|
stroke={chartColors.profit}
|
||||||
fill="url(#financialProfit)"
|
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}
|
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}
|
) : null}
|
||||||
{metrics.margin ? (
|
{metrics.margin ? (
|
||||||
@@ -1426,24 +1497,41 @@ const FinancialOverview = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FinancialStatCardConfig = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
description?: string;
|
||||||
|
trend: TrendSummary | null;
|
||||||
|
accentClass: string;
|
||||||
|
tooltip?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
showDescription?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
function FinancialStatGrid({
|
function FinancialStatGrid({
|
||||||
cards,
|
cards,
|
||||||
}: {
|
}: {
|
||||||
cards: Array<{
|
cards: FinancialStatCardConfig[];
|
||||||
key: string;
|
|
||||||
title: string;
|
|
||||||
value: string;
|
|
||||||
description?: string;
|
|
||||||
trend: TrendSummary | null;
|
|
||||||
accentClass: string;
|
|
||||||
}>;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full">
|
<TooltipProvider delayDuration={150}>
|
||||||
{cards.map((card) => (
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full">
|
||||||
<FinancialStatCard key={card.key} title={card.title} value={card.value} description={card.description} trend={card.trend} accentClass={card.accentClass} />
|
{cards.map((card) => (
|
||||||
))}
|
<FinancialStatCard
|
||||||
</div>
|
key={card.key}
|
||||||
|
title={card.title}
|
||||||
|
value={card.value}
|
||||||
|
description={card.description}
|
||||||
|
trend={card.trend}
|
||||||
|
accentClass={card.accentClass}
|
||||||
|
tooltip={card.tooltip}
|
||||||
|
isLoading={card.isLoading}
|
||||||
|
showDescription={card.showDescription}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1453,18 +1541,69 @@ function FinancialStatCard({
|
|||||||
description,
|
description,
|
||||||
trend,
|
trend,
|
||||||
accentClass,
|
accentClass,
|
||||||
|
tooltip,
|
||||||
|
isLoading,
|
||||||
|
showDescription,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
value: string;
|
value: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
trend: TrendSummary | null;
|
trend: TrendSummary | null;
|
||||||
accentClass: string;
|
accentClass: string;
|
||||||
|
tooltip?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
showDescription?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const shouldShowDescription = isLoading ? showDescription !== false : Boolean(description);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
||||||
<span className="text-sm text-muted-foreground">{title}</span>
|
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
{trend?.label && (
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<span className="relative block w-24">
|
||||||
|
<span className="invisible block">Placeholder</span>
|
||||||
|
<Skeleton className="absolute inset-0 w-full bg-muted rounded-sm" />
|
||||||
|
</span>
|
||||||
|
<span className="relative inline-flex rounded-full p-0.5">
|
||||||
|
<span className="invisible inline-flex">
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<Skeleton className="absolute inset-0 rounded-full" />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>{title}</span>
|
||||||
|
{tooltip ? (
|
||||||
|
<UITooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`What makes up ${title}`}
|
||||||
|
className="text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded-full p-0.5"
|
||||||
|
>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" align="start" className="max-w-[260px] text-xs leading-relaxed">
|
||||||
|
{tooltip}
|
||||||
|
</TooltipContent>
|
||||||
|
</UITooltip>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="text-sm text-muted-foreground flex items-center gap-1">
|
||||||
|
<Skeleton className="h-4 w-4 bg-muted rounded-full" />
|
||||||
|
<span className="relative block w-16">
|
||||||
|
<span className="invisible block">Placeholder</span>
|
||||||
|
<Skeleton className="absolute inset-0 w-full bg-muted rounded-sm" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : trend?.label ? (
|
||||||
<span
|
<span
|
||||||
className={`text-sm flex items-center gap-1 ${
|
className={`text-sm flex items-center gap-1 ${
|
||||||
trend.direction === "up"
|
trend.direction === "up"
|
||||||
@@ -1475,23 +1614,39 @@ function FinancialStatCard({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{trend.direction === "up" ? (
|
{trend.direction === "up" ? (
|
||||||
<ArrowUpRight className="w-4 h-4" />
|
<ArrowUp className="w-4 h-4" />
|
||||||
) : trend.direction === "down" ? (
|
) : trend.direction === "down" ? (
|
||||||
<ArrowDownRight className="w-4 h-4" />
|
<ArrowDown className="w-4 h-4" />
|
||||||
) : (
|
) : (
|
||||||
<Minus className="w-4 h-4" />
|
<Minus className="w-4 h-4" />
|
||||||
)}
|
)}
|
||||||
{trend.label}
|
{trend.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4 pt-0">
|
<CardContent className="p-4 pt-0">
|
||||||
<div className={`text-2xl font-bold mb-1 ${accentClass}`}>
|
<div className={`text-2xl font-bold mb-1 ${accentClass}`}>
|
||||||
{value}
|
{isLoading ? (
|
||||||
|
<span className="relative block">
|
||||||
|
<span className="invisible">0</span>
|
||||||
|
<Skeleton className="absolute inset-0 bg-muted rounded-sm" />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
value
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{description && (
|
{isLoading ? (
|
||||||
|
shouldShowDescription ? (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
<span className="relative block w-32">
|
||||||
|
<span className="invisible block">Placeholder text</span>
|
||||||
|
<Skeleton className="absolute inset-0 w-full bg-muted rounded-sm" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
) : description ? (
|
||||||
<div className="text-sm text-muted-foreground">{description}</div>
|
<div className="text-sm text-muted-foreground">{description}</div>
|
||||||
)}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -1499,18 +1654,18 @@ function FinancialStatCard({
|
|||||||
|
|
||||||
function SkeletonStats() {
|
function SkeletonStats() {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-2 md:gap-3">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full">
|
||||||
{Array.from({ length: 4 }).map((_, index) => (
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
<Card key={index} className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
<FinancialStatCard
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
key={index}
|
||||||
<Skeleton className="h-4 w-24 bg-muted rounded-sm" />
|
title=""
|
||||||
<Skeleton className="h-4 w-8 bg-muted rounded-sm" />
|
value=""
|
||||||
</CardHeader>
|
description=""
|
||||||
<CardContent className="p-4 pt-0">
|
trend={null}
|
||||||
<Skeleton className="h-7 w-32 bg-muted rounded-sm mb-1" />
|
accentClass=""
|
||||||
<Skeleton className="h-4 w-24 bg-muted rounded-sm" />
|
isLoading
|
||||||
</CardContent>
|
showDescription
|
||||||
</Card>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1518,36 +1673,46 @@ function SkeletonStats() {
|
|||||||
|
|
||||||
function SkeletonChart() {
|
function SkeletonChart() {
|
||||||
return (
|
return (
|
||||||
<div className="h-[400px] w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-6">
|
<div className="h-[400px] mt-4 bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-0 relative">
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
{/* Grid lines */}
|
{/* Grid lines */}
|
||||||
{[...Array(6)].map((_, i) => (
|
{[...Array(6)].map((_, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="absolute w-full h-px bg-muted"
|
className="absolute w-full h-px bg-muted/30"
|
||||||
style={{ top: `${(i + 1) * 16}%` }}
|
style={{ top: `${(i + 1) * 16}%` }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{/* Y-axis labels */}
|
{/* Y-axis labels */}
|
||||||
<div className="absolute left-0 top-0 bottom-0 w-16 flex flex-col justify-between py-4">
|
<div className="absolute left-0 top-0 bottom-0 w-16 flex flex-col justify-between py-4">
|
||||||
{[...Array(6)].map((_, i) => (
|
{[...Array(6)].map((_, i) => (
|
||||||
<Skeleton key={i} className="h-4 w-12 bg-muted rounded-sm" />
|
<Skeleton key={i} className="h-3 w-12 bg-muted rounded-sm" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* X-axis labels */}
|
{/* X-axis labels */}
|
||||||
<div className="absolute left-16 right-0 bottom-0 flex justify-between px-4">
|
<div className="absolute left-16 right-0 bottom-0 flex justify-between px-4">
|
||||||
{[...Array(7)].map((_, i) => (
|
{[...Array(7)].map((_, i) => (
|
||||||
<Skeleton key={i} className="h-4 w-12 bg-muted rounded-sm" />
|
<Skeleton key={i} className="h-3 w-12 bg-muted rounded-sm" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* Chart line */}
|
{/* Chart area */}
|
||||||
<div className="absolute inset-0 mt-8 mb-8 ml-20 mr-4">
|
<div className="absolute inset-0 mt-8 mb-8 ml-20 mr-4">
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-muted/50"
|
className="absolute inset-0 bg-muted/20"
|
||||||
style={{
|
style={{
|
||||||
clipPath:
|
clipPath:
|
||||||
"polygon(0 50%, 20% 20%, 40% 40%, 60% 30%, 80% 60%, 100% 40%, 100% 100%, 0 100%)",
|
"polygon(0 60%, 15% 45%, 30% 55%, 45% 35%, 60% 50%, 75% 30%, 90% 45%, 100% 40%, 100% 100%, 0 100%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Simulated line chart */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-blue-500/30"
|
||||||
|
style={{
|
||||||
|
clipPath:
|
||||||
|
"polygon(0 60%, 15% 45%, 30% 55%, 45% 35%, 60% 50%, 75% 30%, 90% 45%, 100% 40%, 100% 40%, 90% 45%, 75% 30%, 60% 50%, 45% 35%, 30% 55%, 15% 45%, 0 60%)",
|
||||||
|
height: "2px",
|
||||||
|
top: "50%",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1575,19 +1740,41 @@ const FinancialTooltip = ({ active, payload, label }: TooltipProps<number, strin
|
|||||||
const resolvedLabel = basePoint?.tooltipLabel ?? label;
|
const resolvedLabel = basePoint?.tooltipLabel ?? label;
|
||||||
const isFuturePoint = basePoint?.isFuture ?? false;
|
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 (
|
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">{resolvedLabel}</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) => {
|
{orderedPayload.map((entry, index) => {
|
||||||
const key = (entry.dataKey ?? "") as ChartSeriesKey;
|
const key = (entry.dataKey ?? "") as ChartSeriesKey;
|
||||||
const rawValue = entry.value;
|
const rawValue = entry.value;
|
||||||
let formattedValue: string;
|
let formattedValue: string;
|
||||||
|
let percentageOfRevenue = "";
|
||||||
|
|
||||||
if (isFuturePoint || rawValue == null) {
|
if (isFuturePoint || rawValue == null) {
|
||||||
formattedValue = "—";
|
formattedValue = "—";
|
||||||
} else if (typeof rawValue === "number") {
|
} else if (typeof rawValue === "number") {
|
||||||
formattedValue = key === "margin" ? formatPercentage(rawValue, 1) : formatCurrency(rawValue, 0);
|
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") {
|
} else if (typeof rawValue === "string") {
|
||||||
formattedValue = rawValue;
|
formattedValue = rawValue;
|
||||||
} else {
|
} else {
|
||||||
@@ -1596,10 +1783,15 @@ const FinancialTooltip = ({ active, payload, label }: TooltipProps<number, strin
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`${key}-${index}`} className="flex items-center justify-between gap-4">
|
<div key={`${key}-${index}`} className="flex items-center justify-between gap-4">
|
||||||
<span className="flex items-center gap-1" style={{ color: entry.stroke || entry.color || "inherit" }}>
|
<span
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
style={{ color: entry.stroke || entry.color || "inherit" }}
|
||||||
|
>
|
||||||
{SERIES_LABELS[key] ?? entry.name ?? key}
|
{SERIES_LABELS[key] ?? entry.name ?? key}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">{formattedValue}</span>
|
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{formattedValue}{percentageOfRevenue}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { Calendar } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
generateNaturalLanguagePreview,
|
generateNaturalLanguagePreview,
|
||||||
parseNaturalLanguagePeriod,
|
parseNaturalLanguagePeriod,
|
||||||
@@ -22,29 +22,6 @@ export type QuickPreset =
|
|||||||
| "lastQuarter"
|
| "lastQuarter"
|
||||||
| "thisYear";
|
| "thisYear";
|
||||||
|
|
||||||
const SUGGESTIONS = [
|
|
||||||
"last 30 days",
|
|
||||||
"this month",
|
|
||||||
"last month",
|
|
||||||
"this quarter",
|
|
||||||
"last quarter",
|
|
||||||
"this year",
|
|
||||||
"last year",
|
|
||||||
"last 3 months",
|
|
||||||
"last 6 months",
|
|
||||||
"last 2 quarters",
|
|
||||||
"Q1 2024",
|
|
||||||
"q1-q3 24",
|
|
||||||
"q1 24 - q2 25",
|
|
||||||
"January 2024",
|
|
||||||
"jan-24",
|
|
||||||
"jan-may 24",
|
|
||||||
"2023",
|
|
||||||
"2021-2023",
|
|
||||||
"21-23",
|
|
||||||
"January to March 2024",
|
|
||||||
"jan 2023 - may 2024",
|
|
||||||
];
|
|
||||||
|
|
||||||
interface PeriodSelectionPopoverProps {
|
interface PeriodSelectionPopoverProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -66,17 +43,7 @@ const PeriodSelectionPopover = ({
|
|||||||
onApplyResult,
|
onApplyResult,
|
||||||
}: PeriodSelectionPopoverProps) => {
|
}: PeriodSelectionPopoverProps) => {
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
||||||
|
|
||||||
const filteredSuggestions = useMemo(() => {
|
|
||||||
if (!inputValue) {
|
|
||||||
return SUGGESTIONS;
|
|
||||||
}
|
|
||||||
return SUGGESTIONS.filter((suggestion) =>
|
|
||||||
suggestion.toLowerCase().includes(inputValue.toLowerCase()) &&
|
|
||||||
suggestion.toLowerCase() !== inputValue.toLowerCase()
|
|
||||||
);
|
|
||||||
}, [inputValue]);
|
|
||||||
|
|
||||||
const preview = useMemo(() => {
|
const preview = useMemo(() => {
|
||||||
if (!inputValue) {
|
if (!inputValue) {
|
||||||
@@ -95,7 +62,6 @@ const PeriodSelectionPopover = ({
|
|||||||
|
|
||||||
const resetInput = () => {
|
const resetInput = () => {
|
||||||
setInputValue("");
|
setInputValue("");
|
||||||
setShowSuggestions(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyResult = (value: string) => {
|
const applyResult = (value: string) => {
|
||||||
@@ -110,7 +76,6 @@ const PeriodSelectionPopover = ({
|
|||||||
|
|
||||||
const handleInputChange = (value: string) => {
|
const handleInputChange = (value: string) => {
|
||||||
setInputValue(value);
|
setInputValue(value);
|
||||||
setShowSuggestions(value.length > 0);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
|
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
|
||||||
@@ -123,10 +88,6 @@ const PeriodSelectionPopover = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSuggestionClick = (suggestion: string) => {
|
|
||||||
setInputValue(suggestion);
|
|
||||||
applyResult(suggestion);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleQuickSelect = (preset: QuickPreset) => {
|
const handleQuickSelect = (preset: QuickPreset) => {
|
||||||
onQuickSelect(preset);
|
onQuickSelect(preset);
|
||||||
@@ -138,8 +99,8 @@ const PeriodSelectionPopover = ({
|
|||||||
<Popover open={open} onOpenChange={onOpenChange}>
|
<Popover open={open} onOpenChange={onOpenChange}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" className="h-9">
|
<Button variant="outline" className="h-9">
|
||||||
<Calendar className="w-4 h-4 mr-2" />
|
|
||||||
{selectedLabel}
|
{selectedLabel}
|
||||||
|
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-96 p-4" align="end">
|
<PopoverContent className="w-96 p-4" align="end">
|
||||||
@@ -200,11 +161,10 @@ const PeriodSelectionPopover = ({
|
|||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="text-xs text-muted-foreground">Or type a custom period</div>
|
<div className="text-xs text-muted-foreground">Or enter a custom period:</div>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
placeholder="e.g., jan-may 24, 2021-2023, Q1-Q3 2024"
|
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(event) => handleInputChange(event.target.value)}
|
onChange={(event) => handleInputChange(event.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
@@ -212,52 +172,22 @@ const PeriodSelectionPopover = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{inputValue && (
|
{inputValue && (
|
||||||
<div className="mt-2">
|
<div className="mt-2 ml-3">
|
||||||
{preview.label ? (
|
{preview.label ? (
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<span className="text-muted-foreground">Recognized as:</span>
|
|
||||||
<span className="font-medium text-green-600 dark:text-green-400">
|
<span className="font-medium text-green-600 dark:text-green-400">
|
||||||
{preview.label}
|
{preview.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-xs text-amber-600 dark:text-amber-400">
|
<div className="text-xs text-amber-600 dark:text-amber-400">
|
||||||
Not recognized - try a different format
|
Not recognized
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showSuggestions && filteredSuggestions.length > 0 && (
|
|
||||||
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-white dark:bg-gray-800 border rounded-md shadow-lg max-h-32 overflow-y-auto">
|
|
||||||
{filteredSuggestions.slice(0, 6).map((suggestion) => (
|
|
||||||
<button
|
|
||||||
key={suggestion}
|
|
||||||
className="w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors"
|
|
||||||
onClick={() => handleSuggestionClick(suggestion)}
|
|
||||||
>
|
|
||||||
{suggestion}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{inputValue === "" && (
|
|
||||||
<div className="text-xs text-muted-foreground mt-2">
|
|
||||||
<div className="mb-1">Examples:</div>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{SUGGESTIONS.slice(0, 6).map((suggestion) => (
|
|
||||||
<button
|
|
||||||
key={suggestion}
|
|
||||||
className="px-2 py-0.5 bg-muted hover:bg-muted/80 rounded text-xs transition-colors"
|
|
||||||
onClick={() => handleSuggestionClick(suggestion)}
|
|
||||||
>
|
|
||||||
{suggestion}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user