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",
|
||||
"diff": "^7.0.0",
|
||||
"framer-motion": "^12.4.4",
|
||||
"immer": "^11.1.3",
|
||||
"input-otp": "^1.4.1",
|
||||
"js-levenshtein": "^1.1.6",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -5749,6 +5750,16 @@
|
||||
"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": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build:deploy": "tsc -b && COPY_BUILD=true vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"mount": "../mountremote.command"
|
||||
@@ -55,6 +56,7 @@
|
||||
"date-fns": "^3.6.0",
|
||||
"diff": "^7.0.0",
|
||||
"framer-motion": "^12.4.4",
|
||||
"immer": "^11.1.3",
|
||||
"input-otp": "^1.4.1",
|
||||
"js-levenshtein": "^1.1.6",
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
// components/AircallDashboard.jsx
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -14,8 +8,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -25,47 +17,39 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
PhoneCall,
|
||||
PhoneMissed,
|
||||
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,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip as RechartsTooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
} from "recharts";
|
||||
|
||||
const COLORS = {
|
||||
inbound: "hsl(262.1 83.3% 57.8%)", // Purple
|
||||
outbound: "hsl(142.1 76.2% 36.3%)", // Green
|
||||
missed: "hsl(47.9 95.8% 53.1%)", // Yellow
|
||||
answered: "hsl(142.1 76.2% 36.3%)", // Green
|
||||
duration: "hsl(221.2 83.2% 53.3%)", // Blue
|
||||
hourly: "hsl(321.2 81.1% 41.2%)", // Pink
|
||||
// Import shared components and tokens
|
||||
import {
|
||||
DashboardChartTooltip,
|
||||
DashboardStatCard,
|
||||
DashboardStatCardSkeleton,
|
||||
DashboardSectionHeader,
|
||||
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 = [
|
||||
@@ -89,41 +73,6 @@ const formatDuration = (seconds) => {
|
||||
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 [sortConfig, setSortConfig] = useState({
|
||||
key: "total",
|
||||
@@ -144,19 +93,19 @@ const AgentPerformanceTable = ({ agents, onSort }) => {
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>Agent</TableHead>
|
||||
<TableHead onClick={() => handleSort("total")}>Total Calls</TableHead>
|
||||
<TableHead onClick={() => handleSort("answered")}>Answered</TableHead>
|
||||
<TableHead onClick={() => handleSort("missed")}>Missed</TableHead>
|
||||
<TableHead onClick={() => handleSort("average_duration")}>Average Duration</TableHead>
|
||||
<TableHead onClick={() => handleSort("total")} className="cursor-pointer">Total Calls</TableHead>
|
||||
<TableHead onClick={() => handleSort("answered")} className="cursor-pointer">Answered</TableHead>
|
||||
<TableHead onClick={() => handleSort("missed")} className="cursor-pointer">Missed</TableHead>
|
||||
<TableHead onClick={() => handleSort("average_duration")} className="cursor-pointer">Average Duration</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{agents.map((agent) => (
|
||||
<TableRow key={agent.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||
<TableCell className="font-medium text-gray-900 dark:text-gray-100">{agent.name}</TableCell>
|
||||
<TableRow key={agent.id} className="hover:bg-muted/50 transition-colors">
|
||||
<TableCell className="font-medium text-foreground">{agent.name}</TableCell>
|
||||
<TableCell>{agent.total}</TableCell>
|
||||
<TableCell className="text-emerald-600 dark:text-emerald-400">{agent.answered}</TableCell>
|
||||
<TableCell className="text-rose-600 dark:text-rose-400">{agent.missed}</TableCell>
|
||||
<TableCell className="text-trend-positive">{agent.answered}</TableCell>
|
||||
<TableCell className="text-trend-negative">{agent.missed}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatDuration(agent.average_duration)}</TableCell>
|
||||
</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 [timeRange, setTimeRange] = useState("last7days");
|
||||
const [metrics, setMetrics] = useState(null);
|
||||
@@ -252,7 +126,6 @@ const AircallDashboard = () => {
|
||||
});
|
||||
|
||||
const safeArray = (arr) => (Array.isArray(arr) ? arr : []);
|
||||
const safeObject = (obj) => (obj && typeof obj === "object" ? obj : {});
|
||||
|
||||
const sortedAgents = metrics?.by_users
|
||||
? 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 = {
|
||||
hourly: metrics?.by_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 () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@@ -356,11 +187,12 @@ const AircallDashboard = () => {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<Card className={CARD_STYLES.base}>
|
||||
<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">
|
||||
Error loading call data: {error}
|
||||
</div>
|
||||
<DashboardErrorState
|
||||
title="Failed to load call data"
|
||||
message={error}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -368,15 +200,12 @@ const AircallDashboard = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="p-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">Calls</CardTitle>
|
||||
</div>
|
||||
|
||||
<Card className={CARD_STYLES.base}>
|
||||
<DashboardSectionHeader
|
||||
title="Calls"
|
||||
timeSelector={
|
||||
<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" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -387,91 +216,73 @@ const AircallDashboard = () => {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
}
|
||||
/>
|
||||
|
||||
<CardContent className="p-6 pt-0 space-y-4">
|
||||
{/* Metric Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{isLoading ? (
|
||||
[...Array(4)].map((_, i) => (
|
||||
<SkeletonMetricCard key={i} />
|
||||
<DashboardStatCardSkeleton key={i} hasSubtitle />
|
||||
))
|
||||
) : metrics ? (
|
||||
<>
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-col items-start p-4">
|
||||
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-300">Total Calls</CardTitle>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100 mt-2">{metrics.total}</div>
|
||||
<div className="flex gap-4 mt-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="text-blue-500">↑ {metrics.by_direction.inbound}</span> inbound
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="text-emerald-500">↓ {metrics.by_direction.outbound}</span> outbound
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-col items-start p-4">
|
||||
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-300">Answer Rate</CardTitle>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100 mt-2">
|
||||
{`${((metrics.by_status.answered / metrics.total) * 100).toFixed(1)}%`}
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="text-emerald-500">{metrics.by_status.answered}</span> answered
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="text-rose-500">{metrics.by_status.missed}</span> missed
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-col items-start p-4">
|
||||
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-300">Peak Hour</CardTitle>
|
||||
<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'}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-2">
|
||||
Busiest Agent: {sortedAgents[0]?.name || "N/A"}
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-col items-start p-4">
|
||||
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-300">Avg Duration</CardTitle>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatDuration(metrics.average_duration)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-2">
|
||||
{metrics?.daily_data?.length > 0
|
||||
<DashboardStatCard
|
||||
title="Total Calls"
|
||||
value={metrics.total}
|
||||
subtitle={
|
||||
<span className="flex gap-3">
|
||||
<span><span className="text-chart-orders">↑ {metrics.by_direction.inbound}</span> in</span>
|
||||
<span><span className="text-chart-revenue">↓ {metrics.by_direction.outbound}</span> out</span>
|
||||
</span>
|
||||
}
|
||||
icon={Phone}
|
||||
iconColor="blue"
|
||||
/>
|
||||
|
||||
<DashboardStatCard
|
||||
title="Answer Rate"
|
||||
value={`${((metrics.by_status.answered / metrics.total) * 100).toFixed(1)}%`}
|
||||
subtitle={
|
||||
<span className="flex gap-3">
|
||||
<span><span className="text-trend-positive">{metrics.by_status.answered}</span> answered</span>
|
||||
<span><span className="text-trend-negative">{metrics.by_status.missed}</span> missed</span>
|
||||
</span>
|
||||
}
|
||||
icon={Zap}
|
||||
iconColor="green"
|
||||
/>
|
||||
|
||||
<DashboardStatCard
|
||||
title="Peak Hour"
|
||||
value={
|
||||
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"}`}
|
||||
icon={Clock}
|
||||
iconColor="purple"
|
||||
/>
|
||||
|
||||
<DashboardStatCard
|
||||
title="Avg Duration"
|
||||
value={formatDuration(metrics.average_duration)}
|
||||
subtitle={
|
||||
metrics?.daily_data?.length > 0
|
||||
? `${Math.round(metrics.total / metrics.daily_data.length)} calls/day`
|
||||
: "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>
|
||||
: "N/A"
|
||||
}
|
||||
tooltip={
|
||||
metrics?.duration_distribution
|
||||
? `Duration Distribution: ${metrics.duration_distribution.map(d => `${d.range}: ${d.count}`).join(', ')}`
|
||||
: undefined
|
||||
}
|
||||
icon={Timer}
|
||||
iconColor="teal"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -481,30 +292,32 @@ const AircallDashboard = () => {
|
||||
{/* Charts Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Daily Call Volume */}
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-gray-100">Daily Call Volume</CardTitle>
|
||||
</CardHeader>
|
||||
<Card className={CARD_STYLES.base}>
|
||||
<DashboardSectionHeader title="Daily Call Volume" compact />
|
||||
<CardContent className="h-[300px]">
|
||||
{isLoading ? (
|
||||
<SkeletonChart type="bar" />
|
||||
<ChartSkeleton type="bar" height="md" withCard={false} />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<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
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 12 }}
|
||||
className="text-muted-foreground"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
className="text-muted-foreground"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<RechartsTooltip content={<CustomTooltip />} />
|
||||
<RechartsTooltip content={<DashboardChartTooltip />} />
|
||||
<Legend />
|
||||
<Bar dataKey="inbound" fill={COLORS.inbound} name="Inbound" />
|
||||
<Bar dataKey="outbound" fill={COLORS.outbound} name="Outbound" />
|
||||
<Bar dataKey="inbound" fill={CHART_COLORS.inbound} name="Inbound" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="outbound" fill={CHART_COLORS.outbound} name="Outbound" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
@@ -512,29 +325,31 @@ const AircallDashboard = () => {
|
||||
</Card>
|
||||
|
||||
{/* Hourly Distribution */}
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-gray-100">Hourly Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<Card className={CARD_STYLES.base}>
|
||||
<DashboardSectionHeader title="Hourly Distribution" compact />
|
||||
<CardContent className="h-[300px]">
|
||||
{isLoading ? (
|
||||
<SkeletonChart type="bar" />
|
||||
<ChartSkeleton type="bar" height="md" withCard={false} />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<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
|
||||
dataKey="hour"
|
||||
tick={{ fontSize: 12 }}
|
||||
interval={2}
|
||||
className="text-muted-foreground"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
className="text-muted-foreground"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<RechartsTooltip content={<CustomTooltip />} />
|
||||
<Bar dataKey="calls" fill={COLORS.hourly} name="Calls" />
|
||||
<RechartsTooltip content={<DashboardChartTooltip />} />
|
||||
<Bar dataKey="calls" fill={CHART_COLORS.hourly} name="Calls" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
@@ -545,15 +360,13 @@ const AircallDashboard = () => {
|
||||
{/* Tables Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Agent Performance */}
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-gray-100">Agent Performance</CardTitle>
|
||||
</CardHeader>
|
||||
<Card className={CARD_STYLES.base}>
|
||||
<DashboardSectionHeader title="Agent Performance" compact />
|
||||
<CardContent>
|
||||
{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
|
||||
agents={sortedAgents}
|
||||
onSort={(key, direction) => setAgentSort({ key, direction })}
|
||||
@@ -564,29 +377,27 @@ const AircallDashboard = () => {
|
||||
</Card>
|
||||
|
||||
{/* Missed Call Reasons Table */}
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-gray-100">Missed Call Reasons</CardTitle>
|
||||
</CardHeader>
|
||||
<Card className={CARD_STYLES.base}>
|
||||
<DashboardSectionHeader title="Missed Call Reasons" compact />
|
||||
<CardContent>
|
||||
{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>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="font-medium text-gray-900 dark:text-gray-100">Reason</TableHead>
|
||||
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">Count</TableHead>
|
||||
<TableHead className="font-medium">Reason</TableHead>
|
||||
<TableHead className="text-right font-medium">Count</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{chartData.missedReasons.map((reason, index) => (
|
||||
<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}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-rose-600 dark:text-rose-400">
|
||||
<TableCell className="text-right text-trend-negative">
|
||||
{reason.count}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
@@ -18,72 +17,25 @@ import {
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
} from "recharts";
|
||||
import { Loader2, TrendingUp, AlertCircle } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { TrendingUp } from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
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
|
||||
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>
|
||||
);
|
||||
// Note: Using ChartSkeleton from @/components/dashboard/shared
|
||||
|
||||
const SkeletonStats = () => (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4">
|
||||
{[...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">
|
||||
<Skeleton className="h-4 w-24 bg-muted rounded-sm" />
|
||||
</CardHeader>
|
||||
@@ -96,46 +48,7 @@ const SkeletonStats = () => (
|
||||
</div>
|
||||
);
|
||||
|
||||
const SkeletonButtons = () => (
|
||||
<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>
|
||||
);
|
||||
// Note: Using shared DashboardStatCard from @/components/dashboard/shared
|
||||
|
||||
// Add color constants
|
||||
const METRIC_COLORS = {
|
||||
@@ -252,41 +165,13 @@ export const AnalyticsDashboard = () => {
|
||||
|
||||
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 (
|
||||
<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">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
<CardTitle className={TYPOGRAPHY.sectionTitle}>
|
||||
Analytics Overview
|
||||
</CardTitle>
|
||||
</div>
|
||||
@@ -301,9 +186,9 @@ export const AnalyticsDashboard = () => {
|
||||
Details
|
||||
</Button>
|
||||
</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">
|
||||
<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 flex-wrap gap-1">
|
||||
{Object.entries(metrics).map(([key, value]) => (
|
||||
@@ -328,7 +213,7 @@ export const AnalyticsDashboard = () => {
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<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">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -399,37 +284,37 @@ export const AnalyticsDashboard = () => {
|
||||
<SkeletonStats />
|
||||
) : summaryStats ? (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4">
|
||||
<StatCard
|
||||
<DashboardStatCard
|
||||
title="Active Users"
|
||||
value={summaryStats.totals.activeUsers.toLocaleString()}
|
||||
description={`Avg: ${Math.round(
|
||||
subtitle={`Avg: ${Math.round(
|
||||
summaryStats.averages.activeUsers
|
||||
).toLocaleString()} per day`}
|
||||
colorClass={METRIC_COLORS.activeUsers.className}
|
||||
size="compact"
|
||||
/>
|
||||
<StatCard
|
||||
<DashboardStatCard
|
||||
title="New Users"
|
||||
value={summaryStats.totals.newUsers.toLocaleString()}
|
||||
description={`Avg: ${Math.round(
|
||||
subtitle={`Avg: ${Math.round(
|
||||
summaryStats.averages.newUsers
|
||||
).toLocaleString()} per day`}
|
||||
colorClass={METRIC_COLORS.newUsers.className}
|
||||
size="compact"
|
||||
/>
|
||||
<StatCard
|
||||
<DashboardStatCard
|
||||
title="Page Views"
|
||||
value={summaryStats.totals.pageViews.toLocaleString()}
|
||||
description={`Avg: ${Math.round(
|
||||
subtitle={`Avg: ${Math.round(
|
||||
summaryStats.averages.pageViews
|
||||
).toLocaleString()} per day`}
|
||||
colorClass={METRIC_COLORS.pageViews.className}
|
||||
size="compact"
|
||||
/>
|
||||
<StatCard
|
||||
<DashboardStatCard
|
||||
title="Conversions"
|
||||
value={summaryStats.totals.conversions.toLocaleString()}
|
||||
description={`Avg: ${Math.round(
|
||||
subtitle={`Avg: ${Math.round(
|
||||
summaryStats.averages.conversions
|
||||
).toLocaleString()} per day`}
|
||||
colorClass={METRIC_COLORS.conversions.className}
|
||||
size="compact"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -499,19 +384,15 @@ export const AnalyticsDashboard = () => {
|
||||
|
||||
<CardContent className="p-6 pt-0">
|
||||
{loading ? (
|
||||
<SkeletonChart />
|
||||
<ChartSkeleton height="default" withCard={false} />
|
||||
) : !data.length ? (
|
||||
<div className="flex items-center justify-center h-[400px] text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<TrendingUp className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<div className="font-medium mb-2 text-gray-900 dark:text-gray-100">No analytics data available</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Try selecting a different time range
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DashboardEmptyState
|
||||
icon={TrendingUp}
|
||||
title="No analytics data available"
|
||||
description="Try selecting a different time range"
|
||||
/>
|
||||
) : (
|
||||
<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%">
|
||||
<LineChart
|
||||
data={data}
|
||||
@@ -538,7 +419,36 @@ export const AnalyticsDashboard = () => {
|
||||
className="text-xs text-muted-foreground"
|
||||
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 />
|
||||
{metrics.activeUsers && (
|
||||
<Line
|
||||
|
||||
@@ -48,7 +48,11 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} 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 = {
|
||||
PLACED_ORDER: "Y8cqcF",
|
||||
@@ -142,9 +146,9 @@ const formatShipMethodSimple = (method) => {
|
||||
|
||||
// Loading State Component
|
||||
const LoadingState = () => (
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
<div className="divide-y divide-border/50">
|
||||
{[...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">
|
||||
<Skeleton className="h-10 w-10 rounded-full bg-muted" />
|
||||
</div>
|
||||
@@ -173,13 +177,13 @@ const LoadingState = () => (
|
||||
// Empty State Component
|
||||
const EmptyState = () => (
|
||||
<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" />
|
||||
</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
|
||||
</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
|
||||
</p>
|
||||
</div>
|
||||
@@ -227,11 +231,11 @@ const OrderStatusTags = ({ details }) => (
|
||||
);
|
||||
|
||||
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-1 min-w-0">
|
||||
<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"}
|
||||
</p>
|
||||
{product.ItemStatus === "Pre-Order" && (
|
||||
@@ -242,13 +246,13 @@ const ProductCard = ({ product }) => (
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{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" />
|
||||
<span>{product.Brand}</span>
|
||||
</div>
|
||||
)}
|
||||
{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" />
|
||||
<span>SKU: {product.SKU}</span>
|
||||
</div>
|
||||
@@ -256,14 +260,14 @@ const ProductCard = ({ product }) => (
|
||||
</div>
|
||||
</div>
|
||||
<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)}
|
||||
</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}
|
||||
</div>
|
||||
{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)}
|
||||
</div>
|
||||
)}
|
||||
@@ -308,10 +312,10 @@ const PromotionalInfo = ({ 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>
|
||||
<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
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
@@ -336,7 +340,7 @@ const OrderSummary = ({ details }) => (
|
||||
</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
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
@@ -354,7 +358,7 @@ const OrderSummary = ({ details }) => (
|
||||
</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>
|
||||
<span className="text-sm font-medium">Total</span>
|
||||
@@ -377,7 +381,7 @@ const OrderSummary = ({ details }) => (
|
||||
const ShippingInfo = ({ details }) => (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<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
|
||||
</h4>
|
||||
<div className="text-sm space-y-1">
|
||||
@@ -396,7 +400,7 @@ const ShippingInfo = ({ details }) => (
|
||||
</div>
|
||||
{details.TrackingNumber && (
|
||||
<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
|
||||
</h4>
|
||||
<div className="text-sm space-y-1">
|
||||
@@ -412,75 +416,6 @@ const ShippingInfo = ({ details }) => (
|
||||
</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 eventType = EVENT_TYPES[event.metric_id];
|
||||
if (!eventType) return children;
|
||||
@@ -681,19 +616,19 @@ const EventDialog = ({ event, children }) => {
|
||||
<>
|
||||
<div className="mt-1">
|
||||
<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)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">•</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
<span className="text-sm text-muted-foreground">•</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
#{details.OrderId}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatShipMethodSimple(details.ShipMethod)}
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
@@ -872,8 +807,8 @@ export { EventDialog };
|
||||
const EventCard = ({ event }) => {
|
||||
const eventType = EVENT_TYPES[event.metric_id] || {
|
||||
label: "Unknown Event",
|
||||
color: "bg-gray-500",
|
||||
textColor: "text-gray-600 dark:text-gray-400",
|
||||
color: "bg-slate-500",
|
||||
textColor: "text-muted-foreground",
|
||||
};
|
||||
|
||||
const Icon = EVENT_ICONS[event.metric_id] || Package;
|
||||
@@ -886,9 +821,9 @@ const EventCard = ({ event }) => {
|
||||
return (
|
||||
<EventDialog event={event}>
|
||||
<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`}>
|
||||
<Icon className="h-5 w-5 text-gray-900 dark:text-gray-100" />
|
||||
<Icon className="h-5 w-5 text-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -902,15 +837,15 @@ const EventCard = ({ event }) => {
|
||||
<>
|
||||
<div className="mt-1">
|
||||
<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)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span className="text-sm text-gray-500">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
#{details.OrderId}
|
||||
</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">
|
||||
{formatCurrency(details.TotalAmount)}
|
||||
</span>
|
||||
@@ -989,19 +924,19 @@ const EventCard = ({ event }) => {
|
||||
<>
|
||||
<div className="mt-1">
|
||||
<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)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">•</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
<span className="text-sm text-muted-foreground">•</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
#{details.OrderId}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatShipMethodSimple(details.ShipMethod)}
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
@@ -1013,7 +948,7 @@ const EventCard = ({ event }) => {
|
||||
{event.metric_id === METRIC_IDS.ACCOUNT_CREATED && (
|
||||
<div className="mt-1">
|
||||
<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
|
||||
? `${toTitleCase(details.FirstName)} ${toTitleCase(
|
||||
details.LastName
|
||||
@@ -1021,7 +956,7 @@ const EventCard = ({ event }) => {
|
||||
: "New Customer"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{details.EmailAddress}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1030,15 +965,15 @@ const EventCard = ({ event }) => {
|
||||
{event.metric_id === METRIC_IDS.CANCELED_ORDER && (
|
||||
<div className="mt-1">
|
||||
<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)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">•</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
<span className="text-sm text-muted-foreground">•</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
#{details.OrderId}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatCurrency(details.TotalAmount)} • {details.CancelReason}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1047,15 +982,15 @@ const EventCard = ({ event }) => {
|
||||
{event.metric_id === METRIC_IDS.PAYMENT_REFUNDED && (
|
||||
<div className="mt-1">
|
||||
<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)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">•</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
<span className="text-sm text-muted-foreground">•</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
#{details.FromOrder}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatCurrency(details.PaymentAmount)} via{" "}
|
||||
{details.PaymentName}
|
||||
</div>
|
||||
@@ -1064,10 +999,10 @@ const EventCard = ({ event }) => {
|
||||
|
||||
{event.metric_id === METRIC_IDS.NEW_BLOG_POST && (
|
||||
<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}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 line-clamp-1">
|
||||
<div className="text-sm text-muted-foreground line-clamp-1">
|
||||
{details.description}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1357,13 +1292,13 @@ const EventFeed = ({
|
||||
);
|
||||
|
||||
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">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">{title}</CardTitle>
|
||||
<CardTitle className={TYPOGRAPHY.sectionTitle}>{title}</CardTitle>
|
||||
{lastUpdate && (
|
||||
<CardDescription className="text-sm text-muted-foreground">
|
||||
<CardDescription className={TYPOGRAPHY.cardDescription}>
|
||||
Last updated {format(lastUpdate, "h:mm a")}
|
||||
</CardDescription>
|
||||
)}
|
||||
@@ -1597,17 +1532,11 @@ const EventFeed = ({
|
||||
{loading && !events.length ? (
|
||||
<LoadingState />
|
||||
) : error ? (
|
||||
<Alert variant="destructive" className="mt-1 mx-6">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>
|
||||
Failed to load event feed: {error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<DashboardErrorState error={`Failed to load event feed: ${error}`} className="mt-1 mx-2" />
|
||||
) : !filteredEvents || filteredEvents.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
<div className="divide-y divide-border/50">
|
||||
{filteredEvents.map((event) => (
|
||||
<EventCard key={event.id} event={event} />
|
||||
))}
|
||||
|
||||
@@ -42,26 +42,20 @@ import {
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type { TooltipProps } from "recharts";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip as UITooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
TooltipProvider,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { ArrowUp, ArrowDown, Minus, TrendingUp, AlertCircle, Info } from "lucide-react";
|
||||
import { TrendingUp, DollarSign, Package, PiggyBank, Percent } from "lucide-react";
|
||||
import PeriodSelectionPopover, {
|
||||
type QuickPreset,
|
||||
} from "@/components/dashboard/PeriodSelectionPopover";
|
||||
import type { CustomPeriod, NaturalLanguagePeriodResult } from "@/utils/naturalLanguagePeriod";
|
||||
|
||||
type TrendDirection = "up" | "down" | "flat";
|
||||
|
||||
type TrendSummary = {
|
||||
direction: TrendDirection;
|
||||
label: string;
|
||||
};
|
||||
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
||||
import {
|
||||
DashboardStatCard,
|
||||
DashboardStatCardSkeleton,
|
||||
DashboardEmptyState,
|
||||
DashboardErrorState,
|
||||
TOOLTIP_STYLES,
|
||||
} from "@/components/dashboard/shared";
|
||||
|
||||
type ComparisonValue = {
|
||||
absolute: number | null;
|
||||
@@ -501,45 +495,6 @@ const monthsBetween = (start: Date, end: Date) => {
|
||||
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) =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
|
||||
@@ -893,52 +848,49 @@ const FinancialOverview = () => {
|
||||
const profitDescription = previousProfitValue != null ? `Previous: ${safeCurrency(previousProfitValue, 0)}` : 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 [
|
||||
{
|
||||
key: "income",
|
||||
title: "Total Income",
|
||||
value: safeCurrency(totalIncome, 0),
|
||||
description: incomeDescription,
|
||||
trend: buildTrendLabel(comparison?.income ?? buildComparisonFromValues(totalIncome, previousIncome ?? null)),
|
||||
accentClass: "text-blue-500 dark:text-blue-400",
|
||||
trendValue: incomeComparison?.percentage,
|
||||
iconColor: "blue" as const,
|
||||
tooltip:
|
||||
"Gross sales minus refunds and discounts, plus shipping fees collected (shipping, small-order, and rush fees). Taxes are excluded.",
|
||||
showDescription: incomeDescription != null,
|
||||
},
|
||||
{
|
||||
key: "cogs",
|
||||
title: "COGS",
|
||||
value: safeCurrency(cogsValue, 0),
|
||||
description: cogsDescription,
|
||||
trend: buildTrendLabel(comparison?.cogs ?? buildComparisonFromValues(cogsValue, previousCogs ?? null), {
|
||||
invertDirection: true,
|
||||
}),
|
||||
accentClass: "text-orange-500 dark:text-orange-400",
|
||||
trendValue: cogsComparison?.percentage,
|
||||
trendInverted: true,
|
||||
iconColor: "orange" as const,
|
||||
tooltip: "Sum of reported product cost of goods sold (cogs_amount) for completed sales actions in the period.",
|
||||
showDescription: cogsDescription != null,
|
||||
},
|
||||
{
|
||||
key: "profit",
|
||||
title: "Gross Profit",
|
||||
value: safeCurrency(profitValue, 0),
|
||||
description: profitDescription,
|
||||
trend: buildTrendLabel(comparison?.profit ?? buildComparisonFromValues(profitValue, previousProfitValue ?? null)),
|
||||
accentClass: "text-emerald-500 dark:text-emerald-400",
|
||||
trendValue: profitComparison?.percentage,
|
||||
iconColor: "emerald" as const,
|
||||
tooltip: "Total Income minus COGS.",
|
||||
showDescription: profitDescription != null,
|
||||
},
|
||||
{
|
||||
key: "margin",
|
||||
title: "Profit Margin",
|
||||
value: safePercentage(marginValue, 1),
|
||||
description: marginDescription,
|
||||
trend: buildTrendLabel(
|
||||
comparison?.margin ?? buildComparisonFromValues(marginValue, previousMarginValue ?? null),
|
||||
{ isPercentage: true }
|
||||
),
|
||||
accentClass: "text-purple-500 dark:text-purple-400",
|
||||
trendValue: marginComparison?.absolute,
|
||||
iconColor: "purple" as const,
|
||||
tooltip: "Gross Profit divided by Total Income, expressed as a percentage.",
|
||||
showDescription: marginDescription != null,
|
||||
},
|
||||
];
|
||||
},
|
||||
@@ -1151,12 +1103,12 @@ const FinancialOverview = () => {
|
||||
|
||||
|
||||
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">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<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
|
||||
</CardTitle>
|
||||
</div>
|
||||
@@ -1170,9 +1122,9 @@ const FinancialOverview = () => {
|
||||
Details
|
||||
</Button>
|
||||
</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">
|
||||
<DialogTitle className="text-gray-900 dark:text-gray-100">
|
||||
<DialogTitle className="text-foreground">
|
||||
Financial Details
|
||||
</DialogTitle>
|
||||
<div className="flex items-center justify-center gap-2 pt-4">
|
||||
@@ -1204,7 +1156,7 @@ const FinancialOverview = () => {
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<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">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -1353,33 +1305,22 @@ const FinancialOverview = () => {
|
||||
<SkeletonChart />
|
||||
</div>
|
||||
) : error ? (
|
||||
<Alert
|
||||
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>
|
||||
<DashboardErrorState error={`Failed to load financial data: ${error}`} className="mx-0 my-0" />
|
||||
) : !hasData ? (
|
||||
<div className="flex items-center justify-center h-[400px] text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<TrendingUp className="h-12 w-12 mx-auto mb-4" />
|
||||
<div className="font-medium mb-2 text-gray-900 dark:text-gray-100">
|
||||
No financial data available
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Try selecting a different time range
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DashboardEmptyState
|
||||
icon={TrendingUp}
|
||||
title="No financial data available"
|
||||
description="Try selecting a different time range"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<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 ? (
|
||||
<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%">
|
||||
<ComposedChart data={chartData} margin={{ top: 5, right: -25, left: 15, bottom: 5 }}>
|
||||
@@ -1502,153 +1443,46 @@ type FinancialStatCardConfig = {
|
||||
title: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
trend: TrendSummary | null;
|
||||
accentClass: string;
|
||||
trendValue?: number | null;
|
||||
trendInverted?: boolean;
|
||||
iconColor: "blue" | "orange" | "emerald" | "purple";
|
||||
tooltip?: string;
|
||||
isLoading?: boolean;
|
||||
showDescription?: boolean;
|
||||
};
|
||||
|
||||
const ICON_MAP = {
|
||||
income: DollarSign,
|
||||
cogs: Package,
|
||||
profit: PiggyBank,
|
||||
margin: Percent,
|
||||
} as const;
|
||||
|
||||
function FinancialStatGrid({
|
||||
cards,
|
||||
}: {
|
||||
cards: FinancialStatCardConfig[];
|
||||
}) {
|
||||
return (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full">
|
||||
{cards.map((card) => (
|
||||
<FinancialStatCard
|
||||
<DashboardStatCard
|
||||
key={card.key}
|
||||
title={card.title}
|
||||
value={card.value}
|
||||
description={card.description}
|
||||
trend={card.trend}
|
||||
accentClass={card.accentClass}
|
||||
subtitle={card.description}
|
||||
trend={
|
||||
card.trendValue != null && Number.isFinite(card.trendValue)
|
||||
? {
|
||||
value: card.trendValue,
|
||||
moreIsBetter: !card.trendInverted,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
icon={ICON_MAP[card.key as keyof typeof ICON_MAP]}
|
||||
iconColor={card.iconColor}
|
||||
tooltip={card.tooltip}
|
||||
isLoading={card.isLoading}
|
||||
showDescription={card.showDescription}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function FinancialStatCard({
|
||||
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 (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<FinancialStatCard
|
||||
key={index}
|
||||
title=""
|
||||
value=""
|
||||
description=""
|
||||
trend={null}
|
||||
accentClass=""
|
||||
isLoading
|
||||
showDescription
|
||||
/>
|
||||
<DashboardStatCardSkeleton key={index} hasIcon hasSubtitle />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -1673,7 +1498,7 @@ function SkeletonStats() {
|
||||
|
||||
function SkeletonChart() {
|
||||
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="flex-1 relative">
|
||||
{/* 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>) => {
|
||||
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);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border/60 bg-white dark:bg-gray-900/80 px-3 py-2 shadow-lg">
|
||||
<p className="text-xs font-semibold text-gray-900 dark:text-gray-100">{resolvedLabel}</p>
|
||||
<div className="mt-1 space-y-1 text-xs">
|
||||
<div className={TOOLTIP_STYLES.container}>
|
||||
<p className={TOOLTIP_STYLES.header}>{resolvedLabel}</p>
|
||||
<div className={TOOLTIP_STYLES.content}>
|
||||
{orderedPayload.map((entry, index) => {
|
||||
const key = (entry.dataKey ?? "") as ChartSeriesKey;
|
||||
const rawValue = entry.value;
|
||||
@@ -1782,14 +1599,17 @@ const FinancialTooltip = ({ active, payload, label }: TooltipProps<number, strin
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`${key}-${index}`} className="flex items-center justify-between gap-4">
|
||||
<div key={`${key}-${index}`} className={TOOLTIP_STYLES.row}>
|
||||
<div className={TOOLTIP_STYLES.rowLabel}>
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
style={{ color: entry.stroke || entry.color || "inherit" }}
|
||||
>
|
||||
className={TOOLTIP_STYLES.dot}
|
||||
style={{ backgroundColor: entry.stroke || entry.color || "#888" }}
|
||||
/>
|
||||
<span className={TOOLTIP_STYLES.name}>
|
||||
{SERIES_LABELS[key] ?? entry.name ?? key}
|
||||
</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
</div>
|
||||
<span className={TOOLTIP_STYLES.value}>
|
||||
{formattedValue}{percentageOfRevenue}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
@@ -17,20 +17,24 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Clock,
|
||||
Star,
|
||||
MessageSquare,
|
||||
Mail,
|
||||
Send,
|
||||
Loader2,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Zap,
|
||||
Timer,
|
||||
BarChart3,
|
||||
ClipboardCheck,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import axios from "axios";
|
||||
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
||||
import {
|
||||
DashboardStatCard,
|
||||
DashboardStatCardSkeleton,
|
||||
DashboardSectionHeader,
|
||||
DashboardErrorState,
|
||||
} from "@/components/dashboard/shared";
|
||||
|
||||
const TIME_RANGES = {
|
||||
"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 = () => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -295,29 +201,27 @@ const GorgiasOverview = () => {
|
||||
|
||||
if (error) {
|
||||
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">
|
||||
<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">
|
||||
{error}
|
||||
</div>
|
||||
<DashboardErrorState
|
||||
title="Failed to load customer service data"
|
||||
error={error}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Customer Service
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Card className={`h-full ${CARD_STYLES.base}`}>
|
||||
<DashboardSectionHeader
|
||||
title="Customer Service"
|
||||
timeSelector={
|
||||
<Select
|
||||
value={timeRange}
|
||||
onValueChange={(value) => setTimeRange(value)}
|
||||
>
|
||||
<SelectTrigger className="w-[130px] bg-white dark:bg-gray-800">
|
||||
<SelectTrigger className="w-[130px] bg-background">
|
||||
<SelectValue placeholder="Select range">
|
||||
{TIME_RANGES[timeRange]}
|
||||
</SelectValue>
|
||||
@@ -336,106 +240,107 @@ const GorgiasOverview = () => {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
}
|
||||
/>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Message & Response Metrics */}
|
||||
{loading ? (
|
||||
[...Array(7)].map((_, i) => (
|
||||
<SkeletonMetricCard key={i} />
|
||||
<DashboardStatCardSkeleton key={i} size="compact" />
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<div className="h-full">
|
||||
<MetricCard
|
||||
<DashboardStatCard
|
||||
title="Messages Received"
|
||||
value={stats.total_messages_received?.value}
|
||||
delta={stats.total_messages_received?.delta}
|
||||
value={stats.total_messages_received?.value ?? 0}
|
||||
trend={stats.total_messages_received?.delta ? {
|
||||
value: stats.total_messages_received.delta,
|
||||
suffix: "",
|
||||
} : undefined}
|
||||
icon={Mail}
|
||||
colorClass="blue"
|
||||
loading={loading}
|
||||
iconColor="blue"
|
||||
size="compact"
|
||||
/>
|
||||
</div>
|
||||
<div className="h-full">
|
||||
<MetricCard
|
||||
<DashboardStatCard
|
||||
title="Messages Sent"
|
||||
value={stats.total_messages_sent?.value}
|
||||
delta={stats.total_messages_sent?.delta}
|
||||
value={stats.total_messages_sent?.value ?? 0}
|
||||
trend={stats.total_messages_sent?.delta ? {
|
||||
value: stats.total_messages_sent.delta,
|
||||
suffix: "",
|
||||
} : undefined}
|
||||
icon={Send}
|
||||
colorClass="green"
|
||||
loading={loading}
|
||||
iconColor="green"
|
||||
size="compact"
|
||||
/>
|
||||
</div>
|
||||
<div className="h-full">
|
||||
<MetricCard
|
||||
<DashboardStatCard
|
||||
title="First Response"
|
||||
value={formatDuration(stats.median_first_response_time?.value)}
|
||||
delta={stats.median_first_response_time?.delta}
|
||||
trend={stats.median_first_response_time?.delta ? {
|
||||
value: stats.median_first_response_time.delta,
|
||||
suffix: "",
|
||||
moreIsBetter: false,
|
||||
} : undefined}
|
||||
icon={Zap}
|
||||
colorClass="purple"
|
||||
more_is_better={false}
|
||||
loading={loading}
|
||||
iconColor="purple"
|
||||
size="compact"
|
||||
/>
|
||||
</div>
|
||||
<div className="h-full">
|
||||
<MetricCard
|
||||
<DashboardStatCard
|
||||
title="One-Touch Rate"
|
||||
value={stats.total_one_touch_tickets?.value}
|
||||
delta={stats.total_one_touch_tickets?.delta}
|
||||
suffix="%"
|
||||
value={stats.total_one_touch_tickets?.value ?? 0}
|
||||
valueSuffix="%"
|
||||
trend={stats.total_one_touch_tickets?.delta ? {
|
||||
value: stats.total_one_touch_tickets.delta,
|
||||
suffix: "%",
|
||||
} : undefined}
|
||||
icon={BarChart3}
|
||||
colorClass="indigo"
|
||||
loading={loading}
|
||||
iconColor="indigo"
|
||||
size="compact"
|
||||
/>
|
||||
</div>
|
||||
<div className="h-full">
|
||||
<MetricCard
|
||||
<DashboardStatCard
|
||||
title="Customer Satisfaction"
|
||||
value={`${satisfactionStats.average_rating?.value}/5`}
|
||||
delta={satisfactionStats.average_rating?.delta}
|
||||
suffix="%"
|
||||
value={`${satisfactionStats.average_rating?.value ?? 0}/5`}
|
||||
trend={satisfactionStats.average_rating?.delta ? {
|
||||
value: satisfactionStats.average_rating.delta,
|
||||
suffix: "%",
|
||||
} : undefined}
|
||||
icon={Star}
|
||||
colorClass="orange"
|
||||
loading={loading}
|
||||
iconColor="orange"
|
||||
size="compact"
|
||||
/>
|
||||
</div>
|
||||
<div className="h-full">
|
||||
<MetricCard
|
||||
<DashboardStatCard
|
||||
title="Survey Response Rate"
|
||||
value={satisfactionStats.response_rate?.value}
|
||||
delta={satisfactionStats.response_rate?.delta}
|
||||
suffix="%"
|
||||
value={satisfactionStats.response_rate?.value ?? 0}
|
||||
valueSuffix="%"
|
||||
trend={satisfactionStats.response_rate?.delta ? {
|
||||
value: satisfactionStats.response_rate.delta,
|
||||
suffix: "%",
|
||||
} : undefined}
|
||||
icon={ClipboardCheck}
|
||||
colorClass="pink"
|
||||
loading={loading}
|
||||
iconColor="pink"
|
||||
size="compact"
|
||||
/>
|
||||
</div>
|
||||
<div className="h-full">
|
||||
<MetricCard
|
||||
<DashboardStatCard
|
||||
title="Resolution Time"
|
||||
value={formatDuration(stats.median_resolution_time?.value)}
|
||||
delta={stats.median_resolution_time?.delta}
|
||||
trend={stats.median_resolution_time?.delta ? {
|
||||
value: stats.median_resolution_time.delta,
|
||||
suffix: "",
|
||||
moreIsBetter: false,
|
||||
} : undefined}
|
||||
icon={Timer}
|
||||
colorClass="teal"
|
||||
more_is_better={false}
|
||||
loading={loading}
|
||||
iconColor="teal"
|
||||
size="compact"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Channel Distribution */}
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="pb-0">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Channel Distribution
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<Card className={CARD_STYLES.base}>
|
||||
<DashboardSectionHeader title="Channel Distribution" compact className="pb-0" />
|
||||
<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 ? (
|
||||
<TableSkeleton />
|
||||
@@ -443,10 +348,10 @@ const GorgiasOverview = () => {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="dark:border-gray-800">
|
||||
<TableHead className="text-left font-medium text-gray-900 dark:text-gray-100">Channel</TableHead>
|
||||
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">Total</TableHead>
|
||||
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">%</TableHead>
|
||||
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">Change</TableHead>
|
||||
<TableHead className="text-left font-medium text-foreground">Channel</TableHead>
|
||||
<TableHead className="text-right font-medium text-foreground">Total</TableHead>
|
||||
<TableHead className="text-right font-medium text-foreground">%</TableHead>
|
||||
<TableHead className="text-right font-medium text-foreground">Change</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -454,7 +359,7 @@ const GorgiasOverview = () => {
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.map((channel, index) => (
|
||||
<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}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">
|
||||
@@ -494,12 +399,8 @@ const GorgiasOverview = () => {
|
||||
</Card>
|
||||
|
||||
{/* Agent Performance */}
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="pb-0">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Agent Performance
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<Card className={CARD_STYLES.base}>
|
||||
<DashboardSectionHeader title="Agent Performance" compact className="pb-0" />
|
||||
<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 ? (
|
||||
<TableSkeleton />
|
||||
@@ -507,10 +408,10 @@ const GorgiasOverview = () => {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="dark:border-gray-800">
|
||||
<TableHead className="text-left font-medium text-gray-900 dark:text-gray-100">Agent</TableHead>
|
||||
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">Closed</TableHead>
|
||||
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">Rating</TableHead>
|
||||
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">Change</TableHead>
|
||||
<TableHead className="text-left font-medium text-foreground">Agent</TableHead>
|
||||
<TableHead className="text-right font-medium text-foreground">Closed</TableHead>
|
||||
<TableHead className="text-right font-medium text-foreground">Rating</TableHead>
|
||||
<TableHead className="text-right font-medium text-foreground">Change</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -518,7 +419,7 @@ const GorgiasOverview = () => {
|
||||
.filter((agent) => agent.name !== "Unassigned")
|
||||
.map((agent, index) => (
|
||||
<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}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
||||
|
||||
const CraftsIcon = () => (
|
||||
<svg viewBox="0 0 2687 3338" className="w-6 h-6" aria-hidden="true">
|
||||
@@ -289,7 +290,7 @@ const Header = () => {
|
||||
return (
|
||||
<Card
|
||||
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"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -16,8 +16,13 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
||||
import {
|
||||
DashboardSectionHeader,
|
||||
DashboardErrorState,
|
||||
} from "@/components/dashboard/shared";
|
||||
|
||||
// Helper functions for formatting
|
||||
const formatRate = (value, isSMS = false, hideForSMS = false) => {
|
||||
@@ -41,27 +46,27 @@ const TableSkeleton = () => (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<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" />
|
||||
</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" />
|
||||
</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" />
|
||||
</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" />
|
||||
</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" />
|
||||
</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" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{[...Array(15)].map((_, i) => (
|
||||
<tr key={i} className="hover:bg-muted/50 transition-colors">
|
||||
<td className="p-2">
|
||||
@@ -110,12 +115,6 @@ const TableSkeleton = () => (
|
||||
</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
|
||||
const MetricCell = ({
|
||||
@@ -232,21 +231,14 @@ const KlaviyoCampaigns = ({ className }) => {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
<Skeleton className="h-6 w-48 bg-muted" />
|
||||
</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<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>
|
||||
<Card className={`h-full ${CARD_STYLES.base}`}>
|
||||
<DashboardSectionHeader
|
||||
title="Klaviyo Campaigns"
|
||||
loading={true}
|
||||
compact
|
||||
actions={<div className="w-[200px]" />}
|
||||
timeSelector={<div className="w-[130px]" />}
|
||||
/>
|
||||
<CardContent className="overflow-y-auto pl-4 max-h-[400px] mb-4">
|
||||
<TableSkeleton />
|
||||
</CardContent>
|
||||
@@ -255,24 +247,26 @@ const KlaviyoCampaigns = ({ className }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
{error && <ErrorAlert description={error} />}
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Klaviyo Campaigns
|
||||
</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex ml-1 gap-1 items-center">
|
||||
<Card className={`h-full ${CARD_STYLES.base}`}>
|
||||
{error && (
|
||||
<DashboardErrorState
|
||||
title="Failed to load campaigns"
|
||||
message={error}
|
||||
className="mx-6 mt-4"
|
||||
/>
|
||||
)}
|
||||
<DashboardSectionHeader
|
||||
title="Klaviyo Campaigns"
|
||||
compact
|
||||
actions={
|
||||
<div className="flex gap-1 items-center">
|
||||
<Button
|
||||
variant={selectedChannels.email ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedChannels(prev => {
|
||||
if (prev.email && Object.values(prev).filter(Boolean).length === 1) {
|
||||
// If only email is selected, show all
|
||||
return { email: true, sms: true, blog: true };
|
||||
}
|
||||
// Show only email
|
||||
return { email: true, sms: false, blog: false };
|
||||
})}
|
||||
>
|
||||
@@ -284,10 +278,8 @@ const KlaviyoCampaigns = ({ className }) => {
|
||||
size="sm"
|
||||
onClick={() => setSelectedChannels(prev => {
|
||||
if (prev.sms && Object.values(prev).filter(Boolean).length === 1) {
|
||||
// If only SMS is selected, show all
|
||||
return { email: true, sms: true, blog: true };
|
||||
}
|
||||
// Show only SMS
|
||||
return { email: false, sms: true, blog: false };
|
||||
})}
|
||||
>
|
||||
@@ -299,10 +291,8 @@ const KlaviyoCampaigns = ({ className }) => {
|
||||
size="sm"
|
||||
onClick={() => setSelectedChannels(prev => {
|
||||
if (prev.blog && Object.values(prev).filter(Boolean).length === 1) {
|
||||
// If only blog is selected, show all
|
||||
return { email: true, sms: true, blog: true };
|
||||
}
|
||||
// Show only blog
|
||||
return { email: false, sms: false, blog: true };
|
||||
})}
|
||||
>
|
||||
@@ -310,6 +300,8 @@ const KlaviyoCampaigns = ({ className }) => {
|
||||
<span className="hidden sm:inline">Blog</span>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
timeSelector={
|
||||
<Select value={selectedTimeRange} onValueChange={setSelectedTimeRange}>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue placeholder="Select time range" />
|
||||
@@ -322,14 +314,13 @@ const KlaviyoCampaigns = ({ className }) => {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
}
|
||||
/>
|
||||
<CardContent className="overflow-y-auto pl-4 max-h-[400px] mb-4">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<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
|
||||
variant="ghost"
|
||||
onClick={() => handleSort("send_time")}
|
||||
@@ -338,7 +329,7 @@ const KlaviyoCampaigns = ({ className }) => {
|
||||
Campaign
|
||||
</Button>
|
||||
</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
|
||||
variant={sortConfig.key === "delivery_rate" ? "default" : "ghost"}
|
||||
onClick={() => handleSort("delivery_rate")}
|
||||
@@ -347,7 +338,7 @@ const KlaviyoCampaigns = ({ className }) => {
|
||||
Delivery
|
||||
</Button>
|
||||
</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
|
||||
variant={sortConfig.key === "open_rate" ? "default" : "ghost"}
|
||||
onClick={() => handleSort("open_rate")}
|
||||
@@ -356,7 +347,7 @@ const KlaviyoCampaigns = ({ className }) => {
|
||||
Opens
|
||||
</Button>
|
||||
</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
|
||||
variant={sortConfig.key === "click_rate" ? "default" : "ghost"}
|
||||
onClick={() => handleSort("click_rate")}
|
||||
@@ -365,7 +356,7 @@ const KlaviyoCampaigns = ({ className }) => {
|
||||
Clicks
|
||||
</Button>
|
||||
</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
|
||||
variant={sortConfig.key === "click_to_open_rate" ? "default" : "ghost"}
|
||||
onClick={() => handleSort("click_to_open_rate")}
|
||||
@@ -374,7 +365,7 @@ const KlaviyoCampaigns = ({ className }) => {
|
||||
CTR
|
||||
</Button>
|
||||
</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
|
||||
variant={sortConfig.key === "conversion_value" ? "default" : "ghost"}
|
||||
onClick={() => handleSort("conversion_value")}
|
||||
@@ -385,7 +376,7 @@ const KlaviyoCampaigns = ({ className }) => {
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{filteredCampaigns.map((campaign) => (
|
||||
<tr
|
||||
key={campaign.id}
|
||||
@@ -403,7 +394,7 @@ const KlaviyoCampaigns = ({ className }) => {
|
||||
) : (
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
@@ -419,7 +410,7 @@ const KlaviyoCampaigns = ({ className }) => {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
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>{campaign.subject}</p>
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -15,7 +9,6 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Instagram,
|
||||
Loader2,
|
||||
Users,
|
||||
DollarSign,
|
||||
Eye,
|
||||
@@ -25,10 +18,16 @@ import {
|
||||
Target,
|
||||
ShoppingCart,
|
||||
MessageCircle,
|
||||
Hash,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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
|
||||
const formatCurrency = (value, decimalPlaces = 2) =>
|
||||
@@ -49,40 +48,6 @@ const formatNumber = (value, decimalPlaces = 0) => {
|
||||
const formatPercent = (value, decimalPlaces = 2) =>
|
||||
`${(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 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 = () => (
|
||||
<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">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-800">
|
||||
<th className="p-2 sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10">
|
||||
<tr className="border-b border-border/50">
|
||||
<th className="p-2 sticky top-0 bg-card z-10">
|
||||
<Skeleton className="h-4 w-32 bg-muted" />
|
||||
</th>
|
||||
{[...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" />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{[...Array(5)].map((_, rowIndex) => (
|
||||
<tr key={rowIndex} className="hover:bg-muted/50 transition-colors">
|
||||
<td className="p-2">
|
||||
@@ -443,24 +392,17 @@ const MetaCampaigns = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Meta Ads Performance
|
||||
</CardTitle>
|
||||
<Select disabled value="7">
|
||||
<SelectTrigger className="w-[130px] bg-white dark:bg-gray-800">
|
||||
<SelectValue placeholder="Select range" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7">Last 7 days</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Card className={`h-full ${CARD_STYLES.base}`}>
|
||||
<DashboardSectionHeader
|
||||
title="Meta Ads Performance"
|
||||
loading={true}
|
||||
compact
|
||||
timeSelector={<div className="w-[130px]" />}
|
||||
/>
|
||||
<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">
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<SkeletonMetricCard key={i} />
|
||||
<DashboardStatCardSkeleton key={i} size="compact" />
|
||||
))}
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -473,25 +415,25 @@ const MetaCampaigns = () => {
|
||||
|
||||
if (error) {
|
||||
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">
|
||||
<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">
|
||||
{error}
|
||||
</div>
|
||||
<DashboardErrorState
|
||||
title="Failed to load Meta Ads data"
|
||||
message={error}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Meta Ads Performance
|
||||
</CardTitle>
|
||||
<Card className={`h-full ${CARD_STYLES.base}`}>
|
||||
<DashboardSectionHeader
|
||||
title="Meta Ads Performance"
|
||||
compact
|
||||
timeSelector={
|
||||
<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" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -502,82 +444,102 @@ const MetaCampaigns = () => {
|
||||
<SelectItem value="90">Last 90 days</SelectItem>
|
||||
</SelectContent>
|
||||
</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">
|
||||
{[
|
||||
{
|
||||
label: "Active Campaigns",
|
||||
value: summaryMetrics?.totalCampaigns,
|
||||
options: { icon: Target, iconColor: "text-purple-500" },
|
||||
},
|
||||
{
|
||||
label: "Total Spend",
|
||||
value: summaryMetrics?.totalSpend,
|
||||
options: { isMonetary: true, decimalPlaces: 0, icon: DollarSign, iconColor: "text-green-500" },
|
||||
},
|
||||
{
|
||||
label: "Total Reach",
|
||||
value: summaryMetrics?.totalReach,
|
||||
options: { icon: Users, iconColor: "text-blue-500" },
|
||||
},
|
||||
{
|
||||
label: "Total Impressions",
|
||||
value: summaryMetrics?.totalImpressions,
|
||||
options: { icon: Eye, iconColor: "text-indigo-500" },
|
||||
},
|
||||
{
|
||||
label: "Avg Frequency",
|
||||
value: summaryMetrics?.avgFrequency,
|
||||
options: { decimalPlaces: 2, icon: Repeat, iconColor: "text-cyan-500" },
|
||||
},
|
||||
{
|
||||
label: "Total Engagements",
|
||||
value: summaryMetrics?.totalPostEngagements,
|
||||
options: { icon: MessageCircle, iconColor: "text-pink-500" },
|
||||
},
|
||||
{
|
||||
label: "Avg CPM",
|
||||
value: summaryMetrics?.avgCpm,
|
||||
options: { isMonetary: true, decimalPlaces: 2, icon: DollarSign, iconColor: "text-emerald-500" },
|
||||
},
|
||||
{
|
||||
label: "Avg CTR",
|
||||
value: summaryMetrics?.avgCtr,
|
||||
options: { isPercentage: true, decimalPlaces: 2, icon: BarChart, iconColor: "text-orange-500" },
|
||||
},
|
||||
{
|
||||
label: "Avg CPC",
|
||||
value: summaryMetrics?.avgCpc,
|
||||
options: { isMonetary: true, decimalPlaces: 2, icon: MousePointer, iconColor: "text-rose-500" },
|
||||
},
|
||||
{
|
||||
label: "Total Link Clicks",
|
||||
value: summaryMetrics?.totalLinkClicks,
|
||||
options: { icon: MousePointer, iconColor: "text-amber-500" },
|
||||
},
|
||||
{
|
||||
label: "Total Purchases",
|
||||
value: summaryMetrics?.totalPurchases,
|
||||
options: { icon: ShoppingCart, iconColor: "text-teal-500" },
|
||||
},
|
||||
{
|
||||
label: "Purchase Value",
|
||||
value: summaryMetrics?.totalPurchaseValue,
|
||||
options: { isMonetary: true, decimalPlaces: 0, icon: DollarSign, iconColor: "text-lime-500" },
|
||||
},
|
||||
].map((card) => (
|
||||
<div key={card.label} className="h-full">
|
||||
{summaryCard(card.label, card.value, card.options)}
|
||||
</div>
|
||||
))}
|
||||
<DashboardStatCard
|
||||
title="Active Campaigns"
|
||||
value={formatNumber(summaryMetrics?.totalCampaigns)}
|
||||
icon={Target}
|
||||
iconColor="purple"
|
||||
size="compact"
|
||||
/>
|
||||
<DashboardStatCard
|
||||
title="Total Spend"
|
||||
value={formatCurrency(summaryMetrics?.totalSpend, 0)}
|
||||
icon={DollarSign}
|
||||
iconColor="green"
|
||||
size="compact"
|
||||
/>
|
||||
<DashboardStatCard
|
||||
title="Total Reach"
|
||||
value={formatNumber(summaryMetrics?.totalReach)}
|
||||
icon={Users}
|
||||
iconColor="blue"
|
||||
size="compact"
|
||||
/>
|
||||
<DashboardStatCard
|
||||
title="Total Impressions"
|
||||
value={formatNumber(summaryMetrics?.totalImpressions)}
|
||||
icon={Eye}
|
||||
iconColor="indigo"
|
||||
size="compact"
|
||||
/>
|
||||
<DashboardStatCard
|
||||
title="Avg Frequency"
|
||||
value={formatNumber(summaryMetrics?.avgFrequency, 2)}
|
||||
icon={Repeat}
|
||||
iconColor="cyan"
|
||||
size="compact"
|
||||
/>
|
||||
<DashboardStatCard
|
||||
title="Total Engagements"
|
||||
value={formatNumber(summaryMetrics?.totalPostEngagements)}
|
||||
icon={MessageCircle}
|
||||
iconColor="pink"
|
||||
size="compact"
|
||||
/>
|
||||
<DashboardStatCard
|
||||
title="Avg CPM"
|
||||
value={formatCurrency(summaryMetrics?.avgCpm, 2)}
|
||||
icon={DollarSign}
|
||||
iconColor="emerald"
|
||||
size="compact"
|
||||
/>
|
||||
<DashboardStatCard
|
||||
title="Avg CTR"
|
||||
value={formatPercent(summaryMetrics?.avgCtr, 2)}
|
||||
icon={BarChart}
|
||||
iconColor="orange"
|
||||
size="compact"
|
||||
/>
|
||||
<DashboardStatCard
|
||||
title="Avg CPC"
|
||||
value={formatCurrency(summaryMetrics?.avgCpc, 2)}
|
||||
icon={MousePointer}
|
||||
iconColor="rose"
|
||||
size="compact"
|
||||
/>
|
||||
<DashboardStatCard
|
||||
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>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="overflow-y-auto pl-4 max-h-[400px] mb-4">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-800">
|
||||
<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">
|
||||
<tr className="border-b border-border/50">
|
||||
<th className="p-2 text-left font-medium sticky top-0 bg-card z-10 text-foreground">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="pl-0 justify-start w-full h-8"
|
||||
@@ -586,7 +548,7 @@ const MetaCampaigns = () => {
|
||||
Campaign
|
||||
</Button>
|
||||
</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
|
||||
variant={sortConfig.key === "spend" ? "default" : "ghost"}
|
||||
className="w-full justify-center h-8"
|
||||
@@ -595,7 +557,7 @@ const MetaCampaigns = () => {
|
||||
Spend
|
||||
</Button>
|
||||
</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
|
||||
variant={sortConfig.key === "reach" ? "default" : "ghost"}
|
||||
className="w-full justify-center h-8"
|
||||
@@ -604,7 +566,7 @@ const MetaCampaigns = () => {
|
||||
Reach
|
||||
</Button>
|
||||
</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
|
||||
variant={sortConfig.key === "impressions" ? "default" : "ghost"}
|
||||
className="w-full justify-center h-8"
|
||||
@@ -613,7 +575,7 @@ const MetaCampaigns = () => {
|
||||
Impressions
|
||||
</Button>
|
||||
</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
|
||||
variant={sortConfig.key === "cpm" ? "default" : "ghost"}
|
||||
className="w-full justify-center h-8"
|
||||
@@ -622,7 +584,7 @@ const MetaCampaigns = () => {
|
||||
CPM
|
||||
</Button>
|
||||
</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
|
||||
variant={sortConfig.key === "ctr" ? "default" : "ghost"}
|
||||
className="w-full justify-center h-8"
|
||||
@@ -631,7 +593,7 @@ const MetaCampaigns = () => {
|
||||
CTR
|
||||
</Button>
|
||||
</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
|
||||
variant={sortConfig.key === "results" ? "default" : "ghost"}
|
||||
className="w-full justify-center h-8"
|
||||
@@ -640,7 +602,7 @@ const MetaCampaigns = () => {
|
||||
Results
|
||||
</Button>
|
||||
</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
|
||||
variant={sortConfig.key === "value" ? "default" : "ghost"}
|
||||
className="w-full justify-center h-8"
|
||||
@@ -649,7 +611,7 @@ const MetaCampaigns = () => {
|
||||
Value
|
||||
</Button>
|
||||
</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
|
||||
variant={sortConfig.key === "engagements" ? "default" : "ghost"}
|
||||
className="w-full justify-center h-8"
|
||||
@@ -660,7 +622,7 @@ const MetaCampaigns = () => {
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{sortedCampaigns.map((campaign) => (
|
||||
<tr
|
||||
key={campaign.id}
|
||||
@@ -668,7 +630,7 @@ const MetaCampaigns = () => {
|
||||
>
|
||||
<td className="p-2 align-top">
|
||||
<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} />
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -22,10 +22,10 @@ import {
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { EventDialog } from "./EventFeed.jsx";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DashboardErrorState } from "@/components/dashboard/shared";
|
||||
|
||||
const METRIC_IDS = {
|
||||
PLACED_ORDER: "Y8cqcF",
|
||||
@@ -439,13 +439,7 @@ const MiniEventFeed = ({
|
||||
{loading && !events.length ? (
|
||||
<LoadingState />
|
||||
) : error ? (
|
||||
<Alert variant="destructive" className="mx-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>
|
||||
Failed to load event feed: {error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<DashboardErrorState error={`Failed to load event feed: ${error}`} className="mx-4" />
|
||||
) : !events || events.length === 0 ? (
|
||||
<div className="px-4">
|
||||
<EmptyState />
|
||||
|
||||
@@ -11,41 +11,11 @@ import {
|
||||
import { AlertTriangle, Users, Activity } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
summaryCard,
|
||||
SkeletonSummaryCard,
|
||||
SkeletonBarChart,
|
||||
processBasicData,
|
||||
} from "./RealtimeAnalytics";
|
||||
import { processBasicData } from "./RealtimeAnalytics";
|
||||
import { DashboardStatCardMini, DashboardStatCardMiniSkeleton, TOOLTIP_THEMES } from "@/components/dashboard/shared";
|
||||
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 [basicData, setBasicData] = useState({
|
||||
@@ -119,8 +89,8 @@ const MiniRealtimeAnalytics = () => {
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-2 mt-1 mb-2">
|
||||
<SkeletonCard colorScheme="sky" />
|
||||
<SkeletonCard colorScheme="sky" />
|
||||
<DashboardStatCardMiniSkeleton gradient="sky" />
|
||||
<DashboardStatCardMiniSkeleton gradient="sky" />
|
||||
</div>
|
||||
|
||||
<Card className="bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm">
|
||||
@@ -168,34 +138,22 @@ const MiniRealtimeAnalytics = () => {
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-2 mt-1 mb-2">
|
||||
{summaryCard(
|
||||
"Last 30 Minutes",
|
||||
"Active users",
|
||||
basicData.last30MinUsers,
|
||||
{
|
||||
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: Users,
|
||||
iconColor: "text-sky-900",
|
||||
iconBackground: "bg-sky-300"
|
||||
}
|
||||
)}
|
||||
{summaryCard(
|
||||
"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"
|
||||
}
|
||||
)}
|
||||
<DashboardStatCardMini
|
||||
title="Last 30 Minutes"
|
||||
value={basicData.last30MinUsers}
|
||||
description="Active users"
|
||||
gradient="sky"
|
||||
icon={Users}
|
||||
iconBackground="bg-sky-300"
|
||||
/>
|
||||
<DashboardStatCardMini
|
||||
title="Last 5 Minutes"
|
||||
value={basicData.last5MinUsers}
|
||||
description="Active users"
|
||||
gradient="sky"
|
||||
icon={Activity}
|
||||
iconBackground="bg-sky-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm">
|
||||
@@ -219,28 +177,25 @@ const MiniRealtimeAnalytics = () => {
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const styles = TOOLTIP_THEMES.sky;
|
||||
return (
|
||||
<Card className="p-2 shadow-lg bg-sky-800 border-none">
|
||||
<CardContent className="p-0 space-y-1">
|
||||
<p className="font-medium text-sm text-sky-100 border-b border-sky-700 pb-1 mb-1">
|
||||
<div className={styles.container}>
|
||||
<p className={styles.header}>
|
||||
{payload[0].payload.timestamp}
|
||||
</p>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-sky-200">
|
||||
Active Users:
|
||||
</span>
|
||||
<span className="font-medium ml-4 text-sky-100">
|
||||
{payload[0].value}
|
||||
</span>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.name}>Active Users</span>
|
||||
<span className={styles.value}>{payload[0].value}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="users" fill="#0EA5E9" />
|
||||
<Bar dataKey="users" fill={METRIC_COLORS.secondary} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import React, { useState, useEffect, useCallback, memo } from "react";
|
||||
import axios from "axios";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { acotService } from "@/services/dashboard/acotService";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
LineChart,
|
||||
@@ -17,141 +13,16 @@ import {
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { DateTime } from "luxon";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { AlertCircle, TrendingUp, DollarSign, ShoppingCart, Truck, PiggyBank, ArrowUp,ArrowDown, Banknote, Package } from "lucide-react";
|
||||
import { formatCurrency, CustomTooltip, processData, StatCard } from "./SalesChart.jsx";
|
||||
|
||||
const SkeletonChart = () => (
|
||||
<div className="h-[216px]">
|
||||
<div className="h-full w-full relative">
|
||||
{/* 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>
|
||||
</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>
|
||||
);
|
||||
import { AlertCircle, PiggyBank, Truck } from "lucide-react";
|
||||
import { formatCurrency, processData } from "./SalesChart.jsx";
|
||||
import { METRIC_COLORS } from "@/lib/dashboard/designTokens";
|
||||
import {
|
||||
DashboardStatCardMini,
|
||||
DashboardStatCardMiniSkeleton,
|
||||
ChartSkeleton,
|
||||
TOOLTIP_THEMES,
|
||||
} from "@/components/dashboard/shared";
|
||||
|
||||
const MiniSalesChart = ({ className = "" }) => {
|
||||
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) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Stat Cards */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<SkeletonCard colorScheme="emerald" />
|
||||
<SkeletonCard colorScheme="blue" />
|
||||
<DashboardStatCardMiniSkeleton gradient="slate" />
|
||||
<DashboardStatCardMiniSkeleton gradient="slate" />
|
||||
</div>
|
||||
|
||||
{/* Chart Card */}
|
||||
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm">
|
||||
<CardContent className="p-4">
|
||||
<SkeletonChart />
|
||||
<ChartSkeleton height="sm" withCard={false} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -294,56 +192,38 @@ const MiniSalesChart = ({ className = "" }) => {
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{loading ? (
|
||||
<>
|
||||
<SkeletonCard colorScheme="emerald" />
|
||||
<SkeletonCard colorScheme="blue" />
|
||||
<DashboardStatCardMiniSkeleton gradient="slate" />
|
||||
<DashboardStatCardMiniSkeleton gradient="slate" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MiniStatCard
|
||||
<DashboardStatCardMini
|
||||
title="30 Days Revenue"
|
||||
value={formatCurrency(summaryStats.totalRevenue, false)}
|
||||
previousValue={formatCurrency(summaryStats.prevRevenue, false)}
|
||||
trend={
|
||||
summaryStats.periodProgress < 100
|
||||
? ((projection?.projectedRevenue || summaryStats.totalRevenue) >= summaryStats.prevRevenue ? "up" : "down")
|
||||
: (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"
|
||||
description={`Prev: ${formatCurrency(summaryStats.prevRevenue, false)}`}
|
||||
trend={{
|
||||
direction: getRevenueTrend(),
|
||||
value: getRevenueTrendValue(),
|
||||
}}
|
||||
icon={PiggyBank}
|
||||
iconColor="text-emerald-900"
|
||||
iconBackground="bg-emerald-300"
|
||||
gradient="slate"
|
||||
className={!visibleMetrics.revenue ? 'opacity-50' : ''}
|
||||
onClick={() => toggleMetric('revenue')}
|
||||
active={visibleMetrics.revenue}
|
||||
/>
|
||||
<MiniStatCard
|
||||
<DashboardStatCardMini
|
||||
title="30 Days Orders"
|
||||
value={summaryStats.totalOrders.toLocaleString()}
|
||||
previousValue={summaryStats.prevOrders.toLocaleString()}
|
||||
trend={
|
||||
summaryStats.periodProgress < 100
|
||||
? ((Math.round(summaryStats.totalOrders * (100 / summaryStats.periodProgress))) >= summaryStats.prevOrders ? "up" : "down")
|
||||
: (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"
|
||||
description={`Prev: ${summaryStats.prevOrders.toLocaleString()}`}
|
||||
trend={{
|
||||
direction: getOrdersTrend(),
|
||||
value: getOrdersTrendValue(),
|
||||
}}
|
||||
icon={Truck}
|
||||
iconColor="text-blue-900"
|
||||
iconBackground="bg-blue-300"
|
||||
gradient="slate"
|
||||
className={!visibleMetrics.orders ? 'opacity-50' : ''}
|
||||
onClick={() => toggleMetric('orders')}
|
||||
active={visibleMetrics.orders}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -354,40 +234,7 @@ const MiniSalesChart = ({ className = "" }) => {
|
||||
<CardContent className="p-4">
|
||||
<div className="h-[216px]">
|
||||
{loading ? (
|
||||
<div className="h-full w-full relative">
|
||||
{/* 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>
|
||||
<ChartSkeleton height="sm" withCard={false} />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
@@ -421,32 +268,33 @@ const MiniSalesChart = ({ className = "" }) => {
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const timestamp = new Date(payload[0].payload.timestamp);
|
||||
const styles = TOOLTIP_THEMES.stone;
|
||||
return (
|
||||
<Card className="p-2 shadow-lg bg-stone-800 border-none">
|
||||
<CardContent className="p-0 space-y-1">
|
||||
<p className="font-medium text-sm text-stone-100 border-b border-stone-700 pb-1 mb-1">
|
||||
<div className={styles.container}>
|
||||
<p className={styles.header}>
|
||||
{timestamp.toLocaleDateString([], {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric"
|
||||
})}
|
||||
</p>
|
||||
<div className={styles.content}>
|
||||
{payload
|
||||
.filter(entry => visibleMetrics[entry.dataKey])
|
||||
.map((entry, index) => (
|
||||
<div key={index} className="flex justify-between items-center text-sm">
|
||||
<span className="text-stone-200">
|
||||
{entry.name}:
|
||||
<div key={index} className={styles.row}>
|
||||
<span className={styles.name}>
|
||||
{entry.name}
|
||||
</span>
|
||||
<span className="font-medium ml-4 text-stone-100">
|
||||
<span className={styles.value}>
|
||||
{entry.dataKey === 'revenue'
|
||||
? formatCurrency(entry.value)
|
||||
: entry.value.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -458,7 +306,7 @@ const MiniSalesChart = ({ className = "" }) => {
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
name="Revenue"
|
||||
stroke="#10b981"
|
||||
stroke={METRIC_COLORS.revenue}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
@@ -469,7 +317,7 @@ const MiniSalesChart = ({ className = "" }) => {
|
||||
type="monotone"
|
||||
dataKey="orders"
|
||||
name="Orders"
|
||||
stroke="#3b82f6"
|
||||
stroke={METRIC_COLORS.orders}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
import React, { useState, useEffect, useCallback, memo } from "react";
|
||||
import axios from "axios";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { acotService } from "@/services/dashboard/acotService";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -22,7 +13,6 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DateTime } from "luxon";
|
||||
import { TIME_RANGES } from "@/lib/dashboard/constants";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
DollarSign,
|
||||
@@ -30,23 +20,7 @@ import {
|
||||
Package,
|
||||
AlertCircle,
|
||||
CircleDollarSign,
|
||||
Loader2,
|
||||
} 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 {
|
||||
@@ -54,163 +28,28 @@ import {
|
||||
OrdersDetails,
|
||||
AverageOrderDetails,
|
||||
ShippingDetails,
|
||||
StatCard,
|
||||
DetailDialog,
|
||||
formatCurrency,
|
||||
formatPercentage,
|
||||
SkeletonCard,
|
||||
} from "./StatCards";
|
||||
import {
|
||||
DashboardStatCardMini,
|
||||
DashboardStatCardMiniSkeleton,
|
||||
ChartSkeleton,
|
||||
TableSkeleton,
|
||||
DashboardErrorState,
|
||||
} from "@/components/dashboard/shared";
|
||||
|
||||
// Mini skeleton components
|
||||
const MiniSkeletonChart = ({ type = "line" }) => (
|
||||
<div className={`h-[230px] w-full ${
|
||||
type === 'revenue' ? 'bg-emerald-50/10' :
|
||||
type === 'orders' ? 'bg-blue-50/10' :
|
||||
type === 'average_order' ? 'bg-violet-50/10' :
|
||||
'bg-orange-50/10'
|
||||
} rounded-lg p-4`}>
|
||||
<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>
|
||||
);
|
||||
// Helper to map metric to colorVariant
|
||||
const getColorVariant = (metric) => {
|
||||
switch (metric) {
|
||||
case 'revenue': return 'emerald';
|
||||
case 'orders': return 'blue';
|
||||
case 'average_order': return 'violet';
|
||||
case 'shipping': return 'orange';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const MiniStatCards = ({
|
||||
timeRange: initialTimeRange = "today",
|
||||
@@ -421,101 +260,16 @@ const MiniStatCards = ({
|
||||
if (loading && !stats) {
|
||||
return (
|
||||
<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">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
||||
<CardTitle className="text-emerald-100 font-bold text-md">
|
||||
<Skeleton className="h-4 w-24 bg-emerald-700" />
|
||||
</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>
|
||||
<DashboardStatCardMiniSkeleton gradient="emerald" className="h-[150px]" />
|
||||
<DashboardStatCardMiniSkeleton gradient="blue" className="h-[150px]" />
|
||||
<DashboardStatCardMiniSkeleton gradient="violet" className="h-[150px]" />
|
||||
<DashboardStatCardMiniSkeleton gradient="orange" className="h-[150px]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>Failed to load stats: {error}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
return <DashboardErrorState error={`Failed to load stats: ${error}`} />;
|
||||
}
|
||||
|
||||
if (!stats) return null;
|
||||
@@ -527,100 +281,68 @@ const MiniStatCards = ({
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<StatCard
|
||||
<DashboardStatCardMini
|
||||
title="Today's Revenue"
|
||||
value={formatCurrency(stats?.revenue || 0)}
|
||||
description={
|
||||
stats?.periodProgress < 100 ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Proj: </span>
|
||||
{projectionLoading ? (
|
||||
<div className="w-20">
|
||||
<Skeleton className="h-4 w-15 bg-emerald-700" />
|
||||
</div>
|
||||
) : (
|
||||
formatCurrency(
|
||||
projection?.projectedRevenue || stats.projectedRevenue
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
stats?.periodProgress < 100
|
||||
? `Proj: ${formatCurrency(projection?.projectedRevenue || stats.projectedRevenue)}`
|
||||
: undefined
|
||||
}
|
||||
progress={stats?.periodProgress < 100 ? stats.periodProgress : undefined}
|
||||
trend={projectionLoading && stats?.periodProgress < 100 ? undefined : revenueTrend?.trend}
|
||||
trendValue={
|
||||
projectionLoading && stats?.periodProgress < 100 ? (
|
||||
<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
|
||||
trend={
|
||||
revenueTrend?.trend && !projectionLoading
|
||||
? { direction: revenueTrend.trend, value: formatPercentage(revenueTrend.value) }
|
||||
: undefined
|
||||
}
|
||||
colorClass="text-emerald-200"
|
||||
titleClass="text-emerald-100 font-bold text-md"
|
||||
descriptionClass="text-emerald-200 text-md font-semibold"
|
||||
icon={DollarSign}
|
||||
iconColor="text-emerald-900"
|
||||
iconBackground="bg-emerald-300"
|
||||
onDetailsClick={() => setSelectedMetric("revenue")}
|
||||
isLoading={loading || !stats}
|
||||
variant="mini"
|
||||
background="h-[150px] bg-gradient-to-br from-emerald-900 to-emerald-800"
|
||||
gradient="emerald"
|
||||
className="h-[150px]"
|
||||
onClick={() => setSelectedMetric("revenue")}
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
<DashboardStatCardMini
|
||||
title="Today's Orders"
|
||||
value={stats?.orderCount}
|
||||
description={`${stats?.itemCount} total items`}
|
||||
trend={orderTrend?.trend}
|
||||
trendValue={orderTrend?.value ? formatPercentage(orderTrend.value) : null}
|
||||
colorClass="text-blue-200"
|
||||
titleClass="text-blue-100 font-bold text-md"
|
||||
descriptionClass="text-blue-200 text-md font-semibold"
|
||||
trend={
|
||||
orderTrend?.trend
|
||||
? { direction: orderTrend.trend, value: formatPercentage(orderTrend.value) }
|
||||
: undefined
|
||||
}
|
||||
icon={ShoppingCart}
|
||||
iconColor="text-blue-900"
|
||||
iconBackground="bg-blue-300"
|
||||
onDetailsClick={() => setSelectedMetric("orders")}
|
||||
isLoading={loading || !stats}
|
||||
variant="mini"
|
||||
background="h-[150px] bg-gradient-to-br from-blue-900 to-blue-800"
|
||||
gradient="blue"
|
||||
className="h-[150px]"
|
||||
onClick={() => setSelectedMetric("orders")}
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
<DashboardStatCardMini
|
||||
title="Today's AOV"
|
||||
value={stats?.averageOrderValue?.toFixed(2)}
|
||||
valuePrefix="$"
|
||||
description={`${stats?.averageItemsPerOrder?.toFixed(1)} items per order`}
|
||||
trend={aovTrend?.trend}
|
||||
trendValue={aovTrend?.value ? formatPercentage(aovTrend.value) : null}
|
||||
colorClass="text-violet-200"
|
||||
titleClass="text-violet-100 font-bold text-md"
|
||||
descriptionClass="text-violet-200 text-md font-semibold"
|
||||
trend={
|
||||
aovTrend?.trend
|
||||
? { direction: aovTrend.trend, value: formatPercentage(aovTrend.value) }
|
||||
: undefined
|
||||
}
|
||||
icon={CircleDollarSign}
|
||||
iconColor="text-violet-900"
|
||||
iconBackground="bg-violet-300"
|
||||
onDetailsClick={() => setSelectedMetric("average_order")}
|
||||
isLoading={loading || !stats}
|
||||
variant="mini"
|
||||
background="h-[150px] bg-gradient-to-br from-violet-900 to-violet-800"
|
||||
gradient="violet"
|
||||
className="h-[150px]"
|
||||
onClick={() => setSelectedMetric("average_order")}
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
<DashboardStatCardMini
|
||||
title="Shipped Today"
|
||||
value={stats?.shipping?.shippedCount || 0}
|
||||
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}
|
||||
iconColor="text-orange-900"
|
||||
iconBackground="bg-orange-300"
|
||||
onDetailsClick={() => setSelectedMetric("shipping")}
|
||||
isLoading={loading || !stats}
|
||||
variant="mini"
|
||||
background="h-[150px] bg-gradient-to-br from-orange-900 to-orange-800"
|
||||
gradient="orange"
|
||||
className="h-[150px]"
|
||||
onClick={() => setSelectedMetric("shipping")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -633,7 +355,7 @@ const MiniStatCards = ({
|
||||
selectedMetric === 'orders' ? 'bg-blue-50 dark:bg-blue-950/30' :
|
||||
selectedMetric === 'average_order' ? 'bg-violet-50 dark:bg-violet-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`}>
|
||||
<div className="transform scale-[2] origin-top-left h-[40vh] w-[40vw]">
|
||||
<div className="h-full w-full p-6">
|
||||
@@ -657,20 +379,18 @@ const MiniStatCards = ({
|
||||
{detailDataLoading[selectedMetric] ? (
|
||||
<div className="space-y-4 h-full">
|
||||
{selectedMetric === "shipping" ? (
|
||||
<MiniSkeletonTable
|
||||
<TableSkeleton
|
||||
rows={8}
|
||||
colorScheme={
|
||||
selectedMetric === 'revenue' ? 'emerald' :
|
||||
selectedMetric === 'orders' ? 'blue' :
|
||||
selectedMetric === 'average_order' ? 'violet' :
|
||||
'orange'
|
||||
}
|
||||
columns={3}
|
||||
colorVariant={getColorVariant(selectedMetric)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<MiniSkeletonChart
|
||||
<ChartSkeleton
|
||||
type={selectedMetric === "orders" ? "bar" : "line"}
|
||||
metric={selectedMetric}
|
||||
height="sm"
|
||||
withCard={false}
|
||||
colorVariant={getColorVariant(selectedMetric)}
|
||||
/>
|
||||
{selectedMetric === "orders" && (
|
||||
<div className="mt-8">
|
||||
@@ -683,7 +403,12 @@ const MiniStatCards = ({
|
||||
}`}>
|
||||
Hourly Distribution
|
||||
</h3>
|
||||
<MiniSkeletonChart type="bar" metric={selectedMetric} />
|
||||
<ChartSkeleton
|
||||
type="bar"
|
||||
height="sm"
|
||||
withCard={false}
|
||||
colorVariant={getColorVariant(selectedMetric)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -226,7 +226,7 @@ const Navigation = () => {
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
"w-full bg-white dark:bg-gray-900 transition-all duration-200",
|
||||
"w-full bg-background transition-all duration-200",
|
||||
isStuck
|
||||
? "rounded-lg mt-2 shadow-md"
|
||||
: "shadow-sm rounded-t-none border-t-0 -mt-6 pb-2"
|
||||
@@ -261,7 +261,7 @@ const Navigation = () => {
|
||||
))}
|
||||
</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
|
||||
variant="icon"
|
||||
size="sm"
|
||||
|
||||
@@ -30,7 +30,8 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} 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 = ({
|
||||
timeRange = "today",
|
||||
@@ -127,8 +128,8 @@ const ProductGrid = ({
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<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-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 w-[50px] min-w-[50px] border-b border-border/50" />
|
||||
<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
|
||||
variant="ghost"
|
||||
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" />
|
||||
</Button>
|
||||
</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
|
||||
variant="ghost"
|
||||
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" />
|
||||
</Button>
|
||||
</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
|
||||
variant="ghost"
|
||||
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" />
|
||||
</Button>
|
||||
</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
|
||||
variant="ghost"
|
||||
className="w-full p-2 justify-center h-8 pointer-events-none"
|
||||
@@ -166,7 +167,7 @@ const ProductGrid = ({
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{[...Array(20)].map((_, i) => (
|
||||
<SkeletonProduct key={i} />
|
||||
))}
|
||||
@@ -178,12 +179,12 @@ const ProductGrid = ({
|
||||
|
||||
if (loading) {
|
||||
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">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<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" />
|
||||
</CardTitle>
|
||||
{description && (
|
||||
@@ -210,14 +211,14 @@ const ProductGrid = ({
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">{title}</CardTitle>
|
||||
<CardTitle className={TYPOGRAPHY.sectionTitle}>{title}</CardTitle>
|
||||
{description && (
|
||||
<CardDescription className="mt-1 text-muted-foreground">{description}</CardDescription>
|
||||
<CardDescription className={`mt-1 ${TYPOGRAPHY.cardDescription}`}>{description}</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<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">
|
||||
<div className="h-full">
|
||||
{error ? (
|
||||
<Alert 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 products: {error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<DashboardErrorState error={`Failed to load products: ${error}`} className="mx-0 my-0" />
|
||||
) : !products?.length ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Package className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="font-medium mb-2 text-gray-900 dark:text-gray-100">No product data available</p>
|
||||
<p className="text-sm text-muted-foreground">Try selecting a different time range</p>
|
||||
</div>
|
||||
<DashboardEmptyState
|
||||
icon={Package}
|
||||
title="No product data available"
|
||||
description="Try selecting a different time range"
|
||||
height="sm"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full">
|
||||
<div className="overflow-y-auto h-full">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<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-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 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-card z-10 border-b border-border/50">
|
||||
<Button
|
||||
variant={sorting.column === "name" ? "default" : "ghost"}
|
||||
onClick={() => handleSort("name")}
|
||||
@@ -308,7 +304,7 @@ const ProductGrid = ({
|
||||
Product
|
||||
</Button>
|
||||
</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
|
||||
variant={sorting.column === "totalQuantity" ? "default" : "ghost"}
|
||||
onClick={() => handleSort("totalQuantity")}
|
||||
@@ -317,7 +313,7 @@ const ProductGrid = ({
|
||||
Sold
|
||||
</Button>
|
||||
</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
|
||||
variant={sorting.column === "totalRevenue" ? "default" : "ghost"}
|
||||
onClick={() => handleSort("totalRevenue")}
|
||||
@@ -326,7 +322,7 @@ const ProductGrid = ({
|
||||
Rev
|
||||
</Button>
|
||||
</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
|
||||
variant={sorting.column === "orderCount" ? "default" : "ghost"}
|
||||
onClick={() => handleSort("orderCount")}
|
||||
@@ -337,7 +333,7 @@ const ProductGrid = ({
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{filteredProducts.map((product) => (
|
||||
<tr
|
||||
key={product.id}
|
||||
@@ -364,7 +360,7 @@ const ProductGrid = ({
|
||||
href={`https://backend.acherryontop.com/product/${product.id}`}
|
||||
target="_blank"
|
||||
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}
|
||||
</a>
|
||||
@@ -376,7 +372,7 @@ const ProductGrid = ({
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</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}
|
||||
</td>
|
||||
<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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
@@ -7,19 +7,13 @@ import {
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
import { Loader2, AlertTriangle } from "lucide-react";
|
||||
import {
|
||||
Tooltip as UITooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
TooltipProvider,
|
||||
} 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 {
|
||||
Table,
|
||||
@@ -30,141 +24,51 @@ import {
|
||||
TableCell,
|
||||
} from "@/components/ui/table";
|
||||
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: {
|
||||
color: "#8b5cf6",
|
||||
className: "text-purple-600 dark:text-purple-400",
|
||||
color: METRIC_COLORS.aov, // Purple
|
||||
className: "text-chart-aov",
|
||||
},
|
||||
pages: {
|
||||
color: "#10b981",
|
||||
className: "text-emerald-600 dark:text-emerald-400",
|
||||
color: METRIC_COLORS.revenue, // Emerald
|
||||
className: "text-chart-revenue",
|
||||
},
|
||||
sources: {
|
||||
color: "#f59e0b",
|
||||
className: "text-amber-600 dark:text-amber-400",
|
||||
color: METRIC_COLORS.comparison, // Amber
|
||||
className: "text-chart-comparison",
|
||||
},
|
||||
};
|
||||
|
||||
export const summaryCard = (label, sublabel, value, options = {}) => {
|
||||
const {
|
||||
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 for backwards compatibility
|
||||
export { REALTIME_COLORS as METRIC_COLORS };
|
||||
|
||||
export const SkeletonSummaryCard = () => (
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<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>
|
||||
<StatCardSkeleton size="default" hasIcon={false} hasSubtitle />
|
||||
);
|
||||
|
||||
export const SkeletonBarChart = () => (
|
||||
<div className="h-[235px] bg-white dark:bg-gray-900/60 backdrop-blur-sm rounded-lg p-4">
|
||||
<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>
|
||||
<ChartSkeleton type="bar" height="sm" withCard={false} />
|
||||
);
|
||||
|
||||
export const SkeletonTable = () => (
|
||||
<div className="space-y-2 h-[230px] overflow-y-auto">
|
||||
<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>
|
||||
<TableSkeleton rows={8} columns={2} scrollable maxHeight="sm" />
|
||||
);
|
||||
|
||||
export const processBasicData = (data) => {
|
||||
@@ -223,10 +127,9 @@ export const QuotaInfo = ({ tokenQuota }) => {
|
||||
|
||||
const {
|
||||
remaining: projectHourlyRemaining = 0,
|
||||
consumed: projectHourlyConsumed = 0,
|
||||
} = projectHourly;
|
||||
|
||||
const { remaining: dailyRemaining = 0, consumed: dailyConsumed = 0 } = daily;
|
||||
const { remaining: dailyRemaining = 0 } = daily;
|
||||
|
||||
const { remaining: errorsRemaining = 10, consumed: errorsConsumed = 0 } =
|
||||
serverErrors;
|
||||
@@ -244,9 +147,9 @@ export const QuotaInfo = ({ tokenQuota }) => {
|
||||
const getStatusColor = (percentage) => {
|
||||
const numericPercentage = parseFloat(percentage);
|
||||
if (isNaN(numericPercentage) || numericPercentage < 20)
|
||||
return "text-red-500 dark:text-red-400";
|
||||
if (numericPercentage < 40) return "text-yellow-500 dark:text-yellow-400";
|
||||
return "text-green-500 dark:text-green-400";
|
||||
return "text-trend-negative";
|
||||
if (numericPercentage < 40) return "text-chart-comparison";
|
||||
return "text-trend-positive";
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -258,10 +161,9 @@ export const QuotaInfo = ({ tokenQuota }) => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="dark:border-gray-700">
|
||||
<div className="space-y-3 mt-2">
|
||||
<div>
|
||||
<div className="font-semibold text-gray-100">
|
||||
<div className="font-semibold text-foreground">
|
||||
Project Hourly
|
||||
</div>
|
||||
<div className={`${getStatusColor(hourlyPercentage)}`}>
|
||||
@@ -269,7 +171,7 @@ export const QuotaInfo = ({ tokenQuota }) => {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-100">
|
||||
<div className="font-semibold text-foreground">
|
||||
Daily
|
||||
</div>
|
||||
<div className={`${getStatusColor(dailyPercentage)}`}>
|
||||
@@ -277,7 +179,7 @@ export const QuotaInfo = ({ tokenQuota }) => {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-100">
|
||||
<div className="font-semibold text-foreground">
|
||||
Server Errors
|
||||
</div>
|
||||
<div className={`${getStatusColor(errorPercentage)}`}>
|
||||
@@ -285,7 +187,7 @@ export const QuotaInfo = ({ tokenQuota }) => {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-100">
|
||||
<div className="font-semibold text-foreground">
|
||||
Thresholded Requests
|
||||
</div>
|
||||
<div className={`${getStatusColor(thresholdPercentage)}`}>
|
||||
@@ -293,11 +195,31 @@ export const QuotaInfo = ({ tokenQuota }) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// 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 = () => {
|
||||
const [basicData, setBasicData] = useState({
|
||||
last30MinUsers: 0,
|
||||
@@ -422,24 +344,13 @@ export const RealtimeAnalytics = () => {
|
||||
};
|
||||
}, [isPaused]);
|
||||
|
||||
const togglePause = () => {
|
||||
setIsPaused(!isPaused);
|
||||
};
|
||||
|
||||
if (loading && !basicData && !detailedData) {
|
||||
return (
|
||||
<Card className="w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm h-full">
|
||||
<CardHeader className="p-6 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>
|
||||
<Card className={`${CARD_STYLES.base} h-full`}>
|
||||
<DashboardSectionHeader title="Real-Time Analytics" className="pb-2" />
|
||||
|
||||
<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 />
|
||||
</div>
|
||||
@@ -447,7 +358,7 @@ export const RealtimeAnalytics = () => {
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
{[...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>
|
||||
<SkeletonBarChart />
|
||||
@@ -458,19 +369,17 @@ export const RealtimeAnalytics = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm h-full">
|
||||
<CardHeader className="p-6 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>
|
||||
<div className="flex items-end">
|
||||
<Card className={`${CARD_STYLES.base} h-full`}>
|
||||
<DashboardSectionHeader
|
||||
title="Real-Time Analytics"
|
||||
className="pb-2"
|
||||
actions={
|
||||
<TooltipProvider>
|
||||
<UITooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className={TYPOGRAPHY.label}>
|
||||
Last updated:{" "}
|
||||
{format(new Date(basicData.lastUpdated), "h:mm a")}
|
||||
{basicData.lastUpdated && format(new Date(basicData.lastUpdated), "h:mm a")}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="p-3">
|
||||
@@ -478,31 +387,27 @@ export const RealtimeAnalytics = () => {
|
||||
</TooltipContent>
|
||||
</UITooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
}
|
||||
/>
|
||||
|
||||
<CardContent className="p-6 pt-0">
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
<DashboardErrorState error={error} className="mx-0 mb-4" />
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mt-1 mb-3">
|
||||
{summaryCard(
|
||||
"Last 30 minutes",
|
||||
"Active users",
|
||||
basicData.last30MinUsers,
|
||||
{ colorClass: METRIC_COLORS.activeUsers.className }
|
||||
)}
|
||||
{summaryCard(
|
||||
"Last 5 minutes",
|
||||
"Active users",
|
||||
basicData.last5MinUsers,
|
||||
{ colorClass: METRIC_COLORS.activeUsers.className }
|
||||
)}
|
||||
<DashboardStatCard
|
||||
title="Last 30 minutes"
|
||||
subtitle="Active users"
|
||||
value={basicData.last30MinUsers}
|
||||
size="large"
|
||||
/>
|
||||
<DashboardStatCard
|
||||
title="Last 5 minutes"
|
||||
subtitle="Active users"
|
||||
value={basicData.last5MinUsers}
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="activity" className="w-full">
|
||||
@@ -513,7 +418,7 @@ export const RealtimeAnalytics = () => {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="activity">
|
||||
<div className="h-[235px] bg-card rounded-lg">
|
||||
<div className="h-[235px] rounded-lg">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={basicData.byMinute}
|
||||
@@ -522,68 +427,47 @@ export const RealtimeAnalytics = () => {
|
||||
<XAxis
|
||||
dataKey="minute"
|
||||
tickFormatter={(value) => value + "m"}
|
||||
className="text-xs"
|
||||
tick={{ fill: "currentColor" }}
|
||||
className="text-xs fill-muted-foreground"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis className="text-xs" tick={{ fill: "currentColor" }} />
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const timestamp = new Date(
|
||||
Date.now() + payload[0].payload.minute * 60000
|
||||
);
|
||||
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">
|
||||
{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;
|
||||
}}
|
||||
<YAxis
|
||||
className="text-xs fill-muted-foreground"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip content={<RealtimeTooltip />} />
|
||||
<Bar
|
||||
dataKey="users"
|
||||
fill={REALTIME_COLORS.activeUsers.color}
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
<Bar dataKey="users" fill={METRIC_COLORS.activeUsers.color} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<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>
|
||||
<TableHeader>
|
||||
<TableRow className="dark:border-gray-800">
|
||||
<TableHead className="text-gray-900 dark:text-gray-100">
|
||||
<TableRow>
|
||||
<TableHead className="text-foreground">
|
||||
Page
|
||||
</TableHead>
|
||||
<TableHead className="text-right text-gray-900 dark:text-gray-100">
|
||||
<TableHead className="text-right text-foreground">
|
||||
Active Users
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detailedData.currentPages.map((page, index) => (
|
||||
<TableRow key={index} className="dark:border-gray-800">
|
||||
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
|
||||
<TableRow key={index} className="hover:bg-muted/50 transition-colors">
|
||||
<TableCell className="font-medium text-foreground">
|
||||
{page.path}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={`text-right ${METRIC_COLORS.pages.className}`}
|
||||
className={`text-right ${REALTIME_COLORS.pages.className}`}
|
||||
>
|
||||
{page.activeUsers}
|
||||
</TableCell>
|
||||
@@ -595,26 +479,26 @@ export const RealtimeAnalytics = () => {
|
||||
</TabsContent>
|
||||
|
||||
<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>
|
||||
<TableHeader>
|
||||
<TableRow className="dark:border-gray-800">
|
||||
<TableHead className="text-gray-900 dark:text-gray-100">
|
||||
<TableRow>
|
||||
<TableHead className="text-foreground">
|
||||
Source
|
||||
</TableHead>
|
||||
<TableHead className="text-right text-gray-900 dark:text-gray-100">
|
||||
<TableHead className="text-right text-foreground">
|
||||
Active Users
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detailedData.sources.map((source, index) => (
|
||||
<TableRow key={index} className="dark:border-gray-800">
|
||||
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
|
||||
<TableRow key={index} className="hover:bg-muted/50 transition-colors">
|
||||
<TableCell className="font-medium text-foreground">
|
||||
{source.source}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={`text-right ${METRIC_COLORS.sources.className}`}
|
||||
className={`text-right ${REALTIME_COLORS.sources.className}`}
|
||||
>
|
||||
{source.activeUsers}
|
||||
</TableCell>
|
||||
|
||||
@@ -24,8 +24,6 @@ import {
|
||||
TrendingDown,
|
||||
Info,
|
||||
AlertCircle,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
LineChart,
|
||||
@@ -70,7 +68,19 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} 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 = {
|
||||
PLACED_ORDER: "Y8cqcF",
|
||||
@@ -127,70 +137,17 @@ const formatPercentage = (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 = {
|
||||
revenue: "#8b5cf6",
|
||||
orders: "#10b981",
|
||||
avgOrderValue: "#9333ea",
|
||||
movingAverage: "#f59e0b",
|
||||
prevRevenue: "#f97316",
|
||||
prevOrders: "#0ea5e9",
|
||||
prevAvgOrderValue: "#f59e0b",
|
||||
revenue: SHARED_METRIC_COLORS.aov, // Purple for revenue
|
||||
orders: SHARED_METRIC_COLORS.revenue, // Emerald for orders
|
||||
avgOrderValue: "#9333ea", // Deep purple for AOV
|
||||
movingAverage: SHARED_METRIC_COLORS.comparison, // Amber for moving average
|
||||
prevRevenue: SHARED_METRIC_COLORS.expense, // Orange for prev revenue
|
||||
prevOrders: SHARED_METRIC_COLORS.secondary, // Cyan for prev orders
|
||||
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 const CustomTooltip = ({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
@@ -202,23 +159,29 @@ export const CustomTooltip = ({ active, payload, label }) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="p-2 shadow-lg bg-white dark:bg-gray-800 border-none">
|
||||
<CardContent className="p-0 space-y-1">
|
||||
<p className="font-medium text-xs">{formattedDate}</p>
|
||||
<div className={TOOLTIP_STYLES.container}>
|
||||
<p className={TOOLTIP_STYLES.header}>{formattedDate}</p>
|
||||
<div className={TOOLTIP_STYLES.content}>
|
||||
{payload.map((entry, index) => {
|
||||
const value = entry.dataKey.toLowerCase().includes('revenue') || entry.dataKey === 'avgOrderValue'
|
||||
? formatCurrency(entry.value)
|
||||
: entry.value.toLocaleString();
|
||||
|
||||
return (
|
||||
<div key={index} className="flex justify-between items-center text-xs gap-3">
|
||||
<span style={{ color: entry.stroke }}>{entry.name}:</span>
|
||||
<span className="font-medium">{value}</span>
|
||||
<div key={index} 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}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -394,54 +357,64 @@ const SummaryStats = memo(({ stats = {}, projection = null, projectionLoading =
|
||||
const aovDiff = Math.abs(currentAOV - prevAvgOrderValue);
|
||||
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 (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 max-w-3xl">
|
||||
<StatCard
|
||||
<DashboardStatCard
|
||||
title="Total Revenue"
|
||||
value={formatCurrency(totalRevenue, false)}
|
||||
description={
|
||||
subtitle={
|
||||
periodProgress < 100
|
||||
? `Projected: ${formatCurrency(projection?.projectedRevenue || totalRevenue, false)}`
|
||||
: `Previous: ${formatCurrency(prevRevenue, false)}`
|
||||
}
|
||||
trend={projectionLoading && periodProgress < 100 ? undefined : revenueTrend}
|
||||
trendValue={projectionLoading && periodProgress < 100 ? null : formatPercentage(revenuePercentage)}
|
||||
info="Total revenue for the selected period"
|
||||
colorClass="text-green-600 dark:text-green-400"
|
||||
trend={getNumericTrend(revenueTrend, revenuePercentage) !== undefined
|
||||
? { value: getNumericTrend(revenueTrend, revenuePercentage) }
|
||||
: undefined}
|
||||
tooltip="Total revenue for the selected period"
|
||||
size="compact"
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
<DashboardStatCard
|
||||
title="Total Orders"
|
||||
value={totalOrders.toLocaleString()}
|
||||
description={
|
||||
subtitle={
|
||||
periodProgress < 100
|
||||
? `Projected: ${(projection?.projectedOrders || totalOrders).toLocaleString()}`
|
||||
: `Previous: ${prevOrders.toLocaleString()}`
|
||||
}
|
||||
trend={projectionLoading && periodProgress < 100 ? undefined : ordersTrend}
|
||||
trendValue={projectionLoading && periodProgress < 100 ? null : formatPercentage(ordersPercentage)}
|
||||
info="Total number of orders for the selected period"
|
||||
colorClass="text-blue-600 dark:text-blue-400"
|
||||
trend={getNumericTrend(ordersTrend, ordersPercentage) !== undefined
|
||||
? { value: getNumericTrend(ordersTrend, ordersPercentage) }
|
||||
: undefined}
|
||||
tooltip="Total number of orders for the selected period"
|
||||
size="compact"
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
<DashboardStatCard
|
||||
title="AOV"
|
||||
value={formatCurrency(avgOrderValue)}
|
||||
description={
|
||||
subtitle={
|
||||
periodProgress < 100
|
||||
? `Projected: ${formatCurrency(currentAOV)}`
|
||||
: `Previous: ${formatCurrency(prevAvgOrderValue)}`
|
||||
}
|
||||
trend={projectionLoading && periodProgress < 100 ? undefined : aovTrend}
|
||||
trendValue={projectionLoading && periodProgress < 100 ? null : formatPercentage(aovPercentage)}
|
||||
info="Average value per order for the selected period"
|
||||
colorClass="text-purple-600 dark:text-purple-400"
|
||||
trend={getNumericTrend(aovTrend, aovPercentage) !== undefined
|
||||
? { value: getNumericTrend(aovTrend, aovPercentage) }
|
||||
: undefined}
|
||||
tooltip="Average value per order for the selected period"
|
||||
size="compact"
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
<DashboardStatCard
|
||||
title="Best Day"
|
||||
value={formatCurrency(bestDay?.revenue || 0, false)}
|
||||
description={
|
||||
subtitle={
|
||||
bestDay?.timestamp
|
||||
? `${new Date(bestDay.timestamp).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
@@ -449,8 +422,8 @@ const SummaryStats = memo(({ stats = {}, projection = null, projectionLoading =
|
||||
})} - ${bestDay.orders} orders`
|
||||
: "No data"
|
||||
}
|
||||
info="Day with highest revenue in the selected period"
|
||||
colorClass="text-orange-600 dark:text-orange-400"
|
||||
tooltip="Day with highest revenue in the selected period"
|
||||
size="compact"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -458,50 +431,12 @@ const SummaryStats = memo(({ stats = {}, projection = null, projectionLoading =
|
||||
|
||||
SummaryStats.displayName = "SummaryStats";
|
||||
|
||||
// Add these skeleton components near the top of the file
|
||||
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>
|
||||
);
|
||||
// Note: Using ChartSkeleton and TableSkeleton from @/components/dashboard/shared
|
||||
|
||||
const SkeletonStats = () => (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-2 md:gap-3">
|
||||
{[...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">
|
||||
<Skeleton className="h-4 w-24 bg-muted rounded-sm" />
|
||||
<Skeleton className="h-4 w-8 bg-muted rounded-sm" />
|
||||
@@ -515,19 +450,6 @@ const SkeletonStats = () => (
|
||||
</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 [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -644,12 +566,12 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
|
||||
: 0;
|
||||
|
||||
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">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
<CardTitle className={TYPOGRAPHY.sectionTitle}>
|
||||
{title}
|
||||
</CardTitle>
|
||||
</div>
|
||||
@@ -661,9 +583,9 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
|
||||
Details
|
||||
</Button>
|
||||
</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">
|
||||
<DialogTitle className="text-gray-900 dark:text-gray-100">
|
||||
<DialogTitle className="text-foreground">
|
||||
Daily Details
|
||||
</DialogTitle>
|
||||
<div className="flex items-center justify-center gap-2 pt-4">
|
||||
@@ -739,7 +661,7 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<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">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -960,38 +882,23 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
|
||||
<CardContent className="p-6 pt-0">
|
||||
{loading ? (
|
||||
<div className="space-y-6">
|
||||
<SkeletonChart />
|
||||
<ChartSkeleton height="default" withCard={false} />
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Skeleton className="h-9 w-24 bg-muted rounded-sm" />
|
||||
</div>
|
||||
{showDailyTable && <SkeletonTable />}
|
||||
{showDailyTable && <TableSkeleton rows={7} columns={3} />}
|
||||
</div>
|
||||
) : error ? (
|
||||
<Alert
|
||||
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>
|
||||
<DashboardErrorState error={`Failed to load sales data: ${error}`} className="mx-0 my-0" />
|
||||
) : !data.length ? (
|
||||
<div className="flex items-center justify-center h-[400px] text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<TrendingUp className="h-12 w-12 mx-auto mb-4" />
|
||||
<div className="font-medium mb-2 text-gray-900 dark:text-gray-100">
|
||||
No sales data available
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Try selecting a different time range
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DashboardEmptyState
|
||||
icon={TrendingUp}
|
||||
title="No sales data available"
|
||||
description="Try selecting a different time range"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<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%">
|
||||
<LineChart
|
||||
data={data}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,6 @@ import axios from "axios";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
@@ -17,10 +16,17 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
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 { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
||||
import {
|
||||
DashboardSectionHeader,
|
||||
DashboardErrorState,
|
||||
DashboardBadge,
|
||||
ChartSkeleton,
|
||||
TableSkeleton,
|
||||
SimpleTooltip,
|
||||
TOOLTIP_STYLES,
|
||||
} from "@/components/dashboard/shared";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
@@ -30,7 +36,6 @@ import {
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
ReferenceLine,
|
||||
} from "recharts";
|
||||
|
||||
// Get form IDs from environment variables
|
||||
@@ -44,74 +49,12 @@ const FORM_NAMES = {
|
||||
[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 }) => (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<DashboardSectionHeader title={title} compact />
|
||||
<CardContent>
|
||||
<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) => (
|
||||
<div key={response.token} className="p-4">
|
||||
{renderSummary(response)}
|
||||
@@ -138,24 +81,18 @@ const ProductRelevanceFeed = ({ responses }) => (
|
||||
{response.hidden?.email ? (
|
||||
<a
|
||||
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"}
|
||||
</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"}
|
||||
</span>
|
||||
)}
|
||||
<Badge
|
||||
className={
|
||||
answer?.boolean
|
||||
? "bg-green-200 text-green-700"
|
||||
: "bg-red-200 text-red-700"
|
||||
}
|
||||
>
|
||||
<DashboardBadge variant={answer?.boolean ? "success" : "error"}>
|
||||
{answer?.boolean ? "Yes" : "No"}
|
||||
</Badge>
|
||||
</DashboardBadge>
|
||||
</div>
|
||||
<time
|
||||
className="text-xs text-muted-foreground"
|
||||
@@ -193,32 +130,32 @@ const WinbackFeed = ({ responses }) => (
|
||||
{response.hidden?.email ? (
|
||||
<a
|
||||
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"}
|
||||
</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"}
|
||||
</span>
|
||||
)}
|
||||
<Badge
|
||||
className={
|
||||
<DashboardBadge
|
||||
variant={
|
||||
likelihoodAnswer?.number === 1
|
||||
? "bg-red-200 text-red-700"
|
||||
? "error"
|
||||
: likelihoodAnswer?.number === 2
|
||||
? "bg-orange-200 text-orange-700"
|
||||
? "orange"
|
||||
: likelihoodAnswer?.number === 3
|
||||
? "bg-yellow-200 text-yellow-700"
|
||||
? "yellow"
|
||||
: likelihoodAnswer?.number === 4
|
||||
? "bg-lime-200 text-lime-700"
|
||||
? "emerald"
|
||||
: likelihoodAnswer?.number === 5
|
||||
? "bg-green-200 text-green-700"
|
||||
: "bg-gray-200 text-gray-700"
|
||||
? "success"
|
||||
: "default"
|
||||
}
|
||||
>
|
||||
{likelihoodAnswer?.number}/5
|
||||
</Badge>
|
||||
</DashboardBadge>
|
||||
</div>
|
||||
<time
|
||||
className="text-xs text-muted-foreground"
|
||||
@@ -390,11 +327,12 @@ const TypeformDashboard = () => {
|
||||
|
||||
if (error) {
|
||||
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">
|
||||
<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">
|
||||
{error}
|
||||
</div>
|
||||
<DashboardErrorState
|
||||
title="Failed to load survey data"
|
||||
message={error}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -413,33 +351,26 @@ const TypeformDashboard = () => {
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<CardHeader className="p-6 pb-0">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Customer Surveys
|
||||
</CardTitle>
|
||||
{newestResponse && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Newest response:{" "}
|
||||
{format(new Date(newestResponse), "MMM d, h:mm a")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<Card className={CARD_STYLES.base}>
|
||||
<DashboardSectionHeader
|
||||
title="Customer Surveys"
|
||||
lastUpdated={newestResponse ? new Date(newestResponse) : null}
|
||||
lastUpdatedFormat={(date) => `Newest response: ${format(date, "MMM d, h:mm a")}`}
|
||||
className="pb-0"
|
||||
/>
|
||||
<CardContent className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
<SkeletonChart />
|
||||
<SkeletonTable />
|
||||
<ChartSkeleton height="md" withCard={false} />
|
||||
<TableSkeleton rows={5} columns={3} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<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">
|
||||
<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?
|
||||
</CardTitle>
|
||||
<span
|
||||
@@ -489,21 +420,12 @@ const TypeformDashboard = () => {
|
||||
/>
|
||||
<YAxis className="text-muted-foreground text-xs md:text-sm" />
|
||||
<Tooltip
|
||||
content={({ payload }) => {
|
||||
if (payload && payload.length) {
|
||||
const { rating, count } = payload[0].payload;
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
content={
|
||||
<SimpleTooltip
|
||||
labelFormatter={(label) => `${label} Rating`}
|
||||
valueFormatter={(value) => `${value} responses`}
|
||||
/>
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="count">
|
||||
{likelihoodCounts.map((_, index) => (
|
||||
@@ -528,10 +450,10 @@ const TypeformDashboard = () => {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<Card className="bg-card">
|
||||
<CardHeader className="p-6">
|
||||
<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?
|
||||
</CardTitle>
|
||||
<div className="flex flex-col items-end">
|
||||
@@ -567,35 +489,27 @@ const TypeformDashboard = () => {
|
||||
const yesCount = payload[0].payload.yes;
|
||||
const noCount = payload[0].payload.no;
|
||||
const total = yesCount + noCount;
|
||||
const yesPercent = Math.round(
|
||||
(yesCount / total) * 100
|
||||
);
|
||||
const noPercent = Math.round(
|
||||
(noCount / total) * 100
|
||||
);
|
||||
const yesPercent = Math.round((yesCount / total) * 100);
|
||||
const noPercent = Math.round((noCount / total) * 100);
|
||||
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">
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-emerald-500 font-medium">
|
||||
Yes:
|
||||
</span>
|
||||
<span className="ml-4 text-muted-foreground">
|
||||
{yesCount} ({yesPercent}%)
|
||||
</span>
|
||||
<div className={TOOLTIP_STYLES.container}>
|
||||
<div className={TOOLTIP_STYLES.content}>
|
||||
<div className={TOOLTIP_STYLES.row}>
|
||||
<div className={TOOLTIP_STYLES.rowLabel}>
|
||||
<span className={TOOLTIP_STYLES.dot} style={{ backgroundColor: '#10b981' }} />
|
||||
<span className={TOOLTIP_STYLES.name}>Yes</span>
|
||||
</div>
|
||||
<span className={TOOLTIP_STYLES.value}>{yesCount} ({yesPercent}%)</span>
|
||||
</div>
|
||||
<div className={TOOLTIP_STYLES.row}>
|
||||
<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 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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -637,24 +551,20 @@ const TypeformDashboard = () => {
|
||||
|
||||
<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">
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm h-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Reasons for Not Ordering
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<Card className="bg-card h-full">
|
||||
<DashboardSectionHeader title="Reasons for Not Ordering" compact />
|
||||
<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">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="font-medium text-gray-900 dark:text-gray-100">
|
||||
<TableHead className="font-medium text-foreground">
|
||||
Reason
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">
|
||||
<TableHead className="text-right font-medium text-foreground">
|
||||
Count
|
||||
</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>
|
||||
</TableRow>
|
||||
@@ -665,7 +575,7 @@ const TypeformDashboard = () => {
|
||||
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}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
Select,
|
||||
@@ -22,62 +22,14 @@ import {
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
// Add skeleton components
|
||||
const SkeletonTable = ({ rows = 12 }) => (
|
||||
<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">
|
||||
<Table>
|
||||
<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>
|
||||
);
|
||||
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
||||
import {
|
||||
DashboardSectionHeader,
|
||||
TableSkeleton,
|
||||
ChartSkeleton,
|
||||
TOOLTIP_STYLES,
|
||||
} from "@/components/dashboard/shared";
|
||||
|
||||
export const UserBehaviorDashboard = () => {
|
||||
const [data, setData] = useState(null);
|
||||
@@ -183,15 +135,13 @@ export const UserBehaviorDashboard = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm h-full">
|
||||
<CardHeader className="p-6 pb-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
User Behavior Analysis
|
||||
</CardTitle>
|
||||
<Skeleton className="h-9 w-36 bg-muted rounded-sm" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<Card className={`${CARD_STYLES.base} h-full`}>
|
||||
<DashboardSectionHeader
|
||||
title="User Behavior Analysis"
|
||||
loading={true}
|
||||
size="large"
|
||||
timeSelector={<div className="w-36" />}
|
||||
/>
|
||||
<CardContent className="p-6 pt-0">
|
||||
<Tabs defaultValue="pages" className="w-full">
|
||||
<TabsList className="mb-4">
|
||||
@@ -201,15 +151,15 @@ export const UserBehaviorDashboard = () => {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pages" className="mt-4 space-y-2">
|
||||
<SkeletonTable rows={15} />
|
||||
<TableSkeleton rows={15} columns={4} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sources" className="mt-4 space-y-2">
|
||||
<SkeletonTable rows={12} />
|
||||
<TableSkeleton rows={12} columns={4} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="devices" className="mt-4 space-y-2">
|
||||
<SkeletonPieChart />
|
||||
<ChartSkeleton type="pie" height="sm" withCard={false} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
@@ -235,20 +185,27 @@ export const UserBehaviorDashboard = () => {
|
||||
const data = payload[0].payload;
|
||||
const percentage = ((data.pageViews / totalViews) * 100).toFixed(1);
|
||||
const sessionPercentage = ((data.sessions / totalSessions) * 100).toFixed(1);
|
||||
const color = COLORS[data.device.toLowerCase()];
|
||||
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="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{data.device}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data.pageViews.toLocaleString()} views ({percentage}%)
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data.sessions.toLocaleString()} sessions ({sessionPercentage}%)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className={TOOLTIP_STYLES.container}>
|
||||
<p className={TOOLTIP_STYLES.header}>{data.device}</p>
|
||||
<div className={TOOLTIP_STYLES.content}>
|
||||
<div className={TOOLTIP_STYLES.row}>
|
||||
<div className={TOOLTIP_STYLES.rowLabel}>
|
||||
<span className={TOOLTIP_STYLES.dot} style={{ backgroundColor: color }} />
|
||||
<span className={TOOLTIP_STYLES.name}>Views</span>
|
||||
</div>
|
||||
<span className={TOOLTIP_STYLES.value}>{data.pageViews.toLocaleString()} ({percentage}%)</span>
|
||||
</div>
|
||||
<div className={TOOLTIP_STYLES.row}>
|
||||
<div className={TOOLTIP_STYLES.rowLabel}>
|
||||
<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;
|
||||
@@ -261,12 +218,11 @@ export const UserBehaviorDashboard = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-white dark:bg-gray-900/60 backdrop-blur-sm h-full">
|
||||
<CardHeader className="p-6 pb-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
User Behavior Analysis
|
||||
</CardTitle>
|
||||
<Card className={`${CARD_STYLES.base} h-full`}>
|
||||
<DashboardSectionHeader
|
||||
title="User Behavior Analysis"
|
||||
size="large"
|
||||
timeSelector={
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
<SelectTrigger className="w-36 h-9">
|
||||
<SelectValue>
|
||||
@@ -283,8 +239,8 @@ export const UserBehaviorDashboard = () => {
|
||||
<SelectItem value="90">Last 90 days</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
}
|
||||
/>
|
||||
<CardContent className="p-6 pt-0">
|
||||
<Tabs defaultValue="pages" className="w-full">
|
||||
<TabsList className="mb-4">
|
||||
@@ -365,7 +321,7 @@ export const UserBehaviorDashboard = () => {
|
||||
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"
|
||||
>
|
||||
<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%">
|
||||
<PieChart>
|
||||
<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-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 {
|
||||
@@ -106,6 +126,26 @@
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--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;
|
||||
}
|
||||
}
|
||||
|
||||
@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))',
|
||||
'3': 'hsl(var(--chart-3))',
|
||||
'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: {
|
||||
DEFAULT: 'hsl(var(--sidebar-background))',
|
||||
|
||||
@@ -15,7 +15,7 @@ export default defineConfig(({ mode }) => {
|
||||
{
|
||||
name: 'copy-build',
|
||||
closeBundle: async () => {
|
||||
if (!isDev) {
|
||||
if (!isDev && process.env.COPY_BUILD === 'true') {
|
||||
const sourcePath = path.resolve(__dirname, '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.remove(targetPath);
|
||||
await fs.copy(sourcePath, targetPath);
|
||||
console.log('✓ Build copied to inventory-server/frontend/build');
|
||||
} catch (error) {
|
||||
console.error('Error copying build files:', error);
|
||||
process.exit(1);
|
||||
|
||||
Reference in New Issue
Block a user