diff --git a/inventory-server/dashboard/acot-server/routes/events.js b/inventory-server/dashboard/acot-server/routes/events.js index aac51c2..c340c23 100644 --- a/inventory-server/dashboard/acot-server/routes/events.js +++ b/inventory-server/dashboard/acot-server/routes/events.js @@ -309,9 +309,9 @@ router.get('/stats/details', async (req, res) => { const { timeRange, startDate, endDate, metric, daily } = req.query; const { connection, release: releaseConn } = await getDbConnection(); release = releaseConn; - + const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate); - + // Daily breakdown query const dailyQuery = ` SELECT @@ -410,6 +410,68 @@ router.get('/stats/details', async (req, res) => { } }); +// Financial performance endpoint +router.get('/financials', async (req, res) => { + let release; + try { + const { timeRange, startDate, endDate } = req.query; + const { connection, release: releaseConn } = await getDbConnection(); + release = releaseConn; + + const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate); + const financialWhere = whereClause.replace(/date_placed/g, 'date_change'); + + const [totalsRows] = await connection.execute( + buildFinancialTotalsQuery(financialWhere), + params + ); + + const totals = normalizeFinancialTotals(totalsRows[0]); + + const [trendRows] = await connection.execute( + buildFinancialTrendQuery(financialWhere), + params + ); + + const trend = trendRows.map(normalizeFinancialTrendRow); + + let previousTotals = null; + let comparison = null; + + const previousRange = getPreviousPeriodRange(timeRange, startDate, endDate); + if (previousRange) { + const prevWhere = previousRange.whereClause.replace(/date_placed/g, 'date_change'); + const [previousRows] = await connection.execute( + buildFinancialTotalsQuery(prevWhere), + previousRange.params + ); + previousTotals = normalizeFinancialTotals(previousRows[0]); + comparison = { + grossSales: calculateComparison(totals.grossSales, previousTotals.grossSales), + refunds: calculateComparison(totals.refunds, previousTotals.refunds), + taxCollected: calculateComparison(totals.taxCollected, previousTotals.taxCollected), + cogs: calculateComparison(totals.cogs, previousTotals.cogs), + netRevenue: calculateComparison(totals.netRevenue, previousTotals.netRevenue), + profit: calculateComparison(totals.profit, previousTotals.profit), + margin: calculateComparison(totals.margin, previousTotals.margin), + }; + } + + res.json({ + dateRange, + totals, + previousTotals, + comparison, + trend, + }); + } catch (error) { + console.error('Error in /financials:', error); + res.status(500).json({ error: error.message }); + } finally { + if (release) release(); + } +}); + // Products endpoint - replaces /api/klaviyo/events/products router.get('/products', async (req, res) => { let release; @@ -639,6 +701,132 @@ function calculatePeriodProgress(timeRange) { } } +function buildFinancialTotalsQuery(whereClause) { + return ` + SELECT + COALESCE(SUM(sale_amount), 0) as grossSales, + COALESCE(SUM(refund_amount), 0) as refunds, + COALESCE(SUM(tax_collected_amount), 0) as taxCollected, + COALESCE(SUM(cogs_amount), 0) as cogs + FROM report_sales_data + WHERE ${whereClause} + `; +} + +function buildFinancialTrendQuery(whereClause) { + return ` + SELECT + DATE(date_change) as date, + SUM(sale_amount) as grossSales, + SUM(refund_amount) as refunds, + SUM(tax_collected_amount) as taxCollected, + SUM(cogs_amount) as cogs + FROM report_sales_data + WHERE ${whereClause} + GROUP BY DATE(date_change) + ORDER BY date ASC + `; +} + +function normalizeFinancialTotals(row = {}) { + const grossSales = parseFloat(row.grossSales || 0); + const refunds = parseFloat(row.refunds || 0); + const taxCollected = parseFloat(row.taxCollected || 0); + const cogs = parseFloat(row.cogs || 0); + const netSales = grossSales - refunds; + const netRevenue = netSales - taxCollected; + const profit = netRevenue - cogs; + const margin = netRevenue !== 0 ? (profit / netRevenue) * 100 : 0; + + return { + grossSales, + refunds, + taxCollected, + cogs, + netSales, + netRevenue, + profit, + margin, + }; +} + +function normalizeFinancialTrendRow(row = {}) { + const grossSales = parseFloat(row.grossSales || 0); + const refunds = parseFloat(row.refunds || 0); + const taxCollected = parseFloat(row.taxCollected || 0); + const cogs = parseFloat(row.cogs || 0); + const netSales = grossSales - refunds; + const netRevenue = netSales - taxCollected; + const profit = netRevenue - cogs; + const margin = netRevenue !== 0 ? (profit / netRevenue) * 100 : 0; + let timestamp = null; + + if (row.date instanceof Date) { + timestamp = new Date(row.date.getTime()).toISOString(); + } else if (typeof row.date === 'string') { + timestamp = new Date(`${row.date}T00:00:00Z`).toISOString(); + } + + return { + date: row.date, + grossSales, + refunds, + taxCollected, + cogs, + netSales, + netRevenue, + profit, + margin, + timestamp, + }; +} + +function calculateComparison(currentValue, previousValue) { + if (typeof previousValue !== 'number') { + return { absolute: null, percentage: null }; + } + + const absolute = typeof currentValue === 'number' ? currentValue - previousValue : null; + const percentage = + absolute !== null && previousValue !== 0 + ? (absolute / Math.abs(previousValue)) * 100 + : null; + + return { absolute, percentage }; +} + +function getPreviousPeriodRange(timeRange, startDate, endDate) { + if (timeRange && timeRange !== 'custom') { + const prevTimeRange = getPreviousTimeRange(timeRange); + if (!prevTimeRange || prevTimeRange === timeRange) { + return null; + } + return getTimeRangeConditions(prevTimeRange); + } + + const hasCustomDates = (timeRange === 'custom' || !timeRange) && startDate && endDate; + if (!hasCustomDates) { + return null; + } + + const start = new Date(startDate); + const end = new Date(endDate); + + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { + return null; + } + + const duration = end.getTime() - start.getTime(); + if (!Number.isFinite(duration) || duration <= 0) { + return null; + } + + const prevEnd = new Date(start.getTime() - 1); + const prevStart = new Date(prevEnd.getTime() - duration); + + return getTimeRangeConditions('custom', prevStart.toISOString(), prevEnd.toISOString()); +} + async function getPreviousPeriodData(connection, timeRange, startDate, endDate) { // Calculate previous period dates let prevWhereClause, prevParams; @@ -764,4 +952,4 @@ router.get('/debug/pool', (req, res) => { }); }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/inventory/src/components/dashboard/FinancialOverview.tsx b/inventory/src/components/dashboard/FinancialOverview.tsx new file mode 100644 index 0000000..d703be0 --- /dev/null +++ b/inventory/src/components/dashboard/FinancialOverview.tsx @@ -0,0 +1,1266 @@ +import { useEffect, useMemo, useState } from "react"; +import { acotService } from "@/services/dashboard/acotService"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/dashboard/ui/card"; +import { Button } from "@/components/dashboard/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/dashboard/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/dashboard/ui/dialog"; +import { Separator } from "@/components/dashboard/ui/separator"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/dashboard/ui/table"; +import { + Area, + CartesianGrid, + ComposedChart, + Legend, + Line, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import type { TooltipProps } from "recharts"; +import { Alert, AlertDescription, AlertTitle } from "@/components/dashboard/ui/alert"; +import { Skeleton } from "@/components/dashboard/ui/skeleton"; +import { ArrowUpRight, ArrowDownRight, Minus, TrendingUp, AlertCircle } from "lucide-react"; + +type TrendDirection = "up" | "down" | "flat"; + +type TrendSummary = { + direction: TrendDirection; + label: string; +}; + +type ComparisonValue = { + absolute: number | null; + percentage: number | null; +}; + +type FinancialTotals = { + grossSales: number; + refunds: number; + taxCollected: number; + cogs: number; + netSales: number; + netRevenue: number; + profit: number; + margin: number; +}; + +type FinancialTrendPoint = { + date: string | Date | null; + grossSales: number; + refunds: number; + taxCollected: number; + cogs: number; + netSales: number; + netRevenue: number; + profit: number; + margin: number; + timestamp: string | null; +}; + +type FinancialComparison = { + grossSales?: ComparisonValue; + refunds?: ComparisonValue; + taxCollected?: ComparisonValue; + cogs?: ComparisonValue; + netRevenue?: 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 MonthPeriod = { + type: "month"; + startYear: number; + startMonth: number; + count: number; +}; + +type QuarterPeriod = { + type: "quarter"; + startYear: number; + startQuarter: number; + count: number; +}; + +type YearPeriod = { + type: "year"; + startYear: number; + count: number; +}; + +type CustomPeriod = MonthPeriod | QuarterPeriod | YearPeriod; + +type ChartSeriesKey = "grossSales" | "netRevenue" | "cogs" | "profit" | "margin"; + +type ChartPoint = { + label: string; + timestamp: string | null; + grossSales: number; + netRevenue: number; + cogs: number; + profit: number; + margin: number; +}; + +const chartColors: Record = { + grossSales: "#7c3aed", + netRevenue: "#6366f1", + cogs: "#f97316", + profit: "#10b981", + margin: "#0ea5e9", +}; + +const SERIES_LABELS: Record = { + grossSales: "Gross Sales", + netRevenue: "Net Revenue", + cogs: "COGS", + profit: "Profit", + margin: "Profit Margin", +}; + +const SERIES_DEFINITIONS: Array<{ + key: ChartSeriesKey; + label: string; + type: "currency" | "percentage"; +}> = [ + { key: "grossSales", label: SERIES_LABELS.grossSales, type: "currency" }, + { key: "netRevenue", label: SERIES_LABELS.netRevenue, type: "currency" }, + { key: "cogs", label: SERIES_LABELS.cogs, type: "currency" }, + { key: "profit", label: SERIES_LABELS.profit, type: "currency" }, + { key: "margin", label: SERIES_LABELS.margin, type: "percentage" }, +]; + +const MONTHS = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; + +const QUARTERS = ["Q1", "Q2", "Q3", "Q4"]; + +const MONTH_COUNT_LIMIT = 12; +const QUARTER_COUNT_LIMIT = 8; +const YEAR_COUNT_LIMIT = 5; + +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}`; +} + +function formatEndPeriodLabel(period: CustomPeriod, count: number): string { + const safe = ensureValidCustomPeriod(period); + switch (safe.type) { + case "month": { + const end = new Date(safe.startYear, safe.startMonth + count - 1, 1); + return formatMonthLabel(end.getFullYear(), end.getMonth()); + } + case "quarter": { + const startMonth = safe.startQuarter * 3; + const end = new Date(safe.startYear, startMonth + (count - 1) * 3, 1); + const endQuarter = Math.floor(end.getMonth() / 3); + return formatQuarterLabel(end.getFullYear(), endQuarter); + } + case "year": + default: { + const endYear = safe.startYear + count - 1; + return `${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}`; +}; + +function formatDetailLabel(timestamp: string | null, fallback = "") { + if (!timestamp) { + return fallback; + } + + const parsed = new Date(timestamp); + if (Number.isNaN(parsed.getTime())) { + return fallback || timestamp; + } + + return parsed.toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + }); +} + +const buildTrendLabel = ( + comparison?: ComparisonValue | null, + options?: { isPercentage?: boolean } +): TrendSummary | null => { + if (!comparison || comparison.absolute === null) { + return null; + } + + const { absolute, percentage } = comparison; + const direction: TrendDirection = absolute > 0 ? "up" : absolute < 0 ? "down" : "flat"; + const absoluteValue = Math.abs(absolute); + + if (options?.isPercentage) { + return { + direction, + label: `${absolute > 0 ? "+" : absolute < 0 ? "-" : ""}${formatPercentage(absoluteValue, 1, "pp")} vs previous`, + }; + } + + if (typeof percentage === "number" && Number.isFinite(percentage)) { + return { + direction, + label: `${absolute > 0 ? "+" : absolute < 0 ? "-" : ""}${formatPercentage(Math.abs(percentage), 1)} vs previous`, + }; + } + + return { + direction, + label: `${absolute > 0 ? "+" : absolute < 0 ? "-" : ""}${formatCurrency(absoluteValue)} vs previous`, + }; +}; + +const generateYearOptions = (span: number) => { + const currentYear = new Date().getFullYear(); + return Array.from({ length: span }, (_, index) => currentYear - index); +}; + +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 [viewMode, setViewMode] = useState<"rolling" | "custom">("rolling"); + const [customPeriod, setCustomPeriod] = useState({ + type: "month", + startYear: currentYear, + startMonth: currentDate.getMonth(), + count: 1, + }); + const [metrics, setMetrics] = useState>({ + grossSales: true, + netRevenue: true, + cogs: false, + profit: true, + margin: true, + }); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const yearOptions = useMemo(() => generateYearOptions(12), []); + + useEffect(() => { + let cancelled = false; + + const fetchData = async () => { + setLoading(true); + setError(null); + + try { + const params: Record = {}; + + if (viewMode === "rolling") { + params.timeRange = "last30days"; + } else { + const range = computePeriodRange(customPeriod); + if (!range) { + setLoading(false); + return; + } + params.timeRange = "custom"; + params.startDate = range.start.toISOString(); + params.endDate = range.end.toISOString(); + } + + 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; + }; + }, [viewMode, customPeriod]); + + const cards = useMemo( + () => { + if (!data?.totals) { + return [] as Array<{ + key: string; + title: string; + value: string; + description?: string; + trend: TrendSummary | null; + accentClass: string; + }>; + } + + const totals = data.totals; + const previous = data.previousTotals ?? undefined; + const comparison = data.comparison ?? {}; + + 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) : "—"; + + return [ + { + key: "grossSales", + title: "Gross Sales", + value: safeCurrency(totals.grossSales, 0), + description: previous?.grossSales != null ? `Previous: ${safeCurrency(previous.grossSales, 0)}` : undefined, + trend: buildTrendLabel(comparison.grossSales), + accentClass: "text-emerald-600 dark:text-emerald-400", + }, + { + key: "netRevenue", + title: "Net Revenue", + value: safeCurrency(totals.netRevenue, 0), + description: previous?.netRevenue != null ? `Previous: ${safeCurrency(previous.netRevenue, 0)}` : undefined, + trend: buildTrendLabel(comparison.netRevenue), + accentClass: "text-indigo-600 dark:text-indigo-400", + }, + { + key: "profit", + title: "Profit", + value: safeCurrency(totals.profit, 0), + description: previous?.profit != null ? `Previous: ${safeCurrency(previous.profit, 0)}` : undefined, + trend: buildTrendLabel(comparison.profit), + accentClass: "text-emerald-600 dark:text-emerald-400", + }, + { + key: "margin", + title: "Profit Margin", + value: safePercentage(totals.margin, 1), + description: previous?.margin != null ? `Previous: ${safePercentage(previous.margin, 1)}` : undefined, + trend: buildTrendLabel(comparison.margin, { isPercentage: true }), + accentClass: "text-sky-600 dark:text-sky-400", + }, + ]; + }, + [data] + ); + + const chartData = useMemo(() => { + if (!data?.trend?.length) { + return []; + } + + return data.trend.map((point) => { + const timestamp = point.timestamp + ?? (typeof point.date === "string" + ? point.date + : point.date instanceof Date + ? point.date.toISOString() + : null); + + let labelDate: Date | null = null; + if (timestamp) { + const parsed = new Date(timestamp); + if (!Number.isNaN(parsed.getTime())) { + labelDate = parsed; + } + } else if (point.date instanceof Date && !Number.isNaN(point.date.getTime())) { + labelDate = point.date; + } + + const label = labelDate + ? labelDate.toLocaleDateString("en-US", { month: "short", day: "numeric" }) + : typeof point.date === "string" + ? point.date + : ""; + + return { + label, + timestamp, + grossSales: Number.isFinite(point.grossSales) ? point.grossSales : 0, + netRevenue: Number.isFinite(point.netRevenue) ? point.netRevenue : 0, + cogs: Number.isFinite(point.cogs) ? point.cogs : 0, + profit: Number.isFinite(point.profit) ? point.profit : 0, + margin: Number.isFinite(point.margin) ? point.margin : 0, + }; + }); + }, [data]); + + const selectedRangeLabel = useMemo(() => { + if (viewMode === "rolling") { + return "Last 30 Days"; + } + const label = formatPeriodRangeLabel(customPeriod); + return label || ""; + }, [viewMode, customPeriod]); + + const marginDomain = useMemo<[number, number]>(() => { + if (!metrics.margin || !chartData.length) { + return [0, 100]; + } + + const values = chartData + .map((point) => point.margin) + .filter((value) => Number.isFinite(value)) as number[]; + + 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]); + + const hasActiveMetrics = useMemo(() => Object.values(metrics).some(Boolean), [metrics]); + + const detailRows = useMemo(() => { + if (!data?.trend?.length) { + return [] as Array<{ + id: string; + label: string; + timestamp: string | null; + grossSales: number; + netRevenue: number; + cogs: number; + profit: number; + margin: number; + }>; + } + + return data.trend.map((point, index) => { + const timestamp = + point.timestamp ?? + (typeof point.date === "string" + ? point.date + : point.date instanceof Date + ? point.date.toISOString() + : null); + + const fallbackLabel = chartData[index]?.label ?? (typeof point.date === "string" ? point.date : ""); + const label = formatDetailLabel(timestamp, fallbackLabel); + + const safe = (value: number | null | undefined) => + typeof value === "number" && Number.isFinite(value) ? value : 0; + + return { + id: timestamp ?? `row-${index}`, + label, + timestamp, + grossSales: safe(point.grossSales), + netRevenue: safe(point.netRevenue), + cogs: safe(point.cogs), + profit: safe(point.profit), + margin: safe(point.margin), + }; + }); + }, [data, chartData]); + + const hasData = chartData.length > 0; + + const handleViewModeChange = (mode: "rolling" | "custom") => { + setViewMode(mode); + }; + + const handleCustomTypeChange = (value: string) => { + const nextType = value as CustomPeriod["type"]; + setViewMode("custom"); + setCustomPeriod((prev) => { + const safePrev = ensureValidCustomPeriod(prev); + switch (nextType) { + case "month": { + const startMonth = safePrev.type === "month" ? safePrev.startMonth : currentDate.getMonth(); + return { + type: "month", + startYear: safePrev.startYear, + startMonth, + count: Math.min(safePrev.count, MONTH_COUNT_LIMIT), + }; + } + case "quarter": { + const startQuarter = + safePrev.type === "quarter" + ? safePrev.startQuarter + : Math.floor(currentDate.getMonth() / 3); + return { + type: "quarter", + startYear: safePrev.startYear, + startQuarter, + count: Math.min(Math.max(Math.ceil(safePrev.count / 3), 1), QUARTER_COUNT_LIMIT), + }; + } + case "year": + default: + return { + type: "year", + startYear: safePrev.startYear, + count: Math.min(Math.max(Math.ceil(safePrev.count / 12), 1), YEAR_COUNT_LIMIT), + }; + } + }); + }; + + const handleStartYearChange = (value: string) => { + const nextYear = Number(value); + setCustomPeriod((prev) => { + const safePrev = ensureValidCustomPeriod(prev); + switch (safePrev.type) { + case "month": + return { ...safePrev, startYear: nextYear }; + case "quarter": + return { ...safePrev, startYear: nextYear }; + case "year": + default: + return { ...safePrev, startYear: nextYear }; + } + }); + }; + + const handleStartMonthChange = (value: string) => { + const nextMonth = Number(value); + setCustomPeriod((prev) => { + if (prev.type !== "month") { + return prev; + } + return { ...prev, startMonth: nextMonth }; + }); + }; + + const handleStartQuarterChange = (value: string) => { + const nextQuarter = Number(value); + setCustomPeriod((prev) => { + if (prev.type !== "quarter") { + return prev; + } + return { ...prev, startQuarter: nextQuarter }; + }); + }; + + const handleCountChange = (value: string) => { + const nextCount = Number(value); + setCustomPeriod((prev) => { + const safePrev = ensureValidCustomPeriod(prev); + return ensureValidCustomPeriod({ ...safePrev, count: nextCount }); + }); + }; + + const endPeriodOptions = useMemo(() => { + const safePrev = ensureValidCustomPeriod(customPeriod); + const limit = safePrev.type === "month" + ? MONTH_COUNT_LIMIT + : safePrev.type === "quarter" + ? QUARTER_COUNT_LIMIT + : YEAR_COUNT_LIMIT; + + return Array.from({ length: limit }, (_, index) => { + const count = index + 1; + return { + count, + label: formatEndPeriodLabel(safePrev, count), + }; + }); + }, [customPeriod]); + + const isSeriesVisible = (series: ChartSeriesKey) => Boolean(metrics[series]); + + const toggleMetric = (series: ChartSeriesKey) => { + setMetrics((prev) => ({ + ...prev, + [series]: !prev[series], + })); + }; + + const canShowDetails = !error; + + return ( + + +
+
+
+ + Financial Overview + + + {data?.dateRange?.label || "Key financial metrics for your selected period"} + +
+ {canShowDetails ? ( + detailRows.length ? ( + + + + + + + Financial Details + + Explore the daily breakdown for the selected metrics. + +
+ {SERIES_DEFINITIONS.map((series) => ( + + ))} +
+
+ +
+ {detailRows.length ? ( +
+ + + + Date + {metrics.grossSales && ( + Gross Sales + )} + {metrics.netRevenue && ( + Net Revenue + )} + {metrics.profit && ( + Profit + )} + {metrics.cogs && ( + COGS + )} + {metrics.margin && ( + Margin + )} + + + + {detailRows.map((row) => ( + + + {row.label || "—"} + + {metrics.grossSales && ( + + {formatCurrency(row.grossSales, 0)} + + )} + {metrics.netRevenue && ( + + {formatCurrency(row.netRevenue, 0)} + + )} + {metrics.profit && ( + + {formatCurrency(row.profit, 0)} + + )} + {metrics.cogs && ( + + {formatCurrency(row.cogs, 0)} + + )} + {metrics.margin && ( + + {formatPercentage(row.margin, 1)} + + )} + + ))} + +
+
+ ) : ( +
+ No detailed data available for this range. +
+ )} +
+
+
+ ) : ( + + ) + ) : null} +
+
+
+
+ + +
+ {viewMode === "custom" && ( +
+ + + + + {customPeriod.type === "month" && ( + + )} + + {customPeriod.type === "quarter" && ( + + )} + + +
+ )} +
+ Range: {selectedRangeLabel} +
+
+
+ + {loading ? ( +
+ + +
+ ) : error ? ( + + + Error + {error} + + ) : ( + <> + {cards.length ? : null} +
+
+ Chart Metrics +
+ {SERIES_DEFINITIONS.map((series) => ( + + ))} +
+
+
+ {!hasActiveMetrics ? ( + + ) : hasData ? ( + + + + + + + + + + + + + + + + + + + + + + + formatCurrency(value, 0)} + className="text-xs text-muted-foreground" + /> + {metrics.margin && ( + formatPercentage(value, 0)} + domain={marginDomain} + className="text-xs text-muted-foreground" + /> + )} + } /> + SERIES_LABELS[value as ChartSeriesKey] ?? value} /> + {metrics.grossSales ? ( + + ) : null} + {metrics.netRevenue ? ( + + ) : null} + {metrics.cogs ? ( + + ) : null} + {metrics.profit ? ( + + ) : null} + {metrics.margin ? ( + + ) : null} + + + ) : ( + + )} +
+
+ + )} +
+
+ ); +}; + +function FinancialStatGrid({ + cards, +}: { + cards: Array<{ + key: string; + title: string; + value: string; + description?: string; + trend: TrendSummary | null; + accentClass: string; + }>; +}) { + return ( +
+ {cards.map((card) => ( + + ))} +
+ ); +} + +function FinancialStatCard({ + title, + value, + description, + trend, + accentClass, +}: { + title: string; + value: string; + description?: string; + trend: TrendSummary | null; + accentClass: string; +}) { + return ( + + + {title} + {trend?.label && ( + + {trend.direction === "up" ? ( + + ) : trend.direction === "down" ? ( + + ) : ( + + )} + {trend.label} + + )} + + +
{value}
+ {description &&
{description}
} +
+
+ ); +} + +function SkeletonStats() { + return ( +
+ {Array.from({ length: 4 }).map((_, index) => ( + + + + + + + + + + + ))} +
+ ); +} + +function SkeletonChart() { + return ( +
+
+
+ {Array.from({ length: 5 }).map((_, index) => ( +
+ ))} +
+
+ {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
+
+ {Array.from({ length: 6 }).map((_, index) => ( + + ))} +
+
+ +
+
+
+ ); +} + +function EmptyChartState({ message }: { message: string }) { + return ( +
+ + {message} +
+ ); +} + +const FinancialTooltip = ({ active, payload, label }: TooltipProps) => { + if (!active || !payload?.length) { + return null; + } + + return ( +
+

{label}

+
+ {payload.map((entry, index) => { + const key = (entry.dataKey ?? "") as ChartSeriesKey; + const rawValue = entry.value; + const formattedValue = + typeof rawValue === "number" + ? key === "margin" + ? formatPercentage(rawValue, 1) + : formatCurrency(rawValue, 0) + : rawValue; + + return ( +
+ + {SERIES_LABELS[key] ?? entry.name ?? key} + + {formattedValue} +
+ ); + })} +
+
+ ); +}; + +export default FinancialOverview; diff --git a/inventory/src/pages/Dashboard.tsx b/inventory/src/pages/Dashboard.tsx index db075f9..158b7b6 100644 --- a/inventory/src/pages/Dashboard.tsx +++ b/inventory/src/pages/Dashboard.tsx @@ -3,6 +3,7 @@ import { ThemeProvider } from "@/components/dashboard/theme/ThemeProvider"; import AircallDashboard from "@/components/dashboard/AircallDashboard"; import EventFeed from "@/components/dashboard/EventFeed"; import StatCards from "@/components/dashboard/StatCards"; +import FinancialOverview from "@/components/dashboard/FinancialOverview"; import ProductGrid from "@/components/dashboard/ProductGrid"; import SalesChart from "@/components/dashboard/SalesChart"; import KlaviyoCampaigns from "@/components/dashboard/KlaviyoCampaigns"; @@ -42,6 +43,11 @@ export function Dashboard() {
+
+
+ +
+
@@ -87,4 +93,4 @@ export function Dashboard() { ); } -export default Dashboard; \ No newline at end of file +export default Dashboard; diff --git a/inventory/src/services/dashboard/acotService.js b/inventory/src/services/dashboard/acotService.js index 4893257..7565492 100644 --- a/inventory/src/services/dashboard/acotService.js +++ b/inventory/src/services/dashboard/acotService.js @@ -157,6 +157,17 @@ export const acotService = { ); }, + // Get financial performance data + getFinancials: async (params) => { + const cacheKey = `financials_${JSON.stringify(params)}`; + return deduplicatedRequest(cacheKey, () => + retryRequest(async () => { + const response = await acotApi.get('/api/acot/events/financials', { params }); + return response.data; + }) + ); + }, + // Get projections - replaces klaviyo events/projection getProjection: async (params) => { const cacheKey = `projection_${JSON.stringify(params)}`; @@ -172,4 +183,4 @@ export const acotService = { clearCache, }; -export default acotService; \ No newline at end of file +export default acotService; diff --git a/inventory/src/types/dashboard-shims.d.ts b/inventory/src/types/dashboard-shims.d.ts new file mode 100644 index 0000000..4491830 --- /dev/null +++ b/inventory/src/types/dashboard-shims.d.ts @@ -0,0 +1,71 @@ +declare module "@/services/dashboard/acotService" { + const acotService: { + getFinancials: (params: unknown) => Promise; + [key: string]: (...args: never[]) => Promise | unknown; + }; + + export { acotService }; + export default acotService; +} + +declare module "@/services/dashboard/*" { + const value: any; + export default value; +} + +declare module "@/lib/dashboard/constants" { + type TimeRangeOption = { + value: string; + label: string; + }; + + export const TIME_RANGES: TimeRangeOption[]; + export const GROUP_BY_OPTIONS: TimeRangeOption[]; + export const formatDateForInput: (date: Date | string | number | null) => string; + export const parseDateFromInput: (dateString: string) => Date | null; +} + +declare module "@/lib/dashboard/*" { + const value: any; + export default value; +} + +declare module "@/components/dashboard/ui/card" { + export const Card: React.ComponentType; + export const CardContent: React.ComponentType; + export const CardDescription: React.ComponentType; + export const CardHeader: React.ComponentType; + export const CardTitle: React.ComponentType; +} + +declare module "@/components/dashboard/ui/button" { + export const Button: React.ComponentType; +} + +declare module "@/components/dashboard/ui/select" { + export const Select: React.ComponentType; + export const SelectContent: React.ComponentType; + export const SelectItem: React.ComponentType; + export const SelectTrigger: React.ComponentType; + export const SelectValue: React.ComponentType; +} + +declare module "@/components/dashboard/ui/alert" { + export const Alert: React.ComponentType; + export const AlertDescription: React.ComponentType; + export const AlertTitle: React.ComponentType; +} + +declare module "@/components/dashboard/ui/skeleton" { + export const Skeleton: React.ComponentType; +} + +declare module "@/components/dashboard/ui/*" { + const components: any; + export default components; +} + +declare module "@/components/dashboard/ui/toggle-group" { + export const ToggleGroup: React.ComponentType; + export const ToggleGroupItem: React.ComponentType; +}