Small dashboard updates

This commit is contained in:
2026-03-24 09:56:51 -04:00
parent 884bcbad78
commit 76a8836769
13 changed files with 982 additions and 462 deletions

View File

@@ -989,6 +989,79 @@ router.get('/best-sellers', async (req, res) => {
} }
}); });
// GET /dashboard/year-revenue-estimate
// Returns YTD actual revenue + rest-of-year forecast revenue for a full-year estimate
router.get('/year-revenue-estimate', async (req, res) => {
const now = new Date();
const yearStart = `${now.getFullYear()}-01-01`;
const todayISO = now.toISOString().split('T')[0];
const yearEndISO = `${now.getFullYear()}-12-31`;
try {
// YTD actual revenue from orders
const { rows: [ytd] } = await executeQuery(`
SELECT COALESCE(SUM(price * quantity), 0) AS revenue
FROM orders
WHERE date >= $1 AND date <= $2 AND canceled = false
`, [yearStart, todayISO]);
// Forecast horizon
const { rows: [horizonRow] } = await executeQuery(
`SELECT MAX(forecast_date) AS max_date FROM product_forecasts`
);
const forecastHorizonISO = horizonRow?.max_date
? (horizonRow.max_date instanceof Date
? horizonRow.max_date.toISOString().split('T')[0]
: horizonRow.max_date)
: todayISO;
const clampedEnd = yearEndISO <= forecastHorizonISO ? yearEndISO : forecastHorizonISO;
// Forecast revenue from tomorrow to clamped end
const { rows: [forecast] } = await executeQuery(`
SELECT COALESCE(SUM(pf.forecast_revenue), 0) AS revenue
FROM product_forecasts pf
JOIN product_metrics pm ON pm.pid = pf.pid
WHERE pm.is_visible = true
AND pf.forecast_date > $1 AND pf.forecast_date <= $2
`, [todayISO, clampedEnd]);
let eoyForecastRevenue = parseFloat(forecast.revenue) || 0;
// If forecast doesn't cover full year, extrapolate remaining days
if (yearEndISO > forecastHorizonISO) {
const { rows: [tailRow] } = await executeQuery(`
SELECT AVG(daily_rev) AS avg_daily FROM (
SELECT forecast_date, SUM(pf.forecast_revenue) AS daily_rev
FROM product_forecasts pf
JOIN product_metrics pm ON pm.pid = pf.pid
WHERE pm.is_visible = true
AND pf.forecast_date > ($1::date - INTERVAL '7 days')
AND pf.forecast_date <= $1
GROUP BY forecast_date
) sub
`, [forecastHorizonISO]);
const baselineDaily = parseFloat(tailRow?.avg_daily) || 0;
const horizonDate = new Date(forecastHorizonISO + 'T00:00:00');
const yearEnd = new Date(yearEndISO + 'T00:00:00');
const extraDays = Math.round((yearEnd - horizonDate) / (1000 * 60 * 60 * 24));
eoyForecastRevenue += baselineDaily * extraDays;
}
const ytdRevenue = parseFloat(ytd.revenue) || 0;
res.json({
ytdRevenue,
eoyForecastRevenue,
yearTotal: ytdRevenue + eoyForecastRevenue,
});
} catch (err) {
console.error('Error fetching year revenue estimate:', err);
res.status(500).json({ error: 'Failed to fetch year revenue estimate' });
}
});
// GET /dashboard/sales/metrics // GET /dashboard/sales/metrics
// Returns sales metrics for specified period // Returns sales metrics for specified period
router.get('/sales/metrics', async (req, res) => { router.get('/sales/metrics', async (req, res) => {

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Calendar as CalendarComponent } from '@/components/ui/calendaredit'; import { Calendar as CalendarComponent } from '@/components/ui/calendaredit';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
@@ -34,8 +35,44 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
const [prevTime, setPrevTime] = useState(getTimeComponents(new Date())); const [prevTime, setPrevTime] = useState(getTimeComponents(new Date()));
const [isTimeChanging, setIsTimeChanging] = useState(false); const [isTimeChanging, setIsTimeChanging] = useState(false);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [weather, setWeather] = useState(null); const { data: weatherData } = useQuery({
const [forecast, setForecast] = useState(null); queryKey: ["weather-current-forecast"],
queryFn: async () => {
const API_KEY = import.meta.env.VITE_OPENWEATHER_API_KEY;
const [weatherResponse, forecastResponse] = await Promise.all([
fetch(
`https://api.openweathermap.org/data/2.5/weather?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial`
),
fetch(
`https://api.openweathermap.org/data/2.5/forecast?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial`
)
]);
const weather = await weatherResponse.json();
const forecastData = await forecastResponse.json();
const dailyForecasts = forecastData.list.reduce((acc, item) => {
const date = new Date(item.dt * 1000).toLocaleDateString();
if (!acc[date]) {
acc[date] = {
...item,
precipitation: item.rain?.['3h'] || item.snow?.['3h'] || 0,
pop: item.pop * 100,
};
}
return acc;
}, {});
return {
weather,
forecast: Object.values(dailyForecasts).slice(0, 5),
};
},
refetchInterval: 300000,
});
const weather = weatherData?.weather ?? null;
const forecast = weatherData?.forecast ?? null;
useEffect(() => { useEffect(() => {
setTimeout(() => setMounted(true), 150); setTimeout(() => setMounted(true), 150);
@@ -43,60 +80,18 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
const timer = setInterval(() => { const timer = setInterval(() => {
const newDate = new Date(); const newDate = new Date();
const newTime = getTimeComponents(newDate); const newTime = getTimeComponents(newDate);
if (newTime.minutes !== prevTime.minutes) { if (newTime.minutes !== prevTime.minutes) {
setIsTimeChanging(true); setIsTimeChanging(true);
setTimeout(() => setIsTimeChanging(false), 200); setTimeout(() => setIsTimeChanging(false), 200);
} }
setPrevTime(newTime); setPrevTime(newTime);
setDatetime(newDate); setDatetime(newDate);
}, 1000); }, 1000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [prevTime]); }, [prevTime]);
useEffect(() => {
const fetchWeatherData = async () => {
try {
const API_KEY = import.meta.env.VITE_OPENWEATHER_API_KEY;
const [weatherResponse, forecastResponse] = await Promise.all([
fetch(
`https://api.openweathermap.org/data/2.5/weather?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial`
),
fetch(
`https://api.openweathermap.org/data/2.5/forecast?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial`
)
]);
const weatherData = await weatherResponse.json();
const forecastData = await forecastResponse.json();
setWeather(weatherData);
// Process forecast data to get daily forecasts with precipitation
const dailyForecasts = forecastData.list.reduce((acc, item) => {
const date = new Date(item.dt * 1000).toLocaleDateString();
if (!acc[date]) {
acc[date] = {
...item,
precipitation: item.rain?.['3h'] || item.snow?.['3h'] || 0,
pop: item.pop * 100 // Probability of precipitation as percentage
};
}
return acc;
}, {});
setForecast(Object.values(dailyForecasts).slice(0, 5));
} catch (error) {
console.error("Error fetching weather:", error);
}
};
fetchWeatherData();
const weatherTimer = setInterval(fetchWeatherData, 300000);
return () => clearInterval(weatherTimer);
}, []);
function getTimeComponents(date) { function getTimeComponents(date) {
let hours = date.getHours(); let hours = date.getHours();
const minutes = date.getMinutes(); const minutes = date.getMinutes();
@@ -242,7 +237,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
const WeatherDetails = () => ( const WeatherDetails = () => (
<div className="space-y-4 p-3"> <div className="space-y-4 p-3">
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
<Card className="bg-slate-700/50 backdrop-blur-sm border-white/[0.06] p-2"> <Card className="bg-slate-700/50 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] p-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<ThermometerSun className="w-5 h-5 text-yellow-300" /> <ThermometerSun className="w-5 h-5 text-yellow-300" />
<div className="flex flex-col"> <div className="flex flex-col">
@@ -252,7 +247,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
</div> </div>
</Card> </Card>
<Card className="bg-slate-700/50 backdrop-blur-sm border-white/[0.06] p-2"> <Card className="bg-slate-700/50 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] p-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<ThermometerSnowflake className="w-5 h-5 text-blue-300" /> <ThermometerSnowflake className="w-5 h-5 text-blue-300" />
<div className="flex flex-col"> <div className="flex flex-col">
@@ -262,7 +257,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
</div> </div>
</Card> </Card>
<Card className="bg-slate-700/50 backdrop-blur-sm border-white/[0.06] p-2"> <Card className="bg-slate-700/50 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] p-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Droplets className="w-5 h-5 text-blue-300" /> <Droplets className="w-5 h-5 text-blue-300" />
<div className="flex flex-col"> <div className="flex flex-col">
@@ -272,7 +267,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
</div> </div>
</Card> </Card>
<Card className="bg-slate-700/50 backdrop-blur-sm border-white/[0.06] p-2"> <Card className="bg-slate-700/50 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] p-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Wind className="w-5 h-5 text-slate-300" /> <Wind className="w-5 h-5 text-slate-300" />
<div className="flex flex-col"> <div className="flex flex-col">
@@ -282,7 +277,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
</div> </div>
</Card> </Card>
<Card className="bg-slate-700/50 backdrop-blur-sm border-white/[0.06] p-2"> <Card className="bg-slate-700/50 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] p-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Sunrise className="w-5 h-5 text-yellow-300" /> <Sunrise className="w-5 h-5 text-yellow-300" />
<div className="flex flex-col"> <div className="flex flex-col">
@@ -292,7 +287,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
</div> </div>
</Card> </Card>
<Card className="bg-slate-700/50 backdrop-blur-sm border-white/[0.06] p-2"> <Card className="bg-slate-700/50 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] p-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Sunset className="w-5 h-5 text-orange-300" /> <Sunset className="w-5 h-5 text-orange-300" />
<div className="flex flex-col"> <div className="flex flex-col">
@@ -314,7 +309,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
key={index} key={index}
className={cn( className={cn(
getWeatherBackground(day.weather[0].id, isNight), getWeatherBackground(day.weather[0].id, isNight),
"p-2" "p-2 border-white/[0.08] ring-1 ring-white/[0.05]"
)} )}
> >
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
@@ -365,23 +360,23 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
{/* Time Display */} {/* Time Display */}
<Card className="bg-gradient-to-br mb-[7px] from-indigo-900/70 to-blue-800/50 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] shadow-lg shadow-black/20 px-1 py-2 w-full hover:scale-[1.02] transition-transform duration-300"> <Card className="bg-gradient-to-br mb-[7px] from-indigo-900/70 to-blue-800/50 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] shadow-lg shadow-black/20 px-1 py-2 w-full hover:scale-[1.02] transition-transform duration-300">
<CardContent className="p-3 h-[106px] flex items-center"> <CardContent className="p-3 h-[106px] flex items-center">
<div className="flex justify-center items-baseline w-full"> <div className="flex justify-center items-baseline w-full tracking-tighter">
<div className={`transition-opacity duration-200 ${isTimeChanging ? 'opacity-60' : 'opacity-100'}`}> <div className={`transition-opacity duration-200 ${isTimeChanging ? 'opacity-60' : 'opacity-100'}`}>
<span className="text-6xl font-bold text-white">{hours}</span> <span className="text-7xl font-light text-white">{hours}</span>
<span className="text-6xl font-bold text-white">:</span> <span className="text-7xl font-light text-white">:</span>
<span className="text-6xl font-bold text-white">{minutes}</span> <span className="text-7xl font-light text-white">{minutes}</span>
<span className="text-lg font-medium text-white/90 ml-1">{ampm}</span> <span className="text-lg font-light text-white/90 ml-1">{ampm}</span>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Date and Weather Display */} {/* Date and Weather Display */}
<div className="h-[125px] mb-[6px] grid grid-cols-2 gap-2 w-full"> <div className="h-[121px] mb-[7px] grid grid-cols-2 gap-2 w-full">
<Card className="h-full bg-gradient-to-br from-violet-900/70 to-purple-800/50 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] shadow-lg shadow-black/20 flex items-center justify-center"> <Card className="h-full bg-gradient-to-br from-violet-900/70 to-purple-800/50 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] shadow-lg shadow-black/20 flex items-center justify-center">
<CardContent className="h-full p-0"> <CardContent className="h-full p-0">
<div className="flex flex-col items-center justify-center h-full"> <div className="flex flex-col items-center justify-center h-full">
<span className="text-6xl font-bold text-white"> <span className="text-6xl font-light text-white tracking-tighter">
{dateInfo.day} {dateInfo.day}
</span> </span>
<span className="text-sm font-bold text-white mt-2"> <span className="text-sm font-bold text-white mt-2">
@@ -404,7 +399,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
<CardContent className="h-full p-3"> <CardContent className="h-full p-3">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
{getWeatherIcon(weather.weather[0]?.id, datetime)} {getWeatherIcon(weather.weather[0]?.id, datetime)}
<span className="text-3xl font-bold ml-1 mt-2 text-white"> <span className="text-3xl font-normal ml-1 mt-2 text-white">
{Math.round(weather.main.temp)}° {Math.round(weather.main.temp)}°
</span> </span>
</div> </div>
@@ -417,7 +412,7 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
</Card> </Card>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
className="w-[450px] bg-gradient-to-br from-slate-800/90 to-slate-700/80 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05]" className="w-[450px] bg-gradient-to-br from-slate-800/90 to-slate-700/80 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] shadow-lg shadow-black/20"
align="start" align="start"
side="right" side="right"
sideOffset={10} sideOffset={10}
@@ -441,21 +436,29 @@ const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => {
</div> </div>
{/* Calendar Display */} {/* Calendar Display */}
<Card className="w-full bg-slate-800/50 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] shadow-lg shadow-black/20"> <Card className="w-full h-[208px] bg-gradient-to-br from-slate-900 to-slate-800/70 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] shadow-lg shadow-black/20">
<CardContent className="p-0"> <CardContent className="p-0">
<CalendarComponent <CalendarComponent
selected={datetime} selected={datetime}
className="w-full" className="w-full"
classNames={{ classNames={{
caption_label: "text-lg font-medium text-white", caption: "flex justify-center relative items-center mt-1 mb-2",
caption_label: "text-sm font-medium text-white",
nav_button: cn( nav_button: cn(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors", "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors",
"hover:bg-white/10 h-6 w-6 bg-transparent p-0 text-slate-300 hover:text-white" "hover:bg-white/10 h-5 w-5 bg-transparent p-0 text-slate-300 hover:text-white"
),
table: "w-full border-collapse",
head_cell: "text-slate-400 rounded-md font-normal text-[0.65rem] w-full mb-0.5",
row: "flex w-full",
cell: cn(
"w-full relative p-0 text-center text-xs focus-within:relative focus-within:z-20",
"[&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50",
"[&:has([aria-selected])]:rounded-md"
), ),
head_cell: "text-slate-400 rounded-md w-6 font-normal text-[0.7rem] w-full",
day: cn( day: cn(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors", "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors",
"hover:bg-white/10 h-6 w-6 p-0 font-normal text-xs text-slate-200 aria-selected:opacity-100" "hover:bg-white/10 h-6 w-full p-0 font-normal text-xs text-slate-200 aria-selected:opacity-100"
), ),
day_selected: "bg-indigo-500/60 text-white hover:bg-indigo-500/70 focus:bg-indigo-500/70", day_selected: "bg-indigo-500/60 text-white hover:bg-indigo-500/70 focus:bg-indigo-500/70",
day_today: "bg-white/10 text-white font-semibold", day_today: "bg-white/10 text-white font-semibold",

View File

@@ -416,17 +416,15 @@ const ShippingInfo = ({ details }) => (
</div> </div>
); );
const EventDialog = ({ event, children }) => { const EventDialog = ({ event, children, scale }) => {
const eventType = EVENT_TYPES[event.metric_id]; const eventType = EVENT_TYPES[event.metric_id];
if (!eventType) return children; if (!eventType) return children;
const details = event.event_properties || {}; const details = event.event_properties || {};
const Icon = EVENT_ICONS[event.metric_id] || Package; const Icon = EVENT_ICONS[event.metric_id] || Package;
return ( const dialogInner = (
<Dialog> <>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader className="border-b border-border px-6 py-4"> <DialogHeader className="border-b border-border px-6 py-4">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
{Icon && <Icon className={`h-5 w-5 ${eventType.textColor}`} />} {Icon && <Icon className={`h-5 w-5 ${eventType.textColor}`} />}
@@ -797,6 +795,24 @@ const EventDialog = ({ event, children }) => {
)} )}
</div> </div>
</div> </div>
</>
);
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className={scale
? "w-[80vw] h-[80vh] max-w-none p-0 overflow-hidden"
: "max-w-2xl max-h-[85vh] overflow-hidden flex flex-col"
}>
{scale ? (
<div
className="origin-top-left flex flex-col overflow-auto"
style={{ transform: `scale(${scale})`, width: `${100 / scale}%`, height: `${100 / scale}%` }}
>
{dialogInner}
</div>
) : dialogInner}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -0,0 +1,241 @@
import React from "react";
import { useQuery } from "@tanstack/react-query";
import { TrendingUp, DollarSign, Percent, Briefcase } from "lucide-react";
import { DashboardMultiStatCardMini } from "@/components/dashboard/shared";
import { acotService } from "@/services/dashboard/acotService";
import config from "@/config";
const fmtK = (value) => {
if (!value && value !== 0) return "$0";
if (value >= 1000000) return `$${(value / 1000000).toFixed(1)}M`;
if (value >= 1000) return `$${(value / 1000).toFixed(0)}K`;
return `$${value.toFixed(0)}`;
};
const fmtDollars = (value) => {
if (!value && value !== 0) return "$0";
return `$${Math.round(value).toLocaleString("en-US")}`;
};
const fmtYear = (value) => {
if (!value && value !== 0) return "$0";
if (value >= 1000000) return `$${(value / 1000000).toFixed(2)}M`;
if (value >= 1000) return `$${(value / 1000).toFixed(0)}K`;
return `$${value.toFixed(0)}`;
};
const TrendSub = ({ label, trend, suffix = "%", invert = false, decimals = 0 }) => {
if (label == null) return null;
const isPositive = trend > 0;
const isGood = invert ? !isPositive : isPositive;
const arrow = trend > 0 ? "\u2191" : trend < 0 ? "\u2193" : "";
const trendColor =
trend == null || Math.abs(trend) < 0.1
? ""
: isGood
? "text-emerald-300"
: "text-rose-300";
const formatted = decimals > 0
? Math.abs(trend).toFixed(decimals)
: Math.abs(Math.round(trend));
return (
<span className="text-sm font-semibold text-gray-200">
{label}
{trend != null && Math.abs(trend) >= 0.1 && (
<span className={`ml-1.5 ${trendColor}`}>
{arrow}{formatted}{suffix}
</span>
)}
</span>
);
};
const MiniBusinessMetrics = () => {
// 30d forecast revenue
const { data: forecastData, isLoading: forecastLoading } = useQuery({
queryKey: ["mini-forecast-30d"],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/forecast/metrics`);
if (!response.ok) throw new Error("Failed to fetch forecast");
return response.json();
},
refetchInterval: 600000,
});
// Year revenue estimate (YTD + rest-of-year forecast)
const { data: yearData, isLoading: yearLoading } = useQuery({
queryKey: ["mini-year-estimate"],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/year-revenue-estimate`);
if (!response.ok) throw new Error("Failed to fetch year estimate");
return response.json();
},
refetchInterval: 600000,
});
// Avg revenue per day (last 30 days via ACOT stats)
const { data: revenueData, isLoading: revenueLoading } = useQuery({
queryKey: ["mini-avg-revenue-30d"],
queryFn: () =>
acotService.getStatsDetails({
timeRange: "last30days",
metric: "revenue",
daily: true,
}),
refetchInterval: 300000,
});
// Financials for profit margin (last 30 days)
const { data: financialData, isLoading: financialLoading } = useQuery({
queryKey: ["mini-financials-30d"],
queryFn: () => acotService.getFinancials({ timeRange: "last30days" }),
refetchInterval: 300000,
});
// Payroll FTE — use employee-metrics with timeRange for true 30d window
const { data: fteData, isLoading: payrollLoading } = useQuery({
queryKey: ["mini-fte-30d"],
queryFn: () => acotService.getEmployeeMetrics({ timeRange: "last30days" }),
refetchInterval: 300000,
});
const loading =
forecastLoading || yearLoading || revenueLoading || financialLoading || payrollLoading;
// --- Avg revenue per day ---
let avgRevPerDay = 0;
let prevAvgRevPerDay = 0;
let revTrend = 0;
if (revenueData?.stats) {
const stats = Array.isArray(revenueData.stats) ? revenueData.stats : [];
if (stats.length > 0) {
avgRevPerDay = stats.reduce((sum, d) => sum + (d.revenue || 0), 0) / stats.length;
prevAvgRevPerDay =
stats.reduce((sum, d) => sum + (d.prevRevenue || 0), 0) / stats.length;
revTrend =
prevAvgRevPerDay > 0
? ((avgRevPerDay - prevAvgRevPerDay) / prevAvgRevPerDay) * 100
: 0;
}
}
// --- Profit margin ---
let margin = 0;
let prevMargin = null;
let marginTrend = null;
if (financialData?.totals) {
const t = financialData.totals;
if (Number.isFinite(t.margin)) {
margin = t.margin;
} else {
const income =
(t.grossSales || 0) - (t.refunds || 0) - (t.discounts || 0) + (t.shippingFees || 0);
const profit = income - (t.cogs || 0);
margin = income > 0 ? (profit / income) * 100 : 0;
}
const prev = financialData.previousTotals;
if (prev) {
if (Number.isFinite(prev.margin)) {
prevMargin = prev.margin;
} else {
const prevIncome =
(prev.grossSales || 0) -
(prev.refunds || 0) -
(prev.discounts || 0) +
(prev.shippingFees || 0);
const prevProfit = prevIncome - (prev.cogs || 0);
prevMargin = prevIncome > 0 ? (prevProfit / prevIncome) * 100 : 0;
}
}
if (financialData.comparison?.margin?.absolute != null) {
marginTrend = financialData.comparison.margin.absolute;
} else if (prevMargin != null) {
marginTrend = margin - prevMargin;
}
}
// --- Payroll FTE (true 30d window from employee-metrics) ---
const fte = fteData?.totals?.fte ?? 0;
const prevFte = fteData?.previousTotals?.fte ?? null;
const fteTrend = fteData?.comparison?.fte?.percentage ?? null;
const ready =
!loading &&
forecastData &&
yearData &&
revenueData?.stats &&
financialData?.totals &&
fteData?.totals;
const entries = ready
? [
{
icon: TrendingUp,
iconBg: "bg-blue-400",
label: "Forecast",
value: fmtK(forecastData.forecastRevenue),
sub: (
<span className="text-sm font-semibold text-gray-200">
{fmtYear(yearData.yearTotal)} for year
</span>
),
},
{
icon: DollarSign,
iconBg: "bg-emerald-400",
label: "Avg Revenue/Day",
value: fmtDollars(avgRevPerDay),
sub: (
<TrendSub
label={`Prev: ${fmtDollars(prevAvgRevPerDay)}`}
trend={revTrend}
/>
),
},
{
icon: Percent,
iconBg: "bg-purple-400",
label: "Profit Margin",
value: `${margin.toFixed(1)}%`,
sub:
prevMargin != null ? (
<TrendSub
label={`Prev: ${prevMargin.toFixed(1)}%`}
trend={marginTrend}
suffix="pp"
decimals={1}
/>
) : undefined,
},
{
icon: Briefcase,
iconBg: "bg-orange-400",
label: "Payroll FTE",
value: fte.toFixed(1),
sub:
prevFte != null ? (
<TrendSub
label={`Prev: ${prevFte.toFixed(1)}`}
trend={fteTrend}
/>
) : undefined,
},
]
: [];
return (
<DashboardMultiStatCardMini
title="30 Days"
entries={entries}
gradient="custom"
className="bg-gradient-to-br from-indigo-900/80 to-indigo-700/40"
loading={!ready}
skeletonRows={4}
/>
);
};
export default MiniBusinessMetrics;

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import axios from "axios"; import axios from "axios";
import { useQuery } from "@tanstack/react-query";
import { import {
Card, Card,
CardContent, CardContent,
@@ -135,7 +136,7 @@ const EventCard = ({ event }) => {
const details = event.event_properties || {}; const details = event.event_properties || {};
return ( return (
<EventDialog event={event}> <EventDialog event={event} scale={1.75}>
<Card className={`w-[230px] border-white/[0.08] ring-1 ring-white/[0.05] shrink-0 hover:brightness-110 hover:ring-white/[0.12] cursor-pointer transition-all h-[125px] bg-gradient-to-br ${eventType.gradient} backdrop-blur-xl shadow-lg shadow-black/20`}> <Card className={`w-[230px] border-white/[0.08] ring-1 ring-white/[0.05] shrink-0 hover:brightness-110 hover:ring-white/[0.12] cursor-pointer transition-all h-[125px] bg-gradient-to-br ${eventType.gradient} backdrop-blur-xl shadow-lg shadow-black/20`}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 px-3 py-2 pb-0"> <CardHeader className="flex flex-row items-center justify-between space-y-0 px-3 py-2 pb-0">
<div className="flex items-baseline justify-between w-full pr-1"> <div className="flex items-baseline justify-between w-full pr-1">
@@ -317,14 +318,28 @@ const DEFAULT_METRICS = Object.values(METRIC_IDS);
const MiniEventFeed = ({ const MiniEventFeed = ({
selectedMetrics = DEFAULT_METRICS, selectedMetrics = DEFAULT_METRICS,
}) => { }) => {
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [lastUpdate, setLastUpdate] = useState(null);
const scrollRef = useRef(null); const scrollRef = useRef(null);
const [showLeftArrow, setShowLeftArrow] = useState(false); const [showLeftArrow, setShowLeftArrow] = useState(false);
const [showRightArrow, setShowRightArrow] = useState(false); const [showRightArrow, setShowRightArrow] = useState(false);
const { data: events = [], isLoading: loading, error } = useQuery({
queryKey: ["mini-event-feed", selectedMetrics],
queryFn: async () => {
const response = await axios.get("/api/klaviyo/events/feed", {
params: {
timeRange: "today",
metricIds: JSON.stringify(selectedMetrics),
},
});
return (response.data.data || []).map((event) => ({
...event,
datetime: event.attributes?.datetime || event.datetime,
event_properties: event.attributes?.event_properties || {}
}));
},
refetchInterval: 30000,
});
const handleScroll = () => { const handleScroll = () => {
if (scrollRef.current) { if (scrollRef.current) {
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current; const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
@@ -351,62 +366,23 @@ const MiniEventFeed = ({
} }
}; };
const fetchEvents = useCallback(async () => { // Scroll to end when events load/change
try { useEffect(() => {
setError(null); if (scrollRef.current && events.length > 0) {
setTimeout(() => {
if (events.length === 0) { scrollRef.current?.scrollTo({
setLoading(true); left: scrollRef.current.scrollWidth,
} behavior: 'instant'
});
const response = await axios.get("/api/klaviyo/events/feed", { handleScroll();
params: { }, 0);
timeRange: "today",
metricIds: JSON.stringify(selectedMetrics),
},
});
const processedEvents = (response.data.data || []).map((event) => ({
...event,
datetime: event.attributes?.datetime || event.datetime,
event_properties: event.attributes?.event_properties || {}
}));
setEvents(processedEvents);
setLastUpdate(new Date());
// Scroll to the right after events are loaded
if (scrollRef.current) {
setTimeout(() => {
scrollRef.current.scrollTo({
left: scrollRef.current.scrollWidth,
behavior: 'instant'
});
handleScroll();
}, 0);
}
} catch (error) {
console.error("Error fetching events:", error);
setError(error.message);
} finally {
setLoading(false);
} }
}, [selectedMetrics]);
useEffect(() => {
fetchEvents();
const interval = setInterval(fetchEvents, 30000);
return () => clearInterval(interval);
}, [fetchEvents]);
useEffect(() => {
handleScroll();
}, [events]); }, [events]);
return ( return (
<div className="fixed bottom-0 left-0 right-0"> <div className="fixed bottom-0 left-0 right-0">
<Card className="rounded-none bg-slate-900/80 backdrop-blur-xl border-0 border-t border-white/[0.08] shadow-[0_-8px_30px_rgba(0,0,0,0.3)]"> <Card className="rounded-none bg-slate-900/80 backdrop-blur-xl border-0 border-t border-white/50 shadow-[0_-8px_30px_rgba(0,0,0,0.3)]">
<div className="px-1 pt-2 pb-3 relative"> <div className=" pt-2 pb-3 relative">
{/* Left fade edge */} {/* Left fade edge */}
{showLeftArrow && ( {showLeftArrow && (
<div <div
@@ -424,11 +400,11 @@ const MiniEventFeed = ({
onScroll={handleScroll} onScroll={handleScroll}
className="overflow-x-auto overflow-y-hidden [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']" className="overflow-x-auto overflow-y-hidden [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']"
> >
<div className="flex flex-row gap-3 px-4" style={{ width: 'max-content' }}> <div className="flex flex-row gap-2.5 px-2.5" style={{ width: 'max-content' }}>
{loading && !events.length ? ( {loading && !events.length ? (
<LoadingState /> <LoadingState />
) : error ? ( ) : error ? (
<DashboardErrorState error={`Failed to load event feed: ${error}`} className="mx-4" /> <DashboardErrorState error={`Failed to load event feed: ${error?.message}`} className="mx-4" />
) : !events || events.length === 0 ? ( ) : !events || events.length === 0 ? (
<div className="px-4"> <div className="px-4">
<EmptyState /> <EmptyState />

View File

@@ -0,0 +1,111 @@
import React from "react";
import { useQuery } from "@tanstack/react-query";
import { Truck, Warehouse, ShoppingBag, AlertTriangle } from "lucide-react";
import { DashboardMultiStatCardMini } from "@/components/dashboard/shared";
import { acotService } from "@/services/dashboard/acotService";
import config from "@/config";
const fmtCurrency = (value) => {
if (!value && value !== 0) return "$0";
if (value >= 1000000) return `$${(value / 1000000).toFixed(1)}M`;
if (value >= 1000) return `$${(value / 1000).toFixed(0)}K`;
return `$${value.toFixed(0)}`;
};
const fmtNum = (value) => {
if (!value && value !== 0) return "0";
if (value >= 1000) return `${(value / 1000).toFixed(1)}K`;
return value.toLocaleString();
};
const MiniInventorySnapshot = () => {
// Operations (shipped/picked today)
const { data: opsData, isLoading: opsLoading } = useQuery({
queryKey: ["mini-ops-today"],
queryFn: () => acotService.getOperationsMetrics({ timeRange: "today" }),
refetchInterval: 120000,
});
// Stock metrics
const { data: stockData, isLoading: stockLoading } = useQuery({
queryKey: ["mini-stock-metrics"],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/stock/metrics`);
if (!response.ok) throw new Error("Failed to fetch stock metrics");
return response.json();
},
refetchInterval: 300000,
});
// Replenishment metrics
const { data: replenishData, isLoading: replenishLoading } = useQuery({
queryKey: ["mini-replenish-metrics"],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/replenishment/metrics`);
if (!response.ok) throw new Error("Failed to fetch replenishment");
return response.json();
},
refetchInterval: 300000,
});
// Overstock metrics
const { data: overstockData, isLoading: overstockLoading } = useQuery({
queryKey: ["mini-overstock-metrics"],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/overstock/metrics`);
if (!response.ok) throw new Error("Failed to fetch overstock");
return response.json();
},
refetchInterval: 300000,
});
const loading = opsLoading || stockLoading || replenishLoading || overstockLoading;
const ready = !loading && opsData?.totals && stockData && replenishData && overstockData;
const t = opsData?.totals;
const entries = ready
? [
{
icon: Truck,
iconBg: "bg-sky-400",
label: "Shipped",
value: `${fmtNum(t.ordersShipped)}`,
sub: `${fmtNum(t.piecesPicked)} pcs picked`,
},
{
icon: Warehouse,
iconBg: "bg-emerald-400",
label: "Stock Value",
value: fmtCurrency(stockData.totalStockCost),
sub: `${fmtNum(stockData.productsInStock)} products`,
},
{
icon: ShoppingBag,
iconBg: "bg-amber-400",
label: "Replenish Units",
value: `${fmtNum(replenishData.unitsToReplenish)}`,
sub: `${fmtCurrency(replenishData.replenishmentCost)} cost`,
},
{
icon: AlertTriangle,
iconBg: overstockData.overstockedProducts > 0 ? "bg-rose-400" : "bg-violet-400",
label: "Overstocked SKUs",
value: `${fmtNum(overstockData.overstockedProducts)}`,
sub: `${fmtCurrency(overstockData.totalExcessCost)} cost`,
},
]
: [];
return (
<DashboardMultiStatCardMini
title="Today"
entries={entries}
gradient="amber"
loading={!ready}
skeletonRows={4}
/>
);
};
export default MiniInventorySnapshot;

View File

@@ -1,22 +1,20 @@
import React, { useState, useEffect, useCallback } from "react"; import React from "react";
import { useQuery } from "@tanstack/react-query";
import { acotService } from "@/services/dashboard/acotService"; import { acotService } from "@/services/dashboard/acotService";
import { Card, CardContent } from "@/components/ui/card";
import { import {
Card, AreaChart,
CardContent, Area,
} from "@/components/ui/card";
import {
LineChart,
Line,
XAxis, XAxis,
YAxis, YAxis,
CartesianGrid,
Tooltip, Tooltip,
ResponsiveContainer, ResponsiveContainer,
} from "recharts"; } from "recharts";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AlertCircle, PiggyBank, Truck } from "lucide-react"; import { AlertCircle, PiggyBank, ShoppingCart } from "lucide-react";
import { formatCurrency, processData } from "./SalesChart.jsx"; import { formatCurrency } from "./SalesChart.jsx";
import { METRIC_COLORS } from "@/lib/dashboard/designTokens"; import { PHASE_CONFIG, PHASE_KEYS_WITH_UNKNOWN as PHASE_KEYS } from "@/utils/lifecyclePhases";
import config from "@/config";
import { import {
DashboardStatCardMini, DashboardStatCardMini,
DashboardStatCardMiniSkeleton, DashboardStatCardMiniSkeleton,
@@ -24,116 +22,80 @@ import {
TOOLTIP_THEMES, TOOLTIP_THEMES,
} from "@/components/dashboard/shared"; } from "@/components/dashboard/shared";
// Brighter chart colors for dark glass backgrounds const formatCompactCurrency = (value) => {
const MINI_CHART_COLORS = { if (!value || isNaN(value)) return "$0";
revenue: "#34d399", // emerald-400 if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`;
orders: "#60a5fa", // blue-400 if (value >= 1_000) return `$${(value / 1_000).toFixed(1)}k`;
return `$${Math.round(value)}`;
};
const formatFullCurrency = (value) => {
if (!value || isNaN(value)) return "$0";
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
}; };
const MiniSalesChart = ({ className = "" }) => { const MiniSalesChart = ({ className = "" }) => {
const [data, setData] = useState([]); const { data: revenueStats, isLoading: statsLoading } = useQuery({
const [loading, setLoading] = useState(true); queryKey: ["mini-sales-stats-30d"],
const [error, setError] = useState(null); queryFn: async () => {
const [visibleMetrics, setVisibleMetrics] = useState({
revenue: true,
orders: true
});
const [summaryStats, setSummaryStats] = useState({
totalRevenue: 0,
totalOrders: 0,
prevRevenue: 0,
prevOrders: 0,
periodProgress: 100
});
const [projection, setProjection] = useState(null);
const [projectionLoading, setProjectionLoading] = useState(false);
const fetchProjection = useCallback(async () => {
if (summaryStats.periodProgress >= 100) return;
try {
setProjectionLoading(true);
const response = await acotService.getProjection({ timeRange: "last30days" });
setProjection(response);
} catch (error) {
console.error("Error loading projection:", error);
} finally {
setProjectionLoading(false);
}
}, [summaryStats.periodProgress]);
const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await acotService.getStatsDetails({ const response = await acotService.getStatsDetails({
timeRange: "last30days", timeRange: "last30days",
metric: "revenue", metric: "revenue",
daily: true, daily: true,
}); });
if (!response.stats) throw new Error("Invalid response format");
const stats = Array.isArray(response.stats) ? response.stats : [];
return stats.reduce(
(acc, day) => ({
totalRevenue: acc.totalRevenue + (Number(day.revenue) || 0),
totalOrders: acc.totalOrders + (Number(day.orders) || 0),
prevRevenue: acc.prevRevenue + (Number(day.prevRevenue) || 0),
prevOrders: acc.prevOrders + (Number(day.prevOrders) || 0),
periodProgress: day.periodProgress || 100,
}),
{ totalRevenue: 0, totalOrders: 0, prevRevenue: 0, prevOrders: 0, periodProgress: 100 }
);
},
refetchInterval: 300000,
});
if (!response.stats) { const summaryStats = revenueStats ?? {
throw new Error("Invalid response format"); totalRevenue: 0, totalOrders: 0, prevRevenue: 0, prevOrders: 0, periodProgress: 100,
} };
const stats = Array.isArray(response.stats) const { data: projection } = useQuery({
? response.stats queryKey: ["mini-sales-projection-30d"],
: []; queryFn: () => acotService.getProjection({ timeRange: "last30days" }),
enabled: summaryStats.periodProgress < 100,
refetchInterval: 300000,
});
const processedData = processData(stats); const { data: chartData, isLoading: chartLoading, error } = useQuery({
queryKey: ["mini-sales-chart-30d"],
// Calculate totals and growth queryFn: async () => {
const totals = stats.reduce((acc, day) => ({ const now = new Date();
totalRevenue: acc.totalRevenue + (Number(day.revenue) || 0), const thirtyDaysAgo = new Date(now);
totalOrders: acc.totalOrders + (Number(day.orders) || 0), thirtyDaysAgo.setDate(now.getDate() - 30);
prevRevenue: acc.prevRevenue + (Number(day.prevRevenue) || 0), const params = new URLSearchParams({
prevOrders: acc.prevOrders + (Number(day.prevOrders) || 0), startDate: thirtyDaysAgo.toISOString(),
periodProgress: day.periodProgress || 100, endDate: now.toISOString(),
}), {
totalRevenue: 0,
totalOrders: 0,
prevRevenue: 0,
prevOrders: 0,
periodProgress: 100
}); });
const response = await fetch(`${config.apiUrl}/dashboard/sales/metrics?${params}`);
setData(processedData); if (!response.ok) throw new Error("Failed to fetch sales metrics");
setSummaryStats(totals); return response.json();
setError(null); },
refetchInterval: 300000,
// Fetch projection if needed });
if (totals.periodProgress < 100) {
fetchProjection();
}
} catch (error) {
console.error("Error fetching data:", error);
setError(error.message);
} finally {
setLoading(false);
}
}, [fetchProjection]);
useEffect(() => {
fetchData();
const intervalId = setInterval(fetchData, 300000);
return () => clearInterval(intervalId);
}, [fetchData]);
const formatXAxis = (value) => { const formatXAxis = (value) => {
if (!value) return ""; if (!value) return "";
const date = new Date(value); const date = new Date(value);
return date.toLocaleDateString([], { return date.toLocaleDateString([], { month: "numeric", day: "numeric" });
month: "short",
day: "numeric"
});
};
const toggleMetric = (metric) => {
setVisibleMetrics(prev => ({
...prev,
[metric]: !prev[metric]
}));
}; };
if (error) { if (error) {
@@ -141,16 +103,16 @@ const MiniSalesChart = ({ className = "" }) => {
<Alert variant="destructive" className="bg-white/10 backdrop-blur-sm"> <Alert variant="destructive" className="bg-white/10 backdrop-blur-sm">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle> <AlertTitle>Error</AlertTitle>
<AlertDescription>Failed to load sales data: {error}</AlertDescription> <AlertDescription>Failed to load sales data: {error.message}</AlertDescription>
</Alert> </Alert>
); );
} }
// Helper to calculate trend values (positive = up, negative = down)
const getRevenueTrendValue = () => { const getRevenueTrendValue = () => {
const current = summaryStats.periodProgress < 100 const current =
? (projection?.projectedRevenue || summaryStats.totalRevenue) summaryStats.periodProgress < 100
: summaryStats.totalRevenue; ? projection?.projectedRevenue || summaryStats.totalRevenue
: summaryStats.totalRevenue;
if (!summaryStats.prevRevenue) return 0; if (!summaryStats.prevRevenue) return 0;
return ((current - summaryStats.prevRevenue) / summaryStats.prevRevenue) * 100; return ((current - summaryStats.prevRevenue) / summaryStats.prevRevenue) * 100;
}; };
@@ -162,30 +124,18 @@ const MiniSalesChart = ({ className = "" }) => {
return ((current - summaryStats.prevOrders) / summaryStats.prevOrders) * 100; return ((current - summaryStats.prevOrders) / summaryStats.prevOrders) * 100;
}; };
if (loading && !data) { const dailyPhase = chartData?.dailySalesByPhase || [];
return ( const phaseBreakdown = chartData?.phaseBreakdown || [];
<div className="space-y-2">
{/* Stat Cards */}
<div className="grid grid-cols-2 gap-2">
<DashboardStatCardMiniSkeleton gradient="slate" />
<DashboardStatCardMiniSkeleton gradient="slate" />
</div>
{/* Chart Card */} const activePhases = phaseBreakdown
<Card className="bg-gradient-to-br from-slate-800/70 to-slate-700/50 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] shadow-lg shadow-black/20"> .filter((p) => p.revenue > 0)
<CardContent className="p-4"> .sort((a, b) => b.revenue - a.revenue);
<ChartSkeleton height="sm" withCard={false} />
</CardContent>
</Card>
</div>
);
}
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{/* Stat Cards */} {/* Stat Cards */}
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{loading && !data?.length ? ( {statsLoading ? (
<> <>
<DashboardStatCardMiniSkeleton gradient="slate" /> <DashboardStatCardMiniSkeleton gradient="slate" />
<DashboardStatCardMiniSkeleton gradient="slate" /> <DashboardStatCardMiniSkeleton gradient="slate" />
@@ -200,19 +150,15 @@ const MiniSalesChart = ({ className = "" }) => {
icon={PiggyBank} icon={PiggyBank}
iconBackground="bg-emerald-400" iconBackground="bg-emerald-400"
gradient="slate" gradient="slate"
className={!visibleMetrics.revenue ? 'opacity-50' : ''}
onClick={() => toggleMetric('revenue')}
/> />
<DashboardStatCardMini <DashboardStatCardMini
title="30 Days Orders" title="30 Days Orders"
value={summaryStats.totalOrders.toLocaleString()} value={summaryStats.totalOrders.toLocaleString()}
subtitle={`Prev: ${summaryStats.prevOrders.toLocaleString()}`} subtitle={`Prev: ${summaryStats.prevOrders.toLocaleString()}`}
trend={{ value: getOrdersTrendValue() }} trend={{ value: getOrdersTrendValue() }}
icon={Truck} icon={ShoppingCart}
iconBackground="bg-blue-400" iconBackground="bg-blue-400"
gradient="slate" gradient="slate"
className={!visibleMetrics.orders ? 'opacity-50' : ''}
onClick={() => toggleMetric('orders')}
/> />
</> </>
)} )}
@@ -221,104 +167,159 @@ const MiniSalesChart = ({ className = "" }) => {
{/* Chart Card */} {/* Chart Card */}
<Card className="bg-gradient-to-br from-slate-800/70 to-slate-700/50 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] shadow-lg shadow-black/20"> <Card className="bg-gradient-to-br from-slate-800/70 to-slate-700/50 backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] shadow-lg shadow-black/20">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="h-[216px]"> {chartLoading ? (
{loading && !data?.length ? ( <>
<ChartSkeleton height="sm" withCard={false} /> <div className="h-[180px]">
) : ( <ChartSkeleton type="area" height="sm" withCard={false} />
<ResponsiveContainer width="100%" height="100%"> </div>
<LineChart <div className="mt-2 space-y-1.5">
data={data} <div className="h-2 rounded-full bg-white/[0.06] animate-pulse" />
margin={{ top: 0, right: -30, left: -5, bottom: -10 }} <div className="flex justify-center gap-x-3 gap-y-0.5">
> {[48, 40, 56, 52, 44].map((w, i) => (
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.15)" /> <div key={i} className="flex items-center gap-1">
<XAxis <div className="h-2 w-2 rounded-sm bg-white/[0.08] animate-pulse" />
dataKey="timestamp" <div className="h-2.5 rounded bg-white/[0.06] animate-pulse" style={{ width: w }} />
tickFormatter={formatXAxis} </div>
className="text-xs" ))}
tick={{ fill: "#e2e8f0" }} </div>
/> </div>
{visibleMetrics.revenue && ( </>
<YAxis ) : (
yAxisId="revenue" <>
tickFormatter={(value) => formatCurrency(value, false)} <div className="h-[180px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={dailyPhase}
margin={{ top: 0, right: 0, left: -5, bottom: -10 }}
>
<XAxis
dataKey="date"
tickFormatter={formatXAxis}
className="text-xs" className="text-xs"
tick={{ fill: "#e2e8f0" }} tick={{ fill: "#e2e8f0" }}
tickLine={false}
axisLine={false}
/> />
)}
{visibleMetrics.orders && (
<YAxis <YAxis
yAxisId="orders" tickFormatter={formatCompactCurrency}
orientation="right"
className="text-xs" className="text-xs"
tick={{ fill: "#e2e8f0" }} tick={{ fill: "#e2e8f0" }}
tickLine={false}
axisLine={false}
/> />
)} <Tooltip
<Tooltip content={({ active, payload }) => {
content={({ active, payload }) => { if (!active || !payload?.length) return null;
if (active && payload && payload.length) { const dateStr = payload[0]?.payload?.date;
const timestamp = new Date(payload[0].payload.timestamp); const date = dateStr ? new Date(dateStr) : null;
const styles = TOOLTIP_THEMES.stone; const styles = TOOLTIP_THEMES.stone;
const items = payload
.filter((entry) => entry.value > 0)
.sort((a, b) => b.value - a.value);
const total = items.reduce((sum, entry) => sum + (entry.value || 0), 0);
return ( return (
<div className={styles.container}> <div className={styles.container}>
<p className={styles.header}> {date && (
{timestamp.toLocaleDateString([], { <p className={styles.header}>
weekday: "short", {date.toLocaleDateString([], {
month: "short", weekday: "short",
day: "numeric" month: "short",
})} day: "numeric",
</p> })}
</p>
)}
<div className={styles.content}> <div className={styles.content}>
{payload {items.map((entry, index) => {
.filter(entry => visibleMetrics[entry.dataKey]) const cfg = PHASE_CONFIG[entry.dataKey] || {};
.map((entry, index) => ( return (
<div key={index} className={styles.row}> <div key={index} className={styles.row}>
<span className={styles.name}> <span className="flex items-center gap-1.5">
{entry.name} <span
className="inline-block h-2 w-2 rounded-full shrink-0"
style={{ backgroundColor: entry.color }}
/>
<span className={styles.name}>
{cfg.label || entry.name}
</span>
</span> </span>
<span className={styles.value}> <span className={styles.value}>
{entry.dataKey === 'revenue' {formatFullCurrency(entry.value)}
? formatCurrency(entry.value)
: entry.value.toLocaleString()}
</span> </span>
</div> </div>
))} );
})}
{items.length > 1 && (
<div className={`${styles.row} border-t border-white/10 pt-1 mt-1`}>
<span className={styles.name}>Total</span>
<span className={styles.value}>
{formatFullCurrency(total)}
</span>
</div>
)}
</div> </div>
</div> </div>
); );
} }}
return null;
}}
/>
{visibleMetrics.revenue && (
<Line
yAxisId="revenue"
type="monotone"
dataKey="revenue"
name="Revenue"
stroke={MINI_CHART_COLORS.revenue}
strokeWidth={2.5}
dot={false}
/> />
)} {PHASE_KEYS.map((phase) => {
{visibleMetrics.orders && ( const cfg = PHASE_CONFIG[phase];
<Line return (
yAxisId="orders" <Area
type="monotone" key={phase}
dataKey="orders" type="monotone"
name="Orders" dataKey={phase}
stroke={MINI_CHART_COLORS.orders} name={phase}
strokeWidth={2.5} stackId="a"
dot={false} stroke={cfg.color}
/> fill={cfg.color}
)} fillOpacity={0.7}
</LineChart> />
</ResponsiveContainer> );
)} })}
</div> </AreaChart>
</ResponsiveContainer>
</div>
{/* Phase breakdown bar + legend */}
{activePhases.length > 0 && (
<div className="mt-2 space-y-1.5">
{(() => {
let pos = 0;
const stops = activePhases.flatMap((p) => {
const cfg = PHASE_CONFIG[p.phase] || { color: "#94A3B8" };
const start = pos;
pos += p.percentage;
return [`${cfg.color} ${start}%`, `${cfg.color} ${pos}%`];
});
return (
<div
className="h-2 rounded-full ring-1 ring-white/20"
style={{ background: `linear-gradient(to right, ${stops.join(', ')})` }}
/>
);
})()}
<div className="flex justify-center gap-x-3">
{activePhases.slice(0, 5).map((p) => {
const cfg = PHASE_CONFIG[p.phase] || { label: p.phase, color: "#94A3B8" };
return (
<span key={p.phase} className="flex items-center gap-1 text-[11.5px] text-slate-200/80 whitespace-nowrap">
<span
className="inline-block h-2 w-2 rounded-sm shrink-0"
style={{ backgroundColor: cfg.color }}
/>
{cfg.label}
</span>
);
})}
</div>
</div>
)}
</>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
); );
}; };
export default MiniSalesChart; export default MiniSalesChart;

View File

@@ -1,26 +1,19 @@
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { acotService } from "@/services/dashboard/acotService"; import { acotService } from "@/services/dashboard/acotService";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { DateTime } from "luxon";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { import {
DollarSign, DollarSign,
ShoppingCart, ShoppingCart,
Package,
AlertCircle,
CircleDollarSign, CircleDollarSign,
Users,
} from "lucide-react"; } from "lucide-react";
import { processBasicData } from "./RealtimeAnalytics";
// Import the detail view components and utilities from StatCards // Import the detail view components and utilities from StatCards
import { import {
@@ -58,16 +51,45 @@ const MiniStatCards = ({
description = "", description = "",
compact = false, compact = false,
}) => { }) => {
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [lastUpdate, setLastUpdate] = useState(null);
const [timeRange, setTimeRange] = useState(initialTimeRange); const [timeRange, setTimeRange] = useState(initialTimeRange);
const [selectedMetric, setSelectedMetric] = useState(null); const [selectedMetric, setSelectedMetric] = useState(null);
const [detailDataLoading, setDetailDataLoading] = useState({}); const [detailDataLoading, setDetailDataLoading] = useState({});
const [detailData, setDetailData] = useState({}); const [detailData, setDetailData] = useState({});
const [projection, setProjection] = useState(null);
const [projectionLoading, setProjectionLoading] = useState(false); // Main stats query
const statsParams = timeRange === "custom" ? { startDate, endDate } : { timeRange };
const { data: stats, isLoading: loading, error: statsError } = useQuery({
queryKey: ["mini-stat-cards", timeRange, startDate, endDate],
queryFn: async () => {
const response = await acotService.getStats(statsParams);
return response.stats;
},
refetchInterval: timeRange === "today" ? 60000 : undefined,
});
// Projection query (depends on stats)
const { data: projection, isLoading: projectionLoading } = useQuery({
queryKey: ["mini-stat-projection", timeRange, startDate, endDate],
queryFn: () => acotService.getProjection(statsParams),
enabled: stats?.periodProgress != null && stats.periodProgress < 100,
refetchInterval: timeRange === "today" ? 60000 : undefined,
});
// Realtime users query
const { data: realtimeData = { last30MinUsers: 0, last5MinUsers: 0 }, isLoading: realtimeLoading } = useQuery({
queryKey: ["mini-realtime-users"],
queryFn: async () => {
const response = await fetch("/api/dashboard-analytics/realtime/basic", {
credentials: "include",
});
if (!response.ok) throw new Error("Failed to fetch realtime");
const result = await response.json();
return processBasicData(result.data);
},
refetchInterval: 30000,
});
const error = statsError?.message ?? null;
// Reuse the trend calculation functions // Reuse the trend calculation functions
const calculateTrend = useCallback((current, previous) => { const calculateTrend = useCallback((current, previous) => {
@@ -125,93 +147,6 @@ const MiniStatCards = ({
return calculateTrend(stats.averageOrderValue, stats.prevPeriodAOV); return calculateTrend(stats.averageOrderValue, stats.prevPeriodAOV);
}, [stats, calculateTrend]); }, [stats, calculateTrend]);
// Initial load effect
useEffect(() => {
let isMounted = true;
const loadData = async () => {
try {
setLoading(true);
setStats(null);
const params =
timeRange === "custom" ? { startDate, endDate } : { timeRange };
const response = await acotService.getStats(params);
if (!isMounted) return;
setStats(response.stats);
setLastUpdate(DateTime.now().setZone("America/New_York"));
setError(null);
} catch (error) {
console.error("Error loading data:", error);
if (isMounted) {
setError(error.message);
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
loadData();
return () => {
isMounted = false;
};
}, [timeRange, startDate, endDate]);
// Load smart projection separately
useEffect(() => {
let isMounted = true;
const loadProjection = async () => {
if (!stats?.periodProgress || stats.periodProgress >= 100) return;
try {
setProjectionLoading(true);
const params =
timeRange === "custom" ? { startDate, endDate } : { timeRange };
const response = await acotService.getProjection(params);
if (!isMounted) return;
setProjection(response);
} catch (error) {
console.error("Error loading projection:", error);
} finally {
if (isMounted) {
setProjectionLoading(false);
}
}
};
loadProjection();
return () => {
isMounted = false;
};
}, [timeRange, startDate, endDate, stats?.periodProgress]);
// Auto-refresh for 'today' view
useEffect(() => {
if (timeRange !== "today") return;
const interval = setInterval(async () => {
try {
const [statsResponse, projectionResponse] = await Promise.all([
acotService.getStats({ timeRange: "today" }),
acotService.getProjection({ timeRange: "today" }),
]);
setStats(statsResponse.stats);
setProjection(projectionResponse);
setLastUpdate(DateTime.now().setZone("America/New_York"));
} catch (error) {
console.error("Error auto-refreshing stats:", error);
}
}, 60000);
return () => clearInterval(interval);
}, [timeRange]);
// Add function to fetch detail data // Add function to fetch detail data
const fetchDetailData = useCallback( const fetchDetailData = useCallback(
@@ -262,13 +197,13 @@ const MiniStatCards = ({
preloadData(); preloadData();
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
if (loading && !stats) { if (loading) {
return ( return (
<div className="grid grid-cols-4 gap-2"> <div className="grid grid-cols-4 gap-2">
<DashboardStatCardMiniSkeleton gradient="emerald" className="h-[150px]" /> <DashboardStatCardMiniSkeleton gradient="emerald" className="h-[150px]" />
<DashboardStatCardMiniSkeleton gradient="blue" className="h-[150px]" /> <DashboardStatCardMiniSkeleton gradient="blue" className="h-[150px]" />
<DashboardStatCardMiniSkeleton gradient="violet" className="h-[150px]" /> <DashboardStatCardMiniSkeleton gradient="violet" className="h-[150px]" />
<DashboardStatCardMiniSkeleton gradient="orange" className="h-[150px]" /> <DashboardStatCardMiniSkeleton gradient="sky" className="h-[150px]" />
</div> </div>
); );
} }
@@ -342,14 +277,18 @@ const MiniStatCards = ({
/> />
<DashboardStatCardMini <DashboardStatCardMini
title="Shipped Today" title="Live Users 5 Min"
value={stats?.shipping?.shippedCount || 0} value={realtimeLoading ? "..." : realtimeData.last5MinUsers}
subtitle={`${stats?.shipping?.locations?.total || 0} locations`} subtitle={
icon={Package} realtimeLoading
iconBackground="bg-orange-400" ? "Loading..."
gradient="orange" : `${realtimeData.last30MinUsers} last 30 minutes`
}
icon={Users}
iconBackground="bg-sky-400"
gradient="sky"
className="h-[150px]" className="h-[150px]"
onClick={() => setSelectedMetric("shipping")} loading={realtimeLoading}
/> />
</div> </div>

View File

@@ -0,0 +1,144 @@
/**
* DashboardMultiStatCardMini
*
* A compact card that displays multiple stats vertically, styled to match
* DashboardStatCardMini's typography and layout. Use for panels like
* Operations Today or Inventory Snapshot where several related metrics
* live inside a single gradient card.
*/
import React from "react";
import { Card, CardContent } from "@/components/ui/card";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { type GradientVariant } from "./DashboardStatCardMini";
// =============================================================================
// TYPES
// =============================================================================
export interface MultiStatEntry {
/** Lucide icon component */
icon: LucideIcon;
/** Tailwind bg class for the icon circle (e.g. "bg-emerald-400") */
iconBg: string;
/** Short uppercase label */
label: string;
/** Main display value */
value: string | number;
/** Optional secondary line below value (string or JSX for trend indicators) */
sub?: React.ReactNode;
}
export interface DashboardMultiStatCardMiniProps {
/** Optional card title shown above stats */
title?: string;
/** Array of stats to display vertically */
entries: MultiStatEntry[];
/** Gradient preset */
gradient?: GradientVariant;
/** Additional className */
className?: string;
/** Loading state — shows skeletons for this many rows */
loading?: boolean;
/** Number of skeleton rows to show when loading (defaults to entries length or 3) */
skeletonRows?: number;
}
// =============================================================================
// GRADIENT PRESETS (mirrors DashboardStatCardMini)
// =============================================================================
const GRADIENT_PRESETS: Record<GradientVariant, string> = {
slate: "bg-gradient-to-br from-slate-800/80 to-slate-700/60",
emerald: "bg-gradient-to-br from-emerald-900/80 to-emerald-700/40",
blue: "bg-gradient-to-br from-blue-900/80 to-blue-700/40",
purple: "bg-gradient-to-br from-purple-900/80 to-purple-700/40",
violet: "bg-gradient-to-br from-violet-900/80 to-violet-700/40",
amber: "bg-gradient-to-br from-amber-800/80 to-amber-600/40",
orange: "bg-gradient-to-br from-orange-900/80 to-orange-700/40",
rose: "bg-gradient-to-br from-rose-900/80 to-rose-700/40",
cyan: "bg-gradient-to-br from-cyan-900/80 to-cyan-700/40",
sky: "bg-gradient-to-br from-sky-900/80 to-sky-700/40",
custom: "",
};
// =============================================================================
// INTERNAL COMPONENTS
// =============================================================================
const StatRow: React.FC<MultiStatEntry> = ({ icon: Icon, iconBg, label, value, sub }) => (
<div className="flex-1 flex items-center justify-between min-h-0">
<div className="flex flex-col justify-center">
<span className="text-xs font-medium text-gray-100 uppercase tracking-wide mb-0.5">
{label}
</span>
<div className="text-2xl font-extrabold text-white leading-tight">
{typeof value === "number" ? value.toLocaleString() : value}
</div>
{sub && (
<div className="text-sm font-semibold text-gray-200 mt-0.5">{sub}</div>
)}
</div>
<div className="relative p-2">
<div className={cn("absolute inset-0 rounded-full", iconBg)} />
<Icon className="h-4 w-4 text-white relative" />
</div>
</div>
);
const SkeletonRow: React.FC = () => (
<div className="flex-1 flex items-center justify-between min-h-0">
<div className="flex flex-col justify-center">
<div className="h-3 w-16 bg-white/20 animate-pulse rounded mb-1" />
<div className="h-7 w-14 bg-white/20 animate-pulse rounded mb-0.5" />
<div className="h-4 w-20 bg-white/10 animate-pulse rounded" />
</div>
<div className="h-8 w-8 bg-white/20 animate-pulse rounded-full" />
</div>
);
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export const DashboardMultiStatCardMini: React.FC<DashboardMultiStatCardMiniProps> = ({
title,
entries,
gradient = "slate",
className,
loading = false,
skeletonRows,
}) => {
const gradientClass = gradient === "custom" ? "" : GRADIENT_PRESETS[gradient];
const rowCount = skeletonRows ?? (entries.length || 3);
return (
<Card
className={cn(
gradientClass,
"backdrop-blur-xl border-white/[0.08] ring-1 ring-white/[0.05] shadow-lg shadow-black/20 h-full",
className
)}
>
<CardContent className="p-4 h-full flex flex-col gap-1">
{title && (
<span className="text-xs font-medium text-gray-100 uppercase tracking-wide mb-1 border-b">
{title}
</span>
)}
{loading ? (
<>
{Array.from({ length: rowCount }, (_, i) => (
<SkeletonRow key={i} />
))}
</>
) : (
entries.map((entry, i) => <StatRow key={i} {...entry} />)
)}
</CardContent>
</Card>
);
};
export default DashboardMultiStatCardMini;

View File

@@ -244,7 +244,7 @@ export const DashboardStatCardMini: React.FC<DashboardStatCardMiniProps> = ({
)} )}
</div> </div>
{(subtitle || trend) && ( {(subtitle || trend) && (
<div className="flex flex-wrap items-center justify-between gap-2 mt-3"> <div className="flex flex-wrap items-center justify-between gap-0 mt-3">
{subtitle && ( {subtitle && (
<span className="text-sm font-semibold text-gray-200"> <span className="text-sm font-semibold text-gray-200">
{subtitle} {subtitle}

View File

@@ -99,6 +99,12 @@ export {
type GradientVariant, type GradientVariant,
} from "./DashboardStatCardMini"; } from "./DashboardStatCardMini";
export {
DashboardMultiStatCardMini,
type DashboardMultiStatCardMiniProps,
type MultiStatEntry,
} from "./DashboardMultiStatCardMini";
// ============================================================================= // =============================================================================
// CHART TOOLTIPS // CHART TOOLTIPS
// ============================================================================= // =============================================================================

View File

@@ -4,9 +4,12 @@ import LockButton from "@/components/dashboard/LockButton";
import PinProtection from "@/components/dashboard/PinProtection"; import PinProtection from "@/components/dashboard/PinProtection";
import DateTimeWeatherDisplay from "@/components/dashboard/DateTime"; import DateTimeWeatherDisplay from "@/components/dashboard/DateTime";
import MiniStatCards from "@/components/dashboard/MiniStatCards"; import MiniStatCards from "@/components/dashboard/MiniStatCards";
import MiniRealtimeAnalytics from "@/components/dashboard/MiniRealtimeAnalytics";
import MiniSalesChart from "@/components/dashboard/MiniSalesChart"; import MiniSalesChart from "@/components/dashboard/MiniSalesChart";
import MiniEventFeed from "@/components/dashboard/MiniEventFeed"; import MiniEventFeed from "@/components/dashboard/MiniEventFeed";
// @ts-expect-error - JSX component without type declarations
import MiniBusinessMetrics from "@/components/dashboard/MiniBusinessMetrics";
// @ts-expect-error - JSX component without type declarations
import MiniInventorySnapshot from "@/components/dashboard/MiniInventorySnapshot";
// Pin Protected Layout // Pin Protected Layout
const PinProtectedLayout = ({ children }: { children: React.ReactNode }) => { const PinProtectedLayout = ({ children }: { children: React.ReactNode }) => {
@@ -30,7 +33,7 @@ const PinProtectedLayout = ({ children }: { children: React.ReactNode }) => {
const SmallLayout = () => { const SmallLayout = () => {
const DATETIME_SCALE = 2; const DATETIME_SCALE = 2;
const STATS_SCALE = 1.65; const STATS_SCALE = 1.65;
const ANALYTICS_SCALE = 1.65; const PANELS_SCALE = 1.65;
const SALES_SCALE = 1.65; const SALES_SCALE = 1.65;
const FEED_SCALE = 1.65; const FEED_SCALE = 1.65;
@@ -86,15 +89,22 @@ const SmallLayout = () => {
</div> </div>
</div> </div>
{/* Mini Realtime Analytics */} {/* Operations + Inventory Panels */}
<div className="-mt-1"> <div className="h-full">
<div style={{ <div className="h-full" style={{
transform: `scale(${ANALYTICS_SCALE})`, transform: `scale(${PANELS_SCALE})`,
transformOrigin: 'top left', transformOrigin: 'top left',
width: `${100/ANALYTICS_SCALE}%`, width: `${100/PANELS_SCALE}%`,
height: `${100/ANALYTICS_SCALE}%` height: '100%',
}}> }}>
<MiniRealtimeAnalytics /> <div className="flex gap-2 h-full">
<div className="flex-1 min-w-0 h-full">
<MiniBusinessMetrics />
</div>
<div className="flex-1 min-w-0 h-full">
<MiniInventorySnapshot />
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

File diff suppressed because one or more lines are too long