Unify dashboard with shared components

This commit is contained in:
2026-01-17 17:03:39 -05:00
parent 0ffd02e22e
commit ef50aec33c
35 changed files with 4891 additions and 3411 deletions

View File

@@ -52,6 +52,7 @@
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"diff": "^7.0.0", "diff": "^7.0.0",
"framer-motion": "^12.4.4", "framer-motion": "^12.4.4",
"immer": "^11.1.3",
"input-otp": "^1.4.1", "input-otp": "^1.4.1",
"js-levenshtein": "^1.1.6", "js-levenshtein": "^1.1.6",
"lodash": "^4.17.21", "lodash": "^4.17.21",
@@ -5749,6 +5750,16 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immer": {
"version": "11.1.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
"integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",

View File

@@ -6,6 +6,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"build:deploy": "tsc -b && COPY_BUILD=true vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"mount": "../mountremote.command" "mount": "../mountremote.command"
@@ -55,6 +56,7 @@
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"diff": "^7.0.0", "diff": "^7.0.0",
"framer-motion": "^12.4.4", "framer-motion": "^12.4.4",
"immer": "^11.1.3",
"input-otp": "^1.4.1", "input-otp": "^1.4.1",
"js-levenshtein": "^1.1.6", "js-levenshtein": "^1.1.6",
"lodash": "^4.17.21", "lodash": "^4.17.21",

View File

@@ -1,12 +1,6 @@
// components/AircallDashboard.jsx // components/AircallDashboard.jsx
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { import { Card, CardContent } from "@/components/ui/card";
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -14,8 +8,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { import {
Table, Table,
TableBody, TableBody,
@@ -25,47 +17,39 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { import {
PhoneCall, BarChart,
PhoneMissed, Bar,
Clock,
UserCheck,
PhoneIncoming,
PhoneOutgoing,
ArrowUpDown,
Timer,
Loader2,
Download,
Search,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
LineChart,
Line,
XAxis, XAxis,
YAxis, YAxis,
CartesianGrid, CartesianGrid,
Tooltip as RechartsTooltip, Tooltip as RechartsTooltip,
Legend, Legend,
ResponsiveContainer, ResponsiveContainer,
BarChart,
Bar,
} from "recharts"; } from "recharts";
const COLORS = { // Import shared components and tokens
inbound: "hsl(262.1 83.3% 57.8%)", // Purple import {
outbound: "hsl(142.1 76.2% 36.3%)", // Green DashboardChartTooltip,
missed: "hsl(47.9 95.8% 53.1%)", // Yellow DashboardStatCard,
answered: "hsl(142.1 76.2% 36.3%)", // Green DashboardStatCardSkeleton,
duration: "hsl(221.2 83.2% 53.3%)", // Blue DashboardSectionHeader,
hourly: "hsl(321.2 81.1% 41.2%)", // Pink DashboardErrorState,
ChartSkeleton,
TableSkeleton,
CARD_STYLES,
SCROLL_STYLES,
METRIC_COLORS,
} from "@/components/dashboard/shared";
import { Phone, Clock, Zap, Timer } from "lucide-react";
// Aircall-specific colors using the standardized palette
const CHART_COLORS = {
inbound: METRIC_COLORS.aov, // Purple for inbound
outbound: METRIC_COLORS.revenue, // Green for outbound
missed: METRIC_COLORS.comparison, // Amber for missed
answered: METRIC_COLORS.revenue, // Green for answered
duration: METRIC_COLORS.orders, // Blue for duration
hourly: METRIC_COLORS.tertiary, // Pink for hourly
}; };
const TIME_RANGES = [ const TIME_RANGES = [
@@ -89,41 +73,6 @@ const formatDuration = (seconds) => {
return `${minutes}m ${remainingSeconds}s`; return `${minutes}m ${remainingSeconds}s`;
}; };
const MetricCard = ({ title, value, subtitle, icon: Icon, iconColor }) => (
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader className="flex flex-row items-center justify-between pb-2 p-4">
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-300">{title}</CardTitle>
<Icon className={`h-4 w-4 ${iconColor}`} />
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">{value}</div>
{subtitle && (
<p className="text-sm text-muted-foreground mt-1">{subtitle}</p>
)}
</CardContent>
</Card>
);
const CustomTooltip = ({ active, payload, label }) => {
if (active && payload && payload.length) {
return (
<Card className="p-3 shadow-lg bg-white dark:bg-gray-900/60 backdrop-blur-sm border-none">
<CardContent className="p-0 space-y-2">
<p className="font-medium text-sm text-gray-900 dark:text-gray-100 border-b border-gray-100 dark:border-gray-800 pb-1 mb-2">{label}</p>
{payload.map((entry, index) => (
<p key={index} className="text-sm text-muted-foreground">
{`${entry.name}: ${entry.value}`}
</p>
))}
</CardContent>
</Card>
);
}
return null;
};
const AgentPerformanceTable = ({ agents, onSort }) => { const AgentPerformanceTable = ({ agents, onSort }) => {
const [sortConfig, setSortConfig] = useState({ const [sortConfig, setSortConfig] = useState({
key: "total", key: "total",
@@ -144,19 +93,19 @@ const AgentPerformanceTable = ({ agents, onSort }) => {
<TableHeader> <TableHeader>
<TableRow className="hover:bg-transparent"> <TableRow className="hover:bg-transparent">
<TableHead>Agent</TableHead> <TableHead>Agent</TableHead>
<TableHead onClick={() => handleSort("total")}>Total Calls</TableHead> <TableHead onClick={() => handleSort("total")} className="cursor-pointer">Total Calls</TableHead>
<TableHead onClick={() => handleSort("answered")}>Answered</TableHead> <TableHead onClick={() => handleSort("answered")} className="cursor-pointer">Answered</TableHead>
<TableHead onClick={() => handleSort("missed")}>Missed</TableHead> <TableHead onClick={() => handleSort("missed")} className="cursor-pointer">Missed</TableHead>
<TableHead onClick={() => handleSort("average_duration")}>Average Duration</TableHead> <TableHead onClick={() => handleSort("average_duration")} className="cursor-pointer">Average Duration</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{agents.map((agent) => ( {agents.map((agent) => (
<TableRow key={agent.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/50"> <TableRow key={agent.id} className="hover:bg-muted/50 transition-colors">
<TableCell className="font-medium text-gray-900 dark:text-gray-100">{agent.name}</TableCell> <TableCell className="font-medium text-foreground">{agent.name}</TableCell>
<TableCell>{agent.total}</TableCell> <TableCell>{agent.total}</TableCell>
<TableCell className="text-emerald-600 dark:text-emerald-400">{agent.answered}</TableCell> <TableCell className="text-trend-positive">{agent.answered}</TableCell>
<TableCell className="text-rose-600 dark:text-rose-400">{agent.missed}</TableCell> <TableCell className="text-trend-negative">{agent.missed}</TableCell>
<TableCell className="text-muted-foreground">{formatDuration(agent.average_duration)}</TableCell> <TableCell className="text-muted-foreground">{formatDuration(agent.average_duration)}</TableCell>
</TableRow> </TableRow>
))} ))}
@@ -165,81 +114,6 @@ const AgentPerformanceTable = ({ agents, onSort }) => {
); );
}; };
const SkeletonMetricCard = () => (
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader className="flex flex-col items-start p-4">
<Skeleton className="h-4 w-24 mb-2 bg-muted" />
<Skeleton className="h-8 w-32 mb-2 bg-muted" />
<div className="flex gap-4">
<Skeleton className="h-4 w-20 bg-muted" />
<Skeleton className="h-4 w-20 bg-muted" />
</div>
</CardHeader>
</Card>
);
const SkeletonChart = ({ type = "line" }) => (
<div className="h-[300px] w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-4">
<div className="h-full flex flex-col">
<div className="flex-1 relative">
{type === "bar" ? (
<div className="h-full flex items-end justify-between gap-1">
{[...Array(24)].map((_, i) => (
<div
key={i}
className="w-full bg-muted rounded-t animate-pulse"
style={{ height: `${15 + Math.random() * 70}%` }}
/>
))}
</div>
) : (
<div className="h-full w-full relative">
{[...Array(5)].map((_, i) => (
<div
key={i}
className="absolute w-full h-px bg-muted"
style={{ top: `${20 + i * 20}%` }}
/>
))}
<div
className="absolute inset-0 bg-muted animate-pulse"
style={{
opacity: 0.2,
clipPath: "polygon(0 50%, 100% 20%, 100% 100%, 0 100%)",
}}
/>
</div>
)}
</div>
</div>
</div>
);
const SkeletonTable = ({ rows = 5 }) => (
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead><Skeleton className="h-4 w-24 bg-muted" /></TableHead>
<TableHead><Skeleton className="h-4 w-24 bg-muted" /></TableHead>
<TableHead><Skeleton className="h-4 w-24 bg-muted" /></TableHead>
<TableHead><Skeleton className="h-4 w-24 bg-muted" /></TableHead>
<TableHead><Skeleton className="h-4 w-24 bg-muted" /></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[...Array(rows)].map((_, i) => (
<TableRow key={i} className="hover:bg-muted/50 transition-colors">
<TableCell><Skeleton className="h-4 w-32 bg-muted" /></TableCell>
<TableCell><Skeleton className="h-4 w-16 bg-muted" /></TableCell>
<TableCell><Skeleton className="h-4 w-16 bg-muted" /></TableCell>
<TableCell><Skeleton className="h-4 w-16 bg-muted" /></TableCell>
<TableCell><Skeleton className="h-4 w-24 bg-muted" /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
const AircallDashboard = () => { const AircallDashboard = () => {
const [timeRange, setTimeRange] = useState("last7days"); const [timeRange, setTimeRange] = useState("last7days");
const [metrics, setMetrics] = useState(null); const [metrics, setMetrics] = useState(null);
@@ -252,7 +126,6 @@ const AircallDashboard = () => {
}); });
const safeArray = (arr) => (Array.isArray(arr) ? arr : []); const safeArray = (arr) => (Array.isArray(arr) ? arr : []);
const safeObject = (obj) => (obj && typeof obj === "object" ? obj : {});
const sortedAgents = metrics?.by_users const sortedAgents = metrics?.by_users
? Object.values(metrics.by_users).sort((a, b) => { ? Object.values(metrics.by_users).sort((a, b) => {
@@ -261,38 +134,6 @@ const AircallDashboard = () => {
}) })
: []; : [];
const formatDate = (dateString) => {
try {
// Parse the date string (YYYY-MM-DD)
const [year, month, day] = dateString.split('-').map(Number);
// Create a date object in ET timezone
const date = new Date(Date.UTC(year, month - 1, day));
// Format the date in ET timezone
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "America/New_York"
}).format(date);
} catch (error) {
console.error("Date formatting error:", error, { dateString });
return "Invalid Date";
}
};
const handleExport = () => {
const timestamp = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
exportToCSV(filteredAgents, `aircall-agent-metrics-${timestamp}`);
};
const chartData = { const chartData = {
hourly: metrics?.by_hour hourly: metrics?.by_hour
? metrics.by_hour.map((count, hour) => ({ ? metrics.by_hour.map((count, hour) => ({
@@ -322,16 +163,6 @@ const AircallDashboard = () => {
})), })),
}; };
const peakHour = metrics?.by_hour
? metrics.by_hour.indexOf(Math.max(...metrics.by_hour))
: null;
const busyAgent = sortedAgents?.length > 0 ? sortedAgents[0] : null;
const bestAnswerRate = sortedAgents
?.filter((agent) => agent.total > 0)
?.sort((a, b) => b.answered / b.total - a.answered / a.total)[0];
const fetchData = async () => { const fetchData = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
@@ -356,11 +187,12 @@ const AircallDashboard = () => {
if (error) { if (error) {
return ( return (
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <Card className={CARD_STYLES.base}>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="p-4 m-6 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/10 rounded-lg border border-red-200 dark:border-red-900/20"> <DashboardErrorState
Error loading call data: {error} title="Failed to load call data"
</div> message={error}
/>
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -368,15 +200,12 @@ const AircallDashboard = () => {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <Card className={CARD_STYLES.base}>
<CardHeader className="p-6"> <DashboardSectionHeader
<div className="flex justify-between items-center"> title="Calls"
<div> timeSelector={
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">Calls</CardTitle>
</div>
<Select value={timeRange} onValueChange={setTimeRange}> <Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-[130px] h-9 bg-white dark:bg-gray-800"> <SelectTrigger className="w-[130px] h-9">
<SelectValue placeholder="Select range" /> <SelectValue placeholder="Select range" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -387,91 +216,73 @@ const AircallDashboard = () => {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> }
</CardHeader> />
<CardContent className="p-6 pt-0 space-y-4"> <CardContent className="p-6 pt-0 space-y-4">
{/* Metric Cards */} {/* Metric Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{isLoading ? ( {isLoading ? (
[...Array(4)].map((_, i) => ( [...Array(4)].map((_, i) => (
<SkeletonMetricCard key={i} /> <DashboardStatCardSkeleton key={i} hasSubtitle />
)) ))
) : metrics ? ( ) : metrics ? (
<> <>
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <DashboardStatCard
<CardHeader className="flex flex-col items-start p-4"> title="Total Calls"
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-300">Total Calls</CardTitle> value={metrics.total}
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100 mt-2">{metrics.total}</div> subtitle={
<div className="flex gap-4 mt-2"> <span className="flex gap-3">
<div className="text-sm text-muted-foreground"> <span><span className="text-chart-orders"> {metrics.by_direction.inbound}</span> in</span>
<span className="text-blue-500"> {metrics.by_direction.inbound}</span> inbound <span><span className="text-chart-revenue"> {metrics.by_direction.outbound}</span> out</span>
</div> </span>
<div className="text-sm text-muted-foreground"> }
<span className="text-emerald-500"> {metrics.by_direction.outbound}</span> outbound icon={Phone}
</div> iconColor="blue"
</div> />
</CardHeader>
</Card> <DashboardStatCard
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm"> title="Answer Rate"
<CardHeader className="flex flex-col items-start p-4"> value={`${((metrics.by_status.answered / metrics.total) * 100).toFixed(1)}%`}
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-300">Answer Rate</CardTitle> subtitle={
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100 mt-2"> <span className="flex gap-3">
{`${((metrics.by_status.answered / metrics.total) * 100).toFixed(1)}%`} <span><span className="text-trend-positive">{metrics.by_status.answered}</span> answered</span>
</div> <span><span className="text-trend-negative">{metrics.by_status.missed}</span> missed</span>
<div className="flex gap-6"> </span>
<div className="text-sm text-muted-foreground"> }
<span className="text-emerald-500">{metrics.by_status.answered}</span> answered icon={Zap}
</div> iconColor="green"
<div className="text-sm text-muted-foreground"> />
<span className="text-rose-500">{metrics.by_status.missed}</span> missed
</div> <DashboardStatCard
</div> title="Peak Hour"
</CardHeader> value={
</Card> metrics?.by_hour
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm"> ? new Date(2000, 0, 1, metrics.by_hour.indexOf(Math.max(...metrics.by_hour)))
<CardHeader className="flex flex-col items-start p-4"> .toLocaleString('en-US', { hour: 'numeric', hour12: true }).toUpperCase()
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-300">Peak Hour</CardTitle> : 'N/A'
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100 mt-2"> }
{metrics?.by_hour ? new Date(2000, 0, 1, metrics.by_hour.indexOf(Math.max(...metrics.by_hour))).toLocaleString('en-US', { hour: 'numeric', hour12: true }).toUpperCase() : 'N/A'} subtitle={`Busiest Agent: ${sortedAgents[0]?.name || "N/A"}`}
</div> icon={Clock}
<div className="text-sm text-muted-foreground mt-2"> iconColor="purple"
Busiest Agent: {sortedAgents[0]?.name || "N/A"} />
</div>
</CardHeader> <DashboardStatCard
</Card> title="Avg Duration"
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm"> value={formatDuration(metrics.average_duration)}
<CardHeader className="flex flex-col items-start p-4"> subtitle={
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-300">Avg Duration</CardTitle> metrics?.daily_data?.length > 0
<TooltipProvider> ? `${Math.round(metrics.total / metrics.daily_data.length)} calls/day`
<Tooltip> : "N/A"
<TooltipTrigger asChild> }
<div> tooltip={
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100"> metrics?.duration_distribution
{formatDuration(metrics.average_duration)} ? `Duration Distribution: ${metrics.duration_distribution.map(d => `${d.range}: ${d.count}`).join(', ')}`
</div> : undefined
<div className="text-sm text-muted-foreground mt-2"> }
{metrics?.daily_data?.length > 0 icon={Timer}
? `${Math.round(metrics.total / metrics.daily_data.length)} calls/day` iconColor="teal"
: "N/A"} />
</div>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="w-[300px] bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<div className="space-y-2">
<p className="font-medium text-gray-900 dark:text-gray-100">Duration Distribution</p>
{metrics?.duration_distribution?.map((d, i) => (
<div key={i} className="flex justify-between text-sm text-muted-foreground">
<span>{d.range}</span>
<span>{d.count} calls</span>
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardHeader>
</Card>
</> </>
) : null} ) : null}
</div> </div>
@@ -481,30 +292,32 @@ const AircallDashboard = () => {
{/* Charts Row */} {/* Charts Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Daily Call Volume */} {/* Daily Call Volume */}
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <Card className={CARD_STYLES.base}>
<CardHeader className="p-4"> <DashboardSectionHeader title="Daily Call Volume" compact />
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-gray-100">Daily Call Volume</CardTitle>
</CardHeader>
<CardContent className="h-[300px]"> <CardContent className="h-[300px]">
{isLoading ? ( {isLoading ? (
<SkeletonChart type="bar" /> <ChartSkeleton type="bar" height="md" withCard={false} />
) : ( ) : (
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData.daily} margin={{ top: 0, right: 5, left: -35, bottom: 0 }}> <BarChart data={chartData.daily} margin={{ top: 0, right: 5, left: -35, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> <CartesianGrid strokeDasharray="3 3" className="stroke-border/40" />
<XAxis <XAxis
dataKey="date" dataKey="date"
tick={{ fontSize: 12 }} tick={{ fontSize: 12 }}
className="text-muted-foreground" className="text-muted-foreground"
tickLine={false}
axisLine={false}
/> />
<YAxis <YAxis
tick={{ fontSize: 12 }} tick={{ fontSize: 12 }}
className="text-muted-foreground" className="text-muted-foreground"
tickLine={false}
axisLine={false}
/> />
<RechartsTooltip content={<CustomTooltip />} /> <RechartsTooltip content={<DashboardChartTooltip />} />
<Legend /> <Legend />
<Bar dataKey="inbound" fill={COLORS.inbound} name="Inbound" /> <Bar dataKey="inbound" fill={CHART_COLORS.inbound} name="Inbound" radius={[4, 4, 0, 0]} />
<Bar dataKey="outbound" fill={COLORS.outbound} name="Outbound" /> <Bar dataKey="outbound" fill={CHART_COLORS.outbound} name="Outbound" radius={[4, 4, 0, 0]} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
)} )}
@@ -512,29 +325,31 @@ const AircallDashboard = () => {
</Card> </Card>
{/* Hourly Distribution */} {/* Hourly Distribution */}
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <Card className={CARD_STYLES.base}>
<CardHeader className="p-4"> <DashboardSectionHeader title="Hourly Distribution" compact />
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-gray-100">Hourly Distribution</CardTitle>
</CardHeader>
<CardContent className="h-[300px]"> <CardContent className="h-[300px]">
{isLoading ? ( {isLoading ? (
<SkeletonChart type="bar" /> <ChartSkeleton type="bar" height="md" withCard={false} />
) : ( ) : (
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData.hourly} margin={{ top: 0, right: 5, left: -35, bottom: 0 }}> <BarChart data={chartData.hourly} margin={{ top: 0, right: 5, left: -35, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> <CartesianGrid strokeDasharray="3 3" className="stroke-border/40" />
<XAxis <XAxis
dataKey="hour" dataKey="hour"
tick={{ fontSize: 12 }} tick={{ fontSize: 12 }}
interval={2} interval={2}
className="text-muted-foreground" className="text-muted-foreground"
tickLine={false}
axisLine={false}
/> />
<YAxis <YAxis
tick={{ fontSize: 12 }} tick={{ fontSize: 12 }}
className="text-muted-foreground" className="text-muted-foreground"
tickLine={false}
axisLine={false}
/> />
<RechartsTooltip content={<CustomTooltip />} /> <RechartsTooltip content={<DashboardChartTooltip />} />
<Bar dataKey="calls" fill={COLORS.hourly} name="Calls" /> <Bar dataKey="calls" fill={CHART_COLORS.hourly} name="Calls" radius={[4, 4, 0, 0]} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
)} )}
@@ -545,15 +360,13 @@ const AircallDashboard = () => {
{/* Tables Row */} {/* Tables Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Agent Performance */} {/* Agent Performance */}
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <Card className={CARD_STYLES.base}>
<CardHeader className="p-4"> <DashboardSectionHeader title="Agent Performance" compact />
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-gray-100">Agent Performance</CardTitle>
</CardHeader>
<CardContent> <CardContent>
{isLoading ? ( {isLoading ? (
<SkeletonTable rows={5} /> <TableSkeleton rows={5} columns={5} />
) : ( ) : (
<div className="overflow-y-auto max-h-[400px] scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600"> <div className={SCROLL_STYLES.md}>
<AgentPerformanceTable <AgentPerformanceTable
agents={sortedAgents} agents={sortedAgents}
onSort={(key, direction) => setAgentSort({ key, direction })} onSort={(key, direction) => setAgentSort({ key, direction })}
@@ -564,29 +377,27 @@ const AircallDashboard = () => {
</Card> </Card>
{/* Missed Call Reasons Table */} {/* Missed Call Reasons Table */}
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <Card className={CARD_STYLES.base}>
<CardHeader className="p-4"> <DashboardSectionHeader title="Missed Call Reasons" compact />
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-gray-100">Missed Call Reasons</CardTitle>
</CardHeader>
<CardContent> <CardContent>
{isLoading ? ( {isLoading ? (
<SkeletonTable rows={5} /> <TableSkeleton rows={5} columns={2} />
) : ( ) : (
<div className="overflow-y-auto max-h-[400px] scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600"> <div className={SCROLL_STYLES.md}>
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="hover:bg-transparent"> <TableRow className="hover:bg-transparent">
<TableHead className="font-medium text-gray-900 dark:text-gray-100">Reason</TableHead> <TableHead className="font-medium">Reason</TableHead>
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">Count</TableHead> <TableHead className="text-right font-medium">Count</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{chartData.missedReasons.map((reason, index) => ( {chartData.missedReasons.map((reason, index) => (
<TableRow key={index} className="hover:bg-muted/50 transition-colors"> <TableRow key={index} className="hover:bg-muted/50 transition-colors">
<TableCell className="font-medium text-gray-900 dark:text-gray-100"> <TableCell className="font-medium text-foreground">
{reason.reason} {reason.reason}
</TableCell> </TableCell>
<TableCell className="text-right text-rose-600 dark:text-rose-400"> <TableCell className="text-right text-trend-negative">
{reason.count} {reason.count}
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@@ -8,7 +8,6 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { import {
LineChart, LineChart,
Line, Line,
@@ -18,72 +17,25 @@ import {
Tooltip, Tooltip,
Legend, Legend,
ResponsiveContainer, ResponsiveContainer,
ReferenceLine,
} from "recharts"; } from "recharts";
import { Loader2, TrendingUp, AlertCircle } from "lucide-react"; import { TrendingUp } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { CARD_STYLES, TYPOGRAPHY } from "@/lib/dashboard/designTokens";
import {
DashboardStatCard,
ChartSkeleton,
DashboardEmptyState,
TOOLTIP_STYLES,
} from "@/components/dashboard/shared";
// Add helper function for currency formatting // Note: Using ChartSkeleton from @/components/dashboard/shared
const formatCurrency = (value, useFractionDigits = true) => {
if (typeof value !== "number") return "$0.00";
const roundedValue = parseFloat(value.toFixed(useFractionDigits ? 2 : 0));
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: useFractionDigits ? 2 : 0,
maximumFractionDigits: useFractionDigits ? 2 : 0,
}).format(roundedValue);
};
// Add skeleton components
const SkeletonChart = () => (
<div className="h-[400px] w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-6">
<div className="h-full flex flex-col">
<div className="flex-1 relative">
{/* Grid lines */}
{[...Array(5)].map((_, i) => (
<div
key={i}
className="absolute w-full h-px bg-muted"
style={{ top: `${(i + 1) * 20}%` }}
/>
))}
{/* Y-axis labels */}
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-3 w-6 bg-muted rounded-sm" />
))}
</div>
{/* X-axis labels */}
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-3 w-8 bg-muted rounded-sm" />
))}
</div>
{/* Chart line */}
<div className="absolute inset-x-8 bottom-6 top-4">
<div className="h-full w-full relative">
<div
className="absolute inset-0 bg-muted rounded-sm"
style={{
opacity: 0.5,
clipPath: "polygon(0 50%, 100% 20%, 100% 100%, 0 100%)",
}}
/>
</div>
</div>
</div>
</div>
</div>
);
const SkeletonStats = () => ( const SkeletonStats = () => (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4">
{[...Array(4)].map((_, i) => ( {[...Array(4)].map((_, i) => (
<Card key={i} className="bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <Card key={i} className={CARD_STYLES.base}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<Skeleton className="h-4 w-24 bg-muted rounded-sm" /> <Skeleton className="h-4 w-24 bg-muted rounded-sm" />
</CardHeader> </CardHeader>
@@ -96,46 +48,7 @@ const SkeletonStats = () => (
</div> </div>
); );
const SkeletonButtons = () => ( // Note: Using shared DashboardStatCard from @/components/dashboard/shared
<div className="flex flex-wrap gap-1">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-8 w-20 bg-muted rounded-sm" />
))}
</div>
);
// Add StatCard component
const StatCard = ({
title,
value,
description,
trend,
trendValue,
colorClass = "text-gray-900 dark:text-gray-100",
}) => (
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<span className="text-sm text-muted-foreground font-medium">{title}</span>
{trend && (
<span
className={`text-sm flex items-center gap-1 font-medium ${
trend === "up"
? "text-emerald-600 dark:text-emerald-400"
: "text-rose-600 dark:text-rose-400"
}`}
>
{trendValue}
</span>
)}
</CardHeader>
<CardContent className="p-4 pt-0">
<div className={`text-2xl font-bold mb-1.5 ${colorClass}`}>{value}</div>
{description && (
<div className="text-sm font-medium text-muted-foreground">{description}</div>
)}
</CardContent>
</Card>
);
// Add color constants // Add color constants
const METRIC_COLORS = { const METRIC_COLORS = {
@@ -252,41 +165,13 @@ export const AnalyticsDashboard = () => {
const summaryStats = calculateSummaryStats(); const summaryStats = calculateSummaryStats();
const CustomTooltip = ({ active, payload, label }) => {
if (active && payload && payload.length) {
return (
<Card className="p-3 shadow-lg bg-white dark:bg-gray-900/60 backdrop-blur-sm border border-border">
<CardContent className="p-0 space-y-2">
<p className="font-medium text-sm border-b border-border pb-1.5 mb-2 text-foreground">
{label instanceof Date ? label.toLocaleDateString() : label}
</p>
<div className="space-y-1.5">
{payload.map((entry, index) => (
<div
key={index}
className="flex justify-between items-center text-sm"
>
<span className="font-medium" style={{ color: entry.color }}>{entry.name}:</span>
<span className="font-medium ml-4 text-foreground">
{entry.value.toLocaleString()}
</span>
</div>
))}
</div>
</CardContent>
</Card>
);
}
return null;
};
return ( return (
<Card className="w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <Card className={`w-full ${CARD_STYLES.base}`}>
<CardHeader className="p-6 pb-4"> <CardHeader className="p-6 pb-4">
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div> <div>
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100"> <CardTitle className={TYPOGRAPHY.sectionTitle}>
Analytics Overview Analytics Overview
</CardTitle> </CardTitle>
</div> </div>
@@ -301,9 +186,9 @@ export const AnalyticsDashboard = () => {
Details Details
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-[95vw] w-fit max-h-[85vh] overflow-hidden flex flex-col bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <DialogContent className={`max-w-[95vw] w-fit max-h-[85vh] overflow-hidden flex flex-col ${CARD_STYLES.base}`}>
<DialogHeader className="flex-none"> <DialogHeader className="flex-none">
<DialogTitle className="text-gray-900 dark:text-gray-100">Daily Details</DialogTitle> <DialogTitle className="text-foreground">Daily Details</DialogTitle>
<div className="flex items-center justify-center gap-2 pt-4"> <div className="flex items-center justify-center gap-2 pt-4">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{Object.entries(metrics).map(([key, value]) => ( {Object.entries(metrics).map(([key, value]) => (
@@ -328,7 +213,7 @@ export const AnalyticsDashboard = () => {
</div> </div>
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-y-auto mt-6"> <div className="flex-1 overflow-y-auto mt-6">
<div className="rounded-lg border bg-white dark:bg-gray-900/60 backdrop-blur-sm w-full"> <div className={`rounded-lg border ${CARD_STYLES.base} w-full`}>
<Table className="w-full"> <Table className="w-full">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -399,37 +284,37 @@ export const AnalyticsDashboard = () => {
<SkeletonStats /> <SkeletonStats />
) : summaryStats ? ( ) : summaryStats ? (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4">
<StatCard <DashboardStatCard
title="Active Users" title="Active Users"
value={summaryStats.totals.activeUsers.toLocaleString()} value={summaryStats.totals.activeUsers.toLocaleString()}
description={`Avg: ${Math.round( subtitle={`Avg: ${Math.round(
summaryStats.averages.activeUsers summaryStats.averages.activeUsers
).toLocaleString()} per day`} ).toLocaleString()} per day`}
colorClass={METRIC_COLORS.activeUsers.className} size="compact"
/> />
<StatCard <DashboardStatCard
title="New Users" title="New Users"
value={summaryStats.totals.newUsers.toLocaleString()} value={summaryStats.totals.newUsers.toLocaleString()}
description={`Avg: ${Math.round( subtitle={`Avg: ${Math.round(
summaryStats.averages.newUsers summaryStats.averages.newUsers
).toLocaleString()} per day`} ).toLocaleString()} per day`}
colorClass={METRIC_COLORS.newUsers.className} size="compact"
/> />
<StatCard <DashboardStatCard
title="Page Views" title="Page Views"
value={summaryStats.totals.pageViews.toLocaleString()} value={summaryStats.totals.pageViews.toLocaleString()}
description={`Avg: ${Math.round( subtitle={`Avg: ${Math.round(
summaryStats.averages.pageViews summaryStats.averages.pageViews
).toLocaleString()} per day`} ).toLocaleString()} per day`}
colorClass={METRIC_COLORS.pageViews.className} size="compact"
/> />
<StatCard <DashboardStatCard
title="Conversions" title="Conversions"
value={summaryStats.totals.conversions.toLocaleString()} value={summaryStats.totals.conversions.toLocaleString()}
description={`Avg: ${Math.round( subtitle={`Avg: ${Math.round(
summaryStats.averages.conversions summaryStats.averages.conversions
).toLocaleString()} per day`} ).toLocaleString()} per day`}
colorClass={METRIC_COLORS.conversions.className} size="compact"
/> />
</div> </div>
) : null} ) : null}
@@ -499,19 +384,15 @@ export const AnalyticsDashboard = () => {
<CardContent className="p-6 pt-0"> <CardContent className="p-6 pt-0">
{loading ? ( {loading ? (
<SkeletonChart /> <ChartSkeleton height="default" withCard={false} />
) : !data.length ? ( ) : !data.length ? (
<div className="flex items-center justify-center h-[400px] text-muted-foreground"> <DashboardEmptyState
<div className="text-center"> icon={TrendingUp}
<TrendingUp className="h-12 w-12 mx-auto mb-4 opacity-50" /> title="No analytics data available"
<div className="font-medium mb-2 text-gray-900 dark:text-gray-100">No analytics data available</div> description="Try selecting a different time range"
<div className="text-sm text-muted-foreground"> />
Try selecting a different time range
</div>
</div>
</div>
) : ( ) : (
<div className="h-[400px] mt-4 bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-0 relative"> <div className={`h-[400px] mt-4 ${CARD_STYLES.base} rounded-lg p-0 relative`}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart <LineChart
data={data} data={data}
@@ -538,7 +419,36 @@ export const AnalyticsDashboard = () => {
className="text-xs text-muted-foreground" className="text-xs text-muted-foreground"
tick={{ fill: "currentColor" }} tick={{ fill: "currentColor" }}
/> />
<Tooltip content={<CustomTooltip />} /> <Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const date = payload[0]?.payload?.date;
const formattedDate = date instanceof Date
? date.toLocaleDateString("en-US", { month: "short", day: "numeric" })
: String(date);
return (
<div className={TOOLTIP_STYLES.container}>
<p className={TOOLTIP_STYLES.header}>{formattedDate}</p>
<div className={TOOLTIP_STYLES.content}>
{payload.map((entry, i) => (
<div key={i} className={TOOLTIP_STYLES.row}>
<div className={TOOLTIP_STYLES.rowLabel}>
<span
className={TOOLTIP_STYLES.dot}
style={{ backgroundColor: entry.stroke || "#888" }}
/>
<span className={TOOLTIP_STYLES.name}>{entry.name}</span>
</div>
<span className={TOOLTIP_STYLES.value}>
{entry.value.toLocaleString()}
</span>
</div>
))}
</div>
</div>
);
}}
/>
<Legend /> <Legend />
{metrics.activeUsers && ( {metrics.activeUsers && (
<Line <Line

View File

@@ -48,7 +48,11 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import {
CARD_STYLES,
TYPOGRAPHY,
} from "@/lib/dashboard/designTokens";
import { DashboardErrorState } from "@/components/dashboard/shared";
const METRIC_IDS = { const METRIC_IDS = {
PLACED_ORDER: "Y8cqcF", PLACED_ORDER: "Y8cqcF",
@@ -142,9 +146,9 @@ const formatShipMethodSimple = (method) => {
// Loading State Component // Loading State Component
const LoadingState = () => ( const LoadingState = () => (
<div className="divide-y divide-gray-100 dark:divide-gray-800"> <div className="divide-y divide-border/50">
{[...Array(8)].map((_, i) => ( {[...Array(8)].map((_, i) => (
<div key={i} className="flex items-center gap-3 p-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"> <div key={i} className="flex items-center gap-3 p-4 hover:bg-muted/50 transition-colors">
<div className="shrink-0"> <div className="shrink-0">
<Skeleton className="h-10 w-10 rounded-full bg-muted" /> <Skeleton className="h-10 w-10 rounded-full bg-muted" />
</div> </div>
@@ -173,13 +177,13 @@ const LoadingState = () => (
// Empty State Component // Empty State Component
const EmptyState = () => ( const EmptyState = () => (
<div className="h-full flex flex-col items-center justify-center py-16 px-4 text-center"> <div className="h-full flex flex-col items-center justify-center py-16 px-4 text-center">
<div className="bg-gray-100 dark:bg-gray-800 rounded-full p-3 mb-4"> <div className="bg-muted rounded-full p-3 mb-4">
<Activity className="h-8 w-8 text-muted-foreground" /> <Activity className="h-8 w-8 text-muted-foreground" />
</div> </div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2"> <h3 className="text-lg font-medium text-foreground mb-2">
No activity yet today No activity yet today
</h3> </h3>
<p className="text-sm text-muted-foreground max-w-sm"> <p className={`${TYPOGRAPHY.cardDescription} max-w-sm`}>
Recent activity will appear here as it happens Recent activity will appear here as it happens
</p> </p>
</div> </div>
@@ -227,11 +231,11 @@ const OrderStatusTags = ({ details }) => (
); );
const ProductCard = ({ product }) => ( const ProductCard = ({ product }) => (
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg mb-3 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"> <div className="p-3 bg-muted/50 rounded-lg mb-3 hover:bg-muted transition-colors">
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<p className="font-medium text-sm text-gray-900 dark:text-gray-100 truncate"> <p className="font-medium text-sm text-foreground truncate">
{product.ProductName || "Unnamed Product"} {product.ProductName || "Unnamed Product"}
</p> </p>
{product.ItemStatus === "Pre-Order" && ( {product.ItemStatus === "Pre-Order" && (
@@ -242,13 +246,13 @@ const ProductCard = ({ product }) => (
</div> </div>
<div className="mt-1 flex flex-wrap gap-2"> <div className="mt-1 flex flex-wrap gap-2">
{product.Brand && ( {product.Brand && (
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 cursor-help"> <div className="flex items-center text-xs text-muted-foreground cursor-help">
<Tag className="w-3 h-3 mr-1" /> <Tag className="w-3 h-3 mr-1" />
<span>{product.Brand}</span> <span>{product.Brand}</span>
</div> </div>
)} )}
{product.SKU && ( {product.SKU && (
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 cursor-help"> <div className="flex items-center text-xs text-muted-foreground cursor-help">
<Box className="w-3 h-3 mr-1" /> <Box className="w-3 h-3 mr-1" />
<span>SKU: {product.SKU}</span> <span>SKU: {product.SKU}</span>
</div> </div>
@@ -256,14 +260,14 @@ const ProductCard = ({ product }) => (
</div> </div>
</div> </div>
<div className="text-right flex-shrink-0"> <div className="text-right flex-shrink-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 cursor-help"> <div className="text-sm font-medium text-foreground cursor-help">
{formatCurrency(product.ItemPrice)} {formatCurrency(product.ItemPrice)}
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400 cursor-help"> <div className="text-xs text-muted-foreground cursor-help">
Qty: {product.Quantity || product.QuantityOrdered || 1} Qty: {product.Quantity || product.QuantityOrdered || 1}
</div> </div>
{product.RowTotal && ( {product.RowTotal && (
<div className="text-xs font-medium text-gray-600 dark:text-gray-300 cursor-help"> <div className="text-xs font-medium text-foreground/80 cursor-help">
Total: {formatCurrency(product.RowTotal)} Total: {formatCurrency(product.RowTotal)}
</div> </div>
)} )}
@@ -308,10 +312,10 @@ const PromotionalInfo = ({ details }) => {
}; };
const OrderSummary = ({ details }) => ( const OrderSummary = ({ details }) => (
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg space-y-3"> <div className="bg-muted/50 p-4 rounded-lg space-y-3">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<h4 className="text-sm font-medium text-gray-500 mb-2 cursor-help"> <h4 className="text-sm font-medium text-muted-foreground mb-2 cursor-help">
Subtotal Subtotal
</h4> </h4>
<div className="space-y-1"> <div className="space-y-1">
@@ -336,7 +340,7 @@ const OrderSummary = ({ details }) => (
</div> </div>
</div> </div>
<div> <div>
<h4 className="text-sm font-medium text-gray-500 mb-2 cursor-help"> <h4 className="text-sm font-medium text-muted-foreground mb-2 cursor-help">
Shipping Shipping
</h4> </h4>
<div className="space-y-1"> <div className="space-y-1">
@@ -354,7 +358,7 @@ const OrderSummary = ({ details }) => (
</div> </div>
</div> </div>
<div className="pt-3 border-t border-gray-200 dark:border-gray-700"> <div className="pt-3 border-t border-border">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div> <div>
<span className="text-sm font-medium">Total</span> <span className="text-sm font-medium">Total</span>
@@ -377,7 +381,7 @@ const OrderSummary = ({ details }) => (
const ShippingInfo = ({ details }) => ( const ShippingInfo = ({ details }) => (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<h4 className="text-sm font-medium text-gray-500 cursor-help"> <h4 className="text-sm font-medium text-muted-foreground cursor-help">
Shipping Address Shipping Address
</h4> </h4>
<div className="text-sm space-y-1"> <div className="text-sm space-y-1">
@@ -396,7 +400,7 @@ const ShippingInfo = ({ details }) => (
</div> </div>
{details.TrackingNumber && ( {details.TrackingNumber && (
<div className="space-y-2"> <div className="space-y-2">
<h4 className="text-sm font-medium text-gray-500 cursor-help"> <h4 className="text-sm font-medium text-muted-foreground cursor-help">
Tracking Information Tracking Information
</h4> </h4>
<div className="text-sm space-y-1"> <div className="text-sm space-y-1">
@@ -412,75 +416,6 @@ const ShippingInfo = ({ details }) => (
</div> </div>
); );
const CustomTooltip = ({ active, payload, label }) => {
if (active && payload && payload.length) {
const date = new Date(label);
const formattedDate = date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric'
});
// Group metrics by type (current vs previous)
const currentMetrics = payload.filter(p => !p.dataKey.toLowerCase().includes('prev'));
const previousMetrics = payload.filter(p => p.dataKey.toLowerCase().includes('prev'));
return (
<Card className="p-3 shadow-lg bg-white dark:bg-gray-800 border-none">
<CardContent className="p-0 space-y-2">
<p className="font-medium text-sm border-b pb-1 mb-2">{formattedDate}</p>
<div className="space-y-1">
{currentMetrics.map((entry, index) => {
const value = entry.dataKey.toLowerCase().includes('revenue') ||
entry.dataKey === 'avgOrderValue' ||
entry.dataKey === 'movingAverage' ||
entry.dataKey === 'aovMovingAverage'
? formatCurrency(entry.value)
: entry.value.toLocaleString();
return (
<div key={index} className="flex justify-between items-center text-sm">
<span style={{ color: entry.stroke || METRIC_COLORS[entry.dataKey.toLowerCase()] }}>
{entry.name}:
</span>
<span className="font-medium ml-4">{value}</span>
</div>
);
})}
</div>
{previousMetrics.length > 0 && (
<>
<div className="border-t my-2"></div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground mb-1">Previous Period</p>
{previousMetrics.map((entry, index) => {
const value = entry.dataKey.toLowerCase().includes('revenue') ||
entry.dataKey.includes('avgOrderValue')
? formatCurrency(entry.value)
: entry.value.toLocaleString();
return (
<div key={index} className="flex justify-between items-center text-sm">
<span style={{ color: entry.stroke || METRIC_COLORS[entry.dataKey.toLowerCase()] }}>
{entry.name.replace('Previous ', '')}:
</span>
<span className="font-medium ml-4">{value}</span>
</div>
);
})}
</div>
</>
)}
</CardContent>
</Card>
);
}
return null;
};
const EventDialog = ({ event, children }) => { const EventDialog = ({ event, children }) => {
const eventType = EVENT_TYPES[event.metric_id]; const eventType = EVENT_TYPES[event.metric_id];
if (!eventType) return children; if (!eventType) return children;
@@ -681,19 +616,19 @@ const EventDialog = ({ event, children }) => {
<> <>
<div className="mt-1"> <div className="mt-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100"> <span className="text-sm font-medium text-foreground">
{toTitleCase(details.ShippingName)} {toTitleCase(details.ShippingName)}
</span> </span>
<span className="text-sm text-gray-500"></span> <span className="text-sm text-muted-foreground"></span>
<span className="text-sm text-gray-500"> <span className="text-sm text-muted-foreground">
#{details.OrderId} #{details.OrderId}
</span> </span>
</div> </div>
<div className="text-sm text-gray-500"> <div className="text-sm text-muted-foreground">
{formatShipMethodSimple(details.ShipMethod)} {formatShipMethodSimple(details.ShipMethod)}
{event.event_properties?.ShippedBy && ( {event.event_properties?.ShippedBy && (
<> <>
<span className="text-sm text-gray-500"> </span> <span className="text-sm text-muted-foreground"> </span>
<span className="text-sm font-medium text-blue-600 dark:text-blue-400">Shipped by {event.event_properties.ShippedBy}</span> <span className="text-sm font-medium text-blue-600 dark:text-blue-400">Shipped by {event.event_properties.ShippedBy}</span>
</> </>
)} )}
@@ -872,8 +807,8 @@ export { EventDialog };
const EventCard = ({ event }) => { const EventCard = ({ event }) => {
const eventType = EVENT_TYPES[event.metric_id] || { const eventType = EVENT_TYPES[event.metric_id] || {
label: "Unknown Event", label: "Unknown Event",
color: "bg-gray-500", color: "bg-slate-500",
textColor: "text-gray-600 dark:text-gray-400", textColor: "text-muted-foreground",
}; };
const Icon = EVENT_ICONS[event.metric_id] || Package; const Icon = EVENT_ICONS[event.metric_id] || Package;
@@ -886,9 +821,9 @@ const EventCard = ({ event }) => {
return ( return (
<EventDialog event={event}> <EventDialog event={event}>
<button className="w-full focus:outline-none text-left"> <button className="w-full focus:outline-none text-left">
<div className="flex items-center gap-3 p-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors border-b border-gray-100 dark:border-gray-800 last:border-b-0"> <div className="flex items-center gap-3 p-4 hover:bg-muted/50 transition-colors border-b border-border/50 last:border-b-0">
<div className={`shrink-0 w-10 h-10 rounded-full ${eventType.color} bg-opacity-10 dark:bg-opacity-20 flex items-center justify-center`}> <div className={`shrink-0 w-10 h-10 rounded-full ${eventType.color} bg-opacity-10 dark:bg-opacity-20 flex items-center justify-center`}>
<Icon className="h-5 w-5 text-gray-900 dark:text-gray-100" /> <Icon className="h-5 w-5 text-foreground" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -902,15 +837,15 @@ const EventCard = ({ event }) => {
<> <>
<div className="mt-1"> <div className="mt-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100"> <span className="text-sm font-medium text-foreground">
{toTitleCase(details.ShippingName)} {toTitleCase(details.ShippingName)}
</span> </span>
</div> </div>
<div className="flex items-center gap-2 text-sm text-gray-500"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="text-sm text-gray-500"> <span className="text-sm text-muted-foreground">
#{details.OrderId} #{details.OrderId}
</span> </span>
<span className="text-sm text-gray-500"></span> <span className="text-sm text-muted-foreground"></span>
<span className="font-medium text-green-600 dark:text-green-400"> <span className="font-medium text-green-600 dark:text-green-400">
{formatCurrency(details.TotalAmount)} {formatCurrency(details.TotalAmount)}
</span> </span>
@@ -989,19 +924,19 @@ const EventCard = ({ event }) => {
<> <>
<div className="mt-1"> <div className="mt-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100"> <span className="text-sm font-medium text-foreground">
{toTitleCase(details.ShippingName)} {toTitleCase(details.ShippingName)}
</span> </span>
<span className="text-sm text-gray-500"></span> <span className="text-sm text-muted-foreground"></span>
<span className="text-sm text-gray-500"> <span className="text-sm text-muted-foreground">
#{details.OrderId} #{details.OrderId}
</span> </span>
</div> </div>
<div className="text-sm text-gray-500"> <div className="text-sm text-muted-foreground">
{formatShipMethodSimple(details.ShipMethod)} {formatShipMethodSimple(details.ShipMethod)}
{event.event_properties?.ShippedBy && ( {event.event_properties?.ShippedBy && (
<> <>
<span className="text-sm text-gray-500"> </span> <span className="text-sm text-muted-foreground"> </span>
<span className="text-sm font-medium text-blue-600 dark:text-blue-400">Shipped by {event.event_properties.ShippedBy}</span> <span className="text-sm font-medium text-blue-600 dark:text-blue-400">Shipped by {event.event_properties.ShippedBy}</span>
</> </>
)} )}
@@ -1013,7 +948,7 @@ const EventCard = ({ event }) => {
{event.metric_id === METRIC_IDS.ACCOUNT_CREATED && ( {event.metric_id === METRIC_IDS.ACCOUNT_CREATED && (
<div className="mt-1"> <div className="mt-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100"> <span className="text-sm font-medium text-foreground">
{details.FirstName && details.LastName {details.FirstName && details.LastName
? `${toTitleCase(details.FirstName)} ${toTitleCase( ? `${toTitleCase(details.FirstName)} ${toTitleCase(
details.LastName details.LastName
@@ -1021,7 +956,7 @@ const EventCard = ({ event }) => {
: "New Customer"} : "New Customer"}
</span> </span>
</div> </div>
<div className="text-sm text-gray-500"> <div className="text-sm text-muted-foreground">
{details.EmailAddress} {details.EmailAddress}
</div> </div>
</div> </div>
@@ -1030,15 +965,15 @@ const EventCard = ({ event }) => {
{event.metric_id === METRIC_IDS.CANCELED_ORDER && ( {event.metric_id === METRIC_IDS.CANCELED_ORDER && (
<div className="mt-1"> <div className="mt-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100"> <span className="text-sm font-medium text-foreground">
{toTitleCase(details.ShippingName)} {toTitleCase(details.ShippingName)}
</span> </span>
<span className="text-sm text-gray-500"></span> <span className="text-sm text-muted-foreground"></span>
<span className="text-sm text-gray-500"> <span className="text-sm text-muted-foreground">
#{details.OrderId} #{details.OrderId}
</span> </span>
</div> </div>
<div className="text-sm text-gray-500"> <div className="text-sm text-muted-foreground">
{formatCurrency(details.TotalAmount)} {details.CancelReason} {formatCurrency(details.TotalAmount)} {details.CancelReason}
</div> </div>
</div> </div>
@@ -1047,15 +982,15 @@ const EventCard = ({ event }) => {
{event.metric_id === METRIC_IDS.PAYMENT_REFUNDED && ( {event.metric_id === METRIC_IDS.PAYMENT_REFUNDED && (
<div className="mt-1"> <div className="mt-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100"> <span className="text-sm font-medium text-foreground">
{toTitleCase(details.ShippingName)} {toTitleCase(details.ShippingName)}
</span> </span>
<span className="text-sm text-gray-500"></span> <span className="text-sm text-muted-foreground"></span>
<span className="text-sm text-gray-500"> <span className="text-sm text-muted-foreground">
#{details.FromOrder} #{details.FromOrder}
</span> </span>
</div> </div>
<div className="text-sm text-gray-500"> <div className="text-sm text-muted-foreground">
{formatCurrency(details.PaymentAmount)} via{" "} {formatCurrency(details.PaymentAmount)} via{" "}
{details.PaymentName} {details.PaymentName}
</div> </div>
@@ -1064,10 +999,10 @@ const EventCard = ({ event }) => {
{event.metric_id === METRIC_IDS.NEW_BLOG_POST && ( {event.metric_id === METRIC_IDS.NEW_BLOG_POST && (
<div className="mt-1"> <div className="mt-1">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100"> <div className="text-sm font-medium text-foreground">
{details.title} {details.title}
</div> </div>
<div className="text-sm text-gray-500 line-clamp-1"> <div className="text-sm text-muted-foreground line-clamp-1">
{details.description} {details.description}
</div> </div>
</div> </div>
@@ -1357,13 +1292,13 @@ const EventFeed = ({
); );
return ( return (
<Card className="flex flex-col h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm w-full"> <Card className={`flex flex-col h-full ${CARD_STYLES.base} w-full`}>
<CardHeader className="p-6 pb-2"> <CardHeader className="p-6 pb-2">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div> <div>
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">{title}</CardTitle> <CardTitle className={TYPOGRAPHY.sectionTitle}>{title}</CardTitle>
{lastUpdate && ( {lastUpdate && (
<CardDescription className="text-sm text-muted-foreground"> <CardDescription className={TYPOGRAPHY.cardDescription}>
Last updated {format(lastUpdate, "h:mm a")} Last updated {format(lastUpdate, "h:mm a")}
</CardDescription> </CardDescription>
)} )}
@@ -1597,17 +1532,11 @@ const EventFeed = ({
{loading && !events.length ? ( {loading && !events.length ? (
<LoadingState /> <LoadingState />
) : error ? ( ) : error ? (
<Alert variant="destructive" className="mt-1 mx-6"> <DashboardErrorState error={`Failed to load event feed: ${error}`} className="mt-1 mx-2" />
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
Failed to load event feed: {error}
</AlertDescription>
</Alert>
) : !filteredEvents || filteredEvents.length === 0 ? ( ) : !filteredEvents || filteredEvents.length === 0 ? (
<EmptyState /> <EmptyState />
) : ( ) : (
<div className="divide-y divide-gray-100 dark:divide-gray-800"> <div className="divide-y divide-border/50">
{filteredEvents.map((event) => ( {filteredEvents.map((event) => (
<EventCard key={event.id} event={event} /> <EventCard key={event.id} event={event} />
))} ))}

View File

@@ -42,26 +42,20 @@ import {
YAxis, YAxis,
} from "recharts"; } from "recharts";
import type { TooltipProps } from "recharts"; import type { TooltipProps } from "recharts";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { import { TrendingUp, DollarSign, Package, PiggyBank, Percent } from "lucide-react";
Tooltip as UITooltip,
TooltipContent,
TooltipTrigger,
TooltipProvider,
} from "@/components/ui/tooltip";
import { ArrowUp, ArrowDown, Minus, TrendingUp, AlertCircle, Info } from "lucide-react";
import PeriodSelectionPopover, { import PeriodSelectionPopover, {
type QuickPreset, type QuickPreset,
} from "@/components/dashboard/PeriodSelectionPopover"; } from "@/components/dashboard/PeriodSelectionPopover";
import type { CustomPeriod, NaturalLanguagePeriodResult } from "@/utils/naturalLanguagePeriod"; import type { CustomPeriod, NaturalLanguagePeriodResult } from "@/utils/naturalLanguagePeriod";
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
type TrendDirection = "up" | "down" | "flat"; import {
DashboardStatCard,
type TrendSummary = { DashboardStatCardSkeleton,
direction: TrendDirection; DashboardEmptyState,
label: string; DashboardErrorState,
}; TOOLTIP_STYLES,
} from "@/components/dashboard/shared";
type ComparisonValue = { type ComparisonValue = {
absolute: number | null; absolute: number | null;
@@ -501,45 +495,6 @@ const monthsBetween = (start: Date, end: Date) => {
return monthApprox; return monthApprox;
}; };
const buildTrendLabel = (
comparison?: ComparisonValue | null,
options?: { isPercentage?: boolean; invertDirection?: boolean }
): TrendSummary | null => {
if (!comparison || comparison.absolute === null) {
return null;
}
const { absolute, percentage } = comparison;
const rawDirection: TrendDirection = absolute > 0 ? "up" : absolute < 0 ? "down" : "flat";
const direction: TrendDirection = options?.invertDirection
? rawDirection === "up"
? "down"
: rawDirection === "down"
? "up"
: "flat"
: rawDirection;
const absoluteValue = Math.abs(absolute);
if (options?.isPercentage) {
return {
direction,
label: `${absolute > 0 ? "+" : absolute < 0 ? "-" : ""}${formatPercentage(absoluteValue, 1, "%")}`,
};
}
if (typeof percentage === "number" && Number.isFinite(percentage)) {
return {
direction,
label: `${absolute > 0 ? "+" : absolute < 0 ? "-" : ""}${formatPercentage(Math.abs(percentage), 1)}`,
};
}
return {
direction,
label: `${absolute > 0 ? "+" : absolute < 0 ? "-" : ""}${formatCurrency(absoluteValue)} vs previous`,
};
};
const safeNumeric = (value: number | null | undefined) => const safeNumeric = (value: number | null | undefined) =>
typeof value === "number" && Number.isFinite(value) ? value : 0; typeof value === "number" && Number.isFinite(value) ? value : 0;
@@ -893,52 +848,49 @@ const FinancialOverview = () => {
const profitDescription = previousProfitValue != null ? `Previous: ${safeCurrency(previousProfitValue, 0)}` : undefined; const profitDescription = previousProfitValue != null ? `Previous: ${safeCurrency(previousProfitValue, 0)}` : undefined;
const marginDescription = previousMarginValue != null ? `Previous: ${safePercentage(previousMarginValue, 1)}` : undefined; const marginDescription = previousMarginValue != null ? `Previous: ${safePercentage(previousMarginValue, 1)}` : undefined;
const incomeComparison = comparison?.income ?? buildComparisonFromValues(totalIncome, previousIncome ?? null);
const cogsComparison = comparison?.cogs ?? buildComparisonFromValues(cogsValue, previousCogs ?? null);
const profitComparison = comparison?.profit ?? buildComparisonFromValues(profitValue, previousProfitValue ?? null);
const marginComparison = comparison?.margin ?? buildComparisonFromValues(marginValue, previousMarginValue ?? null);
return [ return [
{ {
key: "income", key: "income",
title: "Total Income", title: "Total Income",
value: safeCurrency(totalIncome, 0), value: safeCurrency(totalIncome, 0),
description: incomeDescription, description: incomeDescription,
trend: buildTrendLabel(comparison?.income ?? buildComparisonFromValues(totalIncome, previousIncome ?? null)), trendValue: incomeComparison?.percentage,
accentClass: "text-blue-500 dark:text-blue-400", iconColor: "blue" as const,
tooltip: tooltip:
"Gross sales minus refunds and discounts, plus shipping fees collected (shipping, small-order, and rush fees). Taxes are excluded.", "Gross sales minus refunds and discounts, plus shipping fees collected (shipping, small-order, and rush fees). Taxes are excluded.",
showDescription: incomeDescription != null,
}, },
{ {
key: "cogs", key: "cogs",
title: "COGS", title: "COGS",
value: safeCurrency(cogsValue, 0), value: safeCurrency(cogsValue, 0),
description: cogsDescription, description: cogsDescription,
trend: buildTrendLabel(comparison?.cogs ?? buildComparisonFromValues(cogsValue, previousCogs ?? null), { trendValue: cogsComparison?.percentage,
invertDirection: true, trendInverted: true,
}), iconColor: "orange" as const,
accentClass: "text-orange-500 dark:text-orange-400",
tooltip: "Sum of reported product cost of goods sold (cogs_amount) for completed sales actions in the period.", tooltip: "Sum of reported product cost of goods sold (cogs_amount) for completed sales actions in the period.",
showDescription: cogsDescription != null,
}, },
{ {
key: "profit", key: "profit",
title: "Gross Profit", title: "Gross Profit",
value: safeCurrency(profitValue, 0), value: safeCurrency(profitValue, 0),
description: profitDescription, description: profitDescription,
trend: buildTrendLabel(comparison?.profit ?? buildComparisonFromValues(profitValue, previousProfitValue ?? null)), trendValue: profitComparison?.percentage,
accentClass: "text-emerald-500 dark:text-emerald-400", iconColor: "emerald" as const,
tooltip: "Total Income minus COGS.", tooltip: "Total Income minus COGS.",
showDescription: profitDescription != null,
}, },
{ {
key: "margin", key: "margin",
title: "Profit Margin", title: "Profit Margin",
value: safePercentage(marginValue, 1), value: safePercentage(marginValue, 1),
description: marginDescription, description: marginDescription,
trend: buildTrendLabel( trendValue: marginComparison?.absolute,
comparison?.margin ?? buildComparisonFromValues(marginValue, previousMarginValue ?? null), iconColor: "purple" as const,
{ isPercentage: true }
),
accentClass: "text-purple-500 dark:text-purple-400",
tooltip: "Gross Profit divided by Total Income, expressed as a percentage.", tooltip: "Gross Profit divided by Total Income, expressed as a percentage.",
showDescription: marginDescription != null,
}, },
]; ];
}, },
@@ -1151,12 +1103,12 @@ const FinancialOverview = () => {
return ( return (
<Card className="w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <Card className={`w-full ${CARD_STYLES.base}`}>
<CardHeader className="p-6 pb-4"> <CardHeader className="p-6 pb-4">
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div> <div>
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100"> <CardTitle className="text-xl font-semibold text-foreground">
Profit & Loss Overview Profit & Loss Overview
</CardTitle> </CardTitle>
</div> </div>
@@ -1170,9 +1122,9 @@ const FinancialOverview = () => {
Details Details
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="p-4 max-w-[95vw] w-fit max-h-[85vh] overflow-hidden flex flex-col bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <DialogContent className={`p-4 max-w-[95vw] w-fit max-h-[85vh] overflow-hidden flex flex-col ${CARD_STYLES.base}`}>
<DialogHeader className="flex-none"> <DialogHeader className="flex-none">
<DialogTitle className="text-gray-900 dark:text-gray-100"> <DialogTitle className="text-foreground">
Financial Details Financial Details
</DialogTitle> </DialogTitle>
<div className="flex items-center justify-center gap-2 pt-4"> <div className="flex items-center justify-center gap-2 pt-4">
@@ -1204,7 +1156,7 @@ const FinancialOverview = () => {
</div> </div>
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-auto mt-6"> <div className="flex-1 overflow-auto mt-6">
<div className="rounded-lg border bg-white dark:bg-gray-900/60 backdrop-blur-sm w-full"> <div className={`rounded-lg border ${CARD_STYLES.base} w-full`}>
<Table className="w-full"> <Table className="w-full">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -1353,33 +1305,22 @@ const FinancialOverview = () => {
<SkeletonChart /> <SkeletonChart />
</div> </div>
) : error ? ( ) : error ? (
<Alert <DashboardErrorState error={`Failed to load financial data: ${error}`} className="mx-0 my-0" />
variant="destructive"
className="bg-white dark:bg-gray-900/60 backdrop-blur-sm"
>
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
Failed to load financial data: {error}
</AlertDescription>
</Alert>
) : !hasData ? ( ) : !hasData ? (
<div className="flex items-center justify-center h-[400px] text-muted-foreground"> <DashboardEmptyState
<div className="text-center"> icon={TrendingUp}
<TrendingUp className="h-12 w-12 mx-auto mb-4" /> title="No financial data available"
<div className="font-medium mb-2 text-gray-900 dark:text-gray-100"> description="Try selecting a different time range"
No financial data available />
</div>
<div className="text-sm text-muted-foreground">
Try selecting a different time range
</div>
</div>
</div>
) : ( ) : (
<> <>
<div className="h-[400px] mt-4 bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-0 relative"> <div className={`h-[400px] mt-4 ${CARD_STYLES.base} rounded-lg p-0 relative`}>
{!hasActiveMetrics ? ( {!hasActiveMetrics ? (
<EmptyChartState message="Select at least one metric to visualize." /> <DashboardEmptyState
icon={TrendingUp}
title="No metrics selected"
description="Select at least one metric to visualize."
/>
) : ( ) : (
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 5, right: -25, left: 15, bottom: 5 }}> <ComposedChart data={chartData} margin={{ top: 5, right: -25, left: 15, bottom: 5 }}>
@@ -1502,153 +1443,46 @@ type FinancialStatCardConfig = {
title: string; title: string;
value: string; value: string;
description?: string; description?: string;
trend: TrendSummary | null; trendValue?: number | null;
accentClass: string; trendInverted?: boolean;
iconColor: "blue" | "orange" | "emerald" | "purple";
tooltip?: string; tooltip?: string;
isLoading?: boolean;
showDescription?: boolean;
}; };
const ICON_MAP = {
income: DollarSign,
cogs: Package,
profit: PiggyBank,
margin: Percent,
} as const;
function FinancialStatGrid({ function FinancialStatGrid({
cards, cards,
}: { }: {
cards: FinancialStatCardConfig[]; cards: FinancialStatCardConfig[];
}) { }) {
return ( return (
<TooltipProvider delayDuration={150}> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full"> {cards.map((card) => (
{cards.map((card) => ( <DashboardStatCard
<FinancialStatCard key={card.key}
key={card.key} title={card.title}
title={card.title} value={card.value}
value={card.value} subtitle={card.description}
description={card.description} trend={
trend={card.trend} card.trendValue != null && Number.isFinite(card.trendValue)
accentClass={card.accentClass} ? {
tooltip={card.tooltip} value: card.trendValue,
isLoading={card.isLoading} moreIsBetter: !card.trendInverted,
showDescription={card.showDescription} }
/> : undefined
))} }
</div> icon={ICON_MAP[card.key as keyof typeof ICON_MAP]}
</TooltipProvider> iconColor={card.iconColor}
); tooltip={card.tooltip}
} />
))}
function FinancialStatCard({ </div>
title,
value,
description,
trend,
accentClass,
tooltip,
isLoading,
showDescription,
}: {
title: string;
value: string;
description?: string;
trend: TrendSummary | null;
accentClass: string;
tooltip?: string;
isLoading?: boolean;
showDescription?: boolean;
}) {
const shouldShowDescription = isLoading ? showDescription !== false : Boolean(description);
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<div className="flex items-center gap-1 text-sm text-muted-foreground">
{isLoading ? (
<>
<span className="relative block w-24">
<span className="invisible block">Placeholder</span>
<Skeleton className="absolute inset-0 w-full bg-muted rounded-sm" />
</span>
<span className="relative inline-flex rounded-full p-0.5">
<span className="invisible inline-flex">
<Info className="h-4 w-4" />
</span>
<Skeleton className="absolute inset-0 rounded-full" />
</span>
</>
) : (
<>
<span>{title}</span>
{tooltip ? (
<UITooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`What makes up ${title}`}
className="text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background rounded-full p-0.5"
>
<Info className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-[260px] text-xs leading-relaxed">
{tooltip}
</TooltipContent>
</UITooltip>
) : null}
</>
)}
</div>
{isLoading ? (
<span className="text-sm text-muted-foreground flex items-center gap-1">
<Skeleton className="h-4 w-4 bg-muted rounded-full" />
<span className="relative block w-16">
<span className="invisible block">Placeholder</span>
<Skeleton className="absolute inset-0 w-full bg-muted rounded-sm" />
</span>
</span>
) : trend?.label ? (
<span
className={`text-sm flex items-center gap-1 ${
trend.direction === "up"
? "text-emerald-600 dark:text-emerald-400"
: trend.direction === "down"
? "text-rose-600 dark:text-rose-400"
: "text-muted-foreground"
}`}
>
{trend.direction === "up" ? (
<ArrowUp className="w-4 h-4" />
) : trend.direction === "down" ? (
<ArrowDown className="w-4 h-4" />
) : (
<Minus className="w-4 h-4" />
)}
{trend.label}
</span>
) : null}
</CardHeader>
<CardContent className="p-4 pt-0">
<div className={`text-2xl font-bold mb-1 ${accentClass}`}>
{isLoading ? (
<span className="relative block">
<span className="invisible">0</span>
<Skeleton className="absolute inset-0 bg-muted rounded-sm" />
</span>
) : (
value
)}
</div>
{isLoading ? (
shouldShowDescription ? (
<div className="text-sm text-muted-foreground">
<span className="relative block w-32">
<span className="invisible block">Placeholder text</span>
<Skeleton className="absolute inset-0 w-full bg-muted rounded-sm" />
</span>
</div>
) : null
) : description ? (
<div className="text-sm text-muted-foreground">{description}</div>
) : null}
</CardContent>
</Card>
); );
} }
@@ -1656,16 +1490,7 @@ function SkeletonStats() {
return ( return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full">
{Array.from({ length: 4 }).map((_, index) => ( {Array.from({ length: 4 }).map((_, index) => (
<FinancialStatCard <DashboardStatCardSkeleton key={index} hasIcon hasSubtitle />
key={index}
title=""
value=""
description=""
trend={null}
accentClass=""
isLoading
showDescription
/>
))} ))}
</div> </div>
); );
@@ -1673,7 +1498,7 @@ function SkeletonStats() {
function SkeletonChart() { function SkeletonChart() {
return ( return (
<div className="h-[400px] mt-4 bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-0 relative"> <div className={`h-[400px] mt-4 ${CARD_STYLES.base} rounded-lg p-0 relative`}>
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
<div className="flex-1 relative"> <div className="flex-1 relative">
{/* Grid lines */} {/* Grid lines */}
@@ -1722,14 +1547,6 @@ function SkeletonChart() {
); );
} }
function EmptyChartState({ message }: { message: string }) {
return (
<div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-2 text-center text-sm text-muted-foreground">
<TrendingUp className="h-10 w-10 text-muted-foreground/80" />
<span>{message}</span>
</div>
);
}
const FinancialTooltip = ({ active, payload, label }: TooltipProps<number, string>) => { const FinancialTooltip = ({ active, payload, label }: TooltipProps<number, string>) => {
if (!active || !payload?.length) { if (!active || !payload?.length) {
@@ -1755,9 +1572,9 @@ const FinancialTooltip = ({ active, payload, label }: TooltipProps<number, strin
.filter((entry): entry is typeof payload[0] => entry !== undefined); .filter((entry): entry is typeof payload[0] => entry !== undefined);
return ( return (
<div className="rounded-md border border-border/60 bg-white dark:bg-gray-900/80 px-3 py-2 shadow-lg"> <div className={TOOLTIP_STYLES.container}>
<p className="text-xs font-semibold text-gray-900 dark:text-gray-100">{resolvedLabel}</p> <p className={TOOLTIP_STYLES.header}>{resolvedLabel}</p>
<div className="mt-1 space-y-1 text-xs"> <div className={TOOLTIP_STYLES.content}>
{orderedPayload.map((entry, index) => { {orderedPayload.map((entry, index) => {
const key = (entry.dataKey ?? "") as ChartSeriesKey; const key = (entry.dataKey ?? "") as ChartSeriesKey;
const rawValue = entry.value; const rawValue = entry.value;
@@ -1782,14 +1599,17 @@ const FinancialTooltip = ({ active, payload, label }: TooltipProps<number, strin
} }
return ( return (
<div key={`${key}-${index}`} className="flex items-center justify-between gap-4"> <div key={`${key}-${index}`} className={TOOLTIP_STYLES.row}>
<span <div className={TOOLTIP_STYLES.rowLabel}>
className="flex items-center gap-1" <span
style={{ color: entry.stroke || entry.color || "inherit" }} className={TOOLTIP_STYLES.dot}
> style={{ backgroundColor: entry.stroke || entry.color || "#888" }}
{SERIES_LABELS[key] ?? entry.name ?? key} />
</span> <span className={TOOLTIP_STYLES.name}>
<span className="font-semibold text-gray-900 dark:text-gray-100"> {SERIES_LABELS[key] ?? entry.name ?? key}
</span>
</div>
<span className={TOOLTIP_STYLES.value}>
{formattedValue}{percentageOfRevenue} {formattedValue}{percentageOfRevenue}
</span> </span>
</div> </div>

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { import {
Select, Select,
SelectTrigger, SelectTrigger,
@@ -17,20 +17,24 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { import {
Clock,
Star,
MessageSquare,
Mail, Mail,
Send, Send,
Loader2,
ArrowUp, ArrowUp,
ArrowDown, ArrowDown,
Zap, Zap,
Timer, Timer,
BarChart3, BarChart3,
ClipboardCheck, ClipboardCheck,
Star,
} from "lucide-react"; } from "lucide-react";
import axios from "axios"; import axios from "axios";
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import {
DashboardStatCard,
DashboardStatCardSkeleton,
DashboardSectionHeader,
DashboardErrorState,
} from "@/components/dashboard/shared";
const TIME_RANGES = { const TIME_RANGES = {
"today": "Today", "today": "Today",
@@ -81,104 +85,6 @@ const getDateRange = (days) => {
}; };
}; };
const MetricCard = ({
title,
value,
delta,
suffix = "",
icon: Icon,
colorClass = "blue",
more_is_better = true,
loading = false,
}) => {
const getDeltaColor = (d) => {
if (d === 0) return "text-gray-600 dark:text-gray-400";
const isPositive = d > 0;
return isPositive === more_is_better
? "text-green-600 dark:text-green-500"
: "text-red-600 dark:text-red-500";
};
const formatDelta = (d) => {
if (d === undefined || d === null) return null;
if (d === 0) return "0";
return Math.abs(d) + suffix;
};
return (
<Card className="h-full">
<CardContent className="pt-6 h-full">
<div className="flex justify-between items-start">
<div className="flex-1 min-w-0">
{loading ? (
<>
<Skeleton className="h-4 w-24 mb-4 dark:bg-gray-700" />
<div className="flex items-baseline gap-2">
<Skeleton className="h-8 w-20 dark:bg-gray-700" />
<Skeleton className="h-4 w-12 dark:bg-gray-700" />
</div>
</>
) : (
<>
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<div className="flex items-baseline gap-2">
<p className="text-2xl font-bold">
{typeof value === "number"
? value.toLocaleString() + suffix
: value}
</p>
{delta !== undefined && delta !== 0 && (
<div className={`flex items-center ${getDeltaColor(delta)}`}>
{delta > 0 ? (
<ArrowUp className="w-3 h-3" />
) : (
<ArrowDown className="w-3 h-3" />
)}
<span className="text-xs font-medium">
{formatDelta(delta)}
</span>
</div>
)}
</div>
</>
)}
</div>
{!loading && Icon && (
<Icon className={`h-5 w-5 flex-shrink-0 ml-2 ${colorClass === "blue" ? "text-blue-500" :
colorClass === "green" ? "text-green-500" :
colorClass === "purple" ? "text-purple-500" :
colorClass === "indigo" ? "text-indigo-500" :
colorClass === "orange" ? "text-orange-500" :
colorClass === "teal" ? "text-teal-500" :
colorClass === "cyan" ? "text-cyan-500" :
"text-blue-500"}`} />
)}
{loading && (
<Skeleton className="h-5 w-5 rounded-full dark:bg-gray-700" />
)}
</div>
</CardContent>
</Card>
);
};
const SkeletonMetricCard = () => (
<Card className="h-full">
<CardContent className="pt-6 h-full">
<div className="flex justify-between items-start">
<div className="flex-1 min-w-0">
<Skeleton className="h-4 w-24 mb-4 bg-muted" />
<div className="flex items-baseline gap-2">
<Skeleton className="h-8 w-20 bg-muted" />
<Skeleton className="h-4 w-12 bg-muted" />
</div>
</div>
<Skeleton className="h-5 w-5 rounded-full bg-muted" />
</div>
</CardContent>
</Card>
);
const TableSkeleton = () => ( const TableSkeleton = () => (
<Table> <Table>
<TableHeader> <TableHeader>
@@ -295,147 +201,146 @@ const GorgiasOverview = () => {
if (error) { if (error) {
return ( return (
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <Card className={`h-full ${CARD_STYLES.base}`}>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="p-4 m-6 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/10 rounded-lg border border-red-200 dark:border-red-900/20"> <DashboardErrorState
{error} title="Failed to load customer service data"
</div> error={error}
/>
</CardContent> </CardContent>
</Card> </Card>
); );
} }
return ( return (
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <Card className={`h-full ${CARD_STYLES.base}`}>
<CardHeader> <DashboardSectionHeader
<div className="flex justify-between items-center"> title="Customer Service"
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100"> timeSelector={
Customer Service <Select
</h2> value={timeRange}
<div className="flex items-center gap-2"> onValueChange={(value) => setTimeRange(value)}
<Select >
value={timeRange} <SelectTrigger className="w-[130px] bg-background">
onValueChange={(value) => setTimeRange(value)} <SelectValue placeholder="Select range">
> {TIME_RANGES[timeRange]}
<SelectTrigger className="w-[130px] bg-white dark:bg-gray-800"> </SelectValue>
<SelectValue placeholder="Select range"> </SelectTrigger>
{TIME_RANGES[timeRange]} <SelectContent>
</SelectValue> {[
</SelectTrigger> ["today", "Today"],
<SelectContent> ["7", "Last 7 Days"],
{[ ["14", "Last 14 Days"],
["today", "Today"], ["30", "Last 30 Days"],
["7", "Last 7 Days"], ["90", "Last 90 Days"],
["14", "Last 14 Days"], ].map(([value, label]) => (
["30", "Last 30 Days"], <SelectItem key={value} value={value}>
["90", "Last 90 Days"], {label}
].map(([value, label]) => ( </SelectItem>
<SelectItem key={value} value={value}> ))}
{label} </SelectContent>
</SelectItem> </Select>
))} }
</SelectContent> />
</Select>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{/* Message & Response Metrics */} {/* Message & Response Metrics */}
{loading ? ( {loading ? (
[...Array(7)].map((_, i) => ( [...Array(7)].map((_, i) => (
<SkeletonMetricCard key={i} /> <DashboardStatCardSkeleton key={i} size="compact" />
)) ))
) : ( ) : (
<> <>
<div className="h-full"> <DashboardStatCard
<MetricCard title="Messages Received"
title="Messages Received" value={stats.total_messages_received?.value ?? 0}
value={stats.total_messages_received?.value} trend={stats.total_messages_received?.delta ? {
delta={stats.total_messages_received?.delta} value: stats.total_messages_received.delta,
icon={Mail} suffix: "",
colorClass="blue" } : undefined}
loading={loading} icon={Mail}
/> iconColor="blue"
</div> size="compact"
<div className="h-full"> />
<MetricCard <DashboardStatCard
title="Messages Sent" title="Messages Sent"
value={stats.total_messages_sent?.value} value={stats.total_messages_sent?.value ?? 0}
delta={stats.total_messages_sent?.delta} trend={stats.total_messages_sent?.delta ? {
icon={Send} value: stats.total_messages_sent.delta,
colorClass="green" suffix: "",
loading={loading} } : undefined}
/> icon={Send}
</div> iconColor="green"
<div className="h-full"> size="compact"
<MetricCard />
title="First Response" <DashboardStatCard
value={formatDuration(stats.median_first_response_time?.value)} title="First Response"
delta={stats.median_first_response_time?.delta} value={formatDuration(stats.median_first_response_time?.value)}
icon={Zap} trend={stats.median_first_response_time?.delta ? {
colorClass="purple" value: stats.median_first_response_time.delta,
more_is_better={false} suffix: "",
loading={loading} moreIsBetter: false,
/> } : undefined}
</div> icon={Zap}
<div className="h-full"> iconColor="purple"
<MetricCard size="compact"
title="One-Touch Rate" />
value={stats.total_one_touch_tickets?.value} <DashboardStatCard
delta={stats.total_one_touch_tickets?.delta} title="One-Touch Rate"
suffix="%" value={stats.total_one_touch_tickets?.value ?? 0}
icon={BarChart3} valueSuffix="%"
colorClass="indigo" trend={stats.total_one_touch_tickets?.delta ? {
loading={loading} value: stats.total_one_touch_tickets.delta,
/> suffix: "%",
</div> } : undefined}
<div className="h-full"> icon={BarChart3}
<MetricCard iconColor="indigo"
title="Customer Satisfaction" size="compact"
value={`${satisfactionStats.average_rating?.value}/5`} />
delta={satisfactionStats.average_rating?.delta} <DashboardStatCard
suffix="%" title="Customer Satisfaction"
icon={Star} value={`${satisfactionStats.average_rating?.value ?? 0}/5`}
colorClass="orange" trend={satisfactionStats.average_rating?.delta ? {
loading={loading} value: satisfactionStats.average_rating.delta,
/> suffix: "%",
</div> } : undefined}
<div className="h-full"> icon={Star}
<MetricCard iconColor="orange"
title="Survey Response Rate" size="compact"
value={satisfactionStats.response_rate?.value} />
delta={satisfactionStats.response_rate?.delta} <DashboardStatCard
suffix="%" title="Survey Response Rate"
icon={ClipboardCheck} value={satisfactionStats.response_rate?.value ?? 0}
colorClass="pink" valueSuffix="%"
loading={loading} trend={satisfactionStats.response_rate?.delta ? {
/> value: satisfactionStats.response_rate.delta,
</div> suffix: "%",
<div className="h-full"> } : undefined}
<MetricCard icon={ClipboardCheck}
title="Resolution Time" iconColor="pink"
value={formatDuration(stats.median_resolution_time?.value)} size="compact"
delta={stats.median_resolution_time?.delta} />
icon={Timer} <DashboardStatCard
colorClass="teal" title="Resolution Time"
more_is_better={false} value={formatDuration(stats.median_resolution_time?.value)}
loading={loading} trend={stats.median_resolution_time?.delta ? {
/> value: stats.median_resolution_time.delta,
</div> suffix: "",
moreIsBetter: false,
} : undefined}
icon={Timer}
iconColor="teal"
size="compact"
/>
</> </>
)} )}
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Channel Distribution */} {/* Channel Distribution */}
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <Card className={CARD_STYLES.base}>
<CardHeader className="pb-0"> <DashboardSectionHeader title="Channel Distribution" compact className="pb-0" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Channel Distribution
</h3>
</CardHeader>
<CardContent className="overflow-y-auto max-h-[400px] scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600"> <CardContent className="overflow-y-auto max-h-[400px] scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
{loading ? ( {loading ? (
<TableSkeleton /> <TableSkeleton />
@@ -443,10 +348,10 @@ const GorgiasOverview = () => {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="dark:border-gray-800"> <TableRow className="dark:border-gray-800">
<TableHead className="text-left font-medium text-gray-900 dark:text-gray-100">Channel</TableHead> <TableHead className="text-left font-medium text-foreground">Channel</TableHead>
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">Total</TableHead> <TableHead className="text-right font-medium text-foreground">Total</TableHead>
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">%</TableHead> <TableHead className="text-right font-medium text-foreground">%</TableHead>
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">Change</TableHead> <TableHead className="text-right font-medium text-foreground">Change</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -454,7 +359,7 @@ const GorgiasOverview = () => {
.sort((a, b) => b.total - a.total) .sort((a, b) => b.total - a.total)
.map((channel, index) => ( .map((channel, index) => (
<TableRow key={index} className="dark:border-gray-800 hover:bg-muted/50 transition-colors"> <TableRow key={index} className="dark:border-gray-800 hover:bg-muted/50 transition-colors">
<TableCell className="text-gray-900 dark:text-gray-100"> <TableCell className="text-foreground">
{channel.name} {channel.name}
</TableCell> </TableCell>
<TableCell className="text-right text-muted-foreground"> <TableCell className="text-right text-muted-foreground">
@@ -494,12 +399,8 @@ const GorgiasOverview = () => {
</Card> </Card>
{/* Agent Performance */} {/* Agent Performance */}
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <Card className={CARD_STYLES.base}>
<CardHeader className="pb-0"> <DashboardSectionHeader title="Agent Performance" compact className="pb-0" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Agent Performance
</h3>
</CardHeader>
<CardContent className="overflow-y-auto max-h-[400px] scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600"> <CardContent className="overflow-y-auto max-h-[400px] scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
{loading ? ( {loading ? (
<TableSkeleton /> <TableSkeleton />
@@ -507,10 +408,10 @@ const GorgiasOverview = () => {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="dark:border-gray-800"> <TableRow className="dark:border-gray-800">
<TableHead className="text-left font-medium text-gray-900 dark:text-gray-100">Agent</TableHead> <TableHead className="text-left font-medium text-foreground">Agent</TableHead>
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">Closed</TableHead> <TableHead className="text-right font-medium text-foreground">Closed</TableHead>
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">Rating</TableHead> <TableHead className="text-right font-medium text-foreground">Rating</TableHead>
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">Change</TableHead> <TableHead className="text-right font-medium text-foreground">Change</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -518,7 +419,7 @@ const GorgiasOverview = () => {
.filter((agent) => agent.name !== "Unassigned") .filter((agent) => agent.name !== "Unassigned")
.map((agent, index) => ( .map((agent, index) => (
<TableRow key={index} className="dark:border-gray-800 hover:bg-muted/50 transition-colors"> <TableRow key={index} className="dark:border-gray-800 hover:bg-muted/50 transition-colors">
<TableCell className="text-gray-900 dark:text-gray-100"> <TableCell className="text-foreground">
{agent.name} {agent.name}
</TableCell> </TableCell>
<TableCell className="text-right text-muted-foreground"> <TableCell className="text-right text-muted-foreground">

View File

@@ -40,6 +40,7 @@ import {
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
const CraftsIcon = () => ( const CraftsIcon = () => (
<svg viewBox="0 0 2687 3338" className="w-6 h-6" aria-hidden="true"> <svg viewBox="0 0 2687 3338" className="w-6 h-6" aria-hidden="true">
@@ -289,7 +290,7 @@ const Header = () => {
return ( return (
<Card <Card
className={cn( className={cn(
"w-full bg-white dark:bg-gray-900 shadow-sm", `w-full ${CARD_STYLES.solid} shadow-sm`,
isStuck ? "rounded-b-lg border-b-1" : "border-b-0 rounded-b-none" isStuck ? "rounded-b-lg border-b-1" : "border-b-0 rounded-b-none"
)} )}
> >

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -16,8 +16,13 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { TIME_RANGES } from "@/lib/dashboard/constants"; import { TIME_RANGES } from "@/lib/dashboard/constants";
import { Mail, MessageSquare, ArrowUpDown, BookOpen } from "lucide-react"; import { Mail, MessageSquare, BookOpen } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import {
DashboardSectionHeader,
DashboardErrorState,
} from "@/components/dashboard/shared";
// Helper functions for formatting // Helper functions for formatting
const formatRate = (value, isSMS = false, hideForSMS = false) => { const formatRate = (value, isSMS = false, hideForSMS = false) => {
@@ -41,27 +46,27 @@ const TableSkeleton = () => (
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr> <tr>
<th className="p-2 text-left font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10"> <th className="p-2 text-left font-medium sticky top-0 bg-card z-10">
<Skeleton className="h-8 w-24 bg-muted" /> <Skeleton className="h-8 w-24 bg-muted" />
</th> </th>
<th className="p-2 text-center font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10"> <th className="p-2 text-center font-medium sticky top-0 bg-card z-10">
<Skeleton className="h-8 w-20 mx-auto bg-muted" /> <Skeleton className="h-8 w-20 mx-auto bg-muted" />
</th> </th>
<th className="p-2 text-center font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10"> <th className="p-2 text-center font-medium sticky top-0 bg-card z-10">
<Skeleton className="h-8 w-20 mx-auto bg-muted" /> <Skeleton className="h-8 w-20 mx-auto bg-muted" />
</th> </th>
<th className="p-2 text-center font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10"> <th className="p-2 text-center font-medium sticky top-0 bg-card z-10">
<Skeleton className="h-8 w-20 mx-auto bg-muted" /> <Skeleton className="h-8 w-20 mx-auto bg-muted" />
</th> </th>
<th className="p-2 text-center font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10"> <th className="p-2 text-center font-medium sticky top-0 bg-card z-10">
<Skeleton className="h-8 w-20 mx-auto bg-muted" /> <Skeleton className="h-8 w-20 mx-auto bg-muted" />
</th> </th>
<th className="p-2 text-center font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10"> <th className="p-2 text-center font-medium sticky top-0 bg-card z-10">
<Skeleton className="h-8 w-20 mx-auto bg-muted" /> <Skeleton className="h-8 w-20 mx-auto bg-muted" />
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-800"> <tbody className="divide-y divide-border/50">
{[...Array(15)].map((_, i) => ( {[...Array(15)].map((_, i) => (
<tr key={i} className="hover:bg-muted/50 transition-colors"> <tr key={i} className="hover:bg-muted/50 transition-colors">
<td className="p-2"> <td className="p-2">
@@ -110,12 +115,6 @@ const TableSkeleton = () => (
</table> </table>
); );
// Error alert component
const ErrorAlert = ({ description }) => (
<div className="p-4 m-6 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/10 rounded-lg border border-red-200 dark:border-red-900/20">
{description}
</div>
);
// MetricCell component for displaying campaign metrics // MetricCell component for displaying campaign metrics
const MetricCell = ({ const MetricCell = ({
@@ -232,21 +231,14 @@ const KlaviyoCampaigns = ({ className }) => {
if (isLoading) { if (isLoading) {
return ( return (
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <Card className={`h-full ${CARD_STYLES.base}`}>
<CardHeader className="pb-2"> <DashboardSectionHeader
<div className="flex justify-between items-center"> title="Klaviyo Campaigns"
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100"> loading={true}
<Skeleton className="h-6 w-48 bg-muted" /> compact
</CardTitle> actions={<div className="w-[200px]" />}
<div className="flex gap-2"> timeSelector={<div className="w-[130px]" />}
<div className="flex ml-1 gap-1 items-center"> />
<Skeleton className="h-8 w-20 bg-muted" />
<Skeleton className="h-8 w-20 bg-muted" />
</div>
<Skeleton className="h-8 w-[130px] bg-muted" />
</div>
</div>
</CardHeader>
<CardContent className="overflow-y-auto pl-4 max-h-[400px] mb-4"> <CardContent className="overflow-y-auto pl-4 max-h-[400px] mb-4">
<TableSkeleton /> <TableSkeleton />
</CardContent> </CardContent>
@@ -255,81 +247,80 @@ const KlaviyoCampaigns = ({ className }) => {
} }
return ( return (
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <Card className={`h-full ${CARD_STYLES.base}`}>
{error && <ErrorAlert description={error} />} {error && (
<CardHeader className="pb-2"> <DashboardErrorState
<div className="flex justify-between items-center"> title="Failed to load campaigns"
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100"> message={error}
Klaviyo Campaigns className="mx-6 mt-4"
</CardTitle> />
<div className="flex gap-2"> )}
<div className="flex ml-1 gap-1 items-center"> <DashboardSectionHeader
<Button title="Klaviyo Campaigns"
variant={selectedChannels.email ? "default" : "outline"} compact
size="sm" actions={
onClick={() => setSelectedChannels(prev => { <div className="flex gap-1 items-center">
if (prev.email && Object.values(prev).filter(Boolean).length === 1) { <Button
// If only email is selected, show all variant={selectedChannels.email ? "default" : "outline"}
return { email: true, sms: true, blog: true }; size="sm"
} onClick={() => setSelectedChannels(prev => {
// Show only email if (prev.email && Object.values(prev).filter(Boolean).length === 1) {
return { email: true, sms: false, blog: false }; return { email: true, sms: true, blog: true };
})} }
> return { email: true, sms: false, blog: false };
<Mail className="h-4 w-4" /> })}
<span className="hidden sm:inline">Email</span> >
</Button> <Mail className="h-4 w-4" />
<Button <span className="hidden sm:inline">Email</span>
variant={selectedChannels.sms ? "default" : "outline"} </Button>
size="sm" <Button
onClick={() => setSelectedChannels(prev => { variant={selectedChannels.sms ? "default" : "outline"}
if (prev.sms && Object.values(prev).filter(Boolean).length === 1) { size="sm"
// If only SMS is selected, show all onClick={() => setSelectedChannels(prev => {
return { email: true, sms: true, blog: true }; if (prev.sms && Object.values(prev).filter(Boolean).length === 1) {
} return { email: true, sms: true, blog: true };
// Show only SMS }
return { email: false, sms: true, blog: false }; return { email: false, sms: true, blog: false };
})} })}
> >
<MessageSquare className="h-4 w-4" /> <MessageSquare className="h-4 w-4" />
<span className="hidden sm:inline">SMS</span> <span className="hidden sm:inline">SMS</span>
</Button> </Button>
<Button <Button
variant={selectedChannels.blog ? "default" : "outline"} variant={selectedChannels.blog ? "default" : "outline"}
size="sm" size="sm"
onClick={() => setSelectedChannels(prev => { onClick={() => setSelectedChannels(prev => {
if (prev.blog && Object.values(prev).filter(Boolean).length === 1) { if (prev.blog && Object.values(prev).filter(Boolean).length === 1) {
// If only blog is selected, show all return { email: true, sms: true, blog: true };
return { email: true, sms: true, blog: true }; }
} return { email: false, sms: false, blog: true };
// Show only blog })}
return { email: false, sms: false, blog: true }; >
})} <BookOpen className="h-4 w-4" />
> <span className="hidden sm:inline">Blog</span>
<BookOpen className="h-4 w-4" /> </Button>
<span className="hidden sm:inline">Blog</span>
</Button>
</div>
<Select value={selectedTimeRange} onValueChange={setSelectedTimeRange}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="Select time range" />
</SelectTrigger>
<SelectContent>
{TIME_RANGES.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
</div> }
</CardHeader> timeSelector={
<Select value={selectedTimeRange} onValueChange={setSelectedTimeRange}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="Select time range" />
</SelectTrigger>
<SelectContent>
{TIME_RANGES.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
}
/>
<CardContent className="overflow-y-auto pl-4 max-h-[400px] mb-4"> <CardContent className="overflow-y-auto pl-4 max-h-[400px] mb-4">
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr> <tr>
<th className="p-2 text-left font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100"> <th className="p-2 text-left font-medium sticky top-0 bg-card z-10 text-foreground">
<Button <Button
variant="ghost" variant="ghost"
onClick={() => handleSort("send_time")} onClick={() => handleSort("send_time")}
@@ -338,7 +329,7 @@ const KlaviyoCampaigns = ({ className }) => {
Campaign Campaign
</Button> </Button>
</th> </th>
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100"> <th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
<Button <Button
variant={sortConfig.key === "delivery_rate" ? "default" : "ghost"} variant={sortConfig.key === "delivery_rate" ? "default" : "ghost"}
onClick={() => handleSort("delivery_rate")} onClick={() => handleSort("delivery_rate")}
@@ -347,7 +338,7 @@ const KlaviyoCampaigns = ({ className }) => {
Delivery Delivery
</Button> </Button>
</th> </th>
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100"> <th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
<Button <Button
variant={sortConfig.key === "open_rate" ? "default" : "ghost"} variant={sortConfig.key === "open_rate" ? "default" : "ghost"}
onClick={() => handleSort("open_rate")} onClick={() => handleSort("open_rate")}
@@ -356,7 +347,7 @@ const KlaviyoCampaigns = ({ className }) => {
Opens Opens
</Button> </Button>
</th> </th>
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100"> <th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
<Button <Button
variant={sortConfig.key === "click_rate" ? "default" : "ghost"} variant={sortConfig.key === "click_rate" ? "default" : "ghost"}
onClick={() => handleSort("click_rate")} onClick={() => handleSort("click_rate")}
@@ -365,7 +356,7 @@ const KlaviyoCampaigns = ({ className }) => {
Clicks Clicks
</Button> </Button>
</th> </th>
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100"> <th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
<Button <Button
variant={sortConfig.key === "click_to_open_rate" ? "default" : "ghost"} variant={sortConfig.key === "click_to_open_rate" ? "default" : "ghost"}
onClick={() => handleSort("click_to_open_rate")} onClick={() => handleSort("click_to_open_rate")}
@@ -374,7 +365,7 @@ const KlaviyoCampaigns = ({ className }) => {
CTR CTR
</Button> </Button>
</th> </th>
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100"> <th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
<Button <Button
variant={sortConfig.key === "conversion_value" ? "default" : "ghost"} variant={sortConfig.key === "conversion_value" ? "default" : "ghost"}
onClick={() => handleSort("conversion_value")} onClick={() => handleSort("conversion_value")}
@@ -385,7 +376,7 @@ const KlaviyoCampaigns = ({ className }) => {
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-800"> <tbody className="divide-y divide-border/50">
{filteredCampaigns.map((campaign) => ( {filteredCampaigns.map((campaign) => (
<tr <tr
key={campaign.id} key={campaign.id}
@@ -403,7 +394,7 @@ const KlaviyoCampaigns = ({ className }) => {
) : ( ) : (
<Mail className="h-4 w-4 text-muted-foreground" /> <Mail className="h-4 w-4 text-muted-foreground" />
)} )}
<div className="font-medium text-gray-900 dark:text-gray-100"> <div className="font-medium text-foreground">
{campaign.name} {campaign.name}
</div> </div>
</div> </div>
@@ -419,7 +410,7 @@ const KlaviyoCampaigns = ({ className }) => {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent <TooltipContent
side="top" side="top"
className="break-words bg-white dark:bg-gray-900/60 backdrop-blur-sm text-gray-900 dark:text-gray-100 border dark:border-gray-800" className="break-words bg-card text-foreground border dark:border-gray-800"
> >
<p className="font-medium">{campaign.name}</p> <p className="font-medium">{campaign.name}</p>
<p>{campaign.subject}</p> <p>{campaign.subject}</p>

View File

@@ -1,11 +1,5 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -15,7 +9,6 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { import {
Instagram, Instagram,
Loader2,
Users, Users,
DollarSign, DollarSign,
Eye, Eye,
@@ -25,10 +18,16 @@ import {
Target, Target,
ShoppingCart, ShoppingCart,
MessageCircle, MessageCircle,
Hash,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import {
DashboardStatCard,
DashboardStatCardSkeleton,
DashboardSectionHeader,
DashboardErrorState,
} from "@/components/dashboard/shared";
// Helper functions for formatting // Helper functions for formatting
const formatCurrency = (value, decimalPlaces = 2) => const formatCurrency = (value, decimalPlaces = 2) =>
@@ -49,40 +48,6 @@ const formatNumber = (value, decimalPlaces = 0) => {
const formatPercent = (value, decimalPlaces = 2) => const formatPercent = (value, decimalPlaces = 2) =>
`${(value || 0).toFixed(decimalPlaces)}%`; `${(value || 0).toFixed(decimalPlaces)}%`;
const summaryCard = (label, value, options = {}) => {
const {
isMonetary = false,
isPercentage = false,
decimalPlaces = 0,
icon: Icon,
iconColor,
} = options;
let displayValue;
if (isMonetary) {
displayValue = formatCurrency(value, decimalPlaces);
} else if (isPercentage) {
displayValue = formatPercent(value, decimalPlaces);
} else {
displayValue = formatNumber(value, decimalPlaces);
}
return (
<Card className="h-full">
<CardContent className="pt-6 h-full">
<div className="flex justify-between items-start">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-muted-foreground">{label}</p>
<p className="text-2xl font-bold">{displayValue}</p>
</div>
{Icon && (
<Icon className={`h-5 w-5 flex-shrink-0 ml-2 ${iconColor || "text-blue-500"}`} />
)}
</div>
</CardContent>
</Card>
);
};
const MetricCell = ({ value, label, sublabel, isMonetary = false, isPercentage = false, decimalPlaces = 0 }) => { const MetricCell = ({ value, label, sublabel, isMonetary = false, isPercentage = false, decimalPlaces = 0 }) => {
const formattedValue = isMonetary const formattedValue = isMonetary
@@ -243,38 +208,22 @@ const processCampaignData = (campaign) => {
}; };
}; };
const SkeletonMetricCard = () => (
<Card className="h-full">
<CardContent className="pt-6 h-full">
<div className="flex justify-between items-start">
<div className="flex-1 min-w-0">
<Skeleton className="h-4 w-24 mb-4 bg-muted" />
<div className="flex items-baseline gap-2">
<Skeleton className="h-8 w-20 bg-muted" />
</div>
</div>
<Skeleton className="h-5 w-5 rounded-full bg-muted" />
</div>
</CardContent>
</Card>
);
const SkeletonTable = () => ( const SkeletonTable = () => (
<div className="h-full max-h-[400px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2"> <div className="h-full max-h-[400px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2">
<table className="min-w-full"> <table className="min-w-full">
<thead> <thead>
<tr className="border-b border-gray-200 dark:border-gray-800"> <tr className="border-b border-border/50">
<th className="p-2 sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10"> <th className="p-2 sticky top-0 bg-card z-10">
<Skeleton className="h-4 w-32 bg-muted" /> <Skeleton className="h-4 w-32 bg-muted" />
</th> </th>
{[...Array(8)].map((_, i) => ( {[...Array(8)].map((_, i) => (
<th key={i} className="p-2 text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10"> <th key={i} className="p-2 text-center sticky top-0 bg-card z-10">
<Skeleton className="h-4 w-20 mx-auto bg-muted" /> <Skeleton className="h-4 w-20 mx-auto bg-muted" />
</th> </th>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-800"> <tbody className="divide-y divide-border/50">
{[...Array(5)].map((_, rowIndex) => ( {[...Array(5)].map((_, rowIndex) => (
<tr key={rowIndex} className="hover:bg-muted/50 transition-colors"> <tr key={rowIndex} className="hover:bg-muted/50 transition-colors">
<td className="p-2"> <td className="p-2">
@@ -443,24 +392,17 @@ const MetaCampaigns = () => {
if (loading) { if (loading) {
return ( return (
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <Card className={`h-full ${CARD_STYLES.base}`}>
<CardHeader className="pb-2"> <DashboardSectionHeader
<div className="flex justify-between items-start mb-6"> title="Meta Ads Performance"
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100"> loading={true}
Meta Ads Performance compact
</CardTitle> timeSelector={<div className="w-[130px]" />}
<Select disabled value="7"> />
<SelectTrigger className="w-[130px] bg-white dark:bg-gray-800"> <CardHeader className="pt-0 pb-2">
<SelectValue placeholder="Select range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">Last 7 days</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
{[...Array(12)].map((_, i) => ( {[...Array(12)].map((_, i) => (
<SkeletonMetricCard key={i} /> <DashboardStatCardSkeleton key={i} size="compact" />
))} ))}
</div> </div>
</CardHeader> </CardHeader>
@@ -473,25 +415,25 @@ const MetaCampaigns = () => {
if (error) { if (error) {
return ( return (
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <Card className={`h-full ${CARD_STYLES.base}`}>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="p-4 m-6 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/10 rounded-lg border border-red-200 dark:border-red-900/20"> <DashboardErrorState
{error} title="Failed to load Meta Ads data"
</div> message={error}
/>
</CardContent> </CardContent>
</Card> </Card>
); );
} }
return ( return (
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <Card className={`h-full ${CARD_STYLES.base}`}>
<CardHeader className="pb-2"> <DashboardSectionHeader
<div className="flex justify-between items-start mb-6"> title="Meta Ads Performance"
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100"> compact
Meta Ads Performance timeSelector={
</CardTitle>
<Select value={timeframe} onValueChange={setTimeframe}> <Select value={timeframe} onValueChange={setTimeframe}>
<SelectTrigger className="w-[130px] bg-white dark:bg-gray-800"> <SelectTrigger className="w-[130px] bg-background">
<SelectValue placeholder="Select range" /> <SelectValue placeholder="Select range" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -502,82 +444,102 @@ const MetaCampaigns = () => {
<SelectItem value="90">Last 90 days</SelectItem> <SelectItem value="90">Last 90 days</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> }
/>
<CardHeader className="pt-0 pb-2">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
{[ <DashboardStatCard
{ title="Active Campaigns"
label: "Active Campaigns", value={formatNumber(summaryMetrics?.totalCampaigns)}
value: summaryMetrics?.totalCampaigns, icon={Target}
options: { icon: Target, iconColor: "text-purple-500" }, iconColor="purple"
}, size="compact"
{ />
label: "Total Spend", <DashboardStatCard
value: summaryMetrics?.totalSpend, title="Total Spend"
options: { isMonetary: true, decimalPlaces: 0, icon: DollarSign, iconColor: "text-green-500" }, value={formatCurrency(summaryMetrics?.totalSpend, 0)}
}, icon={DollarSign}
{ iconColor="green"
label: "Total Reach", size="compact"
value: summaryMetrics?.totalReach, />
options: { icon: Users, iconColor: "text-blue-500" }, <DashboardStatCard
}, title="Total Reach"
{ value={formatNumber(summaryMetrics?.totalReach)}
label: "Total Impressions", icon={Users}
value: summaryMetrics?.totalImpressions, iconColor="blue"
options: { icon: Eye, iconColor: "text-indigo-500" }, size="compact"
}, />
{ <DashboardStatCard
label: "Avg Frequency", title="Total Impressions"
value: summaryMetrics?.avgFrequency, value={formatNumber(summaryMetrics?.totalImpressions)}
options: { decimalPlaces: 2, icon: Repeat, iconColor: "text-cyan-500" }, icon={Eye}
}, iconColor="indigo"
{ size="compact"
label: "Total Engagements", />
value: summaryMetrics?.totalPostEngagements, <DashboardStatCard
options: { icon: MessageCircle, iconColor: "text-pink-500" }, title="Avg Frequency"
}, value={formatNumber(summaryMetrics?.avgFrequency, 2)}
{ icon={Repeat}
label: "Avg CPM", iconColor="cyan"
value: summaryMetrics?.avgCpm, size="compact"
options: { isMonetary: true, decimalPlaces: 2, icon: DollarSign, iconColor: "text-emerald-500" }, />
}, <DashboardStatCard
{ title="Total Engagements"
label: "Avg CTR", value={formatNumber(summaryMetrics?.totalPostEngagements)}
value: summaryMetrics?.avgCtr, icon={MessageCircle}
options: { isPercentage: true, decimalPlaces: 2, icon: BarChart, iconColor: "text-orange-500" }, iconColor="pink"
}, size="compact"
{ />
label: "Avg CPC", <DashboardStatCard
value: summaryMetrics?.avgCpc, title="Avg CPM"
options: { isMonetary: true, decimalPlaces: 2, icon: MousePointer, iconColor: "text-rose-500" }, value={formatCurrency(summaryMetrics?.avgCpm, 2)}
}, icon={DollarSign}
{ iconColor="emerald"
label: "Total Link Clicks", size="compact"
value: summaryMetrics?.totalLinkClicks, />
options: { icon: MousePointer, iconColor: "text-amber-500" }, <DashboardStatCard
}, title="Avg CTR"
{ value={formatPercent(summaryMetrics?.avgCtr, 2)}
label: "Total Purchases", icon={BarChart}
value: summaryMetrics?.totalPurchases, iconColor="orange"
options: { icon: ShoppingCart, iconColor: "text-teal-500" }, size="compact"
}, />
{ <DashboardStatCard
label: "Purchase Value", title="Avg CPC"
value: summaryMetrics?.totalPurchaseValue, value={formatCurrency(summaryMetrics?.avgCpc, 2)}
options: { isMonetary: true, decimalPlaces: 0, icon: DollarSign, iconColor: "text-lime-500" }, icon={MousePointer}
}, iconColor="rose"
].map((card) => ( size="compact"
<div key={card.label} className="h-full"> />
{summaryCard(card.label, card.value, card.options)} <DashboardStatCard
</div> title="Total Link Clicks"
))} value={formatNumber(summaryMetrics?.totalLinkClicks)}
icon={MousePointer}
iconColor="amber"
size="compact"
/>
<DashboardStatCard
title="Total Purchases"
value={formatNumber(summaryMetrics?.totalPurchases)}
icon={ShoppingCart}
iconColor="teal"
size="compact"
/>
<DashboardStatCard
title="Purchase Value"
value={formatCurrency(summaryMetrics?.totalPurchaseValue, 0)}
icon={DollarSign}
iconColor="lime"
size="compact"
/>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="overflow-y-auto pl-4 max-h-[400px] mb-4"> <CardContent className="overflow-y-auto pl-4 max-h-[400px] mb-4">
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="border-b border-gray-200 dark:border-gray-800"> <tr className="border-b border-border/50">
<th className="p-2 text-left font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100"> <th className="p-2 text-left font-medium sticky top-0 bg-card z-10 text-foreground">
<Button <Button
variant="ghost" variant="ghost"
className="pl-0 justify-start w-full h-8" className="pl-0 justify-start w-full h-8"
@@ -586,7 +548,7 @@ const MetaCampaigns = () => {
Campaign Campaign
</Button> </Button>
</th> </th>
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100"> <th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
<Button <Button
variant={sortConfig.key === "spend" ? "default" : "ghost"} variant={sortConfig.key === "spend" ? "default" : "ghost"}
className="w-full justify-center h-8" className="w-full justify-center h-8"
@@ -595,7 +557,7 @@ const MetaCampaigns = () => {
Spend Spend
</Button> </Button>
</th> </th>
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100"> <th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
<Button <Button
variant={sortConfig.key === "reach" ? "default" : "ghost"} variant={sortConfig.key === "reach" ? "default" : "ghost"}
className="w-full justify-center h-8" className="w-full justify-center h-8"
@@ -604,7 +566,7 @@ const MetaCampaigns = () => {
Reach Reach
</Button> </Button>
</th> </th>
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100"> <th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
<Button <Button
variant={sortConfig.key === "impressions" ? "default" : "ghost"} variant={sortConfig.key === "impressions" ? "default" : "ghost"}
className="w-full justify-center h-8" className="w-full justify-center h-8"
@@ -613,7 +575,7 @@ const MetaCampaigns = () => {
Impressions Impressions
</Button> </Button>
</th> </th>
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100"> <th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
<Button <Button
variant={sortConfig.key === "cpm" ? "default" : "ghost"} variant={sortConfig.key === "cpm" ? "default" : "ghost"}
className="w-full justify-center h-8" className="w-full justify-center h-8"
@@ -622,7 +584,7 @@ const MetaCampaigns = () => {
CPM CPM
</Button> </Button>
</th> </th>
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100"> <th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
<Button <Button
variant={sortConfig.key === "ctr" ? "default" : "ghost"} variant={sortConfig.key === "ctr" ? "default" : "ghost"}
className="w-full justify-center h-8" className="w-full justify-center h-8"
@@ -631,7 +593,7 @@ const MetaCampaigns = () => {
CTR CTR
</Button> </Button>
</th> </th>
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100"> <th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
<Button <Button
variant={sortConfig.key === "results" ? "default" : "ghost"} variant={sortConfig.key === "results" ? "default" : "ghost"}
className="w-full justify-center h-8" className="w-full justify-center h-8"
@@ -640,7 +602,7 @@ const MetaCampaigns = () => {
Results Results
</Button> </Button>
</th> </th>
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100"> <th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
<Button <Button
variant={sortConfig.key === "value" ? "default" : "ghost"} variant={sortConfig.key === "value" ? "default" : "ghost"}
className="w-full justify-center h-8" className="w-full justify-center h-8"
@@ -649,7 +611,7 @@ const MetaCampaigns = () => {
Value Value
</Button> </Button>
</th> </th>
<th className="p-2 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 text-gray-900 dark:text-gray-100"> <th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
<Button <Button
variant={sortConfig.key === "engagements" ? "default" : "ghost"} variant={sortConfig.key === "engagements" ? "default" : "ghost"}
className="w-full justify-center h-8" className="w-full justify-center h-8"
@@ -660,7 +622,7 @@ const MetaCampaigns = () => {
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-800"> <tbody className="divide-y divide-border/50">
{sortedCampaigns.map((campaign) => ( {sortedCampaigns.map((campaign) => (
<tr <tr
key={campaign.id} key={campaign.id}
@@ -668,7 +630,7 @@ const MetaCampaigns = () => {
> >
<td className="p-2 align-top"> <td className="p-2 align-top">
<div> <div>
<div className="font-medium text-gray-900 dark:text-gray-100 break-words min-w-[200px] max-w-[300px]"> <div className="font-medium text-foreground break-words min-w-[200px] max-w-[300px]">
<CampaignName name={campaign.name} /> <CampaignName name={campaign.name} />
</div> </div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">

View File

@@ -22,10 +22,10 @@ import {
ChevronRight, ChevronRight,
} from "lucide-react"; } from "lucide-react";
import { format } from "date-fns"; import { format } from "date-fns";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { EventDialog } from "./EventFeed.jsx"; import { EventDialog } from "./EventFeed.jsx";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DashboardErrorState } from "@/components/dashboard/shared";
const METRIC_IDS = { const METRIC_IDS = {
PLACED_ORDER: "Y8cqcF", PLACED_ORDER: "Y8cqcF",
@@ -439,13 +439,7 @@ const MiniEventFeed = ({
{loading && !events.length ? ( {loading && !events.length ? (
<LoadingState /> <LoadingState />
) : error ? ( ) : error ? (
<Alert variant="destructive" className="mx-4"> <DashboardErrorState error={`Failed to load event feed: ${error}`} className="mx-4" />
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
Failed to load event feed: {error}
</AlertDescription>
</Alert>
) : !events || events.length === 0 ? ( ) : !events || events.length === 0 ? (
<div className="px-4"> <div className="px-4">
<EmptyState /> <EmptyState />

View File

@@ -11,41 +11,11 @@ import {
import { AlertTriangle, Users, Activity } from "lucide-react"; import { AlertTriangle, Users, Activity } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { format } from "date-fns"; import { format } from "date-fns";
import { import { processBasicData } from "./RealtimeAnalytics";
summaryCard, import { DashboardStatCardMini, DashboardStatCardMiniSkeleton, TOOLTIP_THEMES } from "@/components/dashboard/shared";
SkeletonSummaryCard,
SkeletonBarChart,
processBasicData,
} from "./RealtimeAnalytics";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { METRIC_COLORS } from "@/lib/dashboard/designTokens";
const SkeletonCard = ({ colorScheme = "sky" }) => (
<Card className={`w-full h-[150px] bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm`}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<CardTitle>
<div className="space-y-2">
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300/20`} />
</div>
</CardTitle>
<div className="relative p-2">
<div className={`absolute inset-0 rounded-full bg-${colorScheme}-300/20`} />
<div className="h-5 w-5 relative rounded-full bg-${colorScheme}-300/20" />
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="space-y-4">
<div className="space-y-2">
<Skeleton className={`h-8 w-32 bg-${colorScheme}-300/20`} />
<div className="flex justify-between items-center">
<div className="space-y-1">
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300/20`} />
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
const MiniRealtimeAnalytics = () => { const MiniRealtimeAnalytics = () => {
const [basicData, setBasicData] = useState({ const [basicData, setBasicData] = useState({
@@ -119,8 +89,8 @@ const MiniRealtimeAnalytics = () => {
return ( return (
<div> <div>
<div className="grid grid-cols-2 gap-2 mt-1 mb-2"> <div className="grid grid-cols-2 gap-2 mt-1 mb-2">
<SkeletonCard colorScheme="sky" /> <DashboardStatCardMiniSkeleton gradient="sky" />
<SkeletonCard colorScheme="sky" /> <DashboardStatCardMiniSkeleton gradient="sky" />
</div> </div>
<Card className="bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm"> <Card className="bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm">
@@ -168,34 +138,22 @@ const MiniRealtimeAnalytics = () => {
return ( return (
<div> <div>
<div className="grid grid-cols-2 gap-2 mt-1 mb-2"> <div className="grid grid-cols-2 gap-2 mt-1 mb-2">
{summaryCard( <DashboardStatCardMini
"Last 30 Minutes", title="Last 30 Minutes"
"Active users", value={basicData.last30MinUsers}
basicData.last30MinUsers, description="Active users"
{ gradient="sky"
colorClass: "text-sky-200", icon={Users}
titleClass: "text-sky-100 font-bold text-md", iconBackground="bg-sky-300"
descriptionClass: "pt-2 text-sky-200 text-md font-semibold", />
background: "h-[150px] pt-2 bg-gradient-to-br from-sky-900 to-sky-800", <DashboardStatCardMini
icon: Users, title="Last 5 Minutes"
iconColor: "text-sky-900", value={basicData.last5MinUsers}
iconBackground: "bg-sky-300" description="Active users"
} gradient="sky"
)} icon={Activity}
{summaryCard( iconBackground="bg-sky-300"
"Last 5 Minutes", />
"Active users",
basicData.last5MinUsers,
{
colorClass: "text-sky-200",
titleClass: "text-sky-100 font-bold text-md",
descriptionClass: "pt-2 text-sky-200 text-md font-semibold",
background: "h-[150px] pt-2 bg-gradient-to-br from-sky-900 to-sky-800",
icon: Activity,
iconColor: "text-sky-900",
iconBackground: "bg-sky-300"
}
)}
</div> </div>
<Card className="bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm"> <Card className="bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm">
@@ -219,28 +177,25 @@ const MiniRealtimeAnalytics = () => {
<Tooltip <Tooltip
content={({ active, payload }) => { content={({ active, payload }) => {
if (active && payload && payload.length) { if (active && payload && payload.length) {
const styles = TOOLTIP_THEMES.sky;
return ( return (
<Card className="p-2 shadow-lg bg-sky-800 border-none"> <div className={styles.container}>
<CardContent className="p-0 space-y-1"> <p className={styles.header}>
<p className="font-medium text-sm text-sky-100 border-b border-sky-700 pb-1 mb-1"> {payload[0].payload.timestamp}
{payload[0].payload.timestamp} </p>
</p> <div className={styles.content}>
<div className="flex justify-between items-center text-sm"> <div className={styles.row}>
<span className="text-sky-200"> <span className={styles.name}>Active Users</span>
Active Users: <span className={styles.value}>{payload[0].value}</span>
</span>
<span className="font-medium ml-4 text-sky-100">
{payload[0].value}
</span>
</div> </div>
</CardContent> </div>
</Card> </div>
); );
} }
return null; return null;
}} }}
/> />
<Bar dataKey="users" fill="#0EA5E9" /> <Bar dataKey="users" fill={METRIC_COLORS.secondary} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>

View File

@@ -1,12 +1,8 @@
import React, { useState, useEffect, useCallback, memo } from "react"; import React, { useState, useEffect, useCallback } from "react";
import axios from "axios";
import { acotService } from "@/services/dashboard/acotService"; import { acotService } from "@/services/dashboard/acotService";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { import {
LineChart, LineChart,
@@ -17,141 +13,16 @@ import {
Tooltip, Tooltip,
ResponsiveContainer, ResponsiveContainer,
} from "recharts"; } from "recharts";
import { DateTime } from "luxon";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AlertCircle, TrendingUp, DollarSign, ShoppingCart, Truck, PiggyBank, ArrowUp,ArrowDown, Banknote, Package } from "lucide-react"; import { AlertCircle, PiggyBank, Truck } from "lucide-react";
import { formatCurrency, CustomTooltip, processData, StatCard } from "./SalesChart.jsx"; import { formatCurrency, processData } from "./SalesChart.jsx";
import { METRIC_COLORS } from "@/lib/dashboard/designTokens";
const SkeletonChart = () => ( import {
<div className="h-[216px]"> DashboardStatCardMini,
<div className="h-full w-full relative"> DashboardStatCardMiniSkeleton,
{/* Grid lines */} ChartSkeleton,
{[...Array(5)].map((_, i) => ( TOOLTIP_THEMES,
<div } from "@/components/dashboard/shared";
key={i}
className="absolute w-full h-px bg-slate-600"
style={{ top: `${(i + 1) * 20}%` }}
/>
))}
{/* Y-axis labels */}
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-3 w-6 bg-slate-600 rounded-sm" />
))}
</div>
{/* X-axis labels */}
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-3 w-8 bg-slate-600 rounded-sm" />
))}
</div>
{/* Chart lines */}
<div className="absolute inset-x-8 bottom-6 top-4">
<div className="h-full w-full relative">
<div
className="absolute inset-0 bg-slate-600 rounded-sm"
style={{
opacity: 0.5,
clipPath: "polygon(0 50%, 100% 20%, 100% 100%, 0 100%)",
}}
/>
</div>
</div>
</div>
</div>
);
const MiniStatCard = memo(({
title,
value,
icon: Icon,
colorClass,
iconColor,
iconBackground,
background,
previousValue,
trend,
trendValue,
onClick,
active = true,
titleClass = "text-sm font-bold text-gray-100",
descriptionClass = "text-sm font-semibold text-gray-200"
}) => (
<Card
className={`w-full bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm ${
onClick ? 'cursor-pointer transition-all hover:brightness-110' : ''
} ${!active ? 'opacity-50' : ''}`}
onClick={onClick}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<CardTitle className={titleClass}>
{title}
</CardTitle>
{Icon && (
<div className="relative p-2">
<div className={`absolute inset-0 rounded-full ${iconBackground}`} />
<Icon className={`h-5 w-5 ${iconColor} relative`} />
</div>
)}
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="space-y-2">
<div>
<div className={`text-3xl font-extrabold ${colorClass}`}>
{value}
</div>
<div className="mt-2 items-center justify-between flex">
<span className={descriptionClass}>Prev: {previousValue}</span>
{trend && (
<span
className={`flex items-center gap-0 px-1 py-0.5 rounded-full ${
trend === 'up'
? 'text-sm font-bold bg-emerald-300 text-emerald-900'
: 'text-sm font-bold bg-rose-300 text-rose-900'
}`}
>
{trend === "up" ? (
<ArrowUp className="w-4 h-4" />
) : (
<ArrowDown className="w-4 h-4" />
)}
{trendValue}
</span>
)}
</div>
</div>
</div>
</CardContent>
</Card>
));
MiniStatCard.displayName = "MiniStatCard";
const SkeletonCard = ({ colorScheme = "emerald" }) => (
<Card className="w-full h-[150px] bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<CardTitle>
<div className="space-y-2">
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300`} />
</div>
</CardTitle>
<div className="relative p-2">
<div className={`absolute inset-0 rounded-full bg-${colorScheme}-300`} />
<Skeleton className={`h-5 w-5 bg-${colorScheme}-300 relative rounded-full`} />
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="space-y-2">
<Skeleton className={`h-8 w-20 bg-${colorScheme}-300`} />
<div className="flex justify-between items-center">
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300`} />
<Skeleton className={`h-4 w-12 bg-${colorScheme}-300 rounded-full`} />
</div>
</div>
</CardContent>
</Card>
);
const MiniSalesChart = ({ className = "" }) => { const MiniSalesChart = ({ className = "" }) => {
const [data, setData] = useState([]); const [data, setData] = useState([]);
@@ -269,19 +140,46 @@ const MiniSalesChart = ({ className = "" }) => {
); );
} }
// Helper to calculate trend direction
const getRevenueTrend = () => {
const current = summaryStats.periodProgress < 100
? (projection?.projectedRevenue || summaryStats.totalRevenue)
: summaryStats.totalRevenue;
return current >= summaryStats.prevRevenue ? "up" : "down";
};
const getRevenueTrendValue = () => {
const current = summaryStats.periodProgress < 100
? (projection?.projectedRevenue || summaryStats.totalRevenue)
: summaryStats.totalRevenue;
return `${Math.abs(Math.round((current - summaryStats.prevRevenue) / summaryStats.prevRevenue * 100))}%`;
};
const getOrdersTrend = () => {
const projected = Math.round(summaryStats.totalOrders * (100 / summaryStats.periodProgress));
const current = summaryStats.periodProgress < 100 ? projected : summaryStats.totalOrders;
return current >= summaryStats.prevOrders ? "up" : "down";
};
const getOrdersTrendValue = () => {
const projected = Math.round(summaryStats.totalOrders * (100 / summaryStats.periodProgress));
const current = summaryStats.periodProgress < 100 ? projected : summaryStats.totalOrders;
return `${Math.abs(Math.round((current - summaryStats.prevOrders) / summaryStats.prevOrders * 100))}%`;
};
if (loading && !data) { if (loading && !data) {
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">
<SkeletonCard colorScheme="emerald" /> <DashboardStatCardMiniSkeleton gradient="slate" />
<SkeletonCard colorScheme="blue" /> <DashboardStatCardMiniSkeleton gradient="slate" />
</div> </div>
{/* Chart Card */} {/* Chart Card */}
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm"> <Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm">
<CardContent className="p-4"> <CardContent className="p-4">
<SkeletonChart /> <ChartSkeleton height="sm" withCard={false} />
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -294,56 +192,38 @@ const MiniSalesChart = ({ className = "" }) => {
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{loading ? ( {loading ? (
<> <>
<SkeletonCard colorScheme="emerald" /> <DashboardStatCardMiniSkeleton gradient="slate" />
<SkeletonCard colorScheme="blue" /> <DashboardStatCardMiniSkeleton gradient="slate" />
</> </>
) : ( ) : (
<> <>
<MiniStatCard <DashboardStatCardMini
title="30 Days Revenue" title="30 Days Revenue"
value={formatCurrency(summaryStats.totalRevenue, false)} value={formatCurrency(summaryStats.totalRevenue, false)}
previousValue={formatCurrency(summaryStats.prevRevenue, false)} description={`Prev: ${formatCurrency(summaryStats.prevRevenue, false)}`}
trend={ trend={{
summaryStats.periodProgress < 100 direction: getRevenueTrend(),
? ((projection?.projectedRevenue || summaryStats.totalRevenue) >= summaryStats.prevRevenue ? "up" : "down") value: getRevenueTrendValue(),
: (summaryStats.totalRevenue >= summaryStats.prevRevenue ? "up" : "down") }}
}
trendValue={
summaryStats.periodProgress < 100
? `${Math.abs(Math.round(((projection?.projectedRevenue || summaryStats.totalRevenue) - summaryStats.prevRevenue) / summaryStats.prevRevenue * 100))}%`
: `${Math.abs(Math.round(((summaryStats.totalRevenue - summaryStats.prevRevenue) / summaryStats.prevRevenue) * 100))}%`
}
colorClass="text-emerald-300"
titleClass="text-emerald-300 font-bold text-md"
descriptionClass="text-emerald-300 text-md font-semibold pb-1"
icon={PiggyBank} icon={PiggyBank}
iconColor="text-emerald-900"
iconBackground="bg-emerald-300" iconBackground="bg-emerald-300"
gradient="slate"
className={!visibleMetrics.revenue ? 'opacity-50' : ''}
onClick={() => toggleMetric('revenue')} onClick={() => toggleMetric('revenue')}
active={visibleMetrics.revenue}
/> />
<MiniStatCard <DashboardStatCardMini
title="30 Days Orders" title="30 Days Orders"
value={summaryStats.totalOrders.toLocaleString()} value={summaryStats.totalOrders.toLocaleString()}
previousValue={summaryStats.prevOrders.toLocaleString()} description={`Prev: ${summaryStats.prevOrders.toLocaleString()}`}
trend={ trend={{
summaryStats.periodProgress < 100 direction: getOrdersTrend(),
? ((Math.round(summaryStats.totalOrders * (100 / summaryStats.periodProgress))) >= summaryStats.prevOrders ? "up" : "down") value: getOrdersTrendValue(),
: (summaryStats.totalOrders >= summaryStats.prevOrders ? "up" : "down") }}
}
trendValue={
summaryStats.periodProgress < 100
? `${Math.abs(Math.round(((Math.round(summaryStats.totalOrders * (100 / summaryStats.periodProgress))) - summaryStats.prevOrders) / summaryStats.prevOrders * 100))}%`
: `${Math.abs(Math.round(((summaryStats.totalOrders - summaryStats.prevOrders) / summaryStats.prevOrders) * 100))}%`
}
colorClass="text-blue-300"
titleClass="text-blue-300 font-bold text-md"
descriptionClass="text-blue-300 text-md font-semibold pb-1"
icon={Truck} icon={Truck}
iconColor="text-blue-900"
iconBackground="bg-blue-300" iconBackground="bg-blue-300"
gradient="slate"
className={!visibleMetrics.orders ? 'opacity-50' : ''}
onClick={() => toggleMetric('orders')} onClick={() => toggleMetric('orders')}
active={visibleMetrics.orders}
/> />
</> </>
)} )}
@@ -354,40 +234,7 @@ const MiniSalesChart = ({ className = "" }) => {
<CardContent className="p-4"> <CardContent className="p-4">
<div className="h-[216px]"> <div className="h-[216px]">
{loading ? ( {loading ? (
<div className="h-full w-full relative"> <ChartSkeleton height="sm" withCard={false} />
{/* Grid lines */}
{[...Array(5)].map((_, i) => (
<div
key={i}
className="absolute w-full h-px bg-slate-600"
style={{ top: `${(i + 1) * 20}%` }}
/>
))}
{/* Y-axis labels */}
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-3 w-6 bg-slate-600 rounded-sm" />
))}
</div>
{/* X-axis labels */}
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-3 w-8 bg-slate-600 rounded-sm" />
))}
</div>
{/* Chart lines */}
<div className="absolute inset-x-8 bottom-6 top-4">
<div className="h-full w-full relative">
<div
className="absolute inset-0 bg-slate-600 rounded-sm"
style={{
opacity: 0.5,
clipPath: "polygon(0 50%, 100% 20%, 100% 100%, 0 100%)",
}}
/>
</div>
</div>
</div>
) : ( ) : (
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart <LineChart
@@ -421,32 +268,33 @@ const MiniSalesChart = ({ className = "" }) => {
content={({ active, payload }) => { content={({ active, payload }) => {
if (active && payload && payload.length) { if (active && payload && payload.length) {
const timestamp = new Date(payload[0].payload.timestamp); const timestamp = new Date(payload[0].payload.timestamp);
const styles = TOOLTIP_THEMES.stone;
return ( return (
<Card className="p-2 shadow-lg bg-stone-800 border-none"> <div className={styles.container}>
<CardContent className="p-0 space-y-1"> <p className={styles.header}>
<p className="font-medium text-sm text-stone-100 border-b border-stone-700 pb-1 mb-1"> {timestamp.toLocaleDateString([], {
{timestamp.toLocaleDateString([], { weekday: "short",
weekday: "short", month: "short",
month: "short", day: "numeric"
day: "numeric" })}
})} </p>
</p> <div className={styles.content}>
{payload {payload
.filter(entry => visibleMetrics[entry.dataKey]) .filter(entry => visibleMetrics[entry.dataKey])
.map((entry, index) => ( .map((entry, index) => (
<div key={index} className="flex justify-between items-center text-sm"> <div key={index} className={styles.row}>
<span className="text-stone-200"> <span className={styles.name}>
{entry.name}: {entry.name}
</span> </span>
<span className="font-medium ml-4 text-stone-100"> <span className={styles.value}>
{entry.dataKey === 'revenue' {entry.dataKey === 'revenue'
? formatCurrency(entry.value) ? formatCurrency(entry.value)
: entry.value.toLocaleString()} : entry.value.toLocaleString()}
</span> </span>
</div> </div>
))} ))}
</CardContent> </div>
</Card> </div>
); );
} }
return null; return null;
@@ -458,7 +306,7 @@ const MiniSalesChart = ({ className = "" }) => {
type="monotone" type="monotone"
dataKey="revenue" dataKey="revenue"
name="Revenue" name="Revenue"
stroke="#10b981" stroke={METRIC_COLORS.revenue}
strokeWidth={2} strokeWidth={2}
dot={false} dot={false}
/> />
@@ -469,7 +317,7 @@ const MiniSalesChart = ({ className = "" }) => {
type="monotone" type="monotone"
dataKey="orders" dataKey="orders"
name="Orders" name="Orders"
stroke="#3b82f6" stroke={METRIC_COLORS.orders}
strokeWidth={2} strokeWidth={2}
dot={false} dot={false}
/> />

View File

@@ -1,20 +1,11 @@
import React, { useState, useEffect, useCallback, memo } from "react"; import React, { useState, useEffect, useCallback } from "react";
import axios from "axios";
import { acotService } from "@/services/dashboard/acotService"; import { acotService } from "@/services/dashboard/acotService";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -22,7 +13,6 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { TIME_RANGES } from "@/lib/dashboard/constants";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { import {
DollarSign, DollarSign,
@@ -30,23 +20,7 @@ import {
Package, Package,
AlertCircle, AlertCircle,
CircleDollarSign, CircleDollarSign,
Loader2,
} from "lucide-react"; } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
TooltipProvider,
} from "@/components/ui/tooltip";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
// Import the detail view components and utilities from StatCards // Import the detail view components and utilities from StatCards
import { import {
@@ -54,163 +28,28 @@ import {
OrdersDetails, OrdersDetails,
AverageOrderDetails, AverageOrderDetails,
ShippingDetails, ShippingDetails,
StatCard,
DetailDialog, DetailDialog,
formatCurrency, formatCurrency,
formatPercentage, formatPercentage,
SkeletonCard,
} from "./StatCards"; } from "./StatCards";
import {
DashboardStatCardMini,
DashboardStatCardMiniSkeleton,
ChartSkeleton,
TableSkeleton,
DashboardErrorState,
} from "@/components/dashboard/shared";
// Mini skeleton components // Helper to map metric to colorVariant
const MiniSkeletonChart = ({ type = "line" }) => ( const getColorVariant = (metric) => {
<div className={`h-[230px] w-full ${ switch (metric) {
type === 'revenue' ? 'bg-emerald-50/10' : case 'revenue': return 'emerald';
type === 'orders' ? 'bg-blue-50/10' : case 'orders': return 'blue';
type === 'average_order' ? 'bg-violet-50/10' : case 'average_order': return 'violet';
'bg-orange-50/10' case 'shipping': return 'orange';
} rounded-lg p-4`}> default: return 'default';
<div className="h-full relative"> }
{/* Grid lines */} };
{[...Array(5)].map((_, i) => (
<div
key={i}
className={`absolute w-full h-px ${
type === 'revenue' ? 'bg-emerald-200/20' :
type === 'orders' ? 'bg-blue-200/20' :
type === 'average_order' ? 'bg-violet-200/20' :
'bg-orange-200/20'
}`}
style={{ top: `${(i + 1) * 20}%` }}
/>
))}
{/* Y-axis labels */}
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className={`h-3 w-6 ${
type === 'revenue' ? 'bg-emerald-200/20' :
type === 'orders' ? 'bg-blue-200/20' :
type === 'average_order' ? 'bg-violet-200/20' :
'bg-orange-200/20'
} rounded-sm`} />
))}
</div>
{/* X-axis labels */}
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className={`h-3 w-8 ${
type === 'revenue' ? 'bg-emerald-200/20' :
type === 'orders' ? 'bg-blue-200/20' :
type === 'average_order' ? 'bg-violet-200/20' :
'bg-orange-200/20'
} rounded-sm`} />
))}
</div>
{type === "bar" ? (
<div className="absolute inset-x-8 bottom-6 top-4 flex items-end justify-between gap-1">
{[...Array(24)].map((_, i) => (
<div
key={i}
className={`w-2 ${
type === 'revenue' ? 'bg-emerald-200/20' :
type === 'orders' ? 'bg-blue-200/20' :
type === 'average_order' ? 'bg-violet-200/20' :
'bg-orange-200/20'
} rounded-sm`}
style={{ height: `${Math.random() * 80 + 10}%` }}
/>
))}
</div>
) : (
<div className="absolute inset-x-8 bottom-6 top-4">
<div className="h-full w-full relative">
<div
className={`absolute inset-0 ${
type === 'revenue' ? 'bg-emerald-200/20' :
type === 'orders' ? 'bg-blue-200/20' :
type === 'average_order' ? 'bg-violet-200/20' :
'bg-orange-200/20'
} rounded-sm`}
style={{
opacity: 0.5,
clipPath: "polygon(0 50%, 100% 20%, 100% 100%, 0 100%)",
}}
/>
</div>
</div>
)}
</div>
</div>
);
const MiniSkeletonTable = ({ rows = 8, colorScheme = "orange" }) => (
<div className={`rounded-lg border ${
colorScheme === 'orange' ? 'bg-orange-50/10 border-orange-200/20' :
colorScheme === 'emerald' ? 'bg-emerald-50/10 border-emerald-200/20' :
colorScheme === 'blue' ? 'bg-blue-50/10 border-blue-200/20' :
'bg-violet-50/10 border-violet-200/20'
}`}>
<Table>
<TableHeader>
<TableRow>
<TableHead>
<Skeleton className={`h-4 w-32 ${
colorScheme === 'orange' ? 'bg-orange-200/20' :
colorScheme === 'emerald' ? 'bg-emerald-200/20' :
colorScheme === 'blue' ? 'bg-blue-200/20' :
'bg-violet-200/20'
} rounded-sm`} />
</TableHead>
<TableHead className="text-right">
<Skeleton className={`h-4 w-24 ml-auto ${
colorScheme === 'orange' ? 'bg-orange-200/20' :
colorScheme === 'emerald' ? 'bg-emerald-200/20' :
colorScheme === 'blue' ? 'bg-blue-200/20' :
'bg-violet-200/20'
} rounded-sm`} />
</TableHead>
<TableHead className="text-right">
<Skeleton className={`h-4 w-24 ml-auto ${
colorScheme === 'orange' ? 'bg-orange-200/20' :
colorScheme === 'emerald' ? 'bg-emerald-200/20' :
colorScheme === 'blue' ? 'bg-blue-200/20' :
'bg-violet-200/20'
} rounded-sm`} />
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[...Array(rows)].map((_, i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className={`h-4 w-48 ${
colorScheme === 'orange' ? 'bg-orange-200/20' :
colorScheme === 'emerald' ? 'bg-emerald-200/20' :
colorScheme === 'blue' ? 'bg-blue-200/20' :
'bg-violet-200/20'
} rounded-sm`} />
</TableCell>
<TableCell className="text-right">
<Skeleton className={`h-4 w-16 ml-auto ${
colorScheme === 'orange' ? 'bg-orange-200/20' :
colorScheme === 'emerald' ? 'bg-emerald-200/20' :
colorScheme === 'blue' ? 'bg-blue-200/20' :
'bg-violet-200/20'
} rounded-sm`} />
</TableCell>
<TableCell className="text-right">
<Skeleton className={`h-4 w-16 ml-auto ${
colorScheme === 'orange' ? 'bg-orange-200/20' :
colorScheme === 'emerald' ? 'bg-emerald-200/20' :
colorScheme === 'blue' ? 'bg-blue-200/20' :
'bg-violet-200/20'
} rounded-sm`} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
const MiniStatCards = ({ const MiniStatCards = ({
timeRange: initialTimeRange = "today", timeRange: initialTimeRange = "today",
@@ -421,101 +260,16 @@ const MiniStatCards = ({
if (loading && !stats) { if (loading && !stats) {
return ( return (
<div className="grid grid-cols-4 gap-2"> <div className="grid grid-cols-4 gap-2">
<Card className="h-[150px] bg-gradient-to-br from-emerald-900 to-emerald-800 backdrop-blur-sm"> <DashboardStatCardMiniSkeleton gradient="emerald" className="h-[150px]" />
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2"> <DashboardStatCardMiniSkeleton gradient="blue" className="h-[150px]" />
<CardTitle className="text-emerald-100 font-bold text-md"> <DashboardStatCardMiniSkeleton gradient="violet" className="h-[150px]" />
<Skeleton className="h-4 w-24 bg-emerald-700" /> <DashboardStatCardMiniSkeleton gradient="orange" className="h-[150px]" />
</CardTitle>
<div className="relative p-2">
<div className="absolute inset-0 rounded-full bg-emerald-300" />
<Skeleton className="h-5 w-5 bg-emerald-700 relative rounded-full" />
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="space-y-2">
<Skeleton className="h-8 w-20 bg-emerald-700" />
<div className="flex justify-between items-center">
<Skeleton className="h-4 w-24 bg-emerald-700" />
<Skeleton className="h-4 w-12 bg-emerald-700 rounded-full" />
</div>
</div>
</CardContent>
</Card>
<Card className="h-[150px] bg-gradient-to-br from-blue-900 to-blue-800 backdrop-blur-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<CardTitle className="text-blue-100 font-bold text-md">
<Skeleton className="h-4 w-24 bg-blue-700" />
</CardTitle>
<div className="relative p-2">
<div className="absolute inset-0 rounded-full bg-blue-300" />
<Skeleton className="h-5 w-5 bg-blue-700 relative rounded-full" />
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="space-y-2">
<Skeleton className="h-8 w-20 bg-blue-700" />
<div className="flex justify-between items-center">
<Skeleton className="h-4 w-24 bg-blue-700" />
<Skeleton className="h-4 w-12 bg-blue-700 rounded-full" />
</div>
</div>
</CardContent>
</Card>
<Card className="h-[150px] bg-gradient-to-br from-violet-900 to-violet-800 backdrop-blur-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<CardTitle className="text-violet-100 font-bold text-md">
<Skeleton className="h-4 w-24 bg-violet-700" />
</CardTitle>
<div className="relative p-2">
<div className="absolute inset-0 rounded-full bg-violet-300" />
<Skeleton className="h-5 w-5 bg-violet-700 relative rounded-full" />
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="space-y-2">
<Skeleton className="h-8 w-20 bg-violet-700" />
<div className="flex justify-between items-center">
<Skeleton className="h-4 w-24 bg-violet-700" />
<Skeleton className="h-4 w-12 bg-violet-700 rounded-full" />
</div>
</div>
</CardContent>
</Card>
<Card className="h-[150px] bg-gradient-to-br from-orange-900 to-orange-800 backdrop-blur-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<CardTitle className="text-orange-100 font-bold text-md">
<Skeleton className="h-4 w-24 bg-orange-700" />
</CardTitle>
<div className="relative p-2">
<div className="absolute inset-0 rounded-full bg-orange-300" />
<Skeleton className="h-5 w-5 bg-orange-700 relative rounded-full" />
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="space-y-2">
<Skeleton className="h-8 w-20 bg-orange-700" />
<div className="flex justify-between items-center">
<Skeleton className="h-4 w-24 bg-orange-700" />
<Skeleton className="h-4 w-12 bg-orange-700 rounded-full" />
</div>
</div>
</CardContent>
</Card>
</div> </div>
); );
} }
if (error) { if (error) {
return ( return <DashboardErrorState error={`Failed to load stats: ${error}`} />;
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>Failed to load stats: {error}</AlertDescription>
</Alert>
);
} }
if (!stats) return null; if (!stats) return null;
@@ -527,100 +281,68 @@ const MiniStatCards = ({
return ( return (
<> <>
<div className="grid grid-cols-4 gap-2"> <div className="grid grid-cols-4 gap-2">
<StatCard <DashboardStatCardMini
title="Today's Revenue" title="Today's Revenue"
value={formatCurrency(stats?.revenue || 0)} value={formatCurrency(stats?.revenue || 0)}
description={ description={
stats?.periodProgress < 100 ? ( stats?.periodProgress < 100
<div className="flex items-center gap-1"> ? `Proj: ${formatCurrency(projection?.projectedRevenue || stats.projectedRevenue)}`
<span>Proj: </span> : undefined
{projectionLoading ? (
<div className="w-20">
<Skeleton className="h-4 w-15 bg-emerald-700" />
</div>
) : (
formatCurrency(
projection?.projectedRevenue || stats.projectedRevenue
)
)}
</div>
) : null
} }
progress={stats?.periodProgress < 100 ? stats.periodProgress : undefined} trend={
trend={projectionLoading && stats?.periodProgress < 100 ? undefined : revenueTrend?.trend} revenueTrend?.trend && !projectionLoading
trendValue={ ? { direction: revenueTrend.trend, value: formatPercentage(revenueTrend.value) }
projectionLoading && stats?.periodProgress < 100 ? ( : undefined
<div className="flex items-center gap-1">
<Skeleton className="h-4 w-4 bg-emerald-700 rounded-full" />
<Skeleton className="h-4 w-8 bg-emerald-700" />
</div>
) : revenueTrend?.value ? (
formatPercentage(revenueTrend.value)
) : null
} }
colorClass="text-emerald-200"
titleClass="text-emerald-100 font-bold text-md"
descriptionClass="text-emerald-200 text-md font-semibold"
icon={DollarSign} icon={DollarSign}
iconColor="text-emerald-900"
iconBackground="bg-emerald-300" iconBackground="bg-emerald-300"
onDetailsClick={() => setSelectedMetric("revenue")} gradient="emerald"
isLoading={loading || !stats} className="h-[150px]"
variant="mini" onClick={() => setSelectedMetric("revenue")}
background="h-[150px] bg-gradient-to-br from-emerald-900 to-emerald-800"
/> />
<StatCard <DashboardStatCardMini
title="Today's Orders" title="Today's Orders"
value={stats?.orderCount} value={stats?.orderCount}
description={`${stats?.itemCount} total items`} description={`${stats?.itemCount} total items`}
trend={orderTrend?.trend} trend={
trendValue={orderTrend?.value ? formatPercentage(orderTrend.value) : null} orderTrend?.trend
colorClass="text-blue-200" ? { direction: orderTrend.trend, value: formatPercentage(orderTrend.value) }
titleClass="text-blue-100 font-bold text-md" : undefined
descriptionClass="text-blue-200 text-md font-semibold" }
icon={ShoppingCart} icon={ShoppingCart}
iconColor="text-blue-900"
iconBackground="bg-blue-300" iconBackground="bg-blue-300"
onDetailsClick={() => setSelectedMetric("orders")} gradient="blue"
isLoading={loading || !stats} className="h-[150px]"
variant="mini" onClick={() => setSelectedMetric("orders")}
background="h-[150px] bg-gradient-to-br from-blue-900 to-blue-800"
/> />
<StatCard <DashboardStatCardMini
title="Today's AOV" title="Today's AOV"
value={stats?.averageOrderValue?.toFixed(2)} value={stats?.averageOrderValue?.toFixed(2)}
valuePrefix="$" valuePrefix="$"
description={`${stats?.averageItemsPerOrder?.toFixed(1)} items per order`} description={`${stats?.averageItemsPerOrder?.toFixed(1)} items per order`}
trend={aovTrend?.trend} trend={
trendValue={aovTrend?.value ? formatPercentage(aovTrend.value) : null} aovTrend?.trend
colorClass="text-violet-200" ? { direction: aovTrend.trend, value: formatPercentage(aovTrend.value) }
titleClass="text-violet-100 font-bold text-md" : undefined
descriptionClass="text-violet-200 text-md font-semibold" }
icon={CircleDollarSign} icon={CircleDollarSign}
iconColor="text-violet-900"
iconBackground="bg-violet-300" iconBackground="bg-violet-300"
onDetailsClick={() => setSelectedMetric("average_order")} gradient="violet"
isLoading={loading || !stats} className="h-[150px]"
variant="mini" onClick={() => setSelectedMetric("average_order")}
background="h-[150px] bg-gradient-to-br from-violet-900 to-violet-800"
/> />
<StatCard <DashboardStatCardMini
title="Shipped Today" title="Shipped Today"
value={stats?.shipping?.shippedCount || 0} value={stats?.shipping?.shippedCount || 0}
description={`${stats?.shipping?.locations?.total || 0} locations`} description={`${stats?.shipping?.locations?.total || 0} locations`}
colorClass="text-orange-200"
titleClass="text-orange-100 font-bold text-md"
descriptionClass="text-orange-200 text-md font-semibold"
icon={Package} icon={Package}
iconColor="text-orange-900"
iconBackground="bg-orange-300" iconBackground="bg-orange-300"
onDetailsClick={() => setSelectedMetric("shipping")} gradient="orange"
isLoading={loading || !stats} className="h-[150px]"
variant="mini" onClick={() => setSelectedMetric("shipping")}
background="h-[150px] bg-gradient-to-br from-orange-900 to-orange-800"
/> />
</div> </div>
@@ -633,7 +355,7 @@ const MiniStatCards = ({
selectedMetric === 'orders' ? 'bg-blue-50 dark:bg-blue-950/30' : selectedMetric === 'orders' ? 'bg-blue-50 dark:bg-blue-950/30' :
selectedMetric === 'average_order' ? 'bg-violet-50 dark:bg-violet-950/30' : selectedMetric === 'average_order' ? 'bg-violet-50 dark:bg-violet-950/30' :
selectedMetric === 'shipping' ? 'bg-orange-50 dark:bg-orange-950/30' : selectedMetric === 'shipping' ? 'bg-orange-50 dark:bg-orange-950/30' :
'bg-white dark:bg-gray-950' 'bg-card'
} backdrop-blur-md border-none`}> } backdrop-blur-md border-none`}>
<div className="transform scale-[2] origin-top-left h-[40vh] w-[40vw]"> <div className="transform scale-[2] origin-top-left h-[40vh] w-[40vw]">
<div className="h-full w-full p-6"> <div className="h-full w-full p-6">
@@ -657,20 +379,18 @@ const MiniStatCards = ({
{detailDataLoading[selectedMetric] ? ( {detailDataLoading[selectedMetric] ? (
<div className="space-y-4 h-full"> <div className="space-y-4 h-full">
{selectedMetric === "shipping" ? ( {selectedMetric === "shipping" ? (
<MiniSkeletonTable <TableSkeleton
rows={8} rows={8}
colorScheme={ columns={3}
selectedMetric === 'revenue' ? 'emerald' : colorVariant={getColorVariant(selectedMetric)}
selectedMetric === 'orders' ? 'blue' :
selectedMetric === 'average_order' ? 'violet' :
'orange'
}
/> />
) : ( ) : (
<> <>
<MiniSkeletonChart <ChartSkeleton
type={selectedMetric === "orders" ? "bar" : "line"} type={selectedMetric === "orders" ? "bar" : "line"}
metric={selectedMetric} height="sm"
withCard={false}
colorVariant={getColorVariant(selectedMetric)}
/> />
{selectedMetric === "orders" && ( {selectedMetric === "orders" && (
<div className="mt-8"> <div className="mt-8">
@@ -683,7 +403,12 @@ const MiniStatCards = ({
}`}> }`}>
Hourly Distribution Hourly Distribution
</h3> </h3>
<MiniSkeletonChart type="bar" metric={selectedMetric} /> <ChartSkeleton
type="bar"
height="sm"
withCard={false}
colorVariant={getColorVariant(selectedMetric)}
/>
</div> </div>
)} )}
</> </>

View File

@@ -226,7 +226,7 @@ const Navigation = () => {
> >
<Card <Card
className={cn( className={cn(
"w-full bg-white dark:bg-gray-900 transition-all duration-200", "w-full bg-background transition-all duration-200",
isStuck isStuck
? "rounded-lg mt-2 shadow-md" ? "rounded-lg mt-2 shadow-md"
: "shadow-sm rounded-t-none border-t-0 -mt-6 pb-2" : "shadow-sm rounded-t-none border-t-0 -mt-6 pb-2"
@@ -261,7 +261,7 @@ const Navigation = () => {
))} ))}
</div> </div>
</div> </div>
<div className="absolute -right-2.5 top-0 bottom-0 flex items-center bg-white dark:bg-gray-900 pl-1 pr-0"> <div className="absolute -right-2.5 top-0 bottom-0 flex items-center bg-background pl-1 pr-0">
<Button <Button
variant="icon" variant="icon"
size="sm" size="sm"

View File

@@ -30,7 +30,8 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { CARD_STYLES, TYPOGRAPHY } from "@/lib/dashboard/designTokens";
import { DashboardEmptyState, DashboardErrorState } from "@/components/dashboard/shared";
const ProductGrid = ({ const ProductGrid = ({
timeRange = "today", timeRange = "today",
@@ -127,8 +128,8 @@ const ProductGrid = ({
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="hover:bg-transparent"> <tr className="hover:bg-transparent">
<th className="p-1.5 text-left font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 w-[50px] min-w-[50px] border-b dark:border-gray-800" /> <th className="p-1.5 text-left font-medium sticky top-0 bg-card z-10 w-[50px] min-w-[50px] border-b border-border/50" />
<th className="p-1.5 text-left font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 min-w-[200px] border-b dark:border-gray-800"> <th className="p-1.5 text-left font-medium sticky top-0 bg-card z-10 min-w-[200px] border-b border-border/50">
<Button <Button
variant="ghost" variant="ghost"
className="w-full p-2 justify-start h-8 pointer-events-none" className="w-full p-2 justify-start h-8 pointer-events-none"
@@ -137,7 +138,7 @@ const ProductGrid = ({
<Skeleton className="h-4 w-16 bg-muted rounded-sm" /> <Skeleton className="h-4 w-16 bg-muted rounded-sm" />
</Button> </Button>
</th> </th>
<th className="p-1.5 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 border-b dark:border-gray-800"> <th className="p-1.5 font-medium text-center sticky top-0 bg-card z-10 border-b border-border/50">
<Button <Button
variant="ghost" variant="ghost"
className="w-full p-2 justify-center h-8 pointer-events-none" className="w-full p-2 justify-center h-8 pointer-events-none"
@@ -146,7 +147,7 @@ const ProductGrid = ({
<Skeleton className="h-4 w-12 bg-muted rounded-sm" /> <Skeleton className="h-4 w-12 bg-muted rounded-sm" />
</Button> </Button>
</th> </th>
<th className="p-1.5 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 border-b dark:border-gray-800"> <th className="p-1.5 font-medium text-center sticky top-0 bg-card z-10 border-b border-border/50">
<Button <Button
variant="ghost" variant="ghost"
className="w-full p-2 justify-center h-8 pointer-events-none" className="w-full p-2 justify-center h-8 pointer-events-none"
@@ -155,7 +156,7 @@ const ProductGrid = ({
<Skeleton className="h-4 w-12 bg-muted rounded-sm" /> <Skeleton className="h-4 w-12 bg-muted rounded-sm" />
</Button> </Button>
</th> </th>
<th className="p-1.5 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 border-b dark:border-gray-800"> <th className="p-1.5 font-medium text-center sticky top-0 bg-card z-10 border-b border-border/50">
<Button <Button
variant="ghost" variant="ghost"
className="w-full p-2 justify-center h-8 pointer-events-none" className="w-full p-2 justify-center h-8 pointer-events-none"
@@ -166,7 +167,7 @@ const ProductGrid = ({
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-800"> <tbody className="divide-y divide-border/50">
{[...Array(20)].map((_, i) => ( {[...Array(20)].map((_, i) => (
<SkeletonProduct key={i} /> <SkeletonProduct key={i} />
))} ))}
@@ -178,12 +179,12 @@ const ProductGrid = ({
if (loading) { if (loading) {
return ( return (
<Card className="flex flex-col h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <Card className={`flex flex-col h-full ${CARD_STYLES.base}`}>
<CardHeader className="p-6 pb-4"> <CardHeader className="p-6 pb-4">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div> <div>
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100"> <CardTitle className={TYPOGRAPHY.sectionTitle}>
<Skeleton className="h-6 w-32 bg-muted rounded-sm" /> <Skeleton className="h-6 w-32 bg-muted rounded-sm" />
</CardTitle> </CardTitle>
{description && ( {description && (
@@ -210,14 +211,14 @@ const ProductGrid = ({
} }
return ( return (
<Card className="flex flex-col h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <Card className={`flex flex-col h-full ${CARD_STYLES.base}`}>
<CardHeader className="p-6 pb-4"> <CardHeader className="p-6 pb-4">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div> <div>
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">{title}</CardTitle> <CardTitle className={TYPOGRAPHY.sectionTitle}>{title}</CardTitle>
{description && ( {description && (
<CardDescription className="mt-1 text-muted-foreground">{description}</CardDescription> <CardDescription className={`mt-1 ${TYPOGRAPHY.cardDescription}`}>{description}</CardDescription>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -279,27 +280,22 @@ const ProductGrid = ({
<CardContent className="p-6 pt-0 flex-1 overflow-hidden -mt-1"> <CardContent className="p-6 pt-0 flex-1 overflow-hidden -mt-1">
<div className="h-full"> <div className="h-full">
{error ? ( {error ? (
<Alert variant="destructive" className="bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <DashboardErrorState error={`Failed to load products: ${error}`} className="mx-0 my-0" />
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
Failed to load products: {error}
</AlertDescription>
</Alert>
) : !products?.length ? ( ) : !products?.length ? (
<div className="flex flex-col items-center justify-center py-8 text-center"> <DashboardEmptyState
<Package className="h-12 w-12 text-muted-foreground mb-4" /> icon={Package}
<p className="font-medium mb-2 text-gray-900 dark:text-gray-100">No product data available</p> title="No product data available"
<p className="text-sm text-muted-foreground">Try selecting a different time range</p> description="Try selecting a different time range"
</div> height="sm"
/>
) : ( ) : (
<div className="h-full"> <div className="h-full">
<div className="overflow-y-auto h-full"> <div className="overflow-y-auto h-full">
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="hover:bg-transparent"> <tr className="hover:bg-transparent">
<th className="p-1 text-left font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 h-[50px] min-h-[50px] w-[50px] min-w-[35px] border-b dark:border-gray-800" /> <th className="p-1 text-left font-medium sticky top-0 bg-card z-10 h-[50px] min-h-[50px] w-[50px] min-w-[35px] border-b border-border/50" />
<th className="p-1 text-left font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 border-b dark:border-gray-800"> <th className="p-1 text-left font-medium sticky top-0 bg-card z-10 border-b border-border/50">
<Button <Button
variant={sorting.column === "name" ? "default" : "ghost"} variant={sorting.column === "name" ? "default" : "ghost"}
onClick={() => handleSort("name")} onClick={() => handleSort("name")}
@@ -308,7 +304,7 @@ const ProductGrid = ({
Product Product
</Button> </Button>
</th> </th>
<th className="p-1 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 border-b dark:border-gray-800"> <th className="p-1 font-medium text-center sticky top-0 bg-card z-10 border-b border-border/50">
<Button <Button
variant={sorting.column === "totalQuantity" ? "default" : "ghost"} variant={sorting.column === "totalQuantity" ? "default" : "ghost"}
onClick={() => handleSort("totalQuantity")} onClick={() => handleSort("totalQuantity")}
@@ -317,7 +313,7 @@ const ProductGrid = ({
Sold Sold
</Button> </Button>
</th> </th>
<th className="p-1 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 border-b dark:border-gray-800"> <th className="p-1 font-medium text-center sticky top-0 bg-card z-10 border-b border-border/50">
<Button <Button
variant={sorting.column === "totalRevenue" ? "default" : "ghost"} variant={sorting.column === "totalRevenue" ? "default" : "ghost"}
onClick={() => handleSort("totalRevenue")} onClick={() => handleSort("totalRevenue")}
@@ -326,7 +322,7 @@ const ProductGrid = ({
Rev Rev
</Button> </Button>
</th> </th>
<th className="p-1 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 border-b dark:border-gray-800"> <th className="p-1 font-medium text-center sticky top-0 bg-card z-10 border-b border-border/50">
<Button <Button
variant={sorting.column === "orderCount" ? "default" : "ghost"} variant={sorting.column === "orderCount" ? "default" : "ghost"}
onClick={() => handleSort("orderCount")} onClick={() => handleSort("orderCount")}
@@ -337,7 +333,7 @@ const ProductGrid = ({
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-800"> <tbody className="divide-y divide-border/50">
{filteredProducts.map((product) => ( {filteredProducts.map((product) => (
<tr <tr
key={product.id} key={product.id}
@@ -364,7 +360,7 @@ const ProductGrid = ({
href={`https://backend.acherryontop.com/product/${product.id}`} href={`https://backend.acherryontop.com/product/${product.id}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-sm hover:underline line-clamp-2 text-gray-900 dark:text-gray-100" className="text-sm hover:underline line-clamp-2 text-foreground"
> >
{product.name} {product.name}
</a> </a>
@@ -376,7 +372,7 @@ const ProductGrid = ({
</TooltipProvider> </TooltipProvider>
</div> </div>
</td> </td>
<td className="p-1 align-middle text-center text-sm font-medium text-gray-900 dark:text-gray-100"> <td className="p-1 align-middle text-center text-sm font-medium text-foreground">
{product.totalQuantity} {product.totalQuantity}
</td> </td>
<td className="p-1 align-middle text-center text-emerald-600 dark:text-emerald-400 text-sm font-medium"> <td className="p-1 align-middle text-center text-emerald-600 dark:text-emerald-400 text-sm font-medium">

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { import {
BarChart, BarChart,
Bar, Bar,
@@ -7,19 +7,13 @@ import {
YAxis, YAxis,
Tooltip, Tooltip,
ResponsiveContainer, ResponsiveContainer,
PieChart,
Pie,
Cell,
} from "recharts"; } from "recharts";
import { Loader2, AlertTriangle } from "lucide-react";
import { import {
Tooltip as UITooltip, Tooltip as UITooltip,
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
TooltipProvider, TooltipProvider,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { import {
Table, Table,
@@ -30,141 +24,51 @@ import {
TableCell, TableCell,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { format } from "date-fns"; import { format } from "date-fns";
import { Skeleton } from "@/components/ui/skeleton";
export const METRIC_COLORS = { // Import shared components and tokens
import {
DashboardChartTooltip,
DashboardSectionHeader,
DashboardStatCard,
StatCardSkeleton,
ChartSkeleton,
TableSkeleton,
DashboardErrorState,
CARD_STYLES,
TYPOGRAPHY,
SCROLL_STYLES,
METRIC_COLORS,
} from "@/components/dashboard/shared";
// Realtime-specific colors using the standardized palette
const REALTIME_COLORS = {
activeUsers: { activeUsers: {
color: "#8b5cf6", color: METRIC_COLORS.aov, // Purple
className: "text-purple-600 dark:text-purple-400", className: "text-chart-aov",
}, },
pages: { pages: {
color: "#10b981", color: METRIC_COLORS.revenue, // Emerald
className: "text-emerald-600 dark:text-emerald-400", className: "text-chart-revenue",
}, },
sources: { sources: {
color: "#f59e0b", color: METRIC_COLORS.comparison, // Amber
className: "text-amber-600 dark:text-amber-400", className: "text-chart-comparison",
}, },
}; };
export const summaryCard = (label, sublabel, value, options = {}) => { // Export for backwards compatibility
const { export { REALTIME_COLORS as METRIC_COLORS };
colorClass = "text-gray-900 dark:text-gray-100",
titleClass = "text-sm font-medium text-gray-500 dark:text-gray-400",
descriptionClass = "text-sm text-gray-600 dark:text-gray-300",
background = "bg-white dark:bg-gray-900/60",
icon: Icon,
iconColor,
iconBackground
} = options;
return (
<Card className={`w-full ${background} backdrop-blur-sm`}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 px-4 py-2">
<CardTitle className={titleClass}>
{label}
</CardTitle>
{Icon && (
<div className="relative p-2">
<div className={`absolute inset-0 rounded-full ${iconBackground}`} />
<Icon className={`h-5 w-5 ${iconColor} relative`} />
</div>
)}
</CardHeader>
<CardContent className="px-4 pt-0 pb-2">
<div className="space-y-2">
<div>
<div className={`text-3xl font-extrabold ${colorClass}`}>
{value.toLocaleString()}
</div>
<div className={descriptionClass}>
{sublabel}
</div>
</div>
</div>
</CardContent>
</Card>
);
};
export const SkeletonSummaryCard = () => ( export const SkeletonSummaryCard = () => (
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <StatCardSkeleton size="default" hasIcon={false} hasSubtitle />
<CardHeader className="flex flex-row items-center justify-between space-y-0 px-4 py-2">
<Skeleton className="h-4 w-24 bg-muted" />
</CardHeader>
<CardContent className="px-4 pt-0 pb-2">
<Skeleton className="h-8 w-20 mb-1 bg-muted" />
<Skeleton className="h-4 w-32 bg-muted" />
</CardContent>
</Card>
); );
export const SkeletonBarChart = () => ( export const SkeletonBarChart = () => (
<div className="h-[235px] bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-4"> <ChartSkeleton type="bar" height="sm" withCard={false} />
<div className="h-full relative">
{/* Grid lines */}
{[...Array(5)].map((_, i) => (
<div
key={i}
className="absolute w-full h-px bg-muted"
style={{ top: `${(i + 1) * 20}%` }}
/>
))}
{/* Y-axis labels */}
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-3 w-6 bg-muted" />
))}
</div>
{/* X-axis labels */}
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-3 w-8 bg-muted" />
))}
</div>
{/* Bars */}
<div className="absolute inset-x-8 bottom-6 top-4 flex items-end justify-between">
{[...Array(30)].map((_, i) => (
<div
key={i}
className="w-1.5 bg-muted"
style={{
height: `${Math.random() * 80 + 10}%`,
}}
/>
))}
</div>
</div>
</div>
); );
export const SkeletonTable = () => ( export const SkeletonTable = () => (
<div className="space-y-2 h-[230px] overflow-y-auto"> <TableSkeleton rows={8} columns={2} scrollable maxHeight="sm" />
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead>
<Skeleton className="h-4 w-32 bg-muted" />
</TableHead>
<TableHead className="text-right">
<Skeleton className="h-4 w-24 ml-auto bg-muted" />
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[...Array(8)].map((_, i) => (
<TableRow key={i} className="dark:border-gray-800">
<TableCell>
<Skeleton className="h-4 w-48 bg-muted" />
</TableCell>
<TableCell className="text-right">
<Skeleton className="h-4 w-12 ml-auto bg-muted" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
); );
export const processBasicData = (data) => { export const processBasicData = (data) => {
@@ -223,10 +127,9 @@ export const QuotaInfo = ({ tokenQuota }) => {
const { const {
remaining: projectHourlyRemaining = 0, remaining: projectHourlyRemaining = 0,
consumed: projectHourlyConsumed = 0,
} = projectHourly; } = projectHourly;
const { remaining: dailyRemaining = 0, consumed: dailyConsumed = 0 } = daily; const { remaining: dailyRemaining = 0 } = daily;
const { remaining: errorsRemaining = 10, consumed: errorsConsumed = 0 } = const { remaining: errorsRemaining = 10, consumed: errorsConsumed = 0 } =
serverErrors; serverErrors;
@@ -244,9 +147,9 @@ export const QuotaInfo = ({ tokenQuota }) => {
const getStatusColor = (percentage) => { const getStatusColor = (percentage) => {
const numericPercentage = parseFloat(percentage); const numericPercentage = parseFloat(percentage);
if (isNaN(numericPercentage) || numericPercentage < 20) if (isNaN(numericPercentage) || numericPercentage < 20)
return "text-red-500 dark:text-red-400"; return "text-trend-negative";
if (numericPercentage < 40) return "text-yellow-500 dark:text-yellow-400"; if (numericPercentage < 40) return "text-chart-comparison";
return "text-green-500 dark:text-green-400"; return "text-trend-positive";
}; };
return ( return (
@@ -258,39 +161,37 @@ export const QuotaInfo = ({ tokenQuota }) => {
</span> </span>
</div> </div>
<div className="dark:border-gray-700"> <div className="space-y-3 mt-2">
<div className="space-y-3 mt-2"> <div>
<div> <div className="font-semibold text-foreground">
<div className="font-semibold text-gray-100"> Project Hourly
Project Hourly
</div>
<div className={`${getStatusColor(hourlyPercentage)}`}>
{projectHourlyRemaining.toLocaleString()} / 14,000 remaining
</div>
</div> </div>
<div> <div className={`${getStatusColor(hourlyPercentage)}`}>
<div className="font-semibold text-gray-100"> {projectHourlyRemaining.toLocaleString()} / 14,000 remaining
Daily
</div>
<div className={`${getStatusColor(dailyPercentage)}`}>
{dailyRemaining.toLocaleString()} / 200,000 remaining
</div>
</div> </div>
<div> </div>
<div className="font-semibold text-gray-100"> <div>
Server Errors <div className="font-semibold text-foreground">
</div> Daily
<div className={`${getStatusColor(errorPercentage)}`}>
{errorsConsumed} / 10 used this hour
</div>
</div> </div>
<div> <div className={`${getStatusColor(dailyPercentage)}`}>
<div className="font-semibold text-gray-100"> {dailyRemaining.toLocaleString()} / 200,000 remaining
Thresholded Requests </div>
</div> </div>
<div className={`${getStatusColor(thresholdPercentage)}`}> <div>
{thresholdConsumed} / 120 used this hour <div className="font-semibold text-foreground">
</div> Server Errors
</div>
<div className={`${getStatusColor(errorPercentage)}`}>
{errorsConsumed} / 10 used this hour
</div>
</div>
<div>
<div className="font-semibold text-foreground">
Thresholded Requests
</div>
<div className={`${getStatusColor(thresholdPercentage)}`}>
{thresholdConsumed} / 120 used this hour
</div> </div>
</div> </div>
</div> </div>
@@ -298,6 +199,27 @@ export const QuotaInfo = ({ tokenQuota }) => {
); );
}; };
// Custom tooltip for the realtime chart
const RealtimeTooltip = ({ active, payload }) => {
if (active && payload && payload.length) {
const timestamp = new Date(
Date.now() + payload[0].payload.minute * 60000
);
return (
<DashboardChartTooltip
active={active}
payload={[{
name: "Active Users",
value: payload[0].value,
color: REALTIME_COLORS.activeUsers.color,
}]}
label={format(timestamp, "h:mm a")}
/>
);
}
return null;
};
export const RealtimeAnalytics = () => { export const RealtimeAnalytics = () => {
const [basicData, setBasicData] = useState({ const [basicData, setBasicData] = useState({
last30MinUsers: 0, last30MinUsers: 0,
@@ -422,24 +344,13 @@ export const RealtimeAnalytics = () => {
}; };
}, [isPaused]); }, [isPaused]);
const togglePause = () => {
setIsPaused(!isPaused);
};
if (loading && !basicData && !detailedData) { if (loading && !basicData && !detailedData) {
return ( return (
<Card className="w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm h-full"> <Card className={`${CARD_STYLES.base} h-full`}>
<CardHeader className="p-6 pb-2"> <DashboardSectionHeader title="Real-Time Analytics" className="pb-2" />
<div className="flex justify-between items-center">
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Real-Time Analytics
</CardTitle>
<Skeleton className="h-4 w-32 bg-muted" />
</div>
</CardHeader>
<CardContent className="p-6 pt-0"> <CardContent className="p-6 pt-0">
<div className="grid grid-cols-2 gap-2 md:gap-3 mt-1 mb-3"> <div className="grid grid-cols-2 gap-4 mt-1 mb-3">
<SkeletonSummaryCard /> <SkeletonSummaryCard />
<SkeletonSummaryCard /> <SkeletonSummaryCard />
</div> </div>
@@ -447,7 +358,7 @@ export const RealtimeAnalytics = () => {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex gap-2"> <div className="flex gap-2">
{[...Array(3)].map((_, i) => ( {[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-8 w-20 bg-muted rounded-md" /> <div key={i} className="h-8 w-20 bg-muted animate-pulse rounded-md" />
))} ))}
</div> </div>
<SkeletonBarChart /> <SkeletonBarChart />
@@ -458,51 +369,45 @@ export const RealtimeAnalytics = () => {
} }
return ( return (
<Card className="w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm h-full"> <Card className={`${CARD_STYLES.base} h-full`}>
<CardHeader className="p-6 pb-2"> <DashboardSectionHeader
<div className="flex justify-between items-center"> title="Real-Time Analytics"
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100"> className="pb-2"
Real-Time Analytics actions={
</CardTitle> <TooltipProvider>
<div className="flex items-end"> <UITooltip>
<TooltipProvider> <TooltipTrigger>
<UITooltip> <div className={TYPOGRAPHY.label}>
<TooltipTrigger> Last updated:{" "}
<div className="text-xs text-muted-foreground"> {basicData.lastUpdated && format(new Date(basicData.lastUpdated), "h:mm a")}
Last updated:{" "} </div>
{format(new Date(basicData.lastUpdated), "h:mm a")} </TooltipTrigger>
</div> <TooltipContent className="p-3">
</TooltipTrigger> <QuotaInfo tokenQuota={basicData.tokenQuota} />
<TooltipContent className="p-3"> </TooltipContent>
<QuotaInfo tokenQuota={basicData.tokenQuota} /> </UITooltip>
</TooltipContent> </TooltipProvider>
</UITooltip> }
</TooltipProvider> />
</div>
</div>
</CardHeader>
<CardContent className="p-6 pt-0"> <CardContent className="p-6 pt-0">
{error && ( {error && (
<Alert variant="destructive" className="mb-4"> <DashboardErrorState error={error} className="mx-0 mb-4" />
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)} )}
<div className="grid grid-cols-2 gap-4 mt-1 mb-3"> <div className="grid grid-cols-2 gap-4 mt-1 mb-3">
{summaryCard( <DashboardStatCard
"Last 30 minutes", title="Last 30 minutes"
"Active users", subtitle="Active users"
basicData.last30MinUsers, value={basicData.last30MinUsers}
{ colorClass: METRIC_COLORS.activeUsers.className } size="large"
)} />
{summaryCard( <DashboardStatCard
"Last 5 minutes", title="Last 5 minutes"
"Active users", subtitle="Active users"
basicData.last5MinUsers, value={basicData.last5MinUsers}
{ colorClass: METRIC_COLORS.activeUsers.className } size="large"
)} />
</div> </div>
<Tabs defaultValue="activity" className="w-full"> <Tabs defaultValue="activity" className="w-full">
@@ -513,7 +418,7 @@ export const RealtimeAnalytics = () => {
</TabsList> </TabsList>
<TabsContent value="activity"> <TabsContent value="activity">
<div className="h-[235px] bg-card rounded-lg"> <div className="h-[235px] rounded-lg">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart <BarChart
data={basicData.byMinute} data={basicData.byMinute}
@@ -522,68 +427,47 @@ export const RealtimeAnalytics = () => {
<XAxis <XAxis
dataKey="minute" dataKey="minute"
tickFormatter={(value) => value + "m"} tickFormatter={(value) => value + "m"}
className="text-xs" className="text-xs fill-muted-foreground"
tick={{ fill: "currentColor" }} tickLine={false}
axisLine={false}
/> />
<YAxis className="text-xs" tick={{ fill: "currentColor" }} /> <YAxis
<Tooltip className="text-xs fill-muted-foreground"
content={({ active, payload }) => { tickLine={false}
if (active && payload && payload.length) { axisLine={false}
const timestamp = new Date( />
Date.now() + payload[0].payload.minute * 60000 <Tooltip content={<RealtimeTooltip />} />
); <Bar
return ( dataKey="users"
<Card className="p-3 shadow-lg bg-white dark:bg-gray-800 border-none"> fill={REALTIME_COLORS.activeUsers.color}
<CardContent className="p-0 space-y-2"> radius={[4, 4, 0, 0]}
<p className="font-medium text-sm border-b pb-1 mb-2">
{format(timestamp, "h:mm a")}
</p>
<div className="flex justify-between items-center text-sm">
<span
style={{
color: METRIC_COLORS.activeUsers.color,
}}
>
Active Users:
</span>
<span className="font-medium ml-4">
{payload[0].value.toLocaleString()}
</span>
</div>
</CardContent>
</Card>
);
}
return null;
}}
/> />
<Bar dataKey="users" fill={METRIC_COLORS.activeUsers.color} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="pages"> <TabsContent value="pages">
<div className="space-y-2 h-[230px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600"> <div className={`h-[230px] ${SCROLL_STYLES.container}`}>
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="dark:border-gray-800"> <TableRow>
<TableHead className="text-gray-900 dark:text-gray-100"> <TableHead className="text-foreground">
Page Page
</TableHead> </TableHead>
<TableHead className="text-right text-gray-900 dark:text-gray-100"> <TableHead className="text-right text-foreground">
Active Users Active Users
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{detailedData.currentPages.map((page, index) => ( {detailedData.currentPages.map((page, index) => (
<TableRow key={index} className="dark:border-gray-800"> <TableRow key={index} className="hover:bg-muted/50 transition-colors">
<TableCell className="font-medium text-gray-900 dark:text-gray-100"> <TableCell className="font-medium text-foreground">
{page.path} {page.path}
</TableCell> </TableCell>
<TableCell <TableCell
className={`text-right ${METRIC_COLORS.pages.className}`} className={`text-right ${REALTIME_COLORS.pages.className}`}
> >
{page.activeUsers} {page.activeUsers}
</TableCell> </TableCell>
@@ -595,26 +479,26 @@ export const RealtimeAnalytics = () => {
</TabsContent> </TabsContent>
<TabsContent value="sources"> <TabsContent value="sources">
<div className="space-y-2 h-[230px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600"> <div className={`h-[230px] ${SCROLL_STYLES.container}`}>
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="dark:border-gray-800"> <TableRow>
<TableHead className="text-gray-900 dark:text-gray-100"> <TableHead className="text-foreground">
Source Source
</TableHead> </TableHead>
<TableHead className="text-right text-gray-900 dark:text-gray-100"> <TableHead className="text-right text-foreground">
Active Users Active Users
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{detailedData.sources.map((source, index) => ( {detailedData.sources.map((source, index) => (
<TableRow key={index} className="dark:border-gray-800"> <TableRow key={index} className="hover:bg-muted/50 transition-colors">
<TableCell className="font-medium text-gray-900 dark:text-gray-100"> <TableCell className="font-medium text-foreground">
{source.source} {source.source}
</TableCell> </TableCell>
<TableCell <TableCell
className={`text-right ${METRIC_COLORS.sources.className}`} className={`text-right ${REALTIME_COLORS.sources.className}`}
> >
{source.activeUsers} {source.activeUsers}
</TableCell> </TableCell>

View File

@@ -24,8 +24,6 @@ import {
TrendingDown, TrendingDown,
Info, Info,
AlertCircle, AlertCircle,
ArrowUp,
ArrowDown,
} from "lucide-react"; } from "lucide-react";
import { import {
LineChart, LineChart,
@@ -70,7 +68,19 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import {
CARD_STYLES,
TYPOGRAPHY,
METRIC_COLORS as SHARED_METRIC_COLORS,
} from "@/lib/dashboard/designTokens";
import {
DashboardStatCard,
ChartSkeleton,
TableSkeleton,
DashboardEmptyState,
DashboardErrorState,
TOOLTIP_STYLES,
} from "@/components/dashboard/shared";
const METRIC_IDS = { const METRIC_IDS = {
PLACED_ORDER: "Y8cqcF", PLACED_ORDER: "Y8cqcF",
@@ -127,70 +137,17 @@ const formatPercentage = (value) => {
return `${Math.abs(Math.round(value))}%`; return `${Math.abs(Math.round(value))}%`;
}; };
// Add color mapping for metrics // Add color mapping for metrics - using shared tokens where applicable
const METRIC_COLORS = { const METRIC_COLORS = {
revenue: "#8b5cf6", revenue: SHARED_METRIC_COLORS.aov, // Purple for revenue
orders: "#10b981", orders: SHARED_METRIC_COLORS.revenue, // Emerald for orders
avgOrderValue: "#9333ea", avgOrderValue: "#9333ea", // Deep purple for AOV
movingAverage: "#f59e0b", movingAverage: SHARED_METRIC_COLORS.comparison, // Amber for moving average
prevRevenue: "#f97316", prevRevenue: SHARED_METRIC_COLORS.expense, // Orange for prev revenue
prevOrders: "#0ea5e9", prevOrders: SHARED_METRIC_COLORS.secondary, // Cyan for prev orders
prevAvgOrderValue: "#f59e0b", prevAvgOrderValue: SHARED_METRIC_COLORS.comparison, // Amber for prev AOV
}; };
// Memoize the StatCard component
export const StatCard = memo(
({
title,
value,
description,
trend,
trendValue,
valuePrefix = "",
valueSuffix = "",
trendPrefix = "",
trendSuffix = "",
className = "",
colorClass = "text-gray-900 dark:text-gray-100",
}) => (
<Card className={className}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<span className="text-sm text-muted-foreground">{title}</span>
{trend && (
<span
className={`text-sm flex items-center gap-1 ${
trend === "up"
? "text-emerald-600 dark:text-emerald-400"
: "text-rose-600 dark:text-rose-400"
}`}
>
{trend === "up" ? (
<ArrowUp className="w-4 h-4" />
) : (
<ArrowDown className="w-4 h-4" />
)}
{trendPrefix}
{trendValue}
{trendSuffix}
</span>
)}
</CardHeader>
<CardContent className="p-4 pt-0">
<div className={`text-2xl font-bold mb-1 ${colorClass}`}>
{valuePrefix}
{value}
{valueSuffix}
</div>
{description && (
<div className="text-sm text-muted-foreground">{description}</div>
)}
</CardContent>
</Card>
)
);
StatCard.displayName = "StatCard";
// Export CustomTooltip // Export CustomTooltip
export const CustomTooltip = ({ active, payload, label }) => { export const CustomTooltip = ({ active, payload, label }) => {
if (active && payload && payload.length) { if (active && payload && payload.length) {
@@ -202,23 +159,29 @@ export const CustomTooltip = ({ active, payload, label }) => {
}); });
return ( return (
<Card className="p-2 shadow-lg bg-white dark:bg-gray-800 border-none"> <div className={TOOLTIP_STYLES.container}>
<CardContent className="p-0 space-y-1"> <p className={TOOLTIP_STYLES.header}>{formattedDate}</p>
<p className="font-medium text-xs">{formattedDate}</p> <div className={TOOLTIP_STYLES.content}>
{payload.map((entry, index) => { {payload.map((entry, index) => {
const value = entry.dataKey.toLowerCase().includes('revenue') || entry.dataKey === 'avgOrderValue' const value = entry.dataKey.toLowerCase().includes('revenue') || entry.dataKey === 'avgOrderValue'
? formatCurrency(entry.value) ? formatCurrency(entry.value)
: entry.value.toLocaleString(); : entry.value.toLocaleString();
return ( return (
<div key={index} className="flex justify-between items-center text-xs gap-3"> <div key={index} className={TOOLTIP_STYLES.row}>
<span style={{ color: entry.stroke }}>{entry.name}:</span> <div className={TOOLTIP_STYLES.rowLabel}>
<span className="font-medium">{value}</span> <span
className={TOOLTIP_STYLES.dot}
style={{ backgroundColor: entry.stroke || "#888" }}
/>
<span className={TOOLTIP_STYLES.name}>{entry.name}</span>
</div>
<span className={TOOLTIP_STYLES.value}>{value}</span>
</div> </div>
); );
})} })}
</CardContent> </div>
</Card> </div>
); );
} }
return null; return null;
@@ -394,54 +357,64 @@ const SummaryStats = memo(({ stats = {}, projection = null, projectionLoading =
const aovDiff = Math.abs(currentAOV - prevAvgOrderValue); const aovDiff = Math.abs(currentAOV - prevAvgOrderValue);
const aovPercentage = (aovDiff / prevAvgOrderValue) * 100; const aovPercentage = (aovDiff / prevAvgOrderValue) * 100;
// Convert trend direction to numeric value for DashboardStatCard
const getNumericTrend = (trendDir, percentage) => {
if (projectionLoading && periodProgress < 100) return undefined;
if (!isFinite(percentage)) return undefined;
return trendDir === "up" ? percentage : -percentage;
};
return ( return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 max-w-3xl"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 max-w-3xl">
<StatCard <DashboardStatCard
title="Total Revenue" title="Total Revenue"
value={formatCurrency(totalRevenue, false)} value={formatCurrency(totalRevenue, false)}
description={ subtitle={
periodProgress < 100 periodProgress < 100
? `Projected: ${formatCurrency(projection?.projectedRevenue || totalRevenue, false)}` ? `Projected: ${formatCurrency(projection?.projectedRevenue || totalRevenue, false)}`
: `Previous: ${formatCurrency(prevRevenue, false)}` : `Previous: ${formatCurrency(prevRevenue, false)}`
} }
trend={projectionLoading && periodProgress < 100 ? undefined : revenueTrend} trend={getNumericTrend(revenueTrend, revenuePercentage) !== undefined
trendValue={projectionLoading && periodProgress < 100 ? null : formatPercentage(revenuePercentage)} ? { value: getNumericTrend(revenueTrend, revenuePercentage) }
info="Total revenue for the selected period" : undefined}
colorClass="text-green-600 dark:text-green-400" tooltip="Total revenue for the selected period"
size="compact"
/> />
<StatCard <DashboardStatCard
title="Total Orders" title="Total Orders"
value={totalOrders.toLocaleString()} value={totalOrders.toLocaleString()}
description={ subtitle={
periodProgress < 100 periodProgress < 100
? `Projected: ${(projection?.projectedOrders || totalOrders).toLocaleString()}` ? `Projected: ${(projection?.projectedOrders || totalOrders).toLocaleString()}`
: `Previous: ${prevOrders.toLocaleString()}` : `Previous: ${prevOrders.toLocaleString()}`
} }
trend={projectionLoading && periodProgress < 100 ? undefined : ordersTrend} trend={getNumericTrend(ordersTrend, ordersPercentage) !== undefined
trendValue={projectionLoading && periodProgress < 100 ? null : formatPercentage(ordersPercentage)} ? { value: getNumericTrend(ordersTrend, ordersPercentage) }
info="Total number of orders for the selected period" : undefined}
colorClass="text-blue-600 dark:text-blue-400" tooltip="Total number of orders for the selected period"
size="compact"
/> />
<StatCard <DashboardStatCard
title="AOV" title="AOV"
value={formatCurrency(avgOrderValue)} value={formatCurrency(avgOrderValue)}
description={ subtitle={
periodProgress < 100 periodProgress < 100
? `Projected: ${formatCurrency(currentAOV)}` ? `Projected: ${formatCurrency(currentAOV)}`
: `Previous: ${formatCurrency(prevAvgOrderValue)}` : `Previous: ${formatCurrency(prevAvgOrderValue)}`
} }
trend={projectionLoading && periodProgress < 100 ? undefined : aovTrend} trend={getNumericTrend(aovTrend, aovPercentage) !== undefined
trendValue={projectionLoading && periodProgress < 100 ? null : formatPercentage(aovPercentage)} ? { value: getNumericTrend(aovTrend, aovPercentage) }
info="Average value per order for the selected period" : undefined}
colorClass="text-purple-600 dark:text-purple-400" tooltip="Average value per order for the selected period"
size="compact"
/> />
<StatCard <DashboardStatCard
title="Best Day" title="Best Day"
value={formatCurrency(bestDay?.revenue || 0, false)} value={formatCurrency(bestDay?.revenue || 0, false)}
description={ subtitle={
bestDay?.timestamp bestDay?.timestamp
? `${new Date(bestDay.timestamp).toLocaleDateString("en-US", { ? `${new Date(bestDay.timestamp).toLocaleDateString("en-US", {
month: "short", month: "short",
@@ -449,8 +422,8 @@ const SummaryStats = memo(({ stats = {}, projection = null, projectionLoading =
})} - ${bestDay.orders} orders` })} - ${bestDay.orders} orders`
: "No data" : "No data"
} }
info="Day with highest revenue in the selected period" tooltip="Day with highest revenue in the selected period"
colorClass="text-orange-600 dark:text-orange-400" size="compact"
/> />
</div> </div>
); );
@@ -458,50 +431,12 @@ const SummaryStats = memo(({ stats = {}, projection = null, projectionLoading =
SummaryStats.displayName = "SummaryStats"; SummaryStats.displayName = "SummaryStats";
// Add these skeleton components near the top of the file // Note: Using ChartSkeleton and TableSkeleton from @/components/dashboard/shared
const SkeletonChart = () => (
<div className="h-[400px] w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-6">
<div className="h-full flex flex-col">
<div className="flex-1 relative">
{/* Grid lines */}
{[...Array(6)].map((_, i) => (
<div
key={i}
className="absolute w-full h-px bg-muted"
style={{ top: `${(i + 1) * 16}%` }}
/>
))}
{/* Y-axis labels */}
<div className="absolute left-0 top-0 bottom-0 w-16 flex flex-col justify-between py-4">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-4 w-12 bg-muted rounded-sm" />
))}
</div>
{/* X-axis labels */}
<div className="absolute left-16 right-0 bottom-0 flex justify-between px-4">
{[...Array(7)].map((_, i) => (
<Skeleton key={i} className="h-4 w-12 bg-muted rounded-sm" />
))}
</div>
{/* Chart line */}
<div className="absolute inset-0 mt-8 mb-8 ml-20 mr-4">
<div
className="absolute inset-0 bg-muted/50"
style={{
clipPath:
"polygon(0 50%, 20% 20%, 40% 40%, 60% 30%, 80% 60%, 100% 40%, 100% 100%, 0 100%)",
}}
/>
</div>
</div>
</div>
</div>
);
const SkeletonStats = () => ( const SkeletonStats = () => (
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-2 md:gap-3"> <div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-2 md:gap-3">
{[...Array(4)].map((_, i) => ( {[...Array(4)].map((_, i) => (
<Card key={i} className="bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <Card key={i} className="bg-card">
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<Skeleton className="h-4 w-24 bg-muted rounded-sm" /> <Skeleton className="h-4 w-24 bg-muted rounded-sm" />
<Skeleton className="h-4 w-8 bg-muted rounded-sm" /> <Skeleton className="h-4 w-8 bg-muted rounded-sm" />
@@ -515,19 +450,6 @@ const SkeletonStats = () => (
</div> </div>
); );
const SkeletonTable = () => (
<div className="rounded-lg border bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<div className="p-4 space-y-4">
{[...Array(7)].map((_, i) => (
<div key={i} className="flex justify-between items-center">
<Skeleton className="h-4 w-24 bg-muted rounded-sm" />
<Skeleton className="h-4 w-16 bg-muted rounded-sm" />
</div>
))}
</div>
</div>
);
const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => { const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
const [data, setData] = useState([]); const [data, setData] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -644,12 +566,12 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
: 0; : 0;
return ( return (
<Card className="w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <Card className={`w-full ${CARD_STYLES.base}`}>
<CardHeader className="p-6 pb-4"> <CardHeader className="p-6 pb-4">
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div> <div>
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100"> <CardTitle className={TYPOGRAPHY.sectionTitle}>
{title} {title}
</CardTitle> </CardTitle>
</div> </div>
@@ -661,9 +583,9 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
Details Details
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="p-4 max-w-[95vw] w-fit max-h-[85vh] overflow-hidden flex flex-col bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <DialogContent className="p-4 max-w-[95vw] w-fit max-h-[85vh] overflow-hidden flex flex-col bg-card">
<DialogHeader className="flex-none"> <DialogHeader className="flex-none">
<DialogTitle className="text-gray-900 dark:text-gray-100"> <DialogTitle className="text-foreground">
Daily Details Daily Details
</DialogTitle> </DialogTitle>
<div className="flex items-center justify-center gap-2 pt-4"> <div className="flex items-center justify-center gap-2 pt-4">
@@ -739,7 +661,7 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
</div> </div>
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-auto mt-6"> <div className="flex-1 overflow-auto mt-6">
<div className="rounded-lg border bg-white dark:bg-gray-900/60 backdrop-blur-sm w-full"> <div className="rounded-lg border bg-card w-full">
<Table className="w-full"> <Table className="w-full">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -960,38 +882,23 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
<CardContent className="p-6 pt-0"> <CardContent className="p-6 pt-0">
{loading ? ( {loading ? (
<div className="space-y-6"> <div className="space-y-6">
<SkeletonChart /> <ChartSkeleton height="default" withCard={false} />
<div className="mt-4 flex justify-end"> <div className="mt-4 flex justify-end">
<Skeleton className="h-9 w-24 bg-muted rounded-sm" /> <Skeleton className="h-9 w-24 bg-muted rounded-sm" />
</div> </div>
{showDailyTable && <SkeletonTable />} {showDailyTable && <TableSkeleton rows={7} columns={3} />}
</div> </div>
) : error ? ( ) : error ? (
<Alert <DashboardErrorState error={`Failed to load sales data: ${error}`} className="mx-0 my-0" />
variant="destructive"
className="bg-white dark:bg-gray-900/60 backdrop-blur-sm"
>
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
Failed to load sales data: {error}
</AlertDescription>
</Alert>
) : !data.length ? ( ) : !data.length ? (
<div className="flex items-center justify-center h-[400px] text-muted-foreground"> <DashboardEmptyState
<div className="text-center"> icon={TrendingUp}
<TrendingUp className="h-12 w-12 mx-auto mb-4" /> title="No sales data available"
<div className="font-medium mb-2 text-gray-900 dark:text-gray-100"> description="Try selecting a different time range"
No sales data available />
</div>
<div className="text-sm text-muted-foreground">
Try selecting a different time range
</div>
</div>
</div>
) : ( ) : (
<> <>
<div className="h-[400px] mt-4 bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-0 relative"> <div className="h-[400px] mt-4 bg-card rounded-lg p-0 relative">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart <LineChart
data={data} data={data}

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,6 @@ import axios from "axios";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
@@ -17,10 +16,17 @@ import {
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { AlertCircle } from "lucide-react";
import { format } from "date-fns"; import { format } from "date-fns";
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import {
DashboardSectionHeader,
DashboardErrorState,
DashboardBadge,
ChartSkeleton,
TableSkeleton,
SimpleTooltip,
TOOLTIP_STYLES,
} from "@/components/dashboard/shared";
import { import {
BarChart, BarChart,
Bar, Bar,
@@ -30,7 +36,6 @@ import {
Tooltip, Tooltip,
ResponsiveContainer, ResponsiveContainer,
Cell, Cell,
ReferenceLine,
} from "recharts"; } from "recharts";
// Get form IDs from environment variables // Get form IDs from environment variables
@@ -44,74 +49,12 @@ const FORM_NAMES = {
[FORM_IDS.FORM_2]: "Winback Survey", [FORM_IDS.FORM_2]: "Winback Survey",
}; };
// Loading skeleton components
const SkeletonChart = () => (
<div className="h-[300px] w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-6">
<div className="h-full relative">
{[...Array(5)].map((_, i) => (
<div
key={i}
className="absolute w-full h-px bg-muted"
style={{ top: `${(i + 1) * 20}%` }}
/>
))}
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-3 w-6 bg-muted" />
))}
</div>
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-3 w-16 bg-muted" />
))}
</div>
</div>
</div>
);
const SkeletonTable = () => (
<div className="space-y-2">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[200px]">
<Skeleton className="h-4 w-[180px] bg-muted" />
</TableHead>
<TableHead>
<Skeleton className="h-4 w-[100px] bg-muted" />
</TableHead>
<TableHead>
<Skeleton className="h-4 w-[80px] bg-muted" />
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[...Array(5)].map((_, i) => (
<TableRow key={i} className="hover:bg-transparent">
<TableCell>
<Skeleton className="h-4 w-[160px] bg-muted" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[90px] bg-muted" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[70px] bg-muted" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
const ResponseFeed = ({ responses, title, renderSummary }) => ( const ResponseFeed = ({ responses, title, renderSummary }) => (
<Card> <Card>
<CardHeader className="pb-2"> <DashboardSectionHeader title={title} compact />
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
</CardHeader>
<CardContent> <CardContent>
<ScrollArea className="h-[400px]"> <ScrollArea className="h-[400px]">
<div className="divide-y divide-gray-100 dark:divide-gray-800"> <div className="divide-y divide-border/50">
{responses.items.map((response) => ( {responses.items.map((response) => (
<div key={response.token} className="p-4"> <div key={response.token} className="p-4">
{renderSummary(response)} {renderSummary(response)}
@@ -138,24 +81,18 @@ const ProductRelevanceFeed = ({ responses }) => (
{response.hidden?.email ? ( {response.hidden?.email ? (
<a <a
href={`https://backend.acherryontop.com/search/?search_for=customers&search=${response.hidden.email}`} href={`https://backend.acherryontop.com/search/?search_for=customers&search=${response.hidden.email}`}
className="text-sm font-medium text-gray-900 dark:text-gray-100 hover:underline" className="text-sm font-medium text-foreground hover:underline"
> >
{response.hidden?.name || "Anonymous"} {response.hidden?.name || "Anonymous"}
</a> </a>
) : ( ) : (
<span className="text-sm font-medium text-gray-900 dark:text-gray-100"> <span className="text-sm font-medium text-foreground">
{response.hidden?.name || "Anonymous"} {response.hidden?.name || "Anonymous"}
</span> </span>
)} )}
<Badge <DashboardBadge variant={answer?.boolean ? "success" : "error"}>
className={
answer?.boolean
? "bg-green-200 text-green-700"
: "bg-red-200 text-red-700"
}
>
{answer?.boolean ? "Yes" : "No"} {answer?.boolean ? "Yes" : "No"}
</Badge> </DashboardBadge>
</div> </div>
<time <time
className="text-xs text-muted-foreground" className="text-xs text-muted-foreground"
@@ -193,32 +130,32 @@ const WinbackFeed = ({ responses }) => (
{response.hidden?.email ? ( {response.hidden?.email ? (
<a <a
href={`https://backend.acherryontop.com/search/?search_for=customers&search=${response.hidden.email}`} href={`https://backend.acherryontop.com/search/?search_for=customers&search=${response.hidden.email}`}
className="text-sm font-medium text-gray-900 dark:text-gray-100 hover:underline" className="text-sm font-medium text-foreground hover:underline"
> >
{response.hidden?.name || "Anonymous"} {response.hidden?.name || "Anonymous"}
</a> </a>
) : ( ) : (
<span className="text-sm font-medium text-gray-900 dark:text-gray-100"> <span className="text-sm font-medium text-foreground">
{response.hidden?.name || "Anonymous"} {response.hidden?.name || "Anonymous"}
</span> </span>
)} )}
<Badge <DashboardBadge
className={ variant={
likelihoodAnswer?.number === 1 likelihoodAnswer?.number === 1
? "bg-red-200 text-red-700" ? "error"
: likelihoodAnswer?.number === 2 : likelihoodAnswer?.number === 2
? "bg-orange-200 text-orange-700" ? "orange"
: likelihoodAnswer?.number === 3 : likelihoodAnswer?.number === 3
? "bg-yellow-200 text-yellow-700" ? "yellow"
: likelihoodAnswer?.number === 4 : likelihoodAnswer?.number === 4
? "bg-lime-200 text-lime-700" ? "emerald"
: likelihoodAnswer?.number === 5 : likelihoodAnswer?.number === 5
? "bg-green-200 text-green-700" ? "success"
: "bg-gray-200 text-gray-700" : "default"
} }
> >
{likelihoodAnswer?.number}/5 {likelihoodAnswer?.number}/5
</Badge> </DashboardBadge>
</div> </div>
<time <time
className="text-xs text-muted-foreground" className="text-xs text-muted-foreground"
@@ -390,11 +327,12 @@ const TypeformDashboard = () => {
if (error) { if (error) {
return ( return (
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <Card className={`h-full ${CARD_STYLES.base}`}>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="p-4 m-6 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/10 rounded-lg border border-red-200 dark:border-red-900/20"> <DashboardErrorState
{error} title="Failed to load survey data"
</div> message={error}
/>
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -413,33 +351,26 @@ const TypeformDashboard = () => {
: []; : [];
return ( return (
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <Card className={CARD_STYLES.base}>
<CardHeader className="p-6 pb-0"> <DashboardSectionHeader
<div className="space-y-1"> title="Customer Surveys"
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100"> lastUpdated={newestResponse ? new Date(newestResponse) : null}
Customer Surveys lastUpdatedFormat={(date) => `Newest response: ${format(date, "MMM d, h:mm a")}`}
</CardTitle> className="pb-0"
{newestResponse && ( />
<p className="text-sm text-muted-foreground">
Newest response:{" "}
{format(new Date(newestResponse), "MMM d, h:mm a")}
</p>
)}
</div>
</CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{loading ? ( {loading ? (
<div className="space-y-4"> <div className="space-y-4">
<SkeletonChart /> <ChartSkeleton height="md" withCard={false} />
<SkeletonTable /> <TableSkeleton rows={5} columns={3} />
</div> </div>
) : ( ) : (
<> <>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-6">
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <Card className="bg-card">
<CardHeader className="p-6"> <CardHeader className="p-6">
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-gray-100"> <CardTitle className="text-lg font-semibold">
How likely are you to place another order with us? How likely are you to place another order with us?
</CardTitle> </CardTitle>
<span <span
@@ -489,21 +420,12 @@ const TypeformDashboard = () => {
/> />
<YAxis className="text-muted-foreground text-xs md:text-sm" /> <YAxis className="text-muted-foreground text-xs md:text-sm" />
<Tooltip <Tooltip
content={({ payload }) => { content={
if (payload && payload.length) { <SimpleTooltip
const { rating, count } = payload[0].payload; labelFormatter={(label) => `${label} Rating`}
return ( valueFormatter={(value) => `${value} responses`}
<Card className="p-3 shadow-lg bg-white dark:bg-gray-900/60 backdrop-blur-sm border-none"> />
<CardContent className="p-0"> }
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{rating} Rating: {count} responses
</div>
</CardContent>
</Card>
);
}
return null;
}}
/> />
<Bar dataKey="count"> <Bar dataKey="count">
{likelihoodCounts.map((_, index) => ( {likelihoodCounts.map((_, index) => (
@@ -528,10 +450,10 @@ const TypeformDashboard = () => {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm"> <Card className="bg-card">
<CardHeader className="p-6"> <CardHeader className="p-6">
<div className="flex items-baseline justify-between gap-2"> <div className="flex items-baseline justify-between gap-2">
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-gray-100"> <CardTitle className="text-lg font-semibold">
Were the suggested products in this email relevant to you? Were the suggested products in this email relevant to you?
</CardTitle> </CardTitle>
<div className="flex flex-col items-end"> <div className="flex flex-col items-end">
@@ -567,35 +489,27 @@ const TypeformDashboard = () => {
const yesCount = payload[0].payload.yes; const yesCount = payload[0].payload.yes;
const noCount = payload[0].payload.no; const noCount = payload[0].payload.no;
const total = yesCount + noCount; const total = yesCount + noCount;
const yesPercent = Math.round( const yesPercent = Math.round((yesCount / total) * 100);
(yesCount / total) * 100 const noPercent = Math.round((noCount / total) * 100);
);
const noPercent = Math.round(
(noCount / total) * 100
);
return ( return (
<Card className="p-3 shadow-lg bg-white dark:bg-gray-900/60 backdrop-blur-sm border-none"> <div className={TOOLTIP_STYLES.container}>
<CardContent className="p-0 space-y-2"> <div className={TOOLTIP_STYLES.content}>
<div className="space-y-1"> <div className={TOOLTIP_STYLES.row}>
<div className="flex justify-between items-center text-sm"> <div className={TOOLTIP_STYLES.rowLabel}>
<span className="text-emerald-500 font-medium"> <span className={TOOLTIP_STYLES.dot} style={{ backgroundColor: '#10b981' }} />
Yes: <span className={TOOLTIP_STYLES.name}>Yes</span>
</span>
<span className="ml-4 text-muted-foreground">
{yesCount} ({yesPercent}%)
</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-red-500 font-medium">
No:
</span>
<span className="ml-4 text-muted-foreground">
{noCount} ({noPercent}%)
</span>
</div> </div>
<span className={TOOLTIP_STYLES.value}>{yesCount} ({yesPercent}%)</span>
</div> </div>
</CardContent> <div className={TOOLTIP_STYLES.row}>
</Card> <div className={TOOLTIP_STYLES.rowLabel}>
<span className={TOOLTIP_STYLES.dot} style={{ backgroundColor: '#ef4444' }} />
<span className={TOOLTIP_STYLES.name}>No</span>
</div>
<span className={TOOLTIP_STYLES.value}>{noCount} ({noPercent}%)</span>
</div>
</div>
</div>
); );
} }
return null; return null;
@@ -637,24 +551,20 @@ const TypeformDashboard = () => {
<div className="grid grid-cols-2 lg:grid-cols-12 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-12 gap-4">
<div className="col-span-4 lg:col-span-12 xl:col-span-4"> <div className="col-span-4 lg:col-span-12 xl:col-span-4">
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm h-full"> <Card className="bg-card h-full">
<CardHeader> <DashboardSectionHeader title="Reasons for Not Ordering" compact />
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Reasons for Not Ordering
</CardTitle>
</CardHeader>
<CardContent> <CardContent>
<div className="overflow-y-auto max-h-[400px] scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600"> <div className="overflow-y-auto max-h-[400px] scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="font-medium text-gray-900 dark:text-gray-100"> <TableHead className="font-medium text-foreground">
Reason Reason
</TableHead> </TableHead>
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100"> <TableHead className="text-right font-medium text-foreground">
Count Count
</TableHead> </TableHead>
<TableHead className="text-right w-[80px] font-medium text-gray-900 dark:text-gray-100"> <TableHead className="text-right w-[80px] font-medium text-foreground">
% %
</TableHead> </TableHead>
</TableRow> </TableRow>
@@ -665,7 +575,7 @@ const TypeformDashboard = () => {
key={index} key={index}
className="hover:bg-muted/50 transition-colors" className="hover:bg-muted/50 transition-colors"
> >
<TableCell className="font-medium text-gray-900 dark:text-gray-100"> <TableCell className="font-medium text-foreground">
{reason.reason} {reason.reason}
</TableCell> </TableCell>
<TableCell className="text-right text-muted-foreground"> <TableCell className="text-right text-muted-foreground">

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { import {
Select, Select,
@@ -22,62 +22,14 @@ import {
Cell, Cell,
ResponsiveContainer, ResponsiveContainer,
Tooltip, Tooltip,
Legend,
} from "recharts"; } from "recharts";
import { Loader2 } from "lucide-react"; import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import { Skeleton } from "@/components/ui/skeleton"; import {
DashboardSectionHeader,
// Add skeleton components TableSkeleton,
const SkeletonTable = ({ rows = 12 }) => ( ChartSkeleton,
<div className="h-full max-h-[540px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2"> TOOLTIP_STYLES,
<Table> } from "@/components/dashboard/shared";
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead><Skeleton className="h-4 w-48 bg-muted rounded-sm" /></TableHead>
<TableHead className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted rounded-sm" /></TableHead>
<TableHead className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted rounded-sm" /></TableHead>
<TableHead className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted rounded-sm" /></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[...Array(rows)].map((_, i) => (
<TableRow key={i} className="dark:border-gray-800">
<TableCell className="py-3"><Skeleton className="h-4 w-64 bg-muted rounded-sm" /></TableCell>
<TableCell className="text-right py-3"><Skeleton className="h-4 w-12 ml-auto bg-muted rounded-sm" /></TableCell>
<TableCell className="text-right py-3"><Skeleton className="h-4 w-12 ml-auto bg-muted rounded-sm" /></TableCell>
<TableCell className="text-right py-3"><Skeleton className="h-4 w-16 ml-auto bg-muted rounded-sm" /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
const SkeletonPieChart = () => (
<div className="h-60 relative">
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-40 h-40 rounded-full bg-muted animate-pulse" />
</div>
<div className="absolute bottom-0 left-1/2 transform -translate-x-1/2 flex gap-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="flex items-center gap-2">
<Skeleton className="h-3 w-3 rounded-full bg-muted" />
<Skeleton className="h-4 w-16 bg-muted rounded-sm" />
</div>
))}
</div>
</div>
);
const SkeletonTabs = () => (
<div className="space-y-2">
<div className="flex gap-2 mb-4">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-8 w-24 bg-muted rounded-sm" />
))}
</div>
</div>
);
export const UserBehaviorDashboard = () => { export const UserBehaviorDashboard = () => {
const [data, setData] = useState(null); const [data, setData] = useState(null);
@@ -183,15 +135,13 @@ export const UserBehaviorDashboard = () => {
if (loading) { if (loading) {
return ( return (
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm h-full"> <Card className={`${CARD_STYLES.base} h-full`}>
<CardHeader className="p-6 pb-4"> <DashboardSectionHeader
<div className="flex justify-between items-start"> title="User Behavior Analysis"
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100"> loading={true}
User Behavior Analysis size="large"
</CardTitle> timeSelector={<div className="w-36" />}
<Skeleton className="h-9 w-36 bg-muted rounded-sm" /> />
</div>
</CardHeader>
<CardContent className="p-6 pt-0"> <CardContent className="p-6 pt-0">
<Tabs defaultValue="pages" className="w-full"> <Tabs defaultValue="pages" className="w-full">
<TabsList className="mb-4"> <TabsList className="mb-4">
@@ -201,15 +151,15 @@ export const UserBehaviorDashboard = () => {
</TabsList> </TabsList>
<TabsContent value="pages" className="mt-4 space-y-2"> <TabsContent value="pages" className="mt-4 space-y-2">
<SkeletonTable rows={15} /> <TableSkeleton rows={15} columns={4} />
</TabsContent> </TabsContent>
<TabsContent value="sources" className="mt-4 space-y-2"> <TabsContent value="sources" className="mt-4 space-y-2">
<SkeletonTable rows={12} /> <TableSkeleton rows={12} columns={4} />
</TabsContent> </TabsContent>
<TabsContent value="devices" className="mt-4 space-y-2"> <TabsContent value="devices" className="mt-4 space-y-2">
<SkeletonPieChart /> <ChartSkeleton type="pie" height="sm" withCard={false} />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</CardContent> </CardContent>
@@ -235,20 +185,27 @@ export const UserBehaviorDashboard = () => {
const data = payload[0].payload; const data = payload[0].payload;
const percentage = ((data.pageViews / totalViews) * 100).toFixed(1); const percentage = ((data.pageViews / totalViews) * 100).toFixed(1);
const sessionPercentage = ((data.sessions / totalSessions) * 100).toFixed(1); const sessionPercentage = ((data.sessions / totalSessions) * 100).toFixed(1);
const color = COLORS[data.device.toLowerCase()];
return ( return (
<Card className="p-3 shadow-lg bg-white dark:bg-gray-900/60 backdrop-blur-sm border border-border"> <div className={TOOLTIP_STYLES.container}>
<CardContent className="p-0 space-y-2"> <p className={TOOLTIP_STYLES.header}>{data.device}</p>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100"> <div className={TOOLTIP_STYLES.content}>
{data.device} <div className={TOOLTIP_STYLES.row}>
</p> <div className={TOOLTIP_STYLES.rowLabel}>
<p className="text-sm text-muted-foreground"> <span className={TOOLTIP_STYLES.dot} style={{ backgroundColor: color }} />
{data.pageViews.toLocaleString()} views ({percentage}%) <span className={TOOLTIP_STYLES.name}>Views</span>
</p> </div>
<p className="text-sm text-muted-foreground"> <span className={TOOLTIP_STYLES.value}>{data.pageViews.toLocaleString()} ({percentage}%)</span>
{data.sessions.toLocaleString()} sessions ({sessionPercentage}%) </div>
</p> <div className={TOOLTIP_STYLES.row}>
</CardContent> <div className={TOOLTIP_STYLES.rowLabel}>
</Card> <span className={TOOLTIP_STYLES.dot} style={{ backgroundColor: color }} />
<span className={TOOLTIP_STYLES.name}>Sessions</span>
</div>
<span className={TOOLTIP_STYLES.value}>{data.sessions.toLocaleString()} ({sessionPercentage}%)</span>
</div>
</div>
</div>
); );
} }
return null; return null;
@@ -261,12 +218,11 @@ export const UserBehaviorDashboard = () => {
}; };
return ( return (
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm h-full"> <Card className={`${CARD_STYLES.base} h-full`}>
<CardHeader className="p-6 pb-4"> <DashboardSectionHeader
<div className="flex justify-between items-start"> title="User Behavior Analysis"
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100"> size="large"
User Behavior Analysis timeSelector={
</CardTitle>
<Select value={timeRange} onValueChange={setTimeRange}> <Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-36 h-9"> <SelectTrigger className="w-36 h-9">
<SelectValue> <SelectValue>
@@ -283,8 +239,8 @@ export const UserBehaviorDashboard = () => {
<SelectItem value="90">Last 90 days</SelectItem> <SelectItem value="90">Last 90 days</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> }
</CardHeader> />
<CardContent className="p-6 pt-0"> <CardContent className="p-6 pt-0">
<Tabs defaultValue="pages" className="w-full"> <Tabs defaultValue="pages" className="w-full">
<TabsList className="mb-4"> <TabsList className="mb-4">
@@ -365,7 +321,7 @@ export const UserBehaviorDashboard = () => {
value="devices" value="devices"
className="mt-4 space-y-2 h-full max-h-[540px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2" className="mt-4 space-y-2 h-full max-h-[540px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2"
> >
<div className="h-60 bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-4"> <div className={`h-60 ${CARD_STYLES.base} rounded-lg p-4`}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<PieChart> <PieChart>
<Pie <Pie

View File

@@ -0,0 +1,246 @@
/**
* DashboardBadge
*
* Standardized badge component with consistent color variants for dashboard use.
* Uses the BADGE_STYLES tokens from designTokens.
*
* @example
* // Basic usage
* <DashboardBadge variant="success">Active</DashboardBadge>
*
* @example
* // With different colors
* <DashboardBadge variant="blue">New</DashboardBadge>
* <DashboardBadge variant="purple">Premium</DashboardBadge>
* <DashboardBadge variant="yellow">Pending</DashboardBadge>
*
* @example
* // Small size
* <DashboardBadge variant="green" size="sm">✓</DashboardBadge>
*/
import React from "react";
import { cn } from "@/lib/utils";
// =============================================================================
// TYPES
// =============================================================================
export type BadgeVariant =
| "default"
| "blue"
| "green"
| "emerald"
| "red"
| "yellow"
| "amber"
| "orange"
| "purple"
| "violet"
| "pink"
| "indigo"
| "cyan"
| "teal"
// Semantic variants
| "success"
| "warning"
| "error"
| "info";
export type BadgeSize = "sm" | "default" | "lg";
export interface DashboardBadgeProps {
/** Color variant */
variant?: BadgeVariant;
/** Size variant */
size?: BadgeSize;
/** Additional className */
className?: string;
/** Badge content */
children: React.ReactNode;
/** Make the badge pill-shaped (more rounded) */
pill?: boolean;
}
// =============================================================================
// COLOR VARIANTS
// =============================================================================
/**
* Color classes for each variant
* Format: background (light mode) / background (dark mode) / text (light) / text (dark)
*/
const VARIANT_CLASSES: Record<BadgeVariant, string> = {
// Neutral
default: "bg-muted text-muted-foreground",
// Colors
blue: "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300",
green: "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300",
emerald: "bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300",
red: "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300",
yellow: "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300",
amber: "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300",
orange: "bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300",
purple: "bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300",
violet: "bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300",
pink: "bg-pink-100 dark:bg-pink-900/30 text-pink-700 dark:text-pink-300",
indigo: "bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300",
cyan: "bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-300",
teal: "bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300",
// Semantic (maps to colors)
success: "bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300",
warning: "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300",
error: "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300",
info: "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300",
};
const SIZE_CLASSES: Record<BadgeSize, string> = {
sm: "px-1.5 py-0.5 text-[10px]",
default: "px-2.5 py-0.5 text-xs",
lg: "px-3 py-1 text-sm",
};
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export const DashboardBadge: React.FC<DashboardBadgeProps> = ({
variant = "default",
size = "default",
className,
children,
pill = true,
}) => {
const roundedClass = pill ? "rounded-full" : "rounded-md";
return (
<span
className={cn(
"inline-flex items-center font-medium",
roundedClass,
VARIANT_CLASSES[variant],
SIZE_CLASSES[size],
className
)}
>
{children}
</span>
);
};
// =============================================================================
// STATUS BADGE (for common status patterns)
// =============================================================================
export type StatusType = "active" | "inactive" | "pending" | "completed" | "failed" | "cancelled";
const STATUS_CONFIG: Record<StatusType, { variant: BadgeVariant; label: string }> = {
active: { variant: "success", label: "Active" },
inactive: { variant: "default", label: "Inactive" },
pending: { variant: "warning", label: "Pending" },
completed: { variant: "success", label: "Completed" },
failed: { variant: "error", label: "Failed" },
cancelled: { variant: "default", label: "Cancelled" },
};
export interface StatusBadgeProps {
status: StatusType;
/** Override the default label */
label?: string;
/** Size variant */
size?: BadgeSize;
/** Additional className */
className?: string;
}
export const StatusBadge: React.FC<StatusBadgeProps> = ({
status,
label,
size = "default",
className,
}) => {
const config = STATUS_CONFIG[status];
return (
<DashboardBadge variant={config.variant} size={size} className={className}>
{label || config.label}
</DashboardBadge>
);
};
// =============================================================================
// METRIC BADGE (for showing numbers with context)
// =============================================================================
export interface MetricBadgeProps {
/** The metric value */
value: number;
/** Suffix to show after value (e.g., "%", "K") */
suffix?: string;
/** Prefix to show before value (e.g., "+", "$") */
prefix?: string;
/** Color variant */
variant?: BadgeVariant;
/** Size variant */
size?: BadgeSize;
/** Additional className */
className?: string;
}
export const MetricBadge: React.FC<MetricBadgeProps> = ({
value,
suffix = "",
prefix = "",
variant = "default",
size = "default",
className,
}) => {
return (
<DashboardBadge variant={variant} size={size} className={cn("tabular-nums", className)}>
{prefix}{value.toLocaleString()}{suffix}
</DashboardBadge>
);
};
// =============================================================================
// TREND BADGE (for showing positive/negative changes)
// =============================================================================
export interface TrendBadgeProps {
/** The trend value (positive or negative) */
value: number;
/** Whether higher is better (affects color) */
moreIsBetter?: boolean;
/** Show as percentage */
asPercentage?: boolean;
/** Size variant */
size?: BadgeSize;
/** Additional className */
className?: string;
}
export const TrendBadge: React.FC<TrendBadgeProps> = ({
value,
moreIsBetter = true,
asPercentage = true,
size = "default",
className,
}) => {
const isPositive = value > 0;
const isGood = isPositive === moreIsBetter;
const variant: BadgeVariant = value === 0 ? "default" : isGood ? "success" : "error";
const displayValue = asPercentage
? `${value > 0 ? "+" : ""}${value.toFixed(1)}%`
: `${value > 0 ? "+" : ""}${value.toLocaleString()}`;
return (
<DashboardBadge variant={variant} size={size} className={cn("tabular-nums", className)}>
{displayValue}
</DashboardBadge>
);
};
export default DashboardBadge;

View File

@@ -0,0 +1,274 @@
/**
* DashboardChartTooltip
*
* Standardized tooltip components for Recharts across the dashboard.
* Provides consistent styling and formatting for all chart tooltips.
*
* @example
* // Basic usage with Recharts
* <RechartsTooltip content={<DashboardChartTooltip />} />
*
* @example
* // Currency tooltip
* <RechartsTooltip content={<CurrencyTooltip />} />
*
* @example
* // Custom formatter
* <RechartsTooltip
* content={
* <DashboardChartTooltip
* valueFormatter={(value) => `${value} units`}
* labelFormatter={(label) => `Week of ${label}`}
* />
* }
* />
*/
import React from "react";
import { cn } from "@/lib/utils";
import { TOOLTIP_STYLES } from "@/lib/dashboard/chartConfig";
// =============================================================================
// TYPES
// =============================================================================
export interface TooltipPayloadItem {
name: string;
value: number | string;
color?: string;
dataKey?: string;
payload?: Record<string, unknown>;
fill?: string;
stroke?: string;
}
export interface DashboardChartTooltipProps {
/** Whether the tooltip is currently active (provided by Recharts) */
active?: boolean;
/** The payload data (provided by Recharts) */
payload?: TooltipPayloadItem[];
/** The label for the tooltip header (provided by Recharts) */
label?: string;
/** Custom formatter for the value */
valueFormatter?: (value: number | string, name: string) => string;
/** Custom formatter for the label/header */
labelFormatter?: (label: string) => string;
/** Custom formatter for item names */
nameFormatter?: (name: string) => string;
/** Hide the header/label */
hideLabel?: boolean;
/** Additional className for the container */
className?: string;
/** Show a divider between items */
showDivider?: boolean;
/** Custom render function for each item */
itemRenderer?: (item: TooltipPayloadItem, index: number) => React.ReactNode;
}
// =============================================================================
// FORMATTERS
// =============================================================================
/**
* Format a number as currency
*/
export const formatCurrency = (value: number | string): string => {
const num = typeof value === "string" ? parseFloat(value) : value;
if (isNaN(num)) return "$0";
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(num);
};
/**
* Format a number as percentage
*/
export const formatPercentage = (value: number | string): string => {
const num = typeof value === "string" ? parseFloat(value) : value;
if (isNaN(num)) return "0%";
return `${num.toFixed(1)}%`;
};
/**
* Format a number with commas
*/
export const formatNumber = (value: number | string): string => {
const num = typeof value === "string" ? parseFloat(value) : value;
if (isNaN(num)) return "0";
return num.toLocaleString();
};
/**
* Format a duration in seconds to human-readable format
*/
export const formatDuration = (seconds: number | string): string => {
const num = typeof seconds === "string" ? parseFloat(seconds) : seconds;
if (isNaN(num)) return "0s";
const hours = Math.floor(num / 3600);
const minutes = Math.floor((num % 3600) / 60);
const secs = Math.floor(num % 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
if (minutes > 0) {
return `${minutes}m ${secs}s`;
}
return `${secs}s`;
};
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export const DashboardChartTooltip: React.FC<DashboardChartTooltipProps> = ({
active,
payload,
label,
valueFormatter,
labelFormatter,
nameFormatter,
hideLabel = false,
className,
showDivider = false,
itemRenderer,
}) => {
if (!active || !payload?.length) {
return null;
}
const formattedLabel = labelFormatter ? labelFormatter(String(label)) : label;
return (
<div className={cn(TOOLTIP_STYLES.container, className)}>
{!hideLabel && formattedLabel && (
<p className={TOOLTIP_STYLES.header}>{formattedLabel}</p>
)}
<div className={TOOLTIP_STYLES.content}>
{payload.map((item, index) => {
// Allow custom item rendering
if (itemRenderer) {
return (
<React.Fragment key={index}>
{itemRenderer(item, index)}
</React.Fragment>
);
}
const itemColor = item.color || item.fill || item.stroke || "#888";
const itemName = nameFormatter ? nameFormatter(item.name) : item.name;
const itemValue = valueFormatter
? valueFormatter(item.value, item.name)
: String(item.value);
return (
<React.Fragment key={index}>
{showDivider && index > 0 && (
<div className={TOOLTIP_STYLES.divider} />
)}
<div className={TOOLTIP_STYLES.row}>
<div className={TOOLTIP_STYLES.rowLabel}>
<span
className={TOOLTIP_STYLES.dot}
style={{ backgroundColor: itemColor }}
/>
<span className={TOOLTIP_STYLES.name}>{itemName}</span>
</div>
<span className={TOOLTIP_STYLES.value}>{itemValue}</span>
</div>
</React.Fragment>
);
})}
</div>
</div>
);
};
// =============================================================================
// SPECIALIZED TOOLTIPS
// =============================================================================
/**
* Currency-formatted tooltip
*/
export const CurrencyTooltip: React.FC<Omit<DashboardChartTooltipProps, "valueFormatter">> = (
props
) => {
return <DashboardChartTooltip {...props} valueFormatter={formatCurrency} />;
};
/**
* Percentage-formatted tooltip
*/
export const PercentageTooltip: React.FC<Omit<DashboardChartTooltipProps, "valueFormatter">> = (
props
) => {
return <DashboardChartTooltip {...props} valueFormatter={formatPercentage} />;
};
/**
* Number-formatted tooltip (with commas)
*/
export const NumberTooltip: React.FC<Omit<DashboardChartTooltipProps, "valueFormatter">> = (
props
) => {
return <DashboardChartTooltip {...props} valueFormatter={formatNumber} />;
};
/**
* Duration-formatted tooltip (for call times, etc.)
*/
export const DurationTooltip: React.FC<Omit<DashboardChartTooltipProps, "valueFormatter">> = (
props
) => {
return <DashboardChartTooltip {...props} valueFormatter={formatDuration} />;
};
// =============================================================================
// SIMPLE TOOLTIP (for single-value charts)
// =============================================================================
export interface SimpleTooltipProps {
active?: boolean;
payload?: TooltipPayloadItem[];
label?: string;
valueFormatter?: (value: number | string) => string;
labelFormatter?: (label: string) => string;
className?: string;
}
/**
* Simplified tooltip for single-series charts
*/
export const SimpleTooltip: React.FC<SimpleTooltipProps> = ({
active,
payload,
label,
valueFormatter = formatNumber,
labelFormatter,
className,
}) => {
if (!active || !payload?.length) {
return null;
}
const item = payload[0];
const formattedLabel = labelFormatter ? labelFormatter(String(label)) : label;
const formattedValue = valueFormatter(item.value);
return (
<div className={cn(TOOLTIP_STYLES.container, className)}>
{formattedLabel && (
<p className={TOOLTIP_STYLES.header}>{formattedLabel}</p>
)}
<p className={TOOLTIP_STYLES.value}>{formattedValue}</p>
</div>
);
};
export default DashboardChartTooltip;

View File

@@ -0,0 +1,201 @@
/**
* DashboardSectionHeader
*
* A reusable header component for dashboard sections/cards.
* Provides consistent layout for title, description, time selectors, and action buttons.
*
* @example
* // Basic usage
* <DashboardSectionHeader title="Sales Overview" />
*
* @example
* // With time selector and last updated
* <DashboardSectionHeader
* title="Revenue Analytics"
* description="Track your daily revenue performance"
* lastUpdated={new Date()}
* timeSelector={
* <Select value={timeRange} onValueChange={setTimeRange}>
* <SelectTrigger className="w-[130px] h-9">
* <SelectValue />
* </SelectTrigger>
* <SelectContent>
* {TIME_RANGES.map(range => (
* <SelectItem key={range.value} value={range.value}>
* {range.label}
* </SelectItem>
* ))}
* </SelectContent>
* </Select>
* }
* />
*
* @example
* // With actions
* <DashboardSectionHeader
* title="Orders"
* actions={
* <>
* <Button variant="outline" size="sm">Export</Button>
* <Button variant="outline" size="sm" onClick={handleRefresh}>
* <RefreshCcw className="h-4 w-4" />
* </Button>
* </>
* }
* />
*/
import React from "react";
import { CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { TYPOGRAPHY } from "@/lib/dashboard/designTokens";
// =============================================================================
// TYPES
// =============================================================================
export interface DashboardSectionHeaderProps {
/** Section title */
title: string;
/** Optional description shown below title */
description?: string;
/** Last updated timestamp - shown as "Last updated: HH:MM AM/PM" */
lastUpdated?: Date | null;
/** Custom format function for lastUpdated */
lastUpdatedFormat?: (date: Date) => string;
/** Loading state - shows skeletons */
loading?: boolean;
/** Time/period selector component (flexible - accepts any selector UI) */
timeSelector?: React.ReactNode;
/** Action buttons or other controls */
actions?: React.ReactNode;
/** Additional className for the header */
className?: string;
/** Use compact padding */
compact?: boolean;
/** Size variant for title */
size?: "default" | "large";
}
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
const defaultLastUpdatedFormat = (date: Date): string => {
return date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
};
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export const DashboardSectionHeader: React.FC<DashboardSectionHeaderProps> = ({
title,
description,
lastUpdated,
lastUpdatedFormat = defaultLastUpdatedFormat,
loading = false,
timeSelector,
actions,
className,
compact = false,
size = "default",
}) => {
const paddingClass = compact ? "p-4 pb-2" : "p-6 pb-4";
const titleClass = size === "large"
? "text-xl font-semibold text-foreground"
: "text-lg font-semibold text-foreground";
// Loading skeleton
if (loading) {
return (
<CardHeader className={cn(paddingClass, className)}>
<div className="flex justify-between items-start">
<div className="space-y-1">
<Skeleton className="h-6 w-40 bg-muted" />
{description && <Skeleton className="h-4 w-56 bg-muted" />}
</div>
<div className="flex items-center gap-2">
{timeSelector && <Skeleton className="h-9 w-[130px] bg-muted rounded-md" />}
{actions && <Skeleton className="h-9 w-20 bg-muted rounded-md" />}
</div>
</div>
</CardHeader>
);
}
const hasRightContent = timeSelector || actions || (lastUpdated && !loading);
return (
<CardHeader className={cn(paddingClass, className)}>
<div className="flex justify-between items-start gap-4">
{/* Left side: Title and description */}
<div className="min-w-0 flex-1">
<CardTitle className={titleClass}>{title}</CardTitle>
{description && (
<CardDescription className={cn(TYPOGRAPHY.cardDescription, "mt-1")}>
{description}
</CardDescription>
)}
{lastUpdated && !loading && (
<p className="text-xs text-muted-foreground mt-1">
Last updated: {lastUpdatedFormat(lastUpdated)}
</p>
)}
</div>
{/* Right side: Time selector and actions */}
{hasRightContent && (
<div className="flex items-center gap-2 flex-shrink-0">
{timeSelector}
{actions}
</div>
)}
</div>
</CardHeader>
);
};
// =============================================================================
// SKELETON VARIANT
// =============================================================================
export interface DashboardSectionHeaderSkeletonProps {
hasDescription?: boolean;
hasTimeSelector?: boolean;
hasActions?: boolean;
compact?: boolean;
className?: string;
}
export const DashboardSectionHeaderSkeleton: React.FC<DashboardSectionHeaderSkeletonProps> = ({
hasDescription = false,
hasTimeSelector = true,
hasActions = false,
compact = false,
className,
}) => {
const paddingClass = compact ? "p-4 pb-2" : "p-6 pb-4";
return (
<CardHeader className={cn(paddingClass, className)}>
<div className="flex justify-between items-start">
<div className="space-y-2">
<Skeleton className="h-6 w-40 bg-muted" />
{hasDescription && <Skeleton className="h-4 w-56 bg-muted" />}
</div>
<div className="flex items-center gap-2">
{hasTimeSelector && <Skeleton className="h-9 w-[130px] bg-muted rounded-md" />}
{hasActions && <Skeleton className="h-9 w-20 bg-muted rounded-md" />}
</div>
</div>
</CardHeader>
);
};
export default DashboardSectionHeader;

View File

@@ -0,0 +1,491 @@
/**
* DashboardSkeleton
*
* Standardized skeleton loading components for the dashboard.
* Provides consistent loading states across all dashboard sections.
*
* @example
* // Chart skeleton
* {isLoading ? <ChartSkeleton /> : <MyChart />}
*
* @example
* // Table skeleton
* {isLoading ? <TableSkeleton rows={5} columns={4} /> : <MyTable />}
*
* @example
* // Stat card skeleton
* {isLoading ? <StatCardSkeleton /> : <DashboardStatCard {...props} />}
*/
import React from "react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import { CARD_STYLES, SCROLL_STYLES } from "@/lib/dashboard/designTokens";
// =============================================================================
// TYPES
// =============================================================================
export type ChartType = "line" | "bar" | "area" | "pie";
export type ChartHeight = "sm" | "md" | "default" | "lg";
export type ColorVariant = "default" | "emerald" | "blue" | "violet" | "orange" | "slate";
const CHART_HEIGHT_MAP: Record<ChartHeight, string> = {
sm: "h-[200px]",
md: "h-[300px]",
default: "h-[400px]",
lg: "h-[500px]",
};
/**
* Color variant classes for themed skeletons
* Used in colored cards like MiniStatCards dialogs
*/
const COLOR_VARIANT_CLASSES: Record<ColorVariant, { bg: string; skeleton: string }> = {
default: {
bg: "bg-muted",
skeleton: "bg-muted",
},
emerald: {
bg: "bg-emerald-200/20",
skeleton: "bg-emerald-200/30",
},
blue: {
bg: "bg-blue-200/20",
skeleton: "bg-blue-200/30",
},
violet: {
bg: "bg-violet-200/20",
skeleton: "bg-violet-200/30",
},
orange: {
bg: "bg-orange-200/20",
skeleton: "bg-orange-200/30",
},
slate: {
bg: "bg-slate-600",
skeleton: "bg-slate-500",
},
};
// =============================================================================
// CHART SKELETON
// =============================================================================
export interface ChartSkeletonProps {
/** Type of chart to simulate */
type?: ChartType;
/** Height variant */
height?: ChartHeight;
/** Show card wrapper */
withCard?: boolean;
/** Show header placeholder */
withHeader?: boolean;
/** Color variant for themed cards */
colorVariant?: ColorVariant;
/** Additional className */
className?: string;
}
export const ChartSkeleton: React.FC<ChartSkeletonProps> = ({
type = "line",
height = "default",
withCard = true,
withHeader = false,
colorVariant = "default",
className,
}) => {
const heightClass = CHART_HEIGHT_MAP[height];
const colors = COLOR_VARIANT_CLASSES[colorVariant];
const renderChartContent = () => {
switch (type) {
case "bar":
return (
<div className="h-full flex items-end justify-between gap-1 px-8 pb-6">
{[...Array(12)].map((_, i) => (
<div
key={i}
className={cn("flex-1 rounded-t animate-pulse", colors.bg)}
style={{ height: `${15 + Math.random() * 70}%` }}
/>
))}
</div>
);
case "pie":
return (
<div className="h-full flex items-center justify-center">
<div className={cn("w-40 h-40 rounded-full animate-pulse", colors.bg)} />
</div>
);
case "area":
case "line":
default:
return (
<div className="h-full w-full relative">
{/* Grid lines */}
{[...Array(5)].map((_, i) => (
<div
key={i}
className={cn("absolute w-full h-px", colors.bg)}
style={{ top: `${(i + 1) * 20}%` }}
/>
))}
{/* Y-axis labels */}
<div className="absolute left-0 top-0 bottom-0 w-10 flex flex-col justify-between py-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className={cn("h-3 w-8", colors.skeleton)} />
))}
</div>
{/* X-axis labels */}
<div className="absolute left-12 right-4 bottom-0 flex justify-between">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className={cn("h-3 w-10", colors.skeleton)} />
))}
</div>
{/* Chart area */}
<div
className={cn("absolute inset-x-12 bottom-6 top-4 animate-pulse rounded", colors.bg, "opacity-30")}
style={{
clipPath:
type === "area"
? "polygon(0 60%, 20% 40%, 40% 55%, 60% 30%, 80% 45%, 100% 25%, 100% 100%, 0 100%)"
: undefined,
}}
/>
{/* Line for line charts */}
{type === "line" && (
<svg
className="absolute inset-x-12 bottom-6 top-4"
preserveAspectRatio="none"
viewBox="0 0 100 100"
>
<path
d="M0 60 L20 40 L40 55 L60 30 L80 45 L100 25"
fill="none"
stroke="currentColor"
strokeWidth="2"
className={cn("animate-pulse", colorVariant === "default" ? "text-muted" : colors.skeleton.replace("bg-", "text-").replace("/30", ""))}
/>
</svg>
)}
</div>
);
}
};
const content = (
<div className={cn(heightClass, "w-full p-4", className)}>
{withHeader && (
<div className="flex justify-between items-center mb-4">
<Skeleton className="h-5 w-32 bg-muted" />
<Skeleton className="h-8 w-24 bg-muted" />
</div>
)}
<div className={cn("h-full", withHeader && "h-[calc(100%-48px)]")}>
{renderChartContent()}
</div>
</div>
);
if (withCard) {
return <Card className={CARD_STYLES.base}>{content}</Card>;
}
return content;
};
// =============================================================================
// TABLE SKELETON
// =============================================================================
export interface TableSkeletonProps {
/** Number of rows to show */
rows?: number;
/** Number of columns or array of column widths */
columns?: number | string[];
/** Show card wrapper */
withCard?: boolean;
/** Show header placeholder */
withHeader?: boolean;
/** Color variant for themed cards */
colorVariant?: ColorVariant;
/** Additional className */
className?: string;
/** Make container scrollable */
scrollable?: boolean;
/** Max height for scrollable (uses SCROLL_STYLES keys) */
maxHeight?: "sm" | "md" | "lg" | "xl";
}
export const TableSkeleton: React.FC<TableSkeletonProps> = ({
rows = 5,
columns = 4,
withCard = false,
withHeader = false,
colorVariant = "default",
className,
scrollable = false,
maxHeight = "md",
}) => {
const columnCount = Array.isArray(columns) ? columns.length : columns;
const columnWidths = Array.isArray(columns)
? columns
: Array(columnCount).fill("w-24");
const colors = COLOR_VARIANT_CLASSES[colorVariant];
const tableContent = (
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
{Array.from({ length: columnCount }).map((_, i) => (
<TableHead key={i}>
<Skeleton className={cn("h-4", colors.skeleton, columnWidths[i])} />
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: rows }).map((_, rowIndex) => (
<TableRow key={rowIndex} className="hover:bg-muted/50 transition-colors">
{Array.from({ length: columnCount }).map((_, colIndex) => (
<TableCell key={colIndex}>
<Skeleton
className={cn(
"h-4",
colors.skeleton,
colIndex === 0 ? "w-32" : columnWidths[colIndex]
)}
/>
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
);
const content = (
<div className={className}>
{withHeader && (
<div className="flex justify-between items-center mb-4 px-4 pt-4">
<Skeleton className="h-5 w-32 bg-muted" />
<Skeleton className="h-8 w-24 bg-muted" />
</div>
)}
{scrollable ? (
<div className={SCROLL_STYLES[maxHeight]}>{tableContent}</div>
) : (
tableContent
)}
</div>
);
if (withCard) {
return <Card className={CARD_STYLES.base}>{content}</Card>;
}
return content;
};
// =============================================================================
// STAT CARD SKELETON (re-exported from DashboardStatCard)
// =============================================================================
export interface StatCardSkeletonProps {
/** Card size variant */
size?: "default" | "compact" | "large";
/** Show icon placeholder */
hasIcon?: boolean;
/** Show subtitle placeholder */
hasSubtitle?: boolean;
/** Additional className */
className?: string;
}
export const StatCardSkeleton: React.FC<StatCardSkeletonProps> = ({
size = "default",
hasIcon = true,
hasSubtitle = true,
className,
}) => {
const sizeStyles = {
default: {
header: CARD_STYLES.header,
content: CARD_STYLES.content,
valueHeight: "h-8",
valueWidth: "w-32",
},
compact: {
header: CARD_STYLES.headerCompact,
content: "px-4 pt-0 pb-3",
valueHeight: "h-6",
valueWidth: "w-24",
},
large: {
header: CARD_STYLES.header,
content: CARD_STYLES.contentPadded,
valueHeight: "h-10",
valueWidth: "w-36",
},
};
const styles = sizeStyles[size];
return (
<Card className={cn(CARD_STYLES.base, className)}>
<CardHeader className={cn(styles.header, "space-y-0")}>
<Skeleton className="h-4 w-24 bg-muted" />
{hasIcon && <Skeleton className="h-8 w-8 bg-muted rounded-lg" />}
</CardHeader>
<CardContent className={styles.content}>
<Skeleton className={cn(styles.valueHeight, styles.valueWidth, "bg-muted mb-2")} />
{hasSubtitle && <Skeleton className="h-4 w-20 bg-muted" />}
</CardContent>
</Card>
);
};
// =============================================================================
// GRID SKELETON (for multiple stat cards)
// =============================================================================
export interface GridSkeletonProps {
/** Number of cards to show */
count?: number;
/** Grid columns */
columns?: 2 | 3 | 4 | 6;
/** Card size */
cardSize?: "default" | "compact" | "large";
/** Additional className */
className?: string;
}
export const GridSkeleton: React.FC<GridSkeletonProps> = ({
count = 4,
columns = 4,
cardSize = "default",
className,
}) => {
const gridClass = {
2: "grid-cols-1 sm:grid-cols-2",
3: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
4: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4",
6: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-6",
};
return (
<div className={cn("grid gap-4", gridClass[columns], className)}>
{Array.from({ length: count }).map((_, i) => (
<StatCardSkeleton key={i} size={cardSize} />
))}
</div>
);
};
// =============================================================================
// LIST SKELETON (for feeds, events, etc.)
// =============================================================================
export interface ListSkeletonProps {
/** Number of items to show */
items?: number;
/** Show avatar/icon placeholder */
hasAvatar?: boolean;
/** Show timestamp placeholder */
hasTimestamp?: boolean;
/** Show card wrapper */
withCard?: boolean;
/** Additional className */
className?: string;
}
export const ListSkeleton: React.FC<ListSkeletonProps> = ({
items = 5,
hasAvatar = true,
hasTimestamp = true,
withCard = false,
className,
}) => {
const content = (
<div className={cn("divide-y divide-border/50", className)}>
{Array.from({ length: items }).map((_, i) => (
<div key={i} className="flex items-center gap-3 p-4">
{hasAvatar && (
<Skeleton className="h-10 w-10 rounded-full bg-muted shrink-0" />
)}
<div className="flex-1 min-w-0 space-y-2">
<Skeleton className="h-4 w-3/4 bg-muted" />
<Skeleton className="h-3 w-1/2 bg-muted" />
</div>
{hasTimestamp && (
<Skeleton className="h-3 w-16 bg-muted shrink-0" />
)}
</div>
))}
</div>
);
if (withCard) {
return <Card className={CARD_STYLES.base}>{content}</Card>;
}
return content;
};
// =============================================================================
// SECTION SKELETON (card with header and content area)
// =============================================================================
export interface SectionSkeletonProps {
/** Height of the content area */
contentHeight?: string;
/** Show header with title and action button */
withHeader?: boolean;
/** Additional className */
className?: string;
}
export const SectionSkeleton: React.FC<SectionSkeletonProps> = ({
contentHeight = "h-[400px]",
withHeader = true,
className,
}) => {
return (
<Card className={cn(CARD_STYLES.base, className)}>
{withHeader && (
<CardHeader className={CARD_STYLES.header}>
<Skeleton className="h-5 w-32 bg-muted" />
<Skeleton className="h-9 w-28 bg-muted rounded-md" />
</CardHeader>
)}
<CardContent className={CARD_STYLES.content}>
<Skeleton className={cn("w-full bg-muted rounded-lg", contentHeight)} />
</CardContent>
</Card>
);
};
// =============================================================================
// EXPORTS
// =============================================================================
export default {
Chart: ChartSkeleton,
Table: TableSkeleton,
StatCard: StatCardSkeleton,
Grid: GridSkeleton,
List: ListSkeleton,
Section: SectionSkeleton,
};

View File

@@ -0,0 +1,344 @@
/**
* DashboardStatCard
*
* A reusable stat/metric card component for the dashboard.
* Supports icons, trend indicators, tooltips, and multiple size variants.
*
* @example
* // Basic usage
* <DashboardStatCard
* title="Total Revenue"
* value="$12,345"
* subtitle="Last 30 days"
* />
*
* @example
* // With icon and trend
* <DashboardStatCard
* title="Orders"
* value={1234}
* trend={{ value: 12.5, label: "vs last month" }}
* icon={ShoppingCart}
* iconColor="blue"
* />
*
* @example
* // With prefix/suffix and tooltip
* <DashboardStatCard
* title="Average Order Value"
* value={85.50}
* valuePrefix="$"
* valueSuffix="/order"
* tooltip="Calculated as total revenue divided by number of orders"
* />
*/
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { TrendingUp, TrendingDown, Minus, Info, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
CARD_STYLES,
TYPOGRAPHY,
STAT_ICON_STYLES,
getTrendColor,
} from "@/lib/dashboard/designTokens";
// =============================================================================
// TYPES
// =============================================================================
export type TrendDirection = "up" | "down" | "neutral";
export type CardSize = "default" | "compact" | "large";
export type IconColor = keyof typeof STAT_ICON_STYLES.colors;
export interface TrendProps {
/** The percentage or absolute change value */
value: number;
/** Optional label to show after the trend (e.g., "vs last month") */
label?: string;
/** Whether a higher value is better (affects color). Defaults to true. */
moreIsBetter?: boolean;
/** Suffix for the trend value (defaults to "%"). Use "" for no suffix. */
suffix?: string;
}
export interface DashboardStatCardProps {
/** Card title/label */
title: string;
/** The main value to display (can be string for formatted values or number) */
value: string | number;
/** Optional prefix for the value (e.g., "$") */
valuePrefix?: string;
/** Optional suffix for the value (e.g., "/day", "%") */
valueSuffix?: string;
/** Optional subtitle or description (can be string or JSX) */
subtitle?: React.ReactNode;
/** Optional trend indicator */
trend?: TrendProps;
/** Optional icon component */
icon?: LucideIcon;
/** Icon color variant */
iconColor?: IconColor;
/** Card size variant */
size?: CardSize;
/** Additional className for the card */
className?: string;
/** Click handler for interactive cards */
onClick?: () => void;
/** Loading state */
loading?: boolean;
/** Tooltip text shown via info icon next to title */
tooltip?: string;
/** Additional content to render below the main value */
children?: React.ReactNode;
}
// =============================================================================
// HELPER COMPONENTS
// =============================================================================
interface TrendIndicatorProps {
value: number;
label?: string;
moreIsBetter?: boolean;
suffix?: string;
size?: CardSize;
}
const TrendIndicator: React.FC<TrendIndicatorProps> = ({
value,
label,
moreIsBetter = true,
suffix = "%",
size = "default",
}) => {
const colors = getTrendColor(value, moreIsBetter);
const direction: TrendDirection =
value > 0 ? "up" : value < 0 ? "down" : "neutral";
const IconComponent =
direction === "up"
? TrendingUp
: direction === "down"
? TrendingDown
: Minus;
const iconSize = size === "compact" ? "h-3 w-3" : "h-4 w-4";
const textSize = size === "compact" ? "text-xs" : "text-sm";
// Format the value - use fixed decimal for percentages, integer for absolute values
const formattedValue = suffix === "%"
? value.toFixed(1)
: Math.abs(value).toString();
return (
<div className={cn("flex items-center gap-1", colors.text)}>
<IconComponent className={iconSize} />
<span className={cn("font-medium", textSize)}>
{value > 0 && suffix === "%" ? "+" : ""}
{formattedValue}{suffix}
</span>
{label && (
<span className={cn("text-muted-foreground", textSize)}>{label}</span>
)}
</div>
);
};
interface IconContainerProps {
icon: LucideIcon;
color?: IconColor;
size?: CardSize;
}
const IconContainer: React.FC<IconContainerProps> = ({
icon: Icon,
color = "blue",
size = "default",
}) => {
const colorStyles = STAT_ICON_STYLES.colors[color] || STAT_ICON_STYLES.colors.blue;
const containerSize = size === "compact" ? "p-1.5" : "p-2";
const iconSize = size === "compact" ? "h-3.5 w-3.5" : size === "large" ? "h-5 w-5" : "h-4 w-4";
return (
<div
className={cn(
STAT_ICON_STYLES.container,
containerSize,
colorStyles.container
)}
>
<Icon className={cn(iconSize, colorStyles.icon)} />
</div>
);
};
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export const DashboardStatCard: React.FC<DashboardStatCardProps> = ({
title,
value,
valuePrefix,
valueSuffix,
subtitle,
trend,
icon,
iconColor = "blue",
size = "default",
className,
onClick,
loading = false,
tooltip,
children,
}) => {
// Size-based styling
const sizeStyles = {
default: {
header: CARD_STYLES.header,
value: TYPOGRAPHY.cardValue,
title: TYPOGRAPHY.cardTitle,
content: CARD_STYLES.content,
},
compact: {
header: CARD_STYLES.headerCompact,
value: TYPOGRAPHY.cardValueSmall,
title: "text-xs font-medium text-muted-foreground",
content: "px-4 pt-0 pb-3",
},
large: {
header: CARD_STYLES.header,
value: TYPOGRAPHY.cardValueLarge,
title: TYPOGRAPHY.cardTitle,
content: CARD_STYLES.contentPadded,
},
};
const styles = sizeStyles[size];
const cardClass = onClick ? CARD_STYLES.interactive : CARD_STYLES.base;
// Loading state
if (loading) {
return (
<Card className={cn(cardClass, className)}>
<CardHeader className={cn(styles.header, "space-y-0")}>
<div className="h-4 w-24 bg-muted animate-pulse rounded" />
{icon && <div className="h-8 w-8 bg-muted animate-pulse rounded-lg" />}
</CardHeader>
<CardContent className={styles.content}>
<div className="h-8 w-32 bg-muted animate-pulse rounded mb-2" />
{subtitle && <div className="h-4 w-20 bg-muted animate-pulse rounded" />}
</CardContent>
</Card>
);
}
// Format the display value with prefix/suffix
const formattedValue = (
<>
{valuePrefix && <span className="text-muted-foreground">{valuePrefix}</span>}
{typeof value === "number" ? value.toLocaleString() : value}
{valueSuffix && <span className="text-muted-foreground text-lg">{valueSuffix}</span>}
</>
);
return (
<Card
className={cn(cardClass, onClick && "cursor-pointer", className)}
onClick={onClick}
>
<CardHeader className={cn(styles.header, "space-y-0")}>
<div className="flex items-center gap-1.5">
<CardTitle className={styles.title}>{title}</CardTitle>
{tooltip && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground transition-colors"
onClick={(e) => e.stopPropagation()}
>
<Info className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-sm">{tooltip}</p>
</TooltipContent>
</Tooltip>
)}
</div>
{icon && <IconContainer icon={icon} color={iconColor} size={size} />}
</CardHeader>
<CardContent className={styles.content}>
<div className={cn(styles.value, "text-foreground")}>
{formattedValue}
</div>
{(subtitle || trend) && (
<div className="flex flex-wrap items-center justify-between gap-2 mt-1">
{subtitle && (
<span className={TYPOGRAPHY.cardDescription}>{subtitle}</span>
)}
{trend && (
<TrendIndicator
value={trend.value}
label={trend.label}
moreIsBetter={trend.moreIsBetter}
suffix={trend.suffix}
size={size}
/>
)}
</div>
)}
{children}
</CardContent>
</Card>
);
};
// =============================================================================
// SKELETON VARIANT
// =============================================================================
export interface DashboardStatCardSkeletonProps {
size?: CardSize;
hasIcon?: boolean;
hasSubtitle?: boolean;
className?: string;
}
export const DashboardStatCardSkeleton: React.FC<DashboardStatCardSkeletonProps> = ({
size = "default",
hasIcon = true,
hasSubtitle = true,
className,
}) => {
const sizeStyles = {
default: { header: CARD_STYLES.header, content: CARD_STYLES.content },
compact: { header: CARD_STYLES.headerCompact, content: "px-4 pt-0 pb-3" },
large: { header: CARD_STYLES.header, content: CARD_STYLES.contentPadded },
};
const styles = sizeStyles[size];
return (
<Card className={cn(CARD_STYLES.base, className)}>
<CardHeader className={cn(styles.header, "space-y-0")}>
<div className="h-4 w-24 bg-muted animate-pulse rounded" />
{hasIcon && <div className="h-8 w-8 bg-muted animate-pulse rounded-lg" />}
</CardHeader>
<CardContent className={styles.content}>
<div className="h-8 w-32 bg-muted animate-pulse rounded mb-2" />
{hasSubtitle && <div className="h-4 w-20 bg-muted animate-pulse rounded" />}
</CardContent>
</Card>
);
};
export default DashboardStatCard;

View File

@@ -0,0 +1,201 @@
/**
* DashboardStatCardMini
*
* A compact, visually prominent stat card with gradient backgrounds.
* Designed for hero metrics or dashboard headers where cards need to stand out.
*
* @example
* <DashboardStatCardMini
* title="Total Revenue"
* value="$12,345"
* gradient="emerald"
* icon={DollarSign}
* />
*/
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { TrendingUp, TrendingDown, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
// =============================================================================
// TYPES
// =============================================================================
export type GradientVariant =
| "slate"
| "emerald"
| "blue"
| "purple"
| "violet"
| "amber"
| "orange"
| "rose"
| "cyan"
| "sky"
| "custom";
export interface DashboardStatCardMiniProps {
/** Card title/label */
title: string;
/** The main value to display */
value: string | number;
/** Optional prefix for the value (e.g., "$") */
valuePrefix?: string;
/** Optional suffix for the value (e.g., "%") */
valueSuffix?: string;
/** Optional description text or element */
description?: React.ReactNode;
/** Trend direction and value */
trend?: {
direction: "up" | "down";
value: string;
};
/** Optional icon component */
icon?: LucideIcon;
/** Icon background color class (e.g., "bg-emerald-500/20") */
iconBackground?: string;
/** Gradient preset or "custom" to use className */
gradient?: GradientVariant;
/** Additional className (use with gradient="custom" for custom backgrounds) */
className?: string;
/** Click handler */
onClick?: () => void;
}
// =============================================================================
// GRADIENT PRESETS
// =============================================================================
const GRADIENT_PRESETS: Record<GradientVariant, string> = {
slate: "bg-gradient-to-br from-slate-700 to-slate-600",
emerald: "bg-gradient-to-br from-emerald-900 to-emerald-800",
blue: "bg-gradient-to-br from-blue-900 to-blue-800",
purple: "bg-gradient-to-br from-purple-800 to-purple-900",
violet: "bg-gradient-to-br from-violet-900 to-violet-800",
amber: "bg-gradient-to-br from-amber-700 to-amber-900",
orange: "bg-gradient-to-br from-orange-900 to-orange-800",
rose: "bg-gradient-to-br from-rose-800 to-rose-900",
cyan: "bg-gradient-to-br from-cyan-800 to-cyan-900",
sky: "bg-gradient-to-br from-sky-900 to-sky-800",
custom: "",
};
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export const DashboardStatCardMini: React.FC<DashboardStatCardMiniProps> = ({
title,
value,
valuePrefix,
valueSuffix,
description,
trend,
icon: Icon,
iconBackground,
gradient = "slate",
className,
onClick,
}) => {
const gradientClass = gradient === "custom" ? "" : GRADIENT_PRESETS[gradient];
return (
<Card
className={cn(
gradientClass,
"backdrop-blur-md border-white/10",
onClick && "cursor-pointer transition-all hover:brightness-110",
className
)}
onClick={onClick}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<CardTitle className="text-sm font-bold text-gray-100">
{title}
</CardTitle>
{Icon && (
<div className="relative p-2">
{iconBackground && (
<div
className={cn("absolute inset-0 rounded-full", iconBackground)}
/>
)}
<Icon className="h-5 w-5 text-white relative" />
</div>
)}
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="text-3xl font-extrabold text-white">
{valuePrefix}
{typeof value === "number" ? value.toLocaleString() : value}
{valueSuffix && (
<span className="text-xl text-gray-300">{valueSuffix}</span>
)}
</div>
{(description || trend) && (
<div className="flex items-center gap-2 mt-1">
{trend && (
<span
className={cn(
"flex items-center gap-1 text-sm font-semibold",
trend.direction === "up"
? "text-emerald-300"
: "text-rose-300"
)}
>
{trend.direction === "up" ? (
<TrendingUp className="h-4 w-4" />
) : (
<TrendingDown className="h-4 w-4" />
)}
{trend.value}
</span>
)}
{description && (
<span className="text-sm font-semibold text-gray-200">
{description}
</span>
)}
</div>
)}
</CardContent>
</Card>
);
};
// =============================================================================
// SKELETON VARIANT
// =============================================================================
export interface DashboardStatCardMiniSkeletonProps {
gradient?: GradientVariant;
className?: string;
}
export const DashboardStatCardMiniSkeleton: React.FC<
DashboardStatCardMiniSkeletonProps
> = ({ gradient = "slate", className }) => {
const gradientClass = gradient === "custom" ? "" : GRADIENT_PRESETS[gradient];
return (
<Card
className={cn(
gradientClass,
"backdrop-blur-md border-white/10",
className
)}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
<div className="h-4 w-20 bg-white/20 animate-pulse rounded" />
<div className="h-9 w-9 bg-white/20 animate-pulse rounded-full" />
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="h-9 w-28 bg-white/20 animate-pulse rounded mb-2" />
<div className="h-4 w-24 bg-white/10 animate-pulse rounded" />
</CardContent>
</Card>
);
};
export default DashboardStatCardMini;

View File

@@ -0,0 +1,268 @@
/**
* DashboardStates
*
* Reusable empty and error state components for dashboard sections.
* Provides consistent appearance for "no data" and error scenarios.
*
* @example
* // Empty state
* {!data.length && !loading && (
* <DashboardEmptyState
* icon={TrendingUp}
* title="No analytics data available"
* description="Try selecting a different time range"
* />
* )}
*
* @example
* // Error state with retry
* {error && (
* <DashboardErrorState
* error={error}
* onRetry={() => refetch()}
* />
* )}
*
* @example
* // Error state as inline alert
* <DashboardErrorState
* error="Failed to load data"
* variant="inline"
* />
*/
import React from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { AlertCircle, RefreshCcw, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { TYPOGRAPHY } from "@/lib/dashboard/designTokens";
// =============================================================================
// EMPTY STATE
// =============================================================================
export interface DashboardEmptyStateProps {
/** Icon to display (from lucide-react) */
icon?: LucideIcon;
/** Main message/title */
title: string;
/** Supporting description text */
description?: string;
/** Optional action button */
action?: {
label: string;
onClick: () => void;
};
/** Height of the container */
height?: "sm" | "md" | "default" | "lg";
/** Additional className */
className?: string;
}
const HEIGHT_MAP = {
sm: "h-[200px]",
md: "h-[300px]",
default: "h-[400px]",
lg: "h-[500px]",
};
export const DashboardEmptyState: React.FC<DashboardEmptyStateProps> = ({
icon: Icon,
title,
description,
action,
height = "default",
className,
}) => {
return (
<div
className={cn(
"flex items-center justify-center text-muted-foreground",
HEIGHT_MAP[height],
className
)}
>
<div className="text-center max-w-sm px-4">
{Icon && (
<Icon className="h-12 w-12 mx-auto mb-4 opacity-50" />
)}
<div className="font-medium mb-2 text-foreground">{title}</div>
{description && (
<p className={cn(TYPOGRAPHY.cardDescription, "mb-4")}>
{description}
</p>
)}
{action && (
<Button variant="outline" size="sm" onClick={action.onClick}>
{action.label}
</Button>
)}
</div>
</div>
);
};
// =============================================================================
// ERROR STATE
// =============================================================================
export interface DashboardErrorStateProps {
/** Error message or Error object */
error: string | Error;
/** Title shown above the error message */
title?: string;
/** Callback for retry button */
onRetry?: () => void;
/** Retry button label */
retryLabel?: string;
/** Visual variant */
variant?: "default" | "destructive" | "warning" | "inline";
/** Additional className */
className?: string;
}
export const DashboardErrorState: React.FC<DashboardErrorStateProps> = ({
error,
title = "Error",
onRetry,
retryLabel = "Try Again",
variant = "destructive",
className,
}) => {
const errorMessage = error instanceof Error ? error.message : error;
// Inline variant - simple text display
if (variant === "inline") {
return (
<div
className={cn(
"flex items-center justify-center h-[200px] text-muted-foreground",
className
)}
>
<div className="text-center">
<AlertCircle className="h-8 w-8 mx-auto mb-2 text-destructive" />
<p className="text-sm text-destructive">{errorMessage}</p>
{onRetry && (
<Button
variant="outline"
size="sm"
onClick={onRetry}
className="mt-3"
>
<RefreshCcw className="h-3.5 w-3.5 mr-1.5" />
{retryLabel}
</Button>
)}
</div>
</div>
);
}
// Alert variant - only "default" and "destructive" are supported by shadcn/ui Alert
// For "warning", we use custom styling
const alertVariant = variant === "destructive" ? "destructive" : "default";
const warningClass = variant === "warning"
? "border-amber-500/50 text-amber-700 dark:text-amber-400 [&>svg]:text-amber-600"
: "";
return (
<Alert variant={alertVariant} className={cn("mx-4 my-4", warningClass, className)}>
<AlertCircle className="h-4 w-4" />
<AlertTitle>{title}</AlertTitle>
<AlertDescription className="flex items-center justify-between">
<span>{errorMessage}</span>
{onRetry && (
<Button
variant="outline"
size="sm"
onClick={onRetry}
className="ml-4 shrink-0"
>
<RefreshCcw className="h-3.5 w-3.5 mr-1.5" />
{retryLabel}
</Button>
)}
</AlertDescription>
</Alert>
);
};
// =============================================================================
// LOADING STATE (bonus - for consistency)
// =============================================================================
export interface DashboardLoadingStateProps {
/** Message to display */
message?: string;
/** Height of the container */
height?: "sm" | "md" | "default" | "lg";
/** Additional className */
className?: string;
}
export const DashboardLoadingState: React.FC<DashboardLoadingStateProps> = ({
message = "Loading...",
height = "default",
className,
}) => {
return (
<div
className={cn(
"flex items-center justify-center",
HEIGHT_MAP[height],
className
)}
>
<div className="text-center">
<div className="h-8 w-8 mx-auto mb-3 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<p className="text-sm text-muted-foreground">{message}</p>
</div>
</div>
);
};
// =============================================================================
// NO PERMISSION STATE (bonus - for protected content)
// =============================================================================
export interface DashboardNoPermissionStateProps {
/** Feature name that requires permission */
feature?: string;
/** Additional className */
className?: string;
}
export const DashboardNoPermissionState: React.FC<DashboardNoPermissionStateProps> = ({
feature,
className,
}) => {
return (
<div
className={cn(
"flex items-center justify-center h-[300px] text-muted-foreground",
className
)}
>
<div className="text-center">
<div className="h-12 w-12 mx-auto mb-4 rounded-full bg-muted flex items-center justify-center">
<AlertCircle className="h-6 w-6" />
</div>
<div className="font-medium mb-2 text-foreground">Access Restricted</div>
<p className="text-sm text-muted-foreground max-w-xs">
{feature
? `You don't have permission to view ${feature}.`
: "You don't have permission to view this content."}
</p>
</div>
</div>
);
};
export default {
Empty: DashboardEmptyState,
Error: DashboardErrorState,
Loading: DashboardLoadingState,
NoPermission: DashboardNoPermissionState,
};

View File

@@ -0,0 +1,358 @@
/**
* DashboardTable
*
* A standardized table component for dashboard use with consistent styling,
* loading skeletons, and empty states.
*
* @example
* // Basic usage
* <DashboardTable
* columns={[
* { key: "name", header: "Name" },
* { key: "value", header: "Value", align: "right" },
* ]}
* data={items}
* />
*
* @example
* // With custom cell rendering
* <DashboardTable
* columns={[
* { key: "name", header: "Name" },
* {
* key: "status",
* header: "Status",
* render: (value) => <StatusBadge status={value} />
* },
* {
* key: "amount",
* header: "Amount",
* align: "right",
* render: (value) => formatCurrency(value)
* },
* ]}
* data={orders}
* loading={isLoading}
* emptyMessage="No orders found"
* />
*
* @example
* // Scrollable with max height
* <DashboardTable
* columns={columns}
* data={largeDataset}
* maxHeight="md"
* stickyHeader
* />
*/
import React from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import {
TABLE_STYLES,
SCROLL_STYLES,
TYPOGRAPHY,
} from "@/lib/dashboard/designTokens";
// =============================================================================
// TYPES
// =============================================================================
export type CellAlignment = "left" | "center" | "right";
export interface TableColumn<T = Record<string, unknown>> {
/** Unique key for the column (also used to access data) */
key: string;
/** Header text */
header: string;
/** Cell alignment */
align?: CellAlignment;
/** Width class (e.g., "w-24", "w-[100px]", "min-w-[200px]") */
width?: string;
/** Custom render function for cell content */
render?: (value: unknown, row: T, rowIndex: number) => React.ReactNode;
/** Whether this column should be hidden on mobile */
hideOnMobile?: boolean;
/** Additional className for cells in this column */
className?: string;
/** Whether to use tabular-nums for numeric values */
numeric?: boolean;
}
export interface DashboardTableProps<T = Record<string, unknown>> {
/** Column definitions */
columns: TableColumn<T>[];
/** Data rows */
data: T[];
/** Loading state - shows skeleton rows */
loading?: boolean;
/** Number of skeleton rows to show when loading */
skeletonRows?: number;
/** Message when data is empty */
emptyMessage?: string;
/** Icon for empty state (from lucide-react) */
emptyIcon?: React.ReactNode;
/** Row key accessor (defaults to index) */
getRowKey?: (row: T, index: number) => string | number;
/** Row click handler */
onRowClick?: (row: T, index: number) => void;
/** Max height with scroll */
maxHeight?: "sm" | "md" | "lg" | "xl" | "none";
/** Sticky header when scrolling */
stickyHeader?: boolean;
/** Striped rows */
striped?: boolean;
/** Compact padding */
compact?: boolean;
/** Show borders between cells */
bordered?: boolean;
/** Additional className for the table container */
className?: string;
/** Additional className for the table element */
tableClassName?: string;
}
// =============================================================================
// HELPERS
// =============================================================================
const ALIGNMENT_CLASSES: Record<CellAlignment, string> = {
left: "text-left",
center: "text-center",
right: "text-right",
};
const MAX_HEIGHT_CLASSES = {
sm: SCROLL_STYLES.sm,
md: SCROLL_STYLES.md,
lg: SCROLL_STYLES.lg,
xl: SCROLL_STYLES.xl,
none: "",
};
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export function DashboardTable<T extends Record<string, unknown>>({
columns,
data,
loading = false,
skeletonRows = 5,
emptyMessage = "No data available",
emptyIcon,
getRowKey,
onRowClick,
maxHeight = "none",
stickyHeader = false,
striped = false,
compact = false,
bordered = false,
className,
tableClassName,
}: DashboardTableProps<T>): React.ReactElement {
const paddingClass = compact ? "px-3 py-2" : "px-4 py-3";
const scrollClass = maxHeight !== "none" ? MAX_HEIGHT_CLASSES[maxHeight] : "";
// Loading skeleton
if (loading) {
return (
<div className={cn(scrollClass, className)}>
<Table className={tableClassName}>
<TableHeader>
<TableRow className={cn(TABLE_STYLES.row, "hover:bg-transparent")}>
{columns.map((col) => (
<TableHead
key={col.key}
className={cn(
TABLE_STYLES.headerCell,
paddingClass,
ALIGNMENT_CLASSES[col.align || "left"],
col.width,
col.hideOnMobile && "hidden sm:table-cell"
)}
>
<Skeleton className="h-4 w-16 bg-muted" />
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: skeletonRows }).map((_, rowIndex) => (
<TableRow key={rowIndex} className={TABLE_STYLES.row}>
{columns.map((col, colIndex) => (
<TableCell
key={col.key}
className={cn(
paddingClass,
col.hideOnMobile && "hidden sm:table-cell"
)}
>
<Skeleton
className={cn(
"h-4 bg-muted",
colIndex === 0 ? "w-32" : "w-20"
)}
/>
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
// Empty state
if (!data.length) {
return (
<div
className={cn(
"flex items-center justify-center py-12 text-muted-foreground",
className
)}
>
<div className="text-center">
{emptyIcon && <div className="mb-3">{emptyIcon}</div>}
<p className={TYPOGRAPHY.cardDescription}>{emptyMessage}</p>
</div>
</div>
);
}
// Data table
return (
<div className={cn(scrollClass, className)}>
<Table className={tableClassName}>
<TableHeader className={stickyHeader ? "sticky top-0 bg-background z-10" : ""}>
<TableRow className={cn(TABLE_STYLES.row, TABLE_STYLES.header, "hover:bg-transparent")}>
{columns.map((col) => (
<TableHead
key={col.key}
className={cn(
TABLE_STYLES.headerCell,
paddingClass,
ALIGNMENT_CLASSES[col.align || "left"],
col.width,
col.hideOnMobile && "hidden sm:table-cell"
)}
>
{col.header}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{data.map((row, rowIndex) => {
const rowKey = getRowKey ? getRowKey(row, rowIndex) : rowIndex;
return (
<TableRow
key={rowKey}
className={cn(
TABLE_STYLES.row,
TABLE_STYLES.rowHover,
onRowClick && "cursor-pointer",
striped && rowIndex % 2 === 1 && "bg-muted/30",
bordered && "border-b border-border/50"
)}
onClick={onRowClick ? () => onRowClick(row, rowIndex) : undefined}
>
{columns.map((col) => {
const value = row[col.key];
const renderedValue = col.render
? col.render(value, row, rowIndex)
: (value as React.ReactNode);
return (
<TableCell
key={col.key}
className={cn(
TABLE_STYLES.cell,
paddingClass,
ALIGNMENT_CLASSES[col.align || "left"],
col.numeric && "tabular-nums",
col.className,
col.hideOnMobile && "hidden sm:table-cell"
)}
>
{renderedValue}
</TableCell>
);
})}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}
// =============================================================================
// SIMPLE TABLE (for quick key-value displays)
// =============================================================================
export interface SimpleTableRow {
label: string;
value: React.ReactNode;
className?: string;
}
export interface SimpleTableProps {
rows: SimpleTableRow[];
loading?: boolean;
className?: string;
labelWidth?: string;
}
export const SimpleTable: React.FC<SimpleTableProps> = ({
rows,
loading = false,
className,
labelWidth = "w-1/3",
}) => {
if (loading) {
return (
<div className={cn("space-y-3", className)}>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center justify-between py-1">
<Skeleton className="h-4 w-24 bg-muted" />
<Skeleton className="h-4 w-32 bg-muted" />
</div>
))}
</div>
);
}
return (
<div className={cn("divide-y divide-border/50", className)}>
{rows.map((row, index) => (
<div
key={index}
className={cn(
"flex items-center justify-between py-2 first:pt-0 last:pb-0",
row.className
)}
>
<span className={cn("text-sm text-muted-foreground", labelWidth)}>
{row.label}
</span>
<span className="text-sm font-medium text-foreground">{row.value}</span>
</div>
))}
</div>
);
};
export default DashboardTable;

View File

@@ -0,0 +1,196 @@
/**
* Shared Dashboard Components
*
* Centralized exports for reusable dashboard UI components.
* Import from this file for consistent styling across all dashboard sections.
*
* @example
* import {
* DashboardStatCard,
* DashboardSectionHeader,
* DashboardEmptyState,
* DashboardTable,
* DashboardBadge,
* CurrencyTooltip,
* ChartSkeleton,
* } from "@/components/dashboard/shared";
*/
// =============================================================================
// SECTION HEADER
// =============================================================================
export {
DashboardSectionHeader,
DashboardSectionHeaderSkeleton,
type DashboardSectionHeaderProps,
type DashboardSectionHeaderSkeletonProps,
} from "./DashboardSectionHeader";
// =============================================================================
// STATE COMPONENTS (Empty, Error, Loading)
// =============================================================================
export {
DashboardEmptyState,
DashboardErrorState,
DashboardLoadingState,
DashboardNoPermissionState,
type DashboardEmptyStateProps,
type DashboardErrorStateProps,
type DashboardLoadingStateProps,
type DashboardNoPermissionStateProps,
} from "./DashboardStates";
// =============================================================================
// BADGES
// =============================================================================
export {
DashboardBadge,
StatusBadge,
MetricBadge,
TrendBadge,
type DashboardBadgeProps,
type BadgeVariant,
type BadgeSize,
type StatusBadgeProps,
type StatusType,
type MetricBadgeProps,
type TrendBadgeProps,
} from "./DashboardBadge";
// =============================================================================
// TABLES
// =============================================================================
export {
DashboardTable,
SimpleTable,
type DashboardTableProps,
type TableColumn,
type CellAlignment,
type SimpleTableProps,
type SimpleTableRow,
} from "./DashboardTable";
// =============================================================================
// STAT CARDS
// =============================================================================
export {
DashboardStatCard,
DashboardStatCardSkeleton,
type DashboardStatCardProps,
type TrendDirection,
type CardSize,
type IconColor,
type TrendProps,
type DashboardStatCardSkeletonProps,
} from "./DashboardStatCard";
export {
DashboardStatCardMini,
DashboardStatCardMiniSkeleton,
type DashboardStatCardMiniProps,
type DashboardStatCardMiniSkeletonProps,
type GradientVariant,
} from "./DashboardStatCardMini";
// =============================================================================
// CHART TOOLTIPS
// =============================================================================
export {
DashboardChartTooltip,
CurrencyTooltip,
PercentageTooltip,
NumberTooltip,
DurationTooltip,
SimpleTooltip,
// Formatters (useful for custom implementations)
formatCurrency,
formatPercentage,
formatNumber,
formatDuration,
// Types
type DashboardChartTooltipProps,
type TooltipPayloadItem,
type SimpleTooltipProps,
} from "./DashboardChartTooltip";
// =============================================================================
// SKELETONS
// =============================================================================
export {
ChartSkeleton,
TableSkeleton,
StatCardSkeleton,
GridSkeleton,
ListSkeleton,
SectionSkeleton,
// Types
type ChartSkeletonProps,
type TableSkeletonProps,
type StatCardSkeletonProps,
type GridSkeletonProps,
type ListSkeletonProps,
type SectionSkeletonProps,
type ChartType,
type ChartHeight,
type ColorVariant,
} from "./DashboardSkeleton";
// =============================================================================
// CONVENIENCE RE-EXPORTS FROM DESIGN TOKENS
// =============================================================================
export {
CARD_STYLES,
TYPOGRAPHY,
SPACING,
RADIUS,
SCROLL_STYLES,
TREND_COLORS,
EVENT_COLORS,
FINANCIAL_COLORS,
METRIC_COLORS,
METRIC_COLORS_HSL,
STATUS_COLORS,
STAT_ICON_STYLES,
TABLE_STYLES,
BADGE_STYLES,
getTrendColor,
getIconColorVariant,
} from "@/lib/dashboard/designTokens";
// =============================================================================
// CONVENIENCE RE-EXPORTS FROM CHART CONFIG
// =============================================================================
export {
CHART_MARGINS,
CHART_HEIGHTS,
GRID_CONFIG,
X_AXIS_CONFIG,
Y_AXIS_CONFIG,
LINE_CONFIG,
AREA_CONFIG,
BAR_CONFIG,
TOOLTIP_STYLES,
TOOLTIP_THEMES,
TOOLTIP_CURSOR,
LEGEND_CONFIG,
CHART_PALETTE,
FINANCIAL_PALETTE,
COMPARISON_PALETTE,
formatChartCurrency,
formatChartNumber,
formatChartPercent,
formatChartDate,
formatChartTime,
CHART_PRESETS,
CHART_BREAKPOINTS,
getResponsiveHeight,
} from "@/lib/dashboard/chartConfig";

View File

@@ -58,6 +58,26 @@
--sidebar-border: 220 13% 91%; --sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%; --sidebar-ring: 217.2 91.2% 59.8%;
/* Dashboard card with glass effect */
--card-glass: 0 0% 100%;
--card-glass-foreground: 222.2 84% 4.9%;
/* Semantic chart colors - EXACT values for consistency */
--chart-revenue: 160.1 84.1% 39.4%; /* #10b981 - emerald-500 */
--chart-orders: 217.2 91.2% 59.8%; /* #3b82f6 - blue-500 */
--chart-aov: 258.3 89.5% 66.3%; /* #8b5cf6 - violet-500 */
--chart-comparison: 37.7 92.1% 50.2%; /* #f59e0b - amber-500 */
--chart-expense: 24.6 95% 53.1%; /* #f97316 - orange-500 */
--chart-profit: 142.1 76.2% 45.7%; /* #22c55e - green-500 */
--chart-secondary: 187.9 85.7% 53.3%; /* #06b6d4 - cyan-500 */
--chart-tertiary: 330.4 81.2% 60.4%; /* #ec4899 - pink-500 */
/* Trend colors */
--trend-positive: 160.1 84.1% 39.4%; /* emerald-500 */
--trend-positive-muted: 158.1 64.4% 91.6%; /* emerald-100 */
--trend-negative: 346.8 77.2% 49.8%; /* rose-500 */
--trend-negative-muted: 355.7 100% 94.7%; /* rose-100 */
} }
.dark { .dark {
@@ -106,6 +126,26 @@
--sidebar-accent-foreground: 240 4.8% 95.9%; --sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%; --sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%; --sidebar-ring: 217.2 91.2% 59.8%;
/* Dashboard card with glass effect - semi-transparent in dark mode */
--card-glass: 222.2 47.4% 11.2%;
--card-glass-foreground: 210 40% 98%;
/* Semantic chart colors - slightly brighter in dark mode for visibility */
--chart-revenue: 158.1 64.4% 51.6%; /* emerald-400 */
--chart-orders: 213.1 93.9% 67.8%; /* blue-400 */
--chart-aov: 255.1 91.7% 76.3%; /* violet-400 */
--chart-comparison: 43.3 96.4% 56.3%; /* amber-400 */
--chart-expense: 27.0 96.0% 61.0%; /* orange-400 */
--chart-profit: 141.9 69.2% 58%; /* green-400 */
--chart-secondary: 186.0 93.5% 55.7%; /* cyan-400 */
--chart-tertiary: 328.6 85.5% 70.2%; /* pink-400 */
/* Trend colors - brighter in dark mode */
--trend-positive: 158.1 64.4% 51.6%; /* emerald-400 */
--trend-positive-muted: 163.1 88.1% 19.6%; /* emerald-900 */
--trend-negative: 351.3 94.5% 71.4%; /* rose-400 */
--trend-negative-muted: 343.1 87.7% 15.9%; /* rose-950 */
} }
} }
@@ -126,3 +166,26 @@
@apply bg-background text-foreground font-sans; @apply bg-background text-foreground font-sans;
} }
} }
@layer components {
/* Dashboard card with glass effect */
.card-glass {
@apply bg-card-glass/100 dark:bg-card-glass/60 backdrop-blur-sm border border-border/50 shadow-sm rounded-xl text-card-glass-foreground;
}
/* Dashboard scrollable container with custom scrollbar */
.dashboard-scroll {
@apply overflow-y-auto;
scrollbar-width: thin;
scrollbar-color: rgb(229 231 235) transparent;
}
.dark .dashboard-scroll {
scrollbar-color: rgb(55 65 81) transparent;
}
.dashboard-scroll:hover {
scrollbar-color: rgb(209 213 219) transparent;
}
.dark .dashboard-scroll:hover {
scrollbar-color: rgb(75 85 99) transparent;
}
}

View File

@@ -0,0 +1,395 @@
/**
* Chart Configuration
*
* Centralized Recharts configuration for consistent chart appearance
* across all dashboard components.
*/
import { METRIC_COLORS, FINANCIAL_COLORS } from "./designTokens";
// =============================================================================
// CHART LAYOUT CONFIGURATION
// =============================================================================
/**
* Standard chart margins
* Negative left margin accounts for Y-axis label width
*/
export const CHART_MARGINS = {
default: { top: 10, right: 10, left: -15, bottom: 5 },
withLegend: { top: 10, right: 10, left: -15, bottom: 25 },
compact: { top: 5, right: 5, left: -20, bottom: 5 },
spacious: { top: 20, right: 20, left: 0, bottom: 10 },
} as const;
/**
* Standard chart heights
*/
export const CHART_HEIGHTS = {
small: 200,
medium: 300,
default: 400,
large: 500,
} as const;
// =============================================================================
// GRID CONFIGURATION
// =============================================================================
/**
* CartesianGrid styling
*/
export const GRID_CONFIG = {
strokeDasharray: "3 3",
className: "stroke-border/40",
stroke: "hsl(var(--border) / 0.4)",
vertical: false, // Horizontal lines only for cleaner look
} as const;
// =============================================================================
// AXIS CONFIGURATION
// =============================================================================
/**
* X-Axis default configuration
*/
export const X_AXIS_CONFIG = {
className: "text-xs fill-muted-foreground",
tickLine: false,
axisLine: false,
tick: { fontSize: 11 },
dy: 10,
} as const;
/**
* Y-Axis default configuration
*/
export const Y_AXIS_CONFIG = {
className: "text-xs fill-muted-foreground",
tickLine: false,
axisLine: false,
tick: { fontSize: 11 },
width: 60,
} as const;
// =============================================================================
// LINE/BAR CONFIGURATION
// =============================================================================
/**
* Line chart series configuration
*/
export const LINE_CONFIG = {
strokeWidth: 2,
dot: false,
activeDot: {
r: 4,
strokeWidth: 2,
className: "fill-background stroke-current",
},
type: "monotone" as const,
} as const;
/**
* Area chart series configuration
*/
export const AREA_CONFIG = {
strokeWidth: 2,
fillOpacity: 0.1,
type: "monotone" as const,
} as const;
/**
* Bar chart series configuration
*/
export const BAR_CONFIG = {
radius: [4, 4, 0, 0] as [number, number, number, number],
maxBarSize: 50,
} as const;
// =============================================================================
// TOOLTIP CONFIGURATION
// =============================================================================
/**
* Tooltip styling - comprehensive styles for consistent tooltip appearance
*
* Structure:
* <div className={container}>
* <p className={header}>{date/label}</p>
* <div className={content}>
* {items.map(item => (
* <div className={row}>
* <div className={rowLabel}>
* <span className={dot} style={{backgroundColor: color}} />
* <span className={name}>{name}</span>
* </div>
* <span className={value}>{value}</span>
* </div>
* ))}
* </div>
* </div>
*/
export const TOOLTIP_STYLES = {
// Outer container
container: "rounded-lg border border-border/50 bg-popover px-3 py-2 shadow-lg",
// Header/label at top of tooltip
header: "font-medium text-sm text-foreground pb-1.5 mb-1.5 border-b border-border/50",
label: "font-medium text-sm text-foreground pb-1.5 mb-1.5 border-b border-border/50",
// Content wrapper
content: "space-y-1",
// Individual row
row: "flex justify-between items-center gap-4",
// Left side of row (dot + name)
rowLabel: "flex items-center gap-2",
// Color indicator dot
dot: "h-2.5 w-2.5 rounded-full shrink-0",
// Metric name / item label
name: "text-sm text-muted-foreground",
item: "text-sm text-muted-foreground",
// Value on right side
value: "text-sm font-medium text-foreground",
// Divider between sections (if needed)
divider: "border-t border-border/50 my-1.5",
} as const;
/**
* Themed tooltip variants for special contexts (e.g., SmallDashboard TV display)
* These maintain the same structure as TOOLTIP_STYLES but with different color schemes
*/
export const TOOLTIP_THEMES = {
/** Stone/neutral dark theme - for MiniSalesChart */
stone: {
container: "rounded-lg border-none bg-stone-800 px-3 py-2 shadow-lg",
header: "font-medium text-sm text-stone-100 pb-1.5 mb-1.5 border-b border-stone-700",
content: "space-y-1",
row: "flex justify-between items-center gap-4",
rowLabel: "flex items-center gap-2",
dot: "h-2.5 w-2.5 rounded-full shrink-0",
name: "text-sm text-stone-200",
value: "text-sm font-medium text-stone-100",
},
/** Sky/blue dark theme - for MiniRealtimeAnalytics */
sky: {
container: "rounded-lg border-none bg-sky-800 px-3 py-2 shadow-lg",
header: "font-medium text-sm text-sky-100 pb-1.5 mb-1.5 border-b border-sky-700",
content: "space-y-1",
row: "flex justify-between items-center gap-4",
rowLabel: "flex items-center gap-2",
dot: "h-2.5 w-2.5 rounded-full shrink-0",
name: "text-sm text-sky-200",
value: "text-sm font-medium text-sky-100",
},
} as const;
/**
* Tooltip cursor styling (the vertical line that follows the mouse)
*/
export const TOOLTIP_CURSOR = {
stroke: "hsl(var(--border))",
strokeWidth: 1,
strokeDasharray: "4 4",
} as const;
// =============================================================================
// LEGEND CONFIGURATION
// =============================================================================
/**
* Legend styling
*/
export const LEGEND_CONFIG = {
wrapperStyle: {
paddingTop: "10px",
},
iconType: "circle" as const,
iconSize: 8,
formatter: (value: string) => (
`<span class="text-xs text-muted-foreground ml-1">${value}</span>`
),
} as const;
// =============================================================================
// COLOR PALETTES
// =============================================================================
/**
* Standard chart color palette
* Use these in order for multi-series charts
*/
export const CHART_PALETTE = [
METRIC_COLORS.revenue, // Emerald
METRIC_COLORS.orders, // Blue
METRIC_COLORS.aov, // Purple
METRIC_COLORS.comparison, // Amber
METRIC_COLORS.secondary, // Cyan
METRIC_COLORS.tertiary, // Pink
] as const;
/**
* Financial chart palette
* Preserves accounting conventions
*/
export const FINANCIAL_PALETTE = {
income: FINANCIAL_COLORS.income,
cogs: FINANCIAL_COLORS.expense,
profit: FINANCIAL_COLORS.profit,
margin: FINANCIAL_COLORS.margin,
} as const;
/**
* Comparison chart colors (current vs previous period)
*/
export const COMPARISON_PALETTE = {
current: METRIC_COLORS.revenue,
previous: METRIC_COLORS.comparison,
} as const;
// =============================================================================
// FORMATTERS
// =============================================================================
/**
* Currency formatter for chart labels
*/
export const formatChartCurrency = (value: number): string => {
if (value >= 1000000) {
return `$${(value / 1000000).toFixed(1)}M`;
}
if (value >= 1000) {
return `$${(value / 1000).toFixed(1)}K`;
}
return `$${value.toFixed(0)}`;
};
/**
* Number formatter for chart labels
*/
export const formatChartNumber = (value: number): string => {
if (value >= 1000000) {
return `${(value / 1000000).toFixed(1)}M`;
}
if (value >= 1000) {
return `${(value / 1000).toFixed(1)}K`;
}
return value.toFixed(0);
};
/**
* Percentage formatter for chart labels
*/
export const formatChartPercent = (value: number): string => {
return `${value.toFixed(1)}%`;
};
/**
* Date formatter for X-axis labels
*/
export const formatChartDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
};
/**
* Time formatter for real-time charts
*/
export const formatChartTime = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
};
// =============================================================================
// PRESET CONFIGURATIONS
// =============================================================================
/**
* Ready-to-use chart configurations for common use cases
*/
export const CHART_PRESETS = {
/** Revenue/financial line chart */
revenue: {
margins: CHART_MARGINS.default,
height: CHART_HEIGHTS.default,
grid: GRID_CONFIG,
xAxis: X_AXIS_CONFIG,
yAxis: { ...Y_AXIS_CONFIG, tickFormatter: formatChartCurrency },
line: { ...LINE_CONFIG, stroke: METRIC_COLORS.revenue },
},
/** Order count bar chart */
orders: {
margins: CHART_MARGINS.default,
height: CHART_HEIGHTS.default,
grid: GRID_CONFIG,
xAxis: X_AXIS_CONFIG,
yAxis: { ...Y_AXIS_CONFIG, tickFormatter: formatChartNumber },
bar: { ...BAR_CONFIG, fill: METRIC_COLORS.orders },
},
/** Comparison chart (current vs previous) */
comparison: {
margins: CHART_MARGINS.withLegend,
height: CHART_HEIGHTS.default,
grid: GRID_CONFIG,
xAxis: X_AXIS_CONFIG,
yAxis: Y_AXIS_CONFIG,
colors: COMPARISON_PALETTE,
},
/** Real-time metrics chart */
realtime: {
margins: CHART_MARGINS.compact,
height: CHART_HEIGHTS.small,
grid: { ...GRID_CONFIG, vertical: false },
xAxis: { ...X_AXIS_CONFIG, tickFormatter: formatChartTime },
yAxis: Y_AXIS_CONFIG,
},
/** Financial overview chart */
financial: {
margins: CHART_MARGINS.withLegend,
height: CHART_HEIGHTS.default,
grid: GRID_CONFIG,
xAxis: X_AXIS_CONFIG,
yAxis: { ...Y_AXIS_CONFIG, tickFormatter: formatChartCurrency },
colors: FINANCIAL_PALETTE,
},
} as const;
// =============================================================================
// RESPONSIVE BREAKPOINTS
// =============================================================================
/**
* Breakpoints for responsive chart sizing
*/
export const CHART_BREAKPOINTS = {
sm: 640,
md: 768,
lg: 1024,
xl: 1280,
} as const;
/**
* Get responsive chart height based on container width
*/
export function getResponsiveHeight(
containerWidth: number,
baseHeight: number = CHART_HEIGHTS.default
): number {
if (containerWidth < CHART_BREAKPOINTS.sm) {
return Math.round(baseHeight * 0.6);
}
if (containerWidth < CHART_BREAKPOINTS.md) {
return Math.round(baseHeight * 0.75);
}
return baseHeight;
}

View File

@@ -0,0 +1,364 @@
/**
* Dashboard Design Tokens
*
* Centralized styling constants for consistent dashboard appearance.
* These tokens are divided into two categories:
*
* 1. STRUCTURAL - Visual consistency (safe to change)
* 2. SEMANTIC - Convey meaning (preserve carefully)
*/
// =============================================================================
// STRUCTURAL TOKENS - Visual consistency across components
// =============================================================================
/**
* Standard card styling classes
* Uses the glass effect by default for dashboard cards
*/
export const CARD_STYLES = {
/** Base card appearance with glass effect (default for dashboard) */
base: "card-glass",
/** Card with subtle hover effect */
interactive: "card-glass transition-shadow hover:shadow-md",
/** Solid card without glass effect (use sparingly) */
solid: "bg-card border border-border/50 shadow-sm rounded-xl",
/** Card header layout */
header: "flex flex-row items-center justify-between p-4 pb-2",
/** Compact header for stat cards */
headerCompact: "flex flex-row items-center justify-between px-4 pt-4 pb-2",
/** Card content area */
content: "p-4 pt-0",
/** Card content with extra bottom padding */
contentPadded: "p-4 pt-0 pb-4",
} as const;
/**
* Typography scale for dashboard elements
*/
export const TYPOGRAPHY = {
/** Card/section titles */
cardTitle: "text-sm font-medium text-muted-foreground",
/** Primary metric values */
cardValue: "text-2xl font-semibold tracking-tight",
/** Large hero metrics (real-time, attention-grabbing) */
cardValueLarge: "text-3xl font-bold tracking-tight",
/** Small metric values */
cardValueSmall: "text-xl font-semibold tracking-tight",
/** Supporting descriptions */
cardDescription: "text-sm text-muted-foreground",
/** Section headings within cards */
sectionTitle: "text-base font-semibold",
/** Table headers */
tableHeader: "text-xs font-medium text-muted-foreground uppercase tracking-wider",
/** Table cells */
tableCell: "text-sm",
/** Small labels */
label: "text-xs font-medium text-muted-foreground",
} as const;
/**
* Spacing and layout constants
*/
export const SPACING = {
/** Gap between cards in a grid */
cardGap: "gap-4",
/** Standard card padding */
cardPadding: "p-4",
/** Inner content spacing */
contentGap: "space-y-4",
/** Tight content spacing */
contentGapTight: "space-y-2",
} as const;
/**
* Border radius tokens
*/
export const RADIUS = {
card: "rounded-xl",
button: "rounded-lg",
badge: "rounded-full",
input: "rounded-md",
} as const;
/**
* Scrollable container styling
* Uses the dashboard-scroll utility class defined in index.css
*/
export const SCROLL_STYLES = {
/** Standard scrollable container with custom scrollbar */
container: "dashboard-scroll",
/** Scrollable with max height variants */
sm: "dashboard-scroll max-h-[300px]",
md: "dashboard-scroll max-h-[400px]",
lg: "dashboard-scroll max-h-[540px]",
xl: "dashboard-scroll max-h-[700px]",
} as const;
// =============================================================================
// SEMANTIC TOKENS - Colors that convey meaning (preserve carefully!)
// =============================================================================
/**
* Trend indicator colors
* Used for showing positive/negative changes
* Uses CSS variables for consistency (see index.css)
*/
export const TREND_COLORS = {
positive: {
text: "text-trend-positive",
bg: "bg-trend-positive-muted",
border: "border-trend-positive/20",
},
negative: {
text: "text-trend-negative",
bg: "bg-trend-negative-muted",
border: "border-trend-negative/20",
},
neutral: {
text: "text-muted-foreground",
bg: "bg-muted",
border: "border-border",
},
} as const;
/**
* Event type colors - PRESERVE THESE
* Each color has semantic meaning for quick visual recognition
*/
export const EVENT_COLORS = {
orderPlaced: {
bg: "bg-emerald-500 dark:bg-emerald-600",
text: "text-emerald-600 dark:text-emerald-400",
bgSubtle: "bg-emerald-500/10",
},
orderShipped: {
bg: "bg-blue-500 dark:bg-blue-600",
text: "text-blue-600 dark:text-blue-400",
bgSubtle: "bg-blue-500/10",
},
newAccount: {
bg: "bg-purple-500 dark:bg-purple-600",
text: "text-purple-600 dark:text-purple-400",
bgSubtle: "bg-purple-500/10",
},
orderCanceled: {
bg: "bg-red-500 dark:bg-red-600",
text: "text-red-600 dark:text-red-400",
bgSubtle: "bg-red-500/10",
},
paymentRefunded: {
bg: "bg-orange-500 dark:bg-orange-600",
text: "text-orange-600 dark:text-orange-400",
bgSubtle: "bg-orange-500/10",
},
blogPost: {
bg: "bg-indigo-500 dark:bg-indigo-600",
text: "text-indigo-600 dark:text-indigo-400",
bgSubtle: "bg-indigo-500/10",
},
} as const;
/**
* Financial chart colors - PRESERVE THESE
* Follow accounting visualization conventions
*/
export const FINANCIAL_COLORS = {
income: "#3b82f6", // Blue - Revenue streams
expense: "#f97316", // Orange - Costs/Expenses (COGS)
profit: "#10b981", // Green - Positive financial outcome
margin: "#8b5cf6", // Purple - Percentage metrics
} as const;
/**
* Standard metric colors for charts
* Used across multiple dashboard components
*
* For CSS/Tailwind classes, use: text-chart-revenue, bg-chart-orders, etc.
* For Recharts/JS, use these hex values directly.
*
* IMPORTANT: These MUST match the CSS variables in index.css
*/
export const METRIC_COLORS = {
revenue: "#10b981", // Emerald - Primary positive metric (--chart-revenue)
orders: "#3b82f6", // Blue - Count/volume metrics (--chart-orders)
aov: "#8b5cf6", // Purple/Violet - Calculated/derived metrics (--chart-aov)
comparison: "#f59e0b", // Amber - Previous period comparison (--chart-comparison)
expense: "#f97316", // Orange - Costs/expenses (--chart-expense)
profit: "#22c55e", // Green - Profit metrics (--chart-profit)
secondary: "#06b6d4", // Cyan - Secondary metrics (--chart-secondary)
tertiary: "#ec4899", // Pink - Tertiary metrics (--chart-tertiary)
} as const;
/**
* Metric colors as HSL for CSS variable compatibility
* Use these when you need HSL format (e.g., for opacity variations)
*/
export const METRIC_COLORS_HSL = {
revenue: "hsl(var(--chart-revenue))",
orders: "hsl(var(--chart-orders))",
aov: "hsl(var(--chart-aov))",
comparison: "hsl(var(--chart-comparison))",
expense: "hsl(var(--chart-expense))",
profit: "hsl(var(--chart-profit))",
secondary: "hsl(var(--chart-secondary))",
tertiary: "hsl(var(--chart-tertiary))",
} as const;
/**
* Status indicator colors
*/
export const STATUS_COLORS = {
success: {
text: "text-emerald-600 dark:text-emerald-400",
bg: "bg-emerald-500/10",
border: "border-emerald-500/20",
icon: "text-emerald-500",
},
warning: {
text: "text-amber-600 dark:text-amber-400",
bg: "bg-amber-500/10",
border: "border-amber-500/20",
icon: "text-amber-500",
},
error: {
text: "text-red-600 dark:text-red-400",
bg: "bg-red-500/10",
border: "border-red-500/20",
icon: "text-red-500",
},
info: {
text: "text-blue-600 dark:text-blue-400",
bg: "bg-blue-500/10",
border: "border-blue-500/20",
icon: "text-blue-500",
},
} as const;
// =============================================================================
// COMPONENT-SPECIFIC TOKENS
// =============================================================================
/**
* Stat card icon styling
*/
export const STAT_ICON_STYLES = {
container: "p-2 rounded-lg",
icon: "h-4 w-4",
// Color variants for icons
colors: {
emerald: {
container: "bg-emerald-500/10",
icon: "text-emerald-600 dark:text-emerald-400",
},
green: {
container: "bg-green-500/10",
icon: "text-green-600 dark:text-green-400",
},
blue: {
container: "bg-blue-500/10",
icon: "text-blue-600 dark:text-blue-400",
},
purple: {
container: "bg-purple-500/10",
icon: "text-purple-600 dark:text-purple-400",
},
amber: {
container: "bg-amber-500/10",
icon: "text-amber-600 dark:text-amber-400",
},
yellow: {
container: "bg-yellow-500/10",
icon: "text-yellow-600 dark:text-yellow-400",
},
orange: {
container: "bg-orange-500/10",
icon: "text-orange-600 dark:text-orange-400",
},
red: {
container: "bg-red-500/10",
icon: "text-red-600 dark:text-red-400",
},
rose: {
container: "bg-rose-500/10",
icon: "text-rose-600 dark:text-rose-400",
},
pink: {
container: "bg-pink-500/10",
icon: "text-pink-600 dark:text-pink-400",
},
teal: {
container: "bg-teal-500/10",
icon: "text-teal-600 dark:text-teal-400",
},
cyan: {
container: "bg-cyan-500/10",
icon: "text-cyan-600 dark:text-cyan-400",
},
indigo: {
container: "bg-indigo-500/10",
icon: "text-indigo-600 dark:text-indigo-400",
},
violet: {
container: "bg-violet-500/10",
icon: "text-violet-600 dark:text-violet-400",
},
lime: {
container: "bg-lime-500/10",
icon: "text-lime-600 dark:text-lime-400",
},
},
} as const;
/**
* Table styling tokens
*/
export const TABLE_STYLES = {
container: "rounded-lg border border-border/50 overflow-hidden",
header: "bg-muted/30",
headerCell: "text-xs font-medium text-muted-foreground uppercase tracking-wider",
row: "border-b border-border/50 last:border-0",
rowHover: "hover:bg-muted/50 transition-colors",
cell: "text-sm",
cellNumeric: "text-sm tabular-nums text-right",
} as const;
/**
* Badge styling tokens
*/
export const BADGE_STYLES = {
base: "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
variants: {
default: "bg-muted text-muted-foreground",
success: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
warning: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
error: "bg-red-500/10 text-red-600 dark:text-red-400",
info: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
},
} as const;
// =============================================================================
// UTILITY FUNCTIONS
// =============================================================================
/**
* Get trend color based on direction and whether more is better
*/
export function getTrendColor(
value: number,
moreIsBetter: boolean = true
): typeof TREND_COLORS.positive | typeof TREND_COLORS.negative | typeof TREND_COLORS.neutral {
if (value === 0) return TREND_COLORS.neutral;
const isPositive = value > 0;
const isGood = isPositive === moreIsBetter;
return isGood ? TREND_COLORS.positive : TREND_COLORS.negative;
}
/**
* Get icon color variant by name
*/
export function getIconColorVariant(
color: keyof typeof STAT_ICON_STYLES.colors
) {
return STAT_ICON_STYLES.colors[color] || STAT_ICON_STYLES.colors.blue;
}

View File

@@ -68,7 +68,28 @@ export default {
'2': 'hsl(var(--chart-2))', '2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))', '3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))', '4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))' '5': 'hsl(var(--chart-5))',
// Semantic chart colors for dashboard
revenue: 'hsl(var(--chart-revenue))',
orders: 'hsl(var(--chart-orders))',
aov: 'hsl(var(--chart-aov))',
comparison: 'hsl(var(--chart-comparison))',
expense: 'hsl(var(--chart-expense))',
profit: 'hsl(var(--chart-profit))',
secondary: 'hsl(var(--chart-secondary))',
tertiary: 'hsl(var(--chart-tertiary))'
},
// Dashboard glass effect card
'card-glass': {
DEFAULT: 'hsl(var(--card-glass))',
foreground: 'hsl(var(--card-glass-foreground))'
},
// Trend indicator colors
trend: {
positive: 'hsl(var(--trend-positive))',
'positive-muted': 'hsl(var(--trend-positive-muted))',
negative: 'hsl(var(--trend-negative))',
'negative-muted': 'hsl(var(--trend-negative-muted))'
}, },
sidebar: { sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))', DEFAULT: 'hsl(var(--sidebar-background))',

View File

@@ -15,7 +15,7 @@ export default defineConfig(({ mode }) => {
{ {
name: 'copy-build', name: 'copy-build',
closeBundle: async () => { closeBundle: async () => {
if (!isDev) { if (!isDev && process.env.COPY_BUILD === 'true') {
const sourcePath = path.resolve(__dirname, 'build'); const sourcePath = path.resolve(__dirname, 'build');
const targetPath = path.resolve(__dirname, '../inventory-server/frontend/build'); const targetPath = path.resolve(__dirname, '../inventory-server/frontend/build');
@@ -23,6 +23,7 @@ export default defineConfig(({ mode }) => {
await fs.ensureDir(path.dirname(targetPath)); await fs.ensureDir(path.dirname(targetPath));
await fs.remove(targetPath); await fs.remove(targetPath);
await fs.copy(sourcePath, targetPath); await fs.copy(sourcePath, targetPath);
console.log('✓ Build copied to inventory-server/frontend/build');
} catch (error) { } catch (error) {
console.error('Error copying build files:', error); console.error('Error copying build files:', error);
process.exit(1); process.exit(1);