Layout tweaks for financial overview, add cogs % line

This commit is contained in:
2025-09-23 11:45:01 -04:00
parent d8b39979cd
commit 2fe7fd5b2f
3 changed files with 313 additions and 191 deletions

View File

@@ -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>
); );
})} })}

View File

@@ -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