Unify dashboard with shared components
This commit is contained in:
11
inventory/package-lock.json
generated
11
inventory/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
246
inventory/src/components/dashboard/shared/DashboardBadge.tsx
Normal file
246
inventory/src/components/dashboard/shared/DashboardBadge.tsx
Normal 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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
491
inventory/src/components/dashboard/shared/DashboardSkeleton.tsx
Normal file
491
inventory/src/components/dashboard/shared/DashboardSkeleton.tsx
Normal 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,
|
||||||
|
};
|
||||||
344
inventory/src/components/dashboard/shared/DashboardStatCard.tsx
Normal file
344
inventory/src/components/dashboard/shared/DashboardStatCard.tsx
Normal 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;
|
||||||
@@ -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;
|
||||||
268
inventory/src/components/dashboard/shared/DashboardStates.tsx
Normal file
268
inventory/src/components/dashboard/shared/DashboardStates.tsx
Normal 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,
|
||||||
|
};
|
||||||
358
inventory/src/components/dashboard/shared/DashboardTable.tsx
Normal file
358
inventory/src/components/dashboard/shared/DashboardTable.tsx
Normal 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;
|
||||||
196
inventory/src/components/dashboard/shared/index.ts
Normal file
196
inventory/src/components/dashboard/shared/index.ts
Normal 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";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
395
inventory/src/lib/dashboard/chartConfig.ts
Normal file
395
inventory/src/lib/dashboard/chartConfig.ts
Normal 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;
|
||||||
|
}
|
||||||
364
inventory/src/lib/dashboard/designTokens.ts
Normal file
364
inventory/src/lib/dashboard/designTokens.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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))',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user