4 Commits

Author SHA1 Message Date
630945e901 Move more of dashboard to shared components 2026-01-18 16:52:00 -05:00
54ddaa0492 Rewrite validation step part 2 2026-01-18 16:26:34 -05:00
262890a7be Rewrite validation step part 1 2026-01-17 19:19:47 -05:00
ef50aec33c Unify dashboard with shared components 2026-01-17 17:03:39 -05:00
77 changed files with 14517 additions and 4963 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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,58 +8,39 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
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,
DashboardTable,
ChartSkeleton,
CARD_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,157 +64,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",
direction: "desc",
});
const handleSort = (key) => {
const direction =
sortConfig.key === key && sortConfig.direction === "desc"
? "asc"
: "desc";
setSortConfig({ key, direction });
onSort(key, direction);
};
return (
<Table>
<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>
</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>
<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-muted-foreground">{formatDuration(agent.average_duration)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
};
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 +76,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 +84,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,15 +113,57 @@ const AircallDashboard = () => {
})),
};
const peakHour = metrics?.by_hour
? metrics.by_hour.indexOf(Math.max(...metrics.by_hour))
: null;
// Column definitions for Agent Performance table
const agentColumns = [
{
key: "name",
header: "Agent",
render: (value) => <span className="font-medium text-foreground">{value}</span>,
},
{
key: "total",
header: "Total Calls",
align: "right",
sortable: true,
render: (value) => <span className="text-muted-foreground">{value}</span>,
},
{
key: "answered",
header: "Answered",
align: "right",
sortable: true,
render: (value) => <span className="text-trend-positive">{value}</span>,
},
{
key: "missed",
header: "Missed",
align: "right",
sortable: true,
render: (value) => <span className="text-trend-negative">{value}</span>,
},
{
key: "average_duration",
header: "Avg Duration",
align: "right",
sortable: true,
render: (value) => <span className="text-muted-foreground">{formatDuration(value)}</span>,
},
];
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];
// Column definitions for Missed Reasons table
const missedReasonsColumns = [
{
key: "reason",
header: "Reason",
render: (value) => <span className="font-medium text-foreground">{value}</span>,
},
{
key: "count",
header: "Count",
align: "right",
render: (value) => <span className="text-trend-negative">{value}</span>,
},
];
const fetchData = async () => {
try {
@@ -356,11 +189,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 +202,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 +218,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 +294,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 +327,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,56 +362,36 @@ 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} />
) : (
<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">
<AgentPerformanceTable
agents={sortedAgents}
<DashboardTable
columns={agentColumns}
data={sortedAgents}
loading={isLoading}
skeletonRows={5}
getRowKey={(agent) => agent.id}
sortConfig={agentSort}
onSort={(key, direction) => setAgentSort({ key, direction })}
maxHeight="md"
compact
/>
</div>
)}
</CardContent>
</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} />
) : (
<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 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>
</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">
{reason.reason}
</TableCell>
<TableCell className="text-right text-rose-600 dark:text-rose-400">
{reason.count}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<DashboardTable
columns={missedReasonsColumns}
data={chartData.missedReasons}
loading={isLoading}
skeletonRows={5}
getRowKey={(reason, index) => `${reason.reason}-${index}`}
maxHeight="md"
compact
/>
</CardContent>
</Card>
</div>

View File

@@ -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 {
Select,
SelectContent,
@@ -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,124 +17,32 @@ 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 } from "@/lib/dashboard/designTokens";
import {
DashboardSectionHeader,
DashboardStatCard,
DashboardStatCardSkeleton,
DashboardChartTooltip,
ChartSkeleton,
DashboardEmptyState,
} 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">
<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>
<CardContent className="p-4 pt-0">
<Skeleton className="h-8 w-32 bg-muted rounded-sm mb-2" />
<Skeleton className="h-4 w-24 bg-muted rounded-sm" />
</CardContent>
</Card>
<DashboardStatCardSkeleton key={i} size="compact" hasIcon={false} hasSubtitle />
))}
</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,58 +159,32 @@ 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>
// Time selector for DashboardSectionHeader
const timeSelector = (
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-[130px] h-9">
<SelectValue placeholder="Select range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">Last 7 days</SelectItem>
<SelectItem value="14">Last 14 days</SelectItem>
<SelectItem value="30">Last 30 days</SelectItem>
<SelectItem value="90">Last 90 days</SelectItem>
</SelectContent>
</Select>
);
}
return null;
};
return (
<Card className="w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<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">
Analytics Overview
</CardTitle>
</div>
<div className="flex items-center gap-2">
{loading ? (
<Skeleton className="h-9 w-[130px] bg-muted rounded-sm" />
) : (
<>
// Header actions: Details dialog
const headerActions = !loading ? (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" className="h-9">
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 +209,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>
@@ -379,62 +260,66 @@ export const AnalyticsDashboard = () => {
</div>
</DialogContent>
</Dialog>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-[130px] h-9">
<SelectValue placeholder="Select range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">Last 7 days</SelectItem>
<SelectItem value="14">Last 14 days</SelectItem>
<SelectItem value="30">Last 30 days</SelectItem>
<SelectItem value="90">Last 90 days</SelectItem>
</SelectContent>
</Select>
</>
)}
</div>
</div>
) : <Skeleton className="h-9 w-20 bg-muted rounded-sm" />;
// Label formatter for chart tooltip
const analyticsLabelFormatter = (label) => {
const date = label instanceof Date ? label : new Date(label);
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
};
return (
<Card className={`w-full ${CARD_STYLES.base}`}>
<DashboardSectionHeader
title="Analytics Overview"
loading={loading}
timeSelector={timeSelector}
actions={headerActions}
/>
<CardContent className="p-6 pt-0 space-y-4">
{/* Stats cards */}
{loading ? (
<SkeletonStats />
) : summaryStats ? (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4">
<StatCard
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<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}
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4 pt-2">
{/* Metric toggles */}
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4">
<div className="flex flex-wrap gap-1">
<Button
variant={metrics.activeUsers ? "default" : "outline"}
@@ -494,24 +379,16 @@ export const AnalyticsDashboard = () => {
</Button>
</div>
</div>
</div>
</CardHeader>
<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 +415,14 @@ export const AnalyticsDashboard = () => {
className="text-xs text-muted-foreground"
tick={{ fill: "currentColor" }}
/>
<Tooltip content={<CustomTooltip />} />
<Tooltip
content={
<DashboardChartTooltip
labelFormatter={analyticsLabelFormatter}
valueFormatter={(value) => value.toLocaleString()}
/>
}
/>
<Legend />
{metrics.activeUsers && (
<Line

View File

@@ -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} />
))}

View File

@@ -1,11 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { acotService } from "@/services/dashboard/acotService";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Select,
@@ -42,26 +37,21 @@ 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 {
DashboardSectionHeader,
DashboardStatCard,
DashboardStatCardSkeleton,
ChartSkeleton,
DashboardEmptyState,
DashboardErrorState,
TOOLTIP_STYLES,
} from "@/components/dashboard/shared";
type ComparisonValue = {
absolute: number | null;
@@ -501,45 +491,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 +844,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,
},
];
},
@@ -1150,29 +1098,18 @@ const FinancialOverview = () => {
};
return (
<Card className="w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<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">
Profit & Loss Overview
</CardTitle>
</div>
<div className="flex items-center gap-2">
{!error && (
// Header actions: Details dialog and Period selector
const headerActions = !error ? (
<>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" className="h-9" disabled={loading || !detailRows.length}>
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 +1141,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>
@@ -1287,24 +1224,30 @@ const FinancialOverview = () => {
onQuickSelect={handleQuickPeriod}
onApplyResult={handleNaturalLanguageResult}
/>
</>
)}
</div>
</div>
) : null;
return (
<Card className={`w-full ${CARD_STYLES.base}`}>
<DashboardSectionHeader
title="Profit & Loss Overview"
size="large"
actions={headerActions}
/>
<CardContent className="p-6 pt-0 space-y-4">
{/* Show stats only if not in error state */}
{!error &&
(loading ? (
{!error && (
loading ? (
<SkeletonStats />
) : (
cards.length > 0 && <FinancialStatGrid cards={cards} />
))}
)
)}
{/* Show metric toggles only if not in error state */}
{!error && (
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4 pt-2">
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4">
<div className="flex flex-wrap gap-1">
{SERIES_DEFINITIONS.map((series) => (
<Button
@@ -1345,41 +1288,27 @@ const FinancialOverview = () => {
</div>
</div>
)}
</div>
</CardHeader>
<CardContent className="p-6 pt-0">
{loading ? (
<div className="space-y-6">
<SkeletonChart />
<SkeletonChartSection />
</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 +1431,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,80 +1478,18 @@ 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>
);
}
function SkeletonChart() {
function SkeletonChartSection() {
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-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/30"
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-3 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-3 w-12 bg-muted rounded-sm" />
))}
</div>
{/* Chart area */}
<div className="absolute inset-0 mt-8 mb-8 ml-20 mr-4">
<div
className="absolute inset-0 bg-muted/20"
style={{
clipPath:
"polygon(0 60%, 15% 45%, 30% 55%, 45% 35%, 60% 50%, 75% 30%, 90% 45%, 100% 40%, 100% 100%, 0 100%)",
}}
/>
{/* Simulated line chart */}
<div
className="absolute inset-0 bg-blue-500/30"
style={{
clipPath:
"polygon(0 60%, 15% 45%, 30% 55%, 45% 35%, 60% 50%, 75% 30%, 90% 45%, 100% 40%, 100% 40%, 90% 45%, 75% 30%, 60% 50%, 45% 35%, 30% 55%, 15% 45%, 0 60%)",
height: "2px",
top: "50%",
}}
/>
</div>
</div>
</div>
</div>
<ChartSkeleton type="area" height="default" withCard={false} />
);
}
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 +1515,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 +1542,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>

View File

@@ -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,
@@ -8,29 +8,25 @@ import {
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} 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,
DashboardTable,
} from "@/components/dashboard/shared";
const TIME_RANGES = {
"today": "Today",
@@ -47,14 +43,12 @@ const formatDuration = (seconds) => {
};
const getDateRange = (days) => {
// Create date in Eastern Time
const now = new Date();
const easternTime = new Date(
now.toLocaleString("en-US", { timeZone: "America/New_York" })
);
if (days === "today") {
// For today, set the range to be the current day in Eastern Time
const start = new Date(easternTime);
start.setHours(0, 0, 0, 0);
@@ -67,7 +61,6 @@ const getDateRange = (days) => {
};
}
// For other periods, calculate from end of previous day
const end = new Date(easternTime);
end.setHours(23, 59, 59, 999);
@@ -81,127 +74,27 @@ 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
// Trend cell component with arrow and color
const TrendCell = ({ delta }) => {
if (delta === 0) return null;
const isPositive = delta > 0;
const colorClass = isPositive
? "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 ? (
<div className={`flex items-center justify-end gap-0.5 ${colorClass}`}>
{isPositive ? (
<ArrowUp className="w-3 h-3" />
) : (
<ArrowDown className="w-3 h-3" />
)}
<span className="text-xs font-medium">
{formatDelta(delta)}
</span>
<span>{Math.abs(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>
<TableRow className="dark:border-gray-800">
<TableHead><Skeleton className="h-4 w-24 bg-muted" /></TableHead>
<TableHead className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted" /></TableHead>
<TableHead className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted" /></TableHead>
<TableHead className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted" /></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[...Array(5)].map((_, i) => (
<TableRow key={i} className="dark:border-gray-800">
<TableCell><Skeleton className="h-4 w-32 bg-muted" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-4 w-12 ml-auto bg-muted" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-4 w-12 ml-auto bg-muted" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted" /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
const GorgiasOverview = () => {
const [timeRange, setTimeRange] = useState("7");
const [data, setData] = useState({});
@@ -247,7 +140,6 @@ const GorgiasOverview = () => {
useEffect(() => {
loadStats();
// Set up auto-refresh every 5 minutes
const interval = setInterval(loadStats, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [loadStats]);
@@ -277,47 +169,103 @@ const GorgiasOverview = () => {
}, {});
// Process channel data
const channels = data.channels?.map(line => ({
const channels = (data.channels?.map(line => ({
name: line[0]?.value || '',
total: line[1]?.value || 0,
percentage: line[2]?.value || 0,
delta: line[3]?.value || 0
})) || [];
})) || []).sort((a, b) => b.total - a.total);
// Process agent data
const agents = data.agents?.map(line => ({
const agents = (data.agents?.map(line => ({
name: line[0]?.value || '',
closed: line[1]?.value || 0,
rating: line[2]?.value,
percentage: line[3]?.value || 0,
delta: line[4]?.value || 0
})) || [];
})) || []).filter(agent => agent.name !== "Unassigned");
// Column definitions for Channel Distribution table
const channelColumns = [
{
key: "name",
header: "Channel",
render: (value) => <span className="text-foreground">{value}</span>,
},
{
key: "total",
header: "Total",
align: "right",
render: (value) => <span className="text-muted-foreground">{value}</span>,
},
{
key: "percentage",
header: "%",
align: "right",
render: (value) => <span className="text-muted-foreground">{value}%</span>,
},
{
key: "delta",
header: "Change",
align: "right",
render: (value) => <TrendCell delta={value} />,
},
];
// Column definitions for Agent Performance table
const agentColumns = [
{
key: "name",
header: "Agent",
render: (value) => <span className="text-foreground">{value}</span>,
},
{
key: "closed",
header: "Closed",
align: "right",
render: (value) => <span className="text-muted-foreground">{value}</span>,
},
{
key: "rating",
header: "Rating",
align: "right",
render: (value) => (
<span className="text-muted-foreground">
{value ? `${value}/5` : "-"}
</span>
),
},
{
key: "delta",
header: "Change",
align: "right",
render: (value) => <TrendCell delta={value} />,
},
];
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,224 +284,132 @@ 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>
<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 />
) : (
<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>
</TableRow>
</TableHeader>
<TableBody>
{channels
.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">
{channel.name}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{channel.total}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{channel.percentage}%
</TableCell>
<TableCell
className={`text-right ${
channel.delta > 0
? "text-green-600 dark:text-green-500"
: channel.delta < 0
? "text-red-600 dark:text-red-500"
: "text-muted-foreground"
}`}
>
<div className="flex items-center justify-end gap-0.5">
{channel.delta !== 0 && (
<>
{channel.delta > 0 ? (
<ArrowUp className="w-3 h-3" />
) : (
<ArrowDown className="w-3 h-3" />
)}
<span>{Math.abs(channel.delta)}%</span>
</>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<Card className={CARD_STYLES.base}>
<DashboardSectionHeader title="Channel Distribution" compact className="pb-0" />
<CardContent>
<DashboardTable
columns={channelColumns}
data={channels}
loading={loading}
skeletonRows={5}
getRowKey={(channel, index) => `${channel.name}-${index}`}
maxHeight="md"
compact
/>
</CardContent>
</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>
<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 />
) : (
<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>
</TableRow>
</TableHeader>
<TableBody>
{agents
.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">
{agent.name}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{agent.closed}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{agent.rating ? `${agent.rating}/5` : "-"}
</TableCell>
<TableCell
className={`text-right ${
agent.delta > 0
? "text-green-600 dark:text-green-500"
: agent.delta < 0
? "text-red-600 dark:text-red-500"
: "text-muted-foreground"
}`}
>
<div className="flex items-center justify-end gap-0.5">
{agent.delta !== 0 && (
<>
{agent.delta > 0 ? (
<ArrowUp className="w-3 h-3" />
) : (
<ArrowDown className="w-3 h-3" />
)}
<span>{Math.abs(agent.delta)}%</span>
</>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<Card className={CARD_STYLES.base}>
<DashboardSectionHeader title="Agent Performance" compact className="pb-0" />
<CardContent>
<DashboardTable
columns={agentColumns}
data={agents}
loading={loading}
skeletonRows={5}
getRowKey={(agent, index) => `${agent.name}-${index}`}
maxHeight="md"
compact
/>
</CardContent>
</Card>
</div>

View File

@@ -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"
)}
>

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import React, { useState, useEffect } from "react";
import { Card, CardContent } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
@@ -16,8 +16,14 @@ 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 { Skeleton } from "@/components/ui/skeleton";
import { Mail, MessageSquare, BookOpen } from "lucide-react";
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import {
DashboardSectionHeader,
DashboardErrorState,
DashboardTable,
TableSkeleton,
} from "@/components/dashboard/shared";
// Helper functions for formatting
const formatRate = (value, isSMS = false, hideForSMS = false) => {
@@ -36,89 +42,8 @@ const formatCurrency = (value) => {
}).format(value);
};
// Loading skeleton component
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">
<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">
<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">
<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">
<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">
<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">
<Skeleton className="h-8 w-20 mx-auto bg-muted" />
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
{[...Array(15)].map((_, i) => (
<tr key={i} className="hover:bg-muted/50 transition-colors">
<td className="p-2">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4 bg-muted" />
<div className="space-y-2">
<Skeleton className="h-4 w-48 bg-muted" />
<Skeleton className="h-3 w-64 bg-muted" />
<Skeleton className="h-3 w-32 bg-muted" />
</div>
</div>
</td>
<td className="p-2 text-center">
<div className="flex flex-col items-center gap-1">
<Skeleton className="h-4 w-16 bg-muted" />
<Skeleton className="h-3 w-24 bg-muted" />
</div>
</td>
<td className="p-2 text-center">
<div className="flex flex-col items-center gap-1">
<Skeleton className="h-4 w-16 bg-muted" />
<Skeleton className="h-3 w-24 bg-muted" />
</div>
</td>
<td className="p-2 text-center">
<div className="flex flex-col items-center gap-1">
<Skeleton className="h-4 w-16 bg-muted" />
<Skeleton className="h-3 w-24 bg-muted" />
</div>
</td>
<td className="p-2 text-center">
<div className="flex flex-col items-center gap-1">
<Skeleton className="h-4 w-16 bg-muted" />
<Skeleton className="h-3 w-24 bg-muted" />
</div>
</td>
<td className="p-2 text-center">
<div className="flex flex-col items-center gap-1">
<Skeleton className="h-4 w-16 bg-muted" />
<Skeleton className="h-3 w-24 bg-muted" />
</div>
</td>
</tr>
))}
</tbody>
</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 = ({
// MetricCell content component for displaying campaign metrics (returns content, not <td>)
const MetricCellContent = ({
value,
count,
isMonetary = false,
@@ -129,15 +54,15 @@ const MetricCell = ({
}) => {
if (isSMS && hideForSMS) {
return (
<td className="p-2 text-center">
<div className="text-center">
<div className="text-muted-foreground text-lg font-semibold">N/A</div>
<div className="text-muted-foreground text-sm">-</div>
</td>
</div>
);
}
return (
<td className="p-2 text-center">
<div className="text-center">
<div className="text-blue-600 dark:text-blue-400 text-lg font-semibold">
{isMonetary ? formatCurrency(value) : formatRate(value, isSMS, hideForSMS)}
</div>
@@ -147,7 +72,56 @@ const MetricCell = ({
totalRecipients > 0 &&
` (${((count / totalRecipients) * 100).toFixed(2)}%)`}
</div>
</td>
</div>
);
};
// Campaign name cell with tooltip
const CampaignCell = ({ campaign }) => {
const isBlog = campaign.name?.includes("_Blog");
const isSMS = campaign.channel === 'sms';
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-default">
<div className="flex items-center gap-2">
{isBlog ? (
<BookOpen className="h-4 w-4 text-muted-foreground flex-shrink-0" />
) : isSMS ? (
<MessageSquare className="h-4 w-4 text-muted-foreground flex-shrink-0" />
) : (
<Mail className="h-4 w-4 text-muted-foreground flex-shrink-0" />
)}
<div className="font-medium text-foreground">
{campaign.name}
</div>
</div>
<div className="text-sm text-muted-foreground truncate max-w-[300px]">
{campaign.subject}
</div>
<div className="text-xs text-muted-foreground">
{campaign.send_time
? DateTime.fromISO(campaign.send_time).toLocaleString(DateTime.DATETIME_MED)
: "No date"}
</div>
</div>
</TooltipTrigger>
<TooltipContent
side="top"
className="break-words bg-card text-foreground border dark:border-gray-800"
>
<p className="font-medium">{campaign.name}</p>
<p>{campaign.subject}</p>
<p className="text-xs text-muted-foreground">
{campaign.send_time
? DateTime.fromISO(campaign.send_time).toLocaleString(DateTime.DATETIME_MED)
: "No date"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
@@ -155,7 +129,6 @@ const KlaviyoCampaigns = ({ className }) => {
const [campaigns, setCampaigns] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [searchTerm, setSearchTerm] = useState("");
const [selectedChannels, setSelectedChannels] = useState({ email: true, sms: true, blog: true });
const [selectedTimeRange, setSelectedTimeRange] = useState("last7days");
const [sortConfig, setSortConfig] = useState({
@@ -163,11 +136,8 @@ const KlaviyoCampaigns = ({ className }) => {
direction: "desc",
});
const handleSort = (key) => {
setSortConfig((prev) => ({
key,
direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc",
}));
const handleSort = (key, direction) => {
setSortConfig({ key, direction });
};
const fetchCampaigns = async () => {
@@ -194,9 +164,9 @@ const KlaviyoCampaigns = ({ className }) => {
useEffect(() => {
fetchCampaigns();
const interval = setInterval(fetchCampaigns, 10 * 60 * 1000); // Refresh every 10 minutes
const interval = setInterval(fetchCampaigns, 10 * 60 * 1000);
return () => clearInterval(interval);
}, [selectedTimeRange]); // Only refresh when time range changes
}, [selectedTimeRange]);
// Sort campaigns
const sortedCampaigns = [...campaigns].sort((a, b) => {
@@ -220,59 +190,138 @@ const KlaviyoCampaigns = ({ className }) => {
}
});
// Filter campaigns by search term and channels
// Filter campaigns by channels
const filteredCampaigns = sortedCampaigns.filter(
(campaign) => {
const isBlog = campaign?.name?.includes("_Blog");
const channelType = isBlog ? "blog" : campaign?.channel;
return campaign?.name?.toLowerCase().includes((searchTerm || "").toLowerCase()) &&
selectedChannels[channelType];
return selectedChannels[channelType];
}
);
// Column definitions for DashboardTable
const columns = [
{
key: "name",
header: "Campaign",
sortable: true,
sortKey: "send_time",
render: (_, campaign) => <CampaignCell campaign={campaign} />,
},
{
key: "delivery_rate",
header: "Delivery",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent
value={campaign.stats.delivery_rate}
count={campaign.stats.delivered}
totalRecipients={campaign.stats.recipients}
isSMS={campaign.channel === 'sms'}
/>
),
},
{
key: "open_rate",
header: "Opens",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent
value={campaign.stats.open_rate}
count={campaign.stats.opens_unique}
totalRecipients={campaign.stats.recipients}
isSMS={campaign.channel === 'sms'}
hideForSMS={true}
/>
),
},
{
key: "click_rate",
header: "Clicks",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent
value={campaign.stats.click_rate}
count={campaign.stats.clicks_unique}
totalRecipients={campaign.stats.recipients}
isSMS={campaign.channel === 'sms'}
/>
),
},
{
key: "click_to_open_rate",
header: "CTR",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent
value={campaign.stats.click_to_open_rate}
count={campaign.stats.clicks_unique}
totalRecipients={campaign.stats.opens_unique}
isSMS={campaign.channel === 'sms'}
hideForSMS={true}
/>
),
},
{
key: "conversion_value",
header: "Orders",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent
value={campaign.stats.conversion_value}
count={campaign.stats.conversion_uniques}
isMonetary={true}
showConversionRate={true}
totalRecipients={campaign.stats.recipients}
isSMS={campaign.channel === 'sms'}
/>
),
},
];
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 />
<TableSkeleton rows={15} columns={6} variant="detailed" />
</CardContent>
</Card>
);
}
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 +333,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 +346,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 +355,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,153 +369,19 @@ 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">
<Button
variant="ghost"
onClick={() => handleSort("send_time")}
className="w-full justify-start h-8"
>
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">
<Button
variant={sortConfig.key === "delivery_rate" ? "default" : "ghost"}
onClick={() => handleSort("delivery_rate")}
className="w-full justify-center h-8"
>
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">
<Button
variant={sortConfig.key === "open_rate" ? "default" : "ghost"}
onClick={() => handleSort("open_rate")}
className="w-full justify-center h-8"
>
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">
<Button
variant={sortConfig.key === "click_rate" ? "default" : "ghost"}
onClick={() => handleSort("click_rate")}
className="w-full justify-center h-8"
>
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">
<Button
variant={sortConfig.key === "click_to_open_rate" ? "default" : "ghost"}
onClick={() => handleSort("click_to_open_rate")}
className="w-full justify-center h-8"
>
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">
<Button
variant={sortConfig.key === "conversion_value" ? "default" : "ghost"}
onClick={() => handleSort("conversion_value")}
className="w-full justify-center h-8"
>
Orders
</Button>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
{filteredCampaigns.map((campaign) => (
<tr
key={campaign.id}
className="hover:bg-muted/50 transition-colors"
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<td className="p-2 align-top">
<div className="flex items-center gap-2">
{campaign.name?.includes("_Blog") ? (
<BookOpen className="h-4 w-4 text-muted-foreground" />
) : campaign.channel === 'sms' ? (
<MessageSquare className="h-4 w-4 text-muted-foreground" />
) : (
<Mail className="h-4 w-4 text-muted-foreground" />
)}
<div className="font-medium text-gray-900 dark:text-gray-100">
{campaign.name}
</div>
</div>
<div className="text-sm text-muted-foreground truncate max-w-[300px]">
{campaign.subject}
</div>
<div className="text-xs text-muted-foreground">
{campaign.send_time
? DateTime.fromISO(campaign.send_time).toLocaleString(DateTime.DATETIME_MED)
: "No date"}
</div>
</td>
</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"
>
<p className="font-medium">{campaign.name}</p>
<p>{campaign.subject}</p>
<p className="text-xs text-muted-foreground">
{campaign.send_time
? DateTime.fromISO(campaign.send_time).toLocaleString(DateTime.DATETIME_MED)
: "No date"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<MetricCell
value={campaign.stats.delivery_rate}
count={campaign.stats.delivered}
totalRecipients={campaign.stats.recipients}
isSMS={campaign.channel === 'sms'}
}
/>
<MetricCell
value={campaign.stats.open_rate}
count={campaign.stats.opens_unique}
totalRecipients={campaign.stats.recipients}
isSMS={campaign.channel === 'sms'}
hideForSMS={true}
<CardContent className="pl-4 mb-4">
<DashboardTable
columns={columns}
data={filteredCampaigns}
getRowKey={(campaign) => campaign.id}
sortConfig={sortConfig}
onSort={handleSort}
maxHeight="md"
stickyHeader
bordered
/>
<MetricCell
value={campaign.stats.click_rate}
count={campaign.stats.clicks_unique}
totalRecipients={campaign.stats.recipients}
isSMS={campaign.channel === 'sms'}
/>
<MetricCell
value={campaign.stats.click_to_open_rate}
count={campaign.stats.clicks_unique}
totalRecipients={campaign.stats.opens_unique}
isSMS={campaign.channel === 'sms'}
hideForSMS={true}
/>
<MetricCell
value={campaign.stats.conversion_value}
count={campaign.stats.conversion_uniques}
isMonetary={true}
showConversionRate={true}
totalRecipients={campaign.stats.recipients}
isSMS={campaign.channel === 'sms'}
/>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
);

View File

@@ -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,
DashboardTable,
TableSkeleton,
} from "@/components/dashboard/shared";
// Helper functions for formatting
const formatCurrency = (value, decimalPlaces = 2) =>
@@ -49,42 +48,8 @@ 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 }) => {
// MetricCell content component (returns content, not <td>)
const MetricCellContent = ({ value, label, sublabel, isMonetary = false, isPercentage = false, decimalPlaces = 0 }) => {
const formattedValue = isMonetary
? formatCurrency(value, decimalPlaces)
: isPercentage
@@ -92,7 +57,7 @@ const MetricCell = ({ value, label, sublabel, isMonetary = false, isPercentage =
: formatNumber(value, decimalPlaces);
return (
<td className="p-2 text-center align-top">
<div className="text-center">
<div className="text-blue-600 dark:text-blue-400 text-lg font-semibold">
{formattedValue}
</div>
@@ -101,7 +66,7 @@ const MetricCell = ({ value, label, sublabel, isMonetary = false, isPercentage =
{label || sublabel}
</div>
)}
</td>
</div>
);
};
@@ -119,16 +84,27 @@ const getActionValue = (campaign, actionType) => {
return 0;
};
const CampaignName = ({ name }) => {
if (name.startsWith("Instagram post: ")) {
const CampaignNameCell = ({ campaign }) => {
const name = campaign.name;
const isInstagram = name.startsWith("Instagram post: ");
return (
<div>
<div className="font-medium text-foreground break-words min-w-[200px] max-w-[300px]">
{isInstagram ? (
<div className="flex items-center space-x-2">
<Instagram className="w-4 h-4" />
<Instagram className="w-4 h-4 flex-shrink-0" />
<span>{name.replace("Instagram post: ", "")}</span>
</div>
) : (
<span>{name}</span>
)}
</div>
<div className="text-sm text-muted-foreground">
{campaign.objective}
</div>
</div>
);
}
return <span>{name}</span>;
};
const getObjectiveAction = (campaignObjective) => {
@@ -173,7 +149,6 @@ const processMetrics = (campaign) => {
const cpm = parseFloat(insights.cpm || 0);
const frequency = parseFloat(insights.frequency || 0);
// Purchase value and total purchases
const purchaseValue = (insights.action_values || [])
.filter(({ action_type }) => action_type === "purchase")
.reduce((sum, { value }) => sum + parseFloat(value || 0), 0);
@@ -182,7 +157,6 @@ const processMetrics = (campaign) => {
.filter(({ action_type }) => action_type === "purchase")
.reduce((sum, { value }) => sum + parseInt(value || 0), 0);
// Aggregate unique actions
const actionMap = new Map();
(insights.actions || []).forEach(({ action_type, value }) => {
const currentValue = actionMap.get(action_type) || 0;
@@ -194,13 +168,11 @@ const processMetrics = (campaign) => {
value,
}));
// Map of cost per action
const costPerActionMap = new Map();
(insights.cost_per_action_type || []).forEach(({ action_type, value }) => {
costPerActionMap.set(action_type, parseFloat(value || 0));
});
// Total post engagements
const totalPostEngagements = actionMap.get("post_engagement") || 0;
return {
@@ -225,7 +197,6 @@ const processCampaignData = (campaign) => {
const budget = calculateBudget(campaign);
const { action_type, label } = getObjectiveAction(campaign.objective);
// Get cost per result from costPerActionMap
const costPerResult = metrics.costPerActionMap.get(action_type) || 0;
return {
@@ -243,65 +214,6 @@ 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">
<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">
<Skeleton className="h-4 w-20 mx-auto bg-muted" />
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
{[...Array(5)].map((_, rowIndex) => (
<tr key={rowIndex} className="hover:bg-muted/50 transition-colors">
<td className="p-2">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4 bg-muted" />
<div className="space-y-2">
<Skeleton className="h-4 w-48 bg-muted" />
<Skeleton className="h-3 w-64 bg-muted" />
<Skeleton className="h-3 w-32 bg-muted" />
</div>
</div>
</td>
{[...Array(8)].map((_, colIndex) => (
<td key={colIndex} className="p-2 text-center">
<div className="flex flex-col items-center gap-1">
<Skeleton className="h-4 w-16 bg-muted" />
<Skeleton className="h-3 w-24 bg-muted" />
</div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
const MetaCampaigns = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@@ -313,30 +225,24 @@ const MetaCampaigns = () => {
direction: "desc",
});
const handleSort = (key) => {
setSortConfig((prev) => ({
key,
direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc",
}));
const handleSort = (key, direction) => {
setSortConfig({ key, direction });
};
const computeDateRange = (timeframe) => {
// Create date in Eastern Time
const now = new Date();
const easternTime = new Date(
now.toLocaleString("en-US", { timeZone: "America/New_York" })
);
easternTime.setHours(0, 0, 0, 0); // Set to start of day
easternTime.setHours(0, 0, 0, 0);
let sinceDate, untilDate;
if (timeframe === "today") {
// For today, both dates should be the current date in Eastern Time
sinceDate = untilDate = new Date(easternTime);
} else {
// For other periods, calculate the date range
untilDate = new Date(easternTime);
untilDate.setDate(untilDate.getDate() - 1); // Yesterday
untilDate.setDate(untilDate.getDate() - 1);
sinceDate = new Date(untilDate);
sinceDate.setDate(sinceDate.getDate() - parseInt(timeframe) + 1);
@@ -366,7 +272,6 @@ const MetaCampaigns = () => {
accountInsights.json()
]);
// Process campaigns with the new processing logic
const processedCampaigns = campaignsJson.map(processCampaignData);
const activeCampaigns = processedCampaigns.filter(c => c.metrics.spend > 0);
setCampaigns(activeCampaigns);
@@ -418,7 +323,6 @@ const MetaCampaigns = () => {
switch (sortConfig.key) {
case "date":
// Add date sorting using campaign ID (Meta IDs are chronological)
return direction * (parseInt(b.id) - parseInt(a.id));
case "spend":
return direction * ((a.metrics.spend || 0) - (b.metrics.spend || 0));
@@ -441,31 +345,136 @@ const MetaCampaigns = () => {
}
});
// Column definitions for DashboardTable
const columns = [
{
key: "name",
header: "Campaign",
sortable: true,
sortKey: "date",
render: (_, campaign) => <CampaignNameCell campaign={campaign} />,
},
{
key: "spend",
header: "Spend",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent
value={campaign.metrics.spend}
isMonetary
decimalPlaces={2}
sublabel={
campaign.budget
? `${formatCurrency(campaign.budget, 0)}/${campaign.budgetType}`
: "Budget: Ad set"
}
/>
),
},
{
key: "reach",
header: "Reach",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent
value={campaign.metrics.reach}
label={`${formatNumber(campaign.metrics.frequency, 2)}x freq`}
/>
),
},
{
key: "impressions",
header: "Impressions",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent value={campaign.metrics.impressions} />
),
},
{
key: "cpm",
header: "CPM",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent
value={campaign.metrics.cpm}
isMonetary
decimalPlaces={2}
/>
),
},
{
key: "ctr",
header: "CTR",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent
value={campaign.metrics.ctr}
isPercentage
decimalPlaces={2}
label={`${formatCurrency(campaign.metrics.cpc, 2)} CPC`}
/>
),
},
{
key: "results",
header: "Results",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent
value={getActionValue(campaign, campaign.objectiveActionType)}
label={campaign.objective}
/>
),
},
{
key: "value",
header: "Value",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent
value={campaign.metrics.purchaseValue}
isMonetary
decimalPlaces={2}
sublabel={campaign.metrics.costPerResult ? `${formatCurrency(campaign.metrics.costPerResult)}/result` : null}
/>
),
},
{
key: "engagements",
header: "Engagements",
align: "center",
sortable: true,
render: (_, campaign) => (
<MetricCellContent value={campaign.metrics.totalPostEngagements} />
),
},
];
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>
<CardContent className="p-4">
<SkeletonTable />
<TableSkeleton rows={5} columns={9} variant="detailed" />
</CardContent>
</Card>
);
@@ -473,25 +482,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,233 +511,108 @@ 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">
<Button
variant="ghost"
className="pl-0 justify-start w-full h-8"
onClick={() => handleSort("date")}
>
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">
<Button
variant={sortConfig.key === "spend" ? "default" : "ghost"}
className="w-full justify-center h-8"
onClick={() => handleSort("spend")}
>
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">
<Button
variant={sortConfig.key === "reach" ? "default" : "ghost"}
className="w-full justify-center h-8"
onClick={() => handleSort("reach")}
>
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">
<Button
variant={sortConfig.key === "impressions" ? "default" : "ghost"}
className="w-full justify-center h-8"
onClick={() => handleSort("impressions")}
>
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">
<Button
variant={sortConfig.key === "cpm" ? "default" : "ghost"}
className="w-full justify-center h-8"
onClick={() => handleSort("cpm")}
>
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">
<Button
variant={sortConfig.key === "ctr" ? "default" : "ghost"}
className="w-full justify-center h-8"
onClick={() => handleSort("ctr")}
>
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">
<Button
variant={sortConfig.key === "results" ? "default" : "ghost"}
className="w-full justify-center h-8"
onClick={() => handleSort("results")}
>
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">
<Button
variant={sortConfig.key === "value" ? "default" : "ghost"}
className="w-full justify-center h-8"
onClick={() => handleSort("value")}
>
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">
<Button
variant={sortConfig.key === "engagements" ? "default" : "ghost"}
className="w-full justify-center h-8"
onClick={() => handleSort("engagements")}
>
Engagements
</Button>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
{sortedCampaigns.map((campaign) => (
<tr
key={campaign.id}
className="hover:bg-muted/50 transition-colors"
>
<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]">
<CampaignName name={campaign.name} />
</div>
<div className="text-sm text-muted-foreground">
{campaign.objective}
</div>
</div>
</td>
<MetricCell
value={campaign.metrics.spend}
isMonetary
decimalPlaces={2}
sublabel={
campaign.budget
? `${formatCurrency(campaign.budget, 0)}/${campaign.budgetType}`
: "Budget: Ad set"
}
<CardContent className="pl-4 mb-4">
<DashboardTable
columns={columns}
data={sortedCampaigns}
getRowKey={(campaign) => campaign.id}
sortConfig={sortConfig}
onSort={handleSort}
maxHeight="md"
stickyHeader
bordered
/>
<MetricCell
value={campaign.metrics.reach}
label={`${formatNumber(campaign.metrics.frequency, 2)}x freq`}
/>
<MetricCell
value={campaign.metrics.impressions}
/>
<MetricCell
value={campaign.metrics.cpm}
isMonetary
decimalPlaces={2}
/>
<MetricCell
value={campaign.metrics.ctr}
isPercentage
decimalPlaces={2}
label={`${formatCurrency(campaign.metrics.cpc, 2)} CPC`}
/>
<MetricCell
value={getActionValue(campaign, campaign.objectiveActionType)}
label={campaign.objective}
/>
<MetricCell
value={campaign.metrics.purchaseValue}
isMonetary
decimalPlaces={2}
sublabel={campaign.metrics.costPerResult ? `${formatCurrency(campaign.metrics.costPerResult)}/result` : null}
/>
<MetricCell
value={campaign.metrics.totalPostEngagements}
/>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
);

View File

@@ -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 />

View File

@@ -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>

View File

@@ -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}
/>

View File

@@ -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>
)}
</>

View File

@@ -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"

View File

@@ -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">

View File

@@ -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,164 +7,60 @@ 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,
TableHeader,
TableHead,
TableBody,
TableRow,
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,
DashboardTable,
StatCardSkeleton,
ChartSkeleton,
TableSkeleton,
DashboardErrorState,
CARD_STYLES,
TYPOGRAPHY,
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 +119,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 +139,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 +153,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 +163,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 +171,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 +179,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 +187,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 +336,43 @@ export const RealtimeAnalytics = () => {
};
}, [isPaused]);
const togglePause = () => {
setIsPaused(!isPaused);
};
// Column definitions for pages table
const pagesColumns = [
{
key: "path",
header: "Page",
render: (value) => <span className="font-medium text-foreground">{value}</span>,
},
{
key: "activeUsers",
header: "Active Users",
align: "right",
render: (value) => <span className={REALTIME_COLORS.pages.className}>{value}</span>,
},
];
// Column definitions for sources table
const sourcesColumns = [
{
key: "source",
header: "Source",
render: (value) => <span className="font-medium text-foreground">{value}</span>,
},
{
key: "activeUsers",
header: "Active Users",
align: "right",
render: (value) => <span className={REALTIME_COLORS.sources.className}>{value}</span>,
},
];
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 +380,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 +391,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 +409,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 +440,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,106 +449,49 @@ 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">
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead className="text-gray-900 dark:text-gray-100">
Page
</TableHead>
<TableHead className="text-right text-gray-900 dark:text-gray-100">
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">
{page.path}
</TableCell>
<TableCell
className={`text-right ${METRIC_COLORS.pages.className}`}
>
{page.activeUsers}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="h-[230px]">
<DashboardTable
columns={pagesColumns}
data={detailedData.currentPages}
loading={loading}
getRowKey={(page, index) => `${page.path}-${index}`}
maxHeight="sm"
compact
/>
</div>
</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">
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead className="text-gray-900 dark:text-gray-100">
Source
</TableHead>
<TableHead className="text-right text-gray-900 dark:text-gray-100">
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">
{source.source}
</TableCell>
<TableCell
className={`text-right ${METRIC_COLORS.sources.className}`}
>
{source.activeUsers}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="h-[230px]">
<DashboardTable
columns={sourcesColumns}
data={detailedData.sources}
loading={loading}
getRowKey={(source, index) => `${source.source}-${index}`}
maxHeight="sm"
compact
/>
</div>
</TabsContent>
</Tabs>

View File

@@ -1,13 +1,6 @@
import React, { useState, useEffect, useMemo, useCallback, memo } from "react";
import axios from "axios";
import React, { useState, useEffect, useCallback, memo } from "react";
import { acotService } from "@/services/dashboard/acotService";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import {
Select,
SelectContent,
@@ -15,18 +8,8 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import {
Loader2,
TrendingUp,
TrendingDown,
Info,
AlertCircle,
ArrowUp,
ArrowDown,
} from "lucide-react";
import { TrendingUp } from "lucide-react";
import {
LineChart,
Line,
@@ -38,13 +21,7 @@ import {
Legend,
ReferenceLine,
} from "recharts";
import {
TIME_RANGES,
GROUP_BY_OPTIONS,
formatDateForInput,
parseDateFromInput,
} from "@/lib/dashboard/constants";
import { Checkbox } from "@/components/ui/checkbox";
import { TIME_RANGES } from "@/lib/dashboard/constants";
import {
Table,
TableHeader,
@@ -53,62 +30,25 @@ import {
TableBody,
TableCell,
} from "@/components/ui/table";
import { debounce } from "lodash";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
const METRIC_IDS = {
PLACED_ORDER: "Y8cqcF",
PAYMENT_REFUNDED: "R7XUYh",
};
// Map current periods to their previous equivalents
const PREVIOUS_PERIOD_MAP = {
today: "yesterday",
thisWeek: "lastWeek",
thisMonth: "lastMonth",
last7days: "previous7days",
last30days: "previous30days",
last90days: "previous90days",
yesterday: "twoDaysAgo",
};
// Add helper function to calculate previous period dates
const calculatePreviousPeriodDates = (timeRange, startDate, endDate) => {
if (timeRange && timeRange !== "custom") {
return {
timeRange: PREVIOUS_PERIOD_MAP[timeRange],
};
} else if (startDate && endDate) {
const start = new Date(startDate);
const end = new Date(endDate);
const duration = end.getTime() - start.getTime();
const prevEnd = new Date(start.getTime() - 1);
const prevStart = new Date(prevEnd.getTime() - duration);
return {
startDate: prevStart.toISOString(),
endDate: prevEnd.toISOString(),
};
}
return null;
};
DashboardSectionHeader,
DashboardStatCard,
DashboardStatCardSkeleton,
DashboardChartTooltip,
ChartSkeleton,
DashboardEmptyState,
DashboardErrorState,
} from "@/components/dashboard/shared";
// Move formatCurrency to top and export it
export const formatCurrency = (value, minimumFractionDigits = 0) => {
@@ -121,107 +61,23 @@ export const formatCurrency = (value, minimumFractionDigits = 0) => {
}).format(value);
};
// Add a helper function for percentage formatting
const formatPercentage = (value) => {
if (typeof value !== "number") return "0%";
return `${Math.abs(Math.round(value))}%`;
// Sales chart tooltip formatter - formats revenue/AOV as currency, others as numbers
const salesValueFormatter = (value, name) => {
const nameLower = (name || "").toLowerCase();
if (nameLower.includes('revenue') || nameLower.includes('order value') || nameLower.includes('average')) {
return formatCurrency(value);
}
return typeof value === 'number' ? value.toLocaleString() : value;
};
// Add color mapping for metrics
const METRIC_COLORS = {
revenue: "#8b5cf6",
orders: "#10b981",
avgOrderValue: "#9333ea",
movingAverage: "#f59e0b",
prevRevenue: "#f97316",
prevOrders: "#0ea5e9",
prevAvgOrderValue: "#f59e0b",
};
// 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) {
// Sales chart label formatter - formats timestamp as readable date
const salesLabelFormatter = (label) => {
const date = new Date(label);
const formattedDate = date.toLocaleDateString("en-US", {
return date.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
});
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>
{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>
);
})}
</CardContent>
</Card>
);
}
return null;
};
const calculate7DayAverage = (data) => {
@@ -394,54 +250,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 +315,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,76 +324,16 @@ 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">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 max-w-3xl">
{[...Array(4)].map((_, i) => (
<Card key={i} 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">
<Skeleton className="h-4 w-24 bg-muted rounded-sm" />
<Skeleton className="h-4 w-8 bg-muted rounded-sm" />
</CardHeader>
<CardContent className="p-4 pt-0">
<Skeleton className="h-7 w-32 bg-muted rounded-sm mb-1" />
<Skeleton className="h-4 w-24 bg-muted rounded-sm" />
</CardContent>
</Card>
<DashboardStatCardSkeleton key={i} size="compact" hasIcon={false} hasSubtitle />
))}
</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);
@@ -643,27 +449,36 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
? data.reduce((sum, day) => sum + day.revenue, 0) / data.length
: 0;
return (
<Card className="w-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
<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">
{title}
</CardTitle>
</div>
<div className="flex items-center gap-2">
{!error && (
// Time selector for DashboardSectionHeader
const timeSelector = (
<Select
value={selectedTimeRange}
onValueChange={handleTimeRangeChange}
>
<SelectTrigger className="w-[130px] h-9">
<SelectValue placeholder="Select time range" />
</SelectTrigger>
<SelectContent>
{TIME_RANGES.map((range) => (
<SelectItem key={range.value} value={range.value}>
{range.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
// Actions (Details dialog) for DashboardSectionHeader
const headerActions = !error ? (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" className="h-9">
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 +554,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>
@@ -846,28 +661,20 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
</div>
</DialogContent>
</Dialog>
)}
<Select
value={selectedTimeRange}
onValueChange={handleTimeRangeChange}
>
<SelectTrigger className="w-[130px] h-9">
<SelectValue placeholder="Select time range" />
</SelectTrigger>
<SelectContent>
{TIME_RANGES.map((range) => (
<SelectItem key={range.value} value={range.value}>
{range.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
) : null;
return (
<Card className={`w-full ${CARD_STYLES.base}`}>
<DashboardSectionHeader
title={title}
timeSelector={timeSelector}
actions={headerActions}
/>
<CardContent className="p-6 pt-0 space-y-4">
{/* Show stats only if not in error state */}
{!error &&
(loading ? (
{!error && (
loading ? (
<SkeletonStats />
) : (
<SummaryStats
@@ -875,11 +682,12 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
projection={projection}
projectionLoading={projectionLoading}
/>
))}
)
)}
{/* Show metric toggles only if not in error state */}
{!error && (
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4 pt-2">
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4">
<div className="flex flex-wrap gap-1">
<Button
variant={metrics.revenue ? "default" : "outline"}
@@ -954,44 +762,25 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
</Button>
</div>
)}
</div>
</CardHeader>
<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}
@@ -1020,7 +809,7 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
className="text-xs text-muted-foreground"
tick={{ fill: "currentColor" }}
/>
<Tooltip content={<CustomTooltip />} />
<Tooltip content={<DashboardChartTooltip valueFormatter={salesValueFormatter} labelFormatter={salesLabelFormatter} />} />
<Legend />
<ReferenceLine
y={averageRevenue}

File diff suppressed because it is too large Load Diff

View File

@@ -3,24 +3,22 @@ import axios from "axios";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} 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,
DashboardTable,
ChartSkeleton,
TableSkeleton,
SimpleTooltip,
TOOLTIP_STYLES,
} from "@/components/dashboard/shared";
import {
BarChart,
Bar,
@@ -30,7 +28,6 @@ import {
Tooltip,
ResponsiveContainer,
Cell,
ReferenceLine,
} from "recharts";
// Get form IDs from environment variables
@@ -44,74 +41,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 +73,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 +122,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"
@@ -229,14 +158,14 @@ const WinbackFeed = ({ responses }) => (
</div>
<div className="flex flex-wrap gap-1">
{(reasonsAnswer?.choices?.labels || []).map((label, idx) => (
<Badge key={idx} variant="secondary" className="text-xs">
<DashboardBadge key={idx} variant="default" size="sm">
{label}
</Badge>
</DashboardBadge>
))}
{reasonsAnswer?.choices?.other && (
<Badge variant="outline" className="text-xs">
<DashboardBadge variant="purple" size="sm">
{reasonsAnswer.choices.other}
</Badge>
</DashboardBadge>
)}
</div>
{feedbackAnswer?.text && (
@@ -388,13 +317,36 @@ const TypeformDashboard = () => {
const newestResponse = getNewestResponse();
// Column definitions for reasons table
const reasonsColumns = [
{
key: "reason",
header: "Reason",
render: (value) => <span className="font-medium text-foreground">{value}</span>,
},
{
key: "count",
header: "Count",
align: "right",
render: (value) => <span className="text-muted-foreground">{value}</span>,
},
{
key: "percentage",
header: "%",
align: "right",
width: "w-[80px]",
render: (value) => <span className="text-muted-foreground">{value}%</span>,
},
];
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 +365,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 +434,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 +464,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 +503,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,48 +565,16 @@ 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">
Reason
</TableHead>
<TableHead className="text-right font-medium text-gray-900 dark:text-gray-100">
Count
</TableHead>
<TableHead className="text-right w-[80px] font-medium text-gray-900 dark:text-gray-100">
%
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{metrics.winback.reasons.map((reason, index) => (
<TableRow
key={index}
className="hover:bg-muted/50 transition-colors"
>
<TableCell className="font-medium text-gray-900 dark:text-gray-100">
{reason.reason}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{reason.count}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{reason.percentage}%
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DashboardTable
columns={reasonsColumns}
data={metrics?.winback?.reasons || []}
getRowKey={(reason, index) => `${reason.reason}-${index}`}
maxHeight="md"
compact
/>
</CardContent>
</Card>
</div>

View File

@@ -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,
@@ -8,76 +8,22 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
PieChart,
Pie,
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,
DashboardTable,
DashboardChartTooltip,
TableSkeleton,
ChartSkeleton,
TOOLTIP_STYLES,
} from "@/components/dashboard/shared";
export const UserBehaviorDashboard = () => {
const [data, setData] = useState(null);
@@ -152,10 +98,7 @@ export const UserBehaviorDashboard = () => {
throw new Error("Invalid response structure");
}
// Handle both data structures
const rawData = result.data?.data || result.data;
// Try to access the data differently based on the structure
const pageResponse = rawData?.pageResponse || rawData?.reports?.[0];
const deviceResponse = rawData?.deviceResponse || rawData?.reports?.[1];
const sourceResponse = rawData?.sourceResponse || rawData?.reports?.[2];
@@ -181,17 +124,83 @@ export const UserBehaviorDashboard = () => {
fetchData();
}, [timeRange]);
const formatDuration = (seconds) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}m ${remainingSeconds}s`;
};
// Column definitions for pages table
const pagesColumns = [
{
key: "path",
header: "Page Path",
render: (value) => <span className="font-medium text-foreground">{value}</span>,
},
{
key: "pageViews",
header: "Views",
align: "right",
render: (value) => <span className="text-muted-foreground">{value.toLocaleString()}</span>,
},
{
key: "bounceRate",
header: "Bounce Rate",
align: "right",
render: (value) => <span className="text-muted-foreground">{value.toFixed(1)}%</span>,
},
{
key: "avgSessionDuration",
header: "Avg. Duration",
align: "right",
render: (value) => <span className="text-muted-foreground">{formatDuration(value)}</span>,
},
];
// Column definitions for sources table
const sourcesColumns = [
{
key: "source",
header: "Source",
width: "w-[35%] min-w-[120px]",
render: (value) => <span className="font-medium text-foreground break-words max-w-[160px]">{value}</span>,
},
{
key: "sessions",
header: "Sessions",
align: "right",
width: "w-[20%] min-w-[80px]",
render: (value) => <span className="text-muted-foreground whitespace-nowrap">{value.toLocaleString()}</span>,
},
{
key: "conversions",
header: "Conv.",
align: "right",
width: "w-[20%] min-w-[80px]",
render: (value) => <span className="text-muted-foreground whitespace-nowrap">{value.toLocaleString()}</span>,
},
{
key: "conversionRate",
header: "Conv. Rate",
align: "right",
width: "w-[25%] min-w-[80px]",
render: (_, row) => (
<span className="text-muted-foreground whitespace-nowrap">
{((row.conversions / row.sessions) * 100).toFixed(1)}%
</span>
),
},
];
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 +210,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>
@@ -230,43 +239,41 @@ export const UserBehaviorDashboard = () => {
0
);
const CustomTooltip = ({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
const percentage = ((data.pageViews / totalViews) * 100).toFixed(1);
const sessionPercentage = ((data.sessions / totalSessions) * 100).toFixed(1);
// Custom item renderer for the device tooltip - renders both Views and Sessions rows
const deviceTooltipRenderer = (item, index) => {
if (index > 0) return null; // Only render for the first item (pie chart sends single slice)
const deviceData = item.payload;
const color = COLORS[deviceData.device.toLowerCase()];
const viewsPercentage = ((deviceData.pageViews / totalViews) * 100).toFixed(1);
const sessionsPercentage = ((deviceData.sessions / totalSessions) * 100).toFixed(1);
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.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}>{deviceData.pageViews.toLocaleString()} ({viewsPercentage}%)</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}>{deviceData.sessions.toLocaleString()} ({sessionsPercentage}%)</span>
</div>
</>
);
}
return null;
};
const formatDuration = (seconds) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}m ${remainingSeconds}s`;
};
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 +290,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">
@@ -293,79 +300,28 @@ export const UserBehaviorDashboard = () => {
<TabsTrigger value="devices">Device Usage</TabsTrigger>
</TabsList>
<TabsContent
value="pages"
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"
>
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead className="text-foreground">Page Path</TableHead>
<TableHead className="text-right text-foreground">Views</TableHead>
<TableHead className="text-right text-foreground">Bounce Rate</TableHead>
<TableHead className="text-right text-foreground">Avg. Duration</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.data?.pageData?.pageData.map((page, index) => (
<TableRow key={index} className="dark:border-gray-800">
<TableCell className="font-medium text-foreground">
{page.path}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{page.pageViews.toLocaleString()}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{page.bounceRate.toFixed(1)}%
</TableCell>
<TableCell className="text-right text-muted-foreground">
{formatDuration(page.avgSessionDuration)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<TabsContent value="pages" className="mt-4 space-y-2">
<DashboardTable
columns={pagesColumns}
data={data?.data?.pageData?.pageData || []}
getRowKey={(page, index) => `${page.path}-${index}`}
maxHeight="xl"
compact
/>
</TabsContent>
<TabsContent
value="sources"
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"
>
<Table>
<TableHeader>
<TableRow className="dark:border-gray-800">
<TableHead className="text-foreground w-[35%] min-w-[120px]">Source</TableHead>
<TableHead className="text-right text-foreground w-[20%] min-w-[80px]">Sessions</TableHead>
<TableHead className="text-right text-foreground w-[20%] min-w-[80px]">Conv.</TableHead>
<TableHead className="text-right text-foreground w-[25%] min-w-[80px]">Conv. Rate</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.data?.sourceData?.map((source, index) => (
<TableRow key={index} className="dark:border-gray-800">
<TableCell className="font-medium text-foreground break-words max-w-[160px]">
{source.source}
</TableCell>
<TableCell className="text-right text-muted-foreground whitespace-nowrap">
{source.sessions.toLocaleString()}
</TableCell>
<TableCell className="text-right text-muted-foreground whitespace-nowrap">
{source.conversions.toLocaleString()}
</TableCell>
<TableCell className="text-right text-muted-foreground whitespace-nowrap">
{((source.conversions / source.sessions) * 100).toFixed(1)}%
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<TabsContent value="sources" className="mt-4 space-y-2">
<DashboardTable
columns={sourcesColumns}
data={data?.data?.sourceData || []}
getRowKey={(source, index) => `${source.source}-${index}`}
maxHeight="xl"
compact
/>
</TabsContent>
<TabsContent
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">
<TabsContent value="devices" className="mt-4 space-y-2">
<div className={`h-60 ${CARD_STYLES.base} rounded-lg p-4`}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
@@ -387,7 +343,14 @@ export const UserBehaviorDashboard = () => {
/>
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
<Tooltip
content={
<DashboardChartTooltip
labelFormatter={(_, payload) => payload?.[0]?.payload?.device || ""}
itemRenderer={deviceTooltipRenderer}
/>
}
/>
</PieChart>
</ResponsiveContainer>
</div>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,538 @@
/**
* 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 type TableSkeletonVariant = "simple" | "detailed";
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";
/**
* Cell layout variant:
* - "simple": single-line cells (default)
* - "detailed": multi-line cells with icon in first column and value+sublabel in others
*/
variant?: TableSkeletonVariant;
/** Show icon placeholder in first column (only for detailed variant) */
hasIcon?: boolean;
}
export const TableSkeleton: React.FC<TableSkeletonProps> = ({
rows = 5,
columns = 4,
withCard = false,
withHeader = false,
colorVariant = "default",
className,
scrollable = false,
maxHeight = "md",
variant = "simple",
hasIcon = true,
}) => {
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];
// Simple variant - single line cells
const renderSimpleCell = (colIndex: number) => (
<Skeleton
className={cn(
"h-4",
colors.skeleton,
colIndex === 0 ? "w-32" : columnWidths[colIndex]
)}
/>
);
// Detailed variant - first column has icon + stacked text, others have value + sublabel
const renderDetailedFirstCell = () => (
<div className="flex items-center gap-2">
{hasIcon && <Skeleton className={cn("h-4 w-4", colors.skeleton)} />}
<div className="space-y-2">
<Skeleton className={cn("h-4 w-48", colors.skeleton)} />
<Skeleton className={cn("h-3 w-64", colors.skeleton)} />
<Skeleton className={cn("h-3 w-32", colors.skeleton)} />
</div>
</div>
);
const renderDetailedMetricCell = () => (
<div className="flex flex-col items-center gap-1">
<Skeleton className={cn("h-4 w-16", colors.skeleton)} />
<Skeleton className={cn("h-3 w-24", colors.skeleton)} />
</div>
);
const tableContent = (
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
{Array.from({ length: columnCount }).map((_, i) => (
<TableHead key={i} className={i === 0 ? "text-left" : "text-center"}>
<Skeleton
className={cn(
variant === "detailed" ? "h-8" : "h-4",
colors.skeleton,
i === 0 ? "w-24" : "w-20",
i !== 0 && "mx-auto"
)}
/>
</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} className={colIndex !== 0 ? "text-center" : ""}>
{variant === "detailed" ? (
colIndex === 0 ? renderDetailedFirstCell() : renderDetailedMetricCell()
) : (
renderSimpleCell(colIndex)
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
);
const content = (
<div className={className}>
{withHeader && (
<div className="flex justify-between items-center mb-4 px-4 pt-4">
<Skeleton className="h-5 w-32 bg-muted" />
<Skeleton className="h-8 w-24 bg-muted" />
</div>
)}
{scrollable ? (
<div className={SCROLL_STYLES[maxHeight]}>{tableContent}</div>
) : (
tableContent
)}
</div>
);
if (withCard) {
return <Card className={CARD_STYLES.base}>{content}</Card>;
}
return content;
};
// =============================================================================
// STAT CARD SKELETON (re-exported from DashboardStatCard)
// =============================================================================
export interface StatCardSkeletonProps {
/** Card size variant */
size?: "default" | "compact" | "large";
/** Show icon placeholder */
hasIcon?: boolean;
/** Show subtitle placeholder */
hasSubtitle?: boolean;
/** Additional className */
className?: string;
}
export const StatCardSkeleton: React.FC<StatCardSkeletonProps> = ({
size = "default",
hasIcon = true,
hasSubtitle = true,
className,
}) => {
const sizeStyles = {
default: {
header: CARD_STYLES.header,
content: CARD_STYLES.content,
valueHeight: "h-8",
valueWidth: "w-32",
},
compact: {
header: CARD_STYLES.headerCompact,
content: "px-4 pt-0 pb-3",
valueHeight: "h-6",
valueWidth: "w-24",
},
large: {
header: CARD_STYLES.header,
content: CARD_STYLES.contentPadded,
valueHeight: "h-10",
valueWidth: "w-36",
},
};
const styles = sizeStyles[size];
return (
<Card className={cn(CARD_STYLES.base, className)}>
<CardHeader className={cn(styles.header, "space-y-0")}>
<Skeleton className="h-4 w-24 bg-muted" />
{hasIcon && <Skeleton className="h-8 w-8 bg-muted rounded-lg" />}
</CardHeader>
<CardContent className={styles.content}>
<Skeleton className={cn(styles.valueHeight, styles.valueWidth, "bg-muted mb-2")} />
{hasSubtitle && <Skeleton className="h-4 w-20 bg-muted" />}
</CardContent>
</Card>
);
};
// =============================================================================
// GRID SKELETON (for multiple stat cards)
// =============================================================================
export interface GridSkeletonProps {
/** Number of cards to show */
count?: number;
/** Grid columns */
columns?: 2 | 3 | 4 | 6;
/** Card size */
cardSize?: "default" | "compact" | "large";
/** Additional className */
className?: string;
}
export const GridSkeleton: React.FC<GridSkeletonProps> = ({
count = 4,
columns = 4,
cardSize = "default",
className,
}) => {
const gridClass = {
2: "grid-cols-1 sm:grid-cols-2",
3: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3",
4: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4",
6: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-6",
};
return (
<div className={cn("grid gap-4", gridClass[columns], className)}>
{Array.from({ length: count }).map((_, i) => (
<StatCardSkeleton key={i} size={cardSize} />
))}
</div>
);
};
// =============================================================================
// LIST SKELETON (for feeds, events, etc.)
// =============================================================================
export interface ListSkeletonProps {
/** Number of items to show */
items?: number;
/** Show avatar/icon placeholder */
hasAvatar?: boolean;
/** Show timestamp placeholder */
hasTimestamp?: boolean;
/** Show card wrapper */
withCard?: boolean;
/** Additional className */
className?: string;
}
export const ListSkeleton: React.FC<ListSkeletonProps> = ({
items = 5,
hasAvatar = true,
hasTimestamp = true,
withCard = false,
className,
}) => {
const content = (
<div className={cn("divide-y divide-border/50", className)}>
{Array.from({ length: items }).map((_, i) => (
<div key={i} className="flex items-center gap-3 p-4">
{hasAvatar && (
<Skeleton className="h-10 w-10 rounded-full bg-muted shrink-0" />
)}
<div className="flex-1 min-w-0 space-y-2">
<Skeleton className="h-4 w-3/4 bg-muted" />
<Skeleton className="h-3 w-1/2 bg-muted" />
</div>
{hasTimestamp && (
<Skeleton className="h-3 w-16 bg-muted shrink-0" />
)}
</div>
))}
</div>
);
if (withCard) {
return <Card className={CARD_STYLES.base}>{content}</Card>;
}
return content;
};
// =============================================================================
// SECTION SKELETON (card with header and content area)
// =============================================================================
export interface SectionSkeletonProps {
/** Height of the content area */
contentHeight?: string;
/** Show header with title and action button */
withHeader?: boolean;
/** Additional className */
className?: string;
}
export const SectionSkeleton: React.FC<SectionSkeletonProps> = ({
contentHeight = "h-[400px]",
withHeader = true,
className,
}) => {
return (
<Card className={cn(CARD_STYLES.base, className)}>
{withHeader && (
<CardHeader className={CARD_STYLES.header}>
<Skeleton className="h-5 w-32 bg-muted" />
<Skeleton className="h-9 w-28 bg-muted rounded-md" />
</CardHeader>
)}
<CardContent className={CARD_STYLES.content}>
<Skeleton className={cn("w-full bg-muted rounded-lg", contentHeight)} />
</CardContent>
</Card>
);
};
// =============================================================================
// EXPORTS
// =============================================================================
export default {
Chart: ChartSkeleton,
Table: TableSkeleton,
StatCard: StatCardSkeleton,
Grid: GridSkeleton,
List: ListSkeleton,
Section: SectionSkeleton,
};

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,415 @@
/**
* 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 { Button } from "@/components/ui/button";
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;
/** Whether this column is sortable */
sortable?: boolean;
/** Custom sort key if different from column key */
sortKey?: string;
}
export type SortDirection = "asc" | "desc";
export interface SortConfig {
key: string;
direction: SortDirection;
}
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;
/** Current sort configuration (for controlled sorting) */
sortConfig?: SortConfig;
/** Callback when a sortable column header is clicked */
onSort?: (key: string, direction: SortDirection) => void;
}
// =============================================================================
// 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,
sortConfig,
onSort,
}: DashboardTableProps<T>): React.ReactElement {
const paddingClass = compact ? "px-3 py-2" : "px-4 py-3";
const scrollClass = maxHeight !== "none" ? MAX_HEIGHT_CLASSES[maxHeight] : "";
// Handle sort click - toggles direction or sets new sort key
const handleSortClick = (col: TableColumn<T>) => {
if (!onSort || !col.sortable) return;
const sortKey = col.sortKey || col.key;
const currentDirection = sortConfig?.key === sortKey ? sortConfig.direction : null;
const newDirection: SortDirection = currentDirection === "desc" ? "asc" : "desc";
onSort(sortKey, newDirection);
};
// Render header cell content - either plain text or sortable button
const renderHeaderContent = (col: TableColumn<T>) => {
if (!col.sortable || !onSort) {
return col.header;
}
const sortKey = col.sortKey || col.key;
const isActive = sortConfig?.key === sortKey;
return (
<Button
variant={isActive ? "default" : "ghost"}
size="sm"
onClick={() => handleSortClick(col)}
className={cn(
"h-8 font-medium",
col.align === "center" && "w-full justify-center",
col.align === "right" && "w-full justify-end",
col.align === "left" && "justify-start",
!col.align && "justify-start"
)}
>
{col.header}
</Button>
);
};
// 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,
// Reduce padding when sortable since button has its own padding
col.sortable && onSort ? "p-1" : paddingClass,
ALIGNMENT_CLASSES[col.align || "left"],
col.width,
col.hideOnMobile && "hidden sm:table-cell"
)}
>
{renderHeaderContent(col)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{data.map((row, rowIndex) => {
const rowKey = getRowKey ? getRowKey(row, rowIndex) : rowIndex;
return (
<TableRow
key={rowKey}
className={cn(
TABLE_STYLES.row,
TABLE_STYLES.rowHover,
onRowClick && "cursor-pointer",
striped && rowIndex % 2 === 1 && "bg-muted/30",
bordered && "border-b border-border/50"
)}
onClick={onRowClick ? () => onRowClick(row, rowIndex) : undefined}
>
{columns.map((col) => {
const value = row[col.key];
const renderedValue = col.render
? col.render(value, row, rowIndex)
: (value as React.ReactNode);
return (
<TableCell
key={col.key}
className={cn(
TABLE_STYLES.cell,
paddingClass,
ALIGNMENT_CLASSES[col.align || "left"],
col.numeric && "tabular-nums",
col.className,
col.hideOnMobile && "hidden sm:table-cell"
)}
>
{renderedValue}
</TableCell>
);
})}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}
// =============================================================================
// SIMPLE TABLE (for quick key-value displays)
// =============================================================================
export interface SimpleTableRow {
label: string;
value: React.ReactNode;
className?: string;
}
export interface SimpleTableProps {
rows: SimpleTableRow[];
loading?: boolean;
className?: string;
labelWidth?: string;
}
export const SimpleTable: React.FC<SimpleTableProps> = ({
rows,
loading = false,
className,
labelWidth = "w-1/3",
}) => {
if (loading) {
return (
<div className={cn("space-y-3", className)}>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center justify-between py-1">
<Skeleton className="h-4 w-24 bg-muted" />
<Skeleton className="h-4 w-32 bg-muted" />
</div>
))}
</div>
);
}
return (
<div className={cn("divide-y divide-border/50", className)}>
{rows.map((row, index) => (
<div
key={index}
className={cn(
"flex items-center justify-between py-2 first:pt-0 last:pb-0",
row.className
)}
>
<span className={cn("text-sm text-muted-foreground", labelWidth)}>
{row.label}
</span>
<span className="text-sm font-medium text-foreground">{row.value}</span>
</div>
))}
</div>
);
};
export default DashboardTable;

View File

@@ -0,0 +1,199 @@
/**
* 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,
type SortDirection,
type SortConfig,
} 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 TableSkeletonVariant,
type StatCardSkeletonProps,
type GridSkeletonProps,
type ListSkeletonProps,
type SectionSkeletonProps,
type ChartType,
type ChartHeight,
type ColorVariant,
} from "./DashboardSkeleton";
// =============================================================================
// CONVENIENCE RE-EXPORTS FROM DESIGN TOKENS
// =============================================================================
export {
CARD_STYLES,
TYPOGRAPHY,
SPACING,
RADIUS,
SCROLL_STYLES,
TREND_COLORS,
EVENT_COLORS,
FINANCIAL_COLORS,
METRIC_COLORS,
METRIC_COLORS_HSL,
STATUS_COLORS,
STAT_ICON_STYLES,
TABLE_STYLES,
BADGE_STYLES,
getTrendColor,
getIconColorVariant,
} from "@/lib/dashboard/designTokens";
// =============================================================================
// CONVENIENCE RE-EXPORTS FROM CHART CONFIG
// =============================================================================
export {
CHART_MARGINS,
CHART_HEIGHTS,
GRID_CONFIG,
X_AXIS_CONFIG,
Y_AXIS_CONFIG,
LINE_CONFIG,
AREA_CONFIG,
BAR_CONFIG,
TOOLTIP_STYLES,
TOOLTIP_THEMES,
TOOLTIP_CURSOR,
LEGEND_CONFIG,
CHART_PALETTE,
FINANCIAL_PALETTE,
COMPARISON_PALETTE,
formatChartCurrency,
formatChartNumber,
formatChartPercent,
formatChartDate,
formatChartTime,
CHART_PRESETS,
CHART_BREAKPOINTS,
getResponsiveHeight,
} from "@/lib/dashboard/chartConfig";

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Loader2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
import { toast } from "sonner";
import {
@@ -15,12 +15,19 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import config from "@/config";
import { createProductCategory, type CreateProductCategoryResponse } from "@/services/apiv2";
@@ -86,8 +93,24 @@ export function CreateProductCategoryDialog({
const [lines, setLines] = useState<Option[]>([]);
const [linesCache, setLinesCache] = useState<Record<string, Option[]>>({});
// Popover open states
const [companyOpen, setCompanyOpen] = useState(false);
const [lineOpen, setLineOpen] = useState(false);
const companyOptions = useMemo(() => normalizeOptions(companies), [companies]);
// Get display label for selected company
const selectedCompanyLabel = useMemo(() => {
const company = companyOptions.find((c) => c.value === companyId);
return company?.label || "";
}, [companyOptions, companyId]);
// Get display label for selected line
const selectedLineLabel = useMemo(() => {
const line = lines.find((l) => l.value === lineId);
return line?.label || "";
}, [lines, lineId]);
useEffect(() => {
if (!isOpen) {
setCompanyId(defaultCompanyId ?? "");
@@ -241,56 +264,109 @@ export function CreateProductCategoryDialog({
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Company Select - Searchable */}
<div className="space-y-2">
<Label htmlFor="create-category-company">Company</Label>
<Select
value={companyId}
onValueChange={(value) => {
setCompanyId(value);
<Label>Company</Label>
<Popover open={companyOpen} onOpenChange={setCompanyOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={companyOpen}
className="w-full justify-between font-normal"
>
{selectedCompanyLabel || "Select a company"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<Command>
<CommandInput placeholder="Search companies..." />
<CommandList>
<CommandEmpty>No company found.</CommandEmpty>
<CommandGroup>
{companyOptions.map((company) => (
<CommandItem
key={company.value}
value={company.label}
onSelect={() => {
setCompanyId(company.value);
setLineId("");
setCompanyOpen(false);
}}
>
<SelectTrigger id="create-category-company">
<SelectValue placeholder="Select a company" />
</SelectTrigger>
<SelectContent>
{companyOptions.map((company) => (
<SelectItem key={company.value} value={company.value}>
{company.label}
</SelectItem>
{company.value === companyId && (
<Check className="ml-auto h-4 w-4" />
)}
</CommandItem>
))}
</SelectContent>
</Select>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* Line Select - Searchable */}
<div className="space-y-2">
<Label htmlFor="create-category-line">
<Label>
Parent Line <span className="text-muted-foreground">(optional)</span>
</Label>
<Select
value={lineId}
onValueChange={setLineId}
disabled={!companyId || isLoadingLines || !lines.length}
<Popover open={lineOpen} onOpenChange={setLineOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={lineOpen}
className="w-full justify-between font-normal"
disabled={!companyId || isLoadingLines}
>
<SelectTrigger id="create-category-line">
<SelectValue
placeholder={
!companyId
{!companyId
? "Select a company first"
: isLoadingLines
? "Loading product lines..."
: "Leave empty to create a new line"
}
/>
</SelectTrigger>
<SelectContent>
: selectedLineLabel || "Leave empty to create a new line"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<Command>
<CommandInput placeholder="Search lines..." />
<CommandList>
<CommandEmpty>No line found.</CommandEmpty>
<CommandGroup>
{/* Option to clear selection */}
<CommandItem
value="none"
onSelect={() => {
setLineId("");
setLineOpen(false);
}}
>
<span className="text-muted-foreground">None (create new line)</span>
{lineId === "" && <Check className="ml-auto h-4 w-4" />}
</CommandItem>
{lines.map((line) => (
<SelectItem key={line.value} value={line.value}>
<CommandItem
key={line.value}
value={line.label}
onSelect={() => {
setLineId(line.value);
setLineOpen(false);
}}
>
{line.label}
</SelectItem>
{line.value === lineId && (
<Check className="ml-auto h-4 w-4" />
)}
</CommandItem>
))}
</SelectContent>
</Select>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{companyId && !isLoadingLines && !lines.length && (
<p className="text-xs text-muted-foreground">
No existing lines found for this company. A new line will be created.

View File

@@ -57,7 +57,7 @@ export const BASE_IMPORT_FIELDS = [
description: "Universal Product Code/Barcode",
alternateMatches: ["barcode", "bar code", "jan", "ean", "upc code"],
fieldType: { type: "input" },
width: 145,
width: 165,
validations: [
{ rule: "required", errorMessage: "Required", level: "error" },
{ rule: "unique", errorMessage: "Must be unique", level: "error" },

View File

@@ -1288,7 +1288,7 @@ const MatchColumnsStepComponent = <T extends string>({
}, [fields]);
// Fix handleOnContinue - it should be useCallback, not useEffect
const handleOnContinue = useCallback(async () => {
const handleOnContinue = useCallback(async (useNewValidation: boolean = false) => {
setIsLoading(true)
try {
@@ -1325,7 +1325,7 @@ const MatchColumnsStepComponent = <T extends string>({
})
: normalizedData
await onContinue(enhancedData, data, columns, globalSelections)
await onContinue(enhancedData, data, columns, globalSelections, useNewValidation)
} finally {
setIsLoading(false)
}
@@ -1916,13 +1916,21 @@ const MatchColumnsStepComponent = <T extends string>({
</Button>
)}
<div className="flex items-center gap-2 ml-auto">
<Button
className="ml-auto"
variant="outline"
disabled={isLoading}
onClick={handleOnContinue}
onClick={() => handleOnContinue(false)}
>
{translations.matchColumnsStep.nextButtonTitle}
</Button>
<Button
disabled={isLoading}
onClick={() => handleOnContinue(true)}
>
{translations.matchColumnsStep.nextButtonTitle} (New Validation)
</Button>
</div>
</div>
</div>
</div>

View File

@@ -3,7 +3,7 @@ import type { RawData } from "../../types"
export type MatchColumnsProps<T extends string> = {
data: RawData[]
headerValues: RawData
onContinue: (data: any[], rawData: RawData[], columns: Columns<T>, globalSelections?: GlobalSelections) => void
onContinue: (data: any[], rawData: RawData[], columns: Columns<T>, globalSelections?: GlobalSelections, useNewValidation?: boolean) => void
onBack?: () => void
initialGlobalSelections?: GlobalSelections
}

View File

@@ -5,6 +5,7 @@ import { SelectHeaderStep } from "./SelectHeaderStep/SelectHeaderStep"
import { SelectSheetStep } from "./SelectSheetStep/SelectSheetStep"
import { mapWorkbook } from "../utils/mapWorkbook"
import { ValidationStepNew } from "./ValidationStepNew"
import { ValidationStep } from "./ValidationStep"
import { ImageUploadStep } from "./ImageUploadStep/ImageUploadStep"
import { MatchColumnsStep } from "./MatchColumnsStep/MatchColumnsStep"
import type { GlobalSelections } from "./MatchColumnsStep/types"
@@ -21,6 +22,7 @@ export enum StepType {
selectHeader = "selectHeader",
matchColumns = "matchColumns",
validateData = "validateData",
validateDataNew = "validateDataNew",
imageUpload = "imageUpload",
}
@@ -48,6 +50,12 @@ export type StepState =
globalSelections?: GlobalSelections
isFromScratch?: boolean
}
| {
type: StepType.validateDataNew
data: any[]
globalSelections?: GlobalSelections
isFromScratch?: boolean
}
| {
type: StepType.imageUpload
data: any[]
@@ -87,7 +95,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
// Keep track of global selections across steps
const [persistedGlobalSelections, setPersistedGlobalSelections] = useState<GlobalSelections | undefined>(
state.type === StepType.validateData || state.type === StepType.matchColumns
state.type === StepType.validateData || state.type === StepType.validateDataNew || state.type === StepType.matchColumns
? state.globalSelections
: undefined
)
@@ -179,7 +187,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
data={state.data}
headerValues={state.headerValues}
initialGlobalSelections={persistedGlobalSelections}
onContinue={async (values, rawData, columns, globalSelections) => {
onContinue={async (values, rawData, columns, globalSelections, useNewValidation) => {
try {
const data = await matchColumnsStepHook(values, rawData, columns)
const dataWithMeta = await addErrorsAndRunHooks(data, fields, rowHook, tableHook)
@@ -197,8 +205,10 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
: dataWithMeta;
setPersistedGlobalSelections(globalSelections)
// Route to new or old validation step based on user choice
onNext({
type: StepType.validateData,
type: useNewValidation ? StepType.validateDataNew : StepType.validateData,
data: dataWithGlobalSelections,
globalSelections,
})
@@ -238,6 +248,35 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
isFromScratch={state.isFromScratch}
/>
)
case StepType.validateDataNew:
// New Zustand-based ValidationStep component
return (
<ValidationStep
initialData={state.data}
file={uploadedFile || new File([], "empty.xlsx")}
onBack={() => {
// If we started from scratch, we need to go back to the upload step
if (state.isFromScratch) {
onNext({
type: StepType.upload
});
} else if (onBack) {
// Use the provided onBack function
onBack();
}
}}
onNext={(validatedData: any[]) => {
// Go to image upload step with the validated data
onNext({
type: StepType.imageUpload,
data: validatedData,
file: uploadedFile || new File([], "empty.xlsx"),
globalSelections: state.globalSelections
});
}}
isFromScratch={state.isFromScratch}
/>
)
case StepType.imageUpload:
return (
<ImageUploadStep

View File

@@ -0,0 +1,117 @@
/**
* CopyDownBanner Component
*
* Shows instruction banner when copy-down mode is active.
* Positions above the source cell for better context.
* Memoized with minimal subscriptions for performance.
*/
import { memo, useEffect, useState, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { X } from 'lucide-react';
import { useValidationStore } from '../store/validationStore';
import { useIsCopyDownActive } from '../store/selectors';
/**
* Copy-down instruction banner
*
* PERFORMANCE: Only subscribes to copyDownMode.isActive boolean.
* Uses getState() for the cancel action to avoid additional subscriptions.
*
* POSITIONING: Uses the source row index to find the row element and position
* the banner above it for better visual context.
*/
export const CopyDownBanner = memo(() => {
const isActive = useIsCopyDownActive();
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
const bannerRef = useRef<HTMLDivElement>(null);
// Update position based on source cell
useEffect(() => {
if (!isActive) {
setPosition(null);
return;
}
const updatePosition = () => {
const { copyDownMode } = useValidationStore.getState();
if (copyDownMode.sourceRowIndex === null || !copyDownMode.sourceFieldKey) return;
// Find the source cell by row index and field key
const rowElement = document.querySelector(
`[data-row-index="${copyDownMode.sourceRowIndex}"]`
) as HTMLElement | null;
if (!rowElement) return;
const cellElement = rowElement.querySelector(
`[data-cell-field="${copyDownMode.sourceFieldKey}"]`
) as HTMLElement | null;
if (!cellElement) return;
// Get the table container (parent of scroll container)
const tableContainer = rowElement.closest('.relative') as HTMLElement | null;
if (!tableContainer) return;
const tableRect = tableContainer.getBoundingClientRect();
const cellRect = cellElement.getBoundingClientRect();
// Calculate position relative to the table container
// Position banner centered horizontally on the cell, above it
const topPosition = cellRect.top - tableRect.top - 55; // 55px above the cell (enough to not cover it)
const leftPosition = cellRect.left - tableRect.left + cellRect.width / 2;
setPosition({
top: Math.max(topPosition, 8), // Minimum 8px from top
left: leftPosition,
});
};
// Initial position (with small delay to ensure DOM is ready)
setTimeout(updatePosition, 0);
// Update on scroll
const scrollContainer = document.querySelector('.overflow-auto');
if (scrollContainer) {
scrollContainer.addEventListener('scroll', updatePosition);
return () => scrollContainer.removeEventListener('scroll', updatePosition);
}
}, [isActive]);
if (!isActive) return null;
const handleCancel = () => {
useValidationStore.getState().cancelCopyDown();
};
return (
<div
className="absolute z-30 pointer-events-none"
style={{
top: position?.top ?? 8,
left: position?.left ?? '50%',
transform: 'translateX(-50%)',
}}
>
<div ref={bannerRef} className="pointer-events-auto">
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-xl shadow-lg px-4 py-2.5 flex items-center gap-3 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
Click on the last row you want to copy to
</span>
<Button
variant="ghost"
size="sm"
onClick={handleCancel}
className="h-7 w-7 p-0 text-blue-600 hover:text-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
});
CopyDownBanner.displayName = 'CopyDownBanner';

View File

@@ -0,0 +1,244 @@
/**
* FloatingSelectionBar Component
*
* Fixed bottom action bar that appears when rows are selected.
* Provides bulk actions: apply template, save as template, delete.
*
* PERFORMANCE CRITICAL:
* - Only subscribes to selectedRows.size via useSelectedRowCount()
* - Never subscribes to the full rows array or selectedRows Set
* - Uses getState() for action-time data access
*/
import { memo, useCallback, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { X, Trash2, Save } from 'lucide-react';
import { useValidationStore } from '../store/validationStore';
import {
useSelectedRowCount,
useHasSingleRowSelected,
useTemplates,
useTemplatesLoading,
} from '../store/selectors';
import { useTemplateManagement } from '../hooks/useTemplateManagement';
import SearchableTemplateSelect from './SearchableTemplateSelect';
import { toast } from 'sonner';
/**
* Floating selection bar - appears when rows are selected
*
* PERFORMANCE: Only subscribes to:
* - selectedRowCount (number) - minimal subscription
* - hasSingleRowSelected (boolean) - for save template button
* - templates/templatesLoading - rarely changes
*/
export const FloatingSelectionBar = memo(() => {
const selectedCount = useSelectedRowCount();
const hasSingleRow = useHasSingleRowSelected();
const templates = useTemplates();
const templatesLoading = useTemplatesLoading();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const { applyTemplateToSelected, getTemplateDisplayText } = useTemplateManagement();
// Clear selection - uses getState() to avoid subscription
const handleClearSelection = useCallback(() => {
useValidationStore.getState().clearSelection();
}, []);
// Delete selected rows - uses getState() at action time
const handleDeleteSelected = useCallback(() => {
const { rows, selectedRows, deleteRows } = useValidationStore.getState();
// Map selected UUIDs to indices
const indicesToDelete: number[] = [];
rows.forEach((row, index) => {
if (selectedRows.has(row.__index)) {
indicesToDelete.push(index);
}
});
if (indicesToDelete.length === 0) {
toast.error('No rows selected');
return;
}
// Show confirmation dialog for multiple rows
if (indicesToDelete.length > 1) {
setDeleteDialogOpen(true);
return;
}
// For single row, delete directly
deleteRows(indicesToDelete);
toast.success('Row deleted');
}, []);
// Confirm deletion callback
const handleConfirmDelete = useCallback(() => {
const { rows, selectedRows, deleteRows } = useValidationStore.getState();
const indicesToDelete: number[] = [];
rows.forEach((row, index) => {
if (selectedRows.has(row.__index)) {
indicesToDelete.push(index);
}
});
if (indicesToDelete.length > 0) {
deleteRows(indicesToDelete);
toast.success(`${indicesToDelete.length} rows deleted`);
}
setDeleteDialogOpen(false);
}, []);
// Save as template - opens dialog with row data
const handleSaveAsTemplate = useCallback(() => {
const { rows, selectedRows, openTemplateForm } = useValidationStore.getState();
// Find the single selected row
const selectedRow = rows.find((row) => selectedRows.has(row.__index));
if (!selectedRow) {
toast.error('No row selected');
return;
}
// Prepare row data for template form (exclude metadata fields)
const templateData: Record<string, unknown> = {};
const excludeFields = [
'id',
'__index',
'__meta',
'__template',
'__original',
'__corrected',
'__changes',
'__aiSupplemental',
];
for (const [key, value] of Object.entries(selectedRow)) {
if (excludeFields.includes(key)) continue;
templateData[key] = value;
}
openTemplateForm(templateData);
}, []);
// Handle template selection for bulk apply
const handleTemplateChange = useCallback(
(templateId: string) => {
if (!templateId) return;
applyTemplateToSelected(templateId);
},
[applyTemplateToSelected]
);
// Don't render if nothing selected
if (selectedCount === 0) return null;
return (
<>
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 animate-in fade-in slide-in-from-bottom-4 duration-200">
<div className="bg-background border border-border rounded-xl shadow-xl px-4 py-3 flex items-center gap-4">
{/* Selection count badge */}
<div className="flex items-center gap-2">
<div className="bg-primary text-primary-foreground text-sm font-medium px-2.5 py-1 rounded-md">
{selectedCount} selected
</div>
<Button
variant="ghost"
size="sm"
onClick={handleClearSelection}
className="h-8 w-8 p-0"
title="Clear selection"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Divider */}
<div className="h-8 w-px bg-border" />
{/* Apply template to selected */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Apply template:</span>
<SearchableTemplateSelect
templates={templates}
value=""
onValueChange={handleTemplateChange}
getTemplateDisplayText={getTemplateDisplayText}
placeholder="Select template"
triggerClassName="w-[200px]"
disabled={templatesLoading}
/>
</div>
{/* Divider */}
<div className="h-8 w-px bg-border" />
{/* Save as template - only when single row selected */}
{hasSingleRow && (
<>
<Button
variant="outline"
size="sm"
onClick={handleSaveAsTemplate}
className="gap-2"
>
<Save className="h-4 w-4" />
Save as Template
</Button>
{/* Divider */}
<div className="h-8 w-px bg-border" />
</>
)}
{/* Delete selected */}
<Button
variant="destructive"
size="sm"
onClick={handleDeleteSelected}
className="gap-2"
>
<Trash2 className="h-4 w-4" />
Delete
</Button>
</div>
</div>
{/* Delete confirmation dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {selectedCount} rows?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the selected rows from your import data.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
});
FloatingSelectionBar.displayName = 'FloatingSelectionBar';

View File

@@ -0,0 +1,55 @@
/**
* InitializingOverlay Component
*
* Displays a loading state while the validation step is initializing.
* Shows the current initialization phase to keep users informed.
*/
import { Loader2 } from 'lucide-react';
import { Progress } from '@/components/ui/progress';
import type { InitPhase } from '../store/types';
interface InitializingOverlayProps {
phase: InitPhase;
message?: string;
}
const phaseMessages: Record<InitPhase, string> = {
idle: 'Preparing...',
'loading-options': 'Loading field options...',
'loading-templates': 'Loading templates...',
'validating-upcs': 'Validating UPC codes...',
'validating-fields': 'Running field validation...',
ready: 'Ready',
};
const phaseProgress: Record<InitPhase, number> = {
idle: 0,
'loading-options': 20,
'loading-templates': 40,
'validating-upcs': 60,
'validating-fields': 80,
ready: 100,
};
export const InitializingOverlay = ({ phase, message }: InitializingOverlayProps) => {
const displayMessage = message || phaseMessages[phase];
const progress = phaseProgress[phase];
return (
<div className="flex flex-col items-center justify-center h-[calc(100vh-9.5rem)] gap-6">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="text-lg font-medium">{displayMessage}</div>
</div>
<div className="w-64">
<Progress value={progress} className="h-2" />
</div>
<div className="text-sm text-muted-foreground">
This may take a moment for large imports
</div>
</div>
);
};

View File

@@ -0,0 +1,337 @@
/**
* SearchableTemplateSelect Component
*
* A template dropdown with brand filtering and search.
* Ported from ValidationStepNew with updated imports for the new store types.
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import { Check, ChevronsUpDown } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { Template } from '../store/types';
interface SearchableTemplateSelectProps {
templates: Template[] | undefined;
value: string;
onValueChange: (value: string) => void;
getTemplateDisplayText: (templateId: string | null) => string;
placeholder?: string;
className?: string;
triggerClassName?: string;
defaultBrand?: string;
disabled?: boolean;
}
const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
templates = [],
value,
onValueChange,
getTemplateDisplayText,
placeholder = 'Select template',
className,
triggerClassName,
defaultBrand,
disabled = false,
}) => {
const [searchTerm, setSearchTerm] = useState('');
const [selectedBrand, setSelectedBrand] = useState<string | null>(null);
const [open, setOpen] = useState(false);
// Set default brand when component mounts or defaultBrand changes
useEffect(() => {
if (defaultBrand) {
setSelectedBrand(defaultBrand);
}
}, [defaultBrand]);
// Force a re-render when templates change from empty to non-empty
useEffect(() => {
if (templates && templates.length > 0) {
setSearchTerm('');
}
}, [templates]);
// Handle wheel events for scrolling
const handleWheel = (e: React.WheelEvent) => {
const scrollArea = e.currentTarget;
scrollArea.scrollTop += e.deltaY;
};
// Extract unique brands from templates
const brands = useMemo(() => {
try {
if (!Array.isArray(templates) || templates.length === 0) {
return [];
}
const brandSet = new Set<string>();
const brandNames: { id: string; name: string }[] = [];
templates.forEach((template) => {
if (!template?.company) return;
const companyId = template.company;
if (!brandSet.has(companyId)) {
brandSet.add(companyId);
// Try to get the company name from the template display text
try {
const displayText = getTemplateDisplayText(template.id.toString());
const companyName = displayText.split(' - ')[0];
brandNames.push({ id: companyId, name: companyName || companyId });
} catch (err) {
brandNames.push({ id: companyId, name: companyId });
}
}
});
return brandNames.sort((a, b) => a.name.localeCompare(b.name));
} catch (err) {
return [];
}
}, [templates, getTemplateDisplayText]);
// Group templates by company for better organization
const groupedTemplates = useMemo(() => {
try {
if (!Array.isArray(templates) || templates.length === 0) return {};
const groups: Record<string, Template[]> = {};
templates.forEach((template) => {
if (!template?.company) return;
const companyId = template.company;
if (!groups[companyId]) {
groups[companyId] = [];
}
groups[companyId].push(template);
});
return groups;
} catch (err) {
return {};
}
}, [templates]);
// Filter templates based on selected brand and search term
const filteredTemplates = useMemo(() => {
try {
if (!Array.isArray(templates) || templates.length === 0) return [];
// First filter by brand if selected
let brandFiltered = templates;
if (selectedBrand) {
// Check if the selected brand has any templates
const brandTemplates = templates.filter((t) => t?.company === selectedBrand);
// If the selected brand has templates, use them; otherwise, show all templates
brandFiltered = brandTemplates.length > 0 ? brandTemplates : templates;
}
// Then filter by search term if provided
if (!searchTerm.trim()) return brandFiltered;
const lowerSearchTerm = searchTerm.toLowerCase();
return brandFiltered.filter((template) => {
if (!template?.id) return false;
try {
const displayText = getTemplateDisplayText(template.id.toString());
const productType = template.product_type?.toLowerCase() || '';
return displayText.toLowerCase().includes(lowerSearchTerm) || productType.includes(lowerSearchTerm);
} catch (error) {
return false;
}
});
} catch (err) {
return [];
}
}, [templates, selectedBrand, searchTerm, getTemplateDisplayText]);
// Handle errors gracefully
const getDisplayText = useCallback(() => {
try {
if (!value) return placeholder;
const template = templates.find((t) => t.id.toString() === value);
if (!template) return placeholder;
// Get the original display text
const originalText = getTemplateDisplayText(value);
// Check if it has the expected format "Brand - Product Type"
if (originalText.includes(' - ')) {
const [brand, productType] = originalText.split(' - ', 2);
// Reverse the order to "Product Type - Brand"
return `${productType} - ${brand}`;
}
// If it doesn't match the expected format, return the original text
return originalText;
} catch (err) {
console.error('Error getting display text:', err);
return placeholder;
}
}, [getTemplateDisplayText, placeholder, value, templates]);
// Safe render function for CommandItem
const renderCommandItem = useCallback(
(template: Template) => {
if (!template?.id) return null;
try {
const displayText = getTemplateDisplayText(template.id.toString());
return (
<CommandItem
key={template.id}
value={template.id.toString()}
onSelect={(currentValue) => {
try {
onValueChange(currentValue);
setOpen(false);
setSearchTerm('');
} catch (err) {
console.error('Error selecting template:', err);
}
}}
className="flex items-center justify-between"
>
<span>{displayText}</span>
{value === template.id.toString() && <Check className="h-4 w-4 ml-2" />}
</CommandItem>
);
} catch (err) {
console.error('Error rendering template item:', err);
return null;
}
},
[onValueChange, value, getTemplateDisplayText]
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn('w-full justify-between overflow-hidden', triggerClassName)}
>
<span className="truncate overflow-hidden mr-1">{getDisplayText()}</span>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50 flex-none" />
</Button>
</PopoverTrigger>
<PopoverContent className={cn('w-[300px] p-0', className)}>
<Command>
<div className="flex flex-col p-2 gap-2">
{brands.length > 0 && (
<div className="flex items-center gap-2">
<Select
value={selectedBrand || 'all'}
onValueChange={(value) => {
setSelectedBrand(value === 'all' ? null : value);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="All Brands" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Brands</SelectItem>
{brands.map((brand) => (
<SelectItem key={brand.id} value={brand.id}>
{brand.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<CommandSeparator />
<div className="flex items-center gap-2">
<CommandInput
placeholder="Search by product type..."
value={searchTerm}
onValueChange={setSearchTerm}
className="h-8 flex-1"
/>
</div>
</div>
<CommandEmpty>
<div className="py-6 text-center">
<p className="text-sm text-muted-foreground">No templates found.</p>
</div>
</CommandEmpty>
<CommandList>
<ScrollArea className="max-h-[200px] overflow-y-auto" onWheel={handleWheel}>
{!searchTerm ? (
selectedBrand ? (
groupedTemplates[selectedBrand]?.length > 0 ? (
<CommandGroup heading={brands.find((b) => b.id === selectedBrand)?.name || selectedBrand}>
{groupedTemplates[selectedBrand]?.map((template) => renderCommandItem(template))}
</CommandGroup>
) : (
// If selected brand has no templates, show all brands
Object.entries(groupedTemplates).map(([companyId, companyTemplates]) => {
const brand = brands.find((b) => b.id === companyId);
const companyName = brand ? brand.name : companyId;
return (
<CommandGroup key={companyId} heading={companyName}>
{companyTemplates.map((template) => renderCommandItem(template))}
</CommandGroup>
);
})
)
) : (
Object.entries(groupedTemplates).map(([companyId, companyTemplates]) => {
const brand = brands.find((b) => b.id === companyId);
const companyName = brand ? brand.name : companyId;
return (
<CommandGroup key={companyId} heading={companyName}>
{companyTemplates.map((template) => renderCommandItem(template))}
</CommandGroup>
);
})
)
) : (
<CommandGroup>{filteredTemplates.map((template) => renderCommandItem(template))}</CommandGroup>
)}
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
export default SearchableTemplateSelect;

View File

@@ -0,0 +1,161 @@
/**
* ValidationContainer Component
*
* Main orchestrator component for the validation step.
* Coordinates sub-components once initialization is complete.
* Note: Initialization effects are in index.tsx so they run before this mounts.
*/
import { useCallback, useMemo } from 'react';
import { useValidationStore } from '../store/validationStore';
import {
useTotalErrorCount,
useRowsWithErrorsCount,
useIsTemplateFormOpen,
useTemplateFormData,
} from '../store/selectors';
import { ValidationTable } from './ValidationTable';
import { ValidationToolbar } from './ValidationToolbar';
import { ValidationFooter } from './ValidationFooter';
import { FloatingSelectionBar } from './FloatingSelectionBar';
import { useAiValidationFlow } from '../hooks/useAiValidation';
import { useFieldOptions } from '../hooks/useFieldOptions';
import { useTemplateManagement } from '../hooks/useTemplateManagement';
import { AiValidationProgressDialog } from '../dialogs/AiValidationProgress';
import { AiValidationResultsDialog } from '../dialogs/AiValidationResults';
import { AiDebugDialog } from '../dialogs/AiDebugDialog';
import { TemplateForm } from '@/components/templates/TemplateForm';
import type { CleanRowData } from '../store/types';
interface ValidationContainerProps {
onBack?: () => void;
onNext?: (data: CleanRowData[]) => void;
isFromScratch?: boolean;
}
export const ValidationContainer = ({
onBack,
onNext,
isFromScratch: _isFromScratch,
}: ValidationContainerProps) => {
// PERFORMANCE: Only subscribe to row COUNT, not the full rows array
// Subscribing to rows causes re-render on EVERY cell change!
const rowCount = useValidationStore((state) => state.rows.length);
const totalErrorCount = useTotalErrorCount();
const rowsWithErrorsCount = useRowsWithErrorsCount();
// Template form dialog state
const isTemplateFormOpen = useIsTemplateFormOpen();
const templateFormData = useTemplateFormData();
// Store actions
const getCleanedData = useValidationStore((state) => state.getCleanedData);
const closeTemplateForm = useValidationStore((state) => state.closeTemplateForm);
// Hooks
const aiValidation = useAiValidationFlow();
const { data: fieldOptionsData } = useFieldOptions();
const { loadTemplates } = useTemplateManagement();
// Convert field options to TemplateForm format
const templateFormFieldOptions = useMemo(() => {
if (!fieldOptionsData) return null;
return {
companies: fieldOptionsData.companies || [],
artists: fieldOptionsData.artists || [],
sizes: fieldOptionsData.sizes || [], // API returns 'sizes'
themes: fieldOptionsData.themes || [],
categories: fieldOptionsData.categories || [],
colors: fieldOptionsData.colors || [],
suppliers: fieldOptionsData.suppliers || [],
taxCategories: fieldOptionsData.taxCategories || [],
shippingRestrictions: fieldOptionsData.shippingRestrictions || [], // API returns 'shippingRestrictions'
};
}, [fieldOptionsData]);
// Handle template form success - refresh templates
const handleTemplateFormSuccess = useCallback(() => {
loadTemplates();
}, [loadTemplates]);
// Handle proceeding to next step
const handleNext = useCallback(() => {
if (onNext) {
const cleanedData = getCleanedData();
onNext(cleanedData);
}
}, [onNext, getCleanedData]);
// Handle going back
const handleBack = useCallback(() => {
if (onBack) {
onBack();
}
}, [onBack]);
return (
<div className="flex flex-col h-[calc(100vh-9.5rem)] overflow-hidden">
{/* Toolbar */}
<ValidationToolbar
rowCount={rowCount}
errorCount={totalErrorCount}
rowsWithErrors={rowsWithErrorsCount}
/>
{/* Main table area */}
<div className="flex-1 overflow-hidden">
<ValidationTable />
</div>
{/* Footer with navigation */}
<ValidationFooter
onBack={handleBack}
onNext={handleNext}
canGoBack={!!onBack}
canProceed={totalErrorCount === 0}
errorCount={totalErrorCount}
rowCount={rowCount}
onAiValidate={aiValidation.validate}
isAiValidating={aiValidation.isValidating}
onShowDebug={aiValidation.showPromptPreview}
/>
{/* Floating selection bar - appears when rows selected */}
<FloatingSelectionBar />
{/* AI Validation dialogs */}
{aiValidation.isValidating && aiValidation.progress && (
<AiValidationProgressDialog
progress={aiValidation.progress}
onCancel={aiValidation.cancel}
/>
)}
{aiValidation.results && !aiValidation.isValidating && (
<AiValidationResultsDialog
results={aiValidation.results}
revertedChanges={aiValidation.revertedChanges}
onRevert={aiValidation.revertChange}
onDismiss={aiValidation.dismissResults}
/>
)}
{/* AI Debug Dialog - for viewing prompt */}
<AiDebugDialog
open={aiValidation.showDebugDialog}
onClose={aiValidation.closePromptPreview}
debugData={aiValidation.debugPrompt}
/>
{/* Template form dialog - for saving row as template */}
<TemplateForm
isOpen={isTemplateFormOpen}
onClose={closeTemplateForm}
onSuccess={handleTemplateFormSuccess}
initialData={templateFormData as Parameters<typeof TemplateForm>[0]['initialData']}
mode="create"
fieldOptions={templateFormFieldOptions}
/>
</div>
);
};

View File

@@ -0,0 +1,104 @@
/**
* ValidationFooter Component
*
* Navigation footer with back/next buttons, AI validate, and summary info.
*/
import { Button } from '@/components/ui/button';
import { ChevronLeft, ChevronRight, CheckCircle, Wand2, FileText } from 'lucide-react';
import { Protected } from '@/components/auth/Protected';
interface ValidationFooterProps {
onBack?: () => void;
onNext?: () => void;
canGoBack: boolean;
canProceed: boolean;
errorCount: number;
rowCount: number;
onAiValidate?: () => void;
isAiValidating?: boolean;
onShowDebug?: () => void;
}
export const ValidationFooter = ({
onBack,
onNext,
canGoBack,
canProceed,
errorCount,
rowCount,
onAiValidate,
isAiValidating = false,
onShowDebug,
}: ValidationFooterProps) => {
return (
<div className="flex items-center justify-between border-t bg-muted/50 px-6 py-4">
{/* Back button */}
<div>
{canGoBack && onBack && (
<Button variant="outline" onClick={onBack}>
<ChevronLeft className="h-4 w-4 mr-1" />
Back
</Button>
)}
</div>
{/* Status summary - only show success message when no errors */}
<div className="flex items-center gap-4">
{errorCount === 0 && rowCount > 0 && (
<div className="flex items-center gap-2 text-green-600">
<CheckCircle className="h-4 w-4" />
<span className="text-sm font-medium">
All {rowCount} products validated
</span>
</div>
)}
</div>
{/* Action buttons */}
<div className="flex items-center gap-2">
{/* Show Prompt Debug - Admin only */}
{onShowDebug && (
<Protected permission="admin:debug">
<Button
variant="outline"
onClick={onShowDebug}
disabled={isAiValidating}
>
<FileText className="h-4 w-4 mr-1" />
Show Prompt
</Button>
</Protected>
)}
{/* AI Validate */}
{onAiValidate && (
<Button
variant="outline"
onClick={onAiValidate}
disabled={isAiValidating || rowCount === 0}
>
<Wand2 className="h-4 w-4 mr-1" />
{isAiValidating ? 'Validating...' : 'AI Validate'}
</Button>
)}
{/* Next button */}
{onNext && (
<Button
onClick={onNext}
disabled={!canProceed}
title={
!canProceed
? `Fix ${errorCount} validation errors before proceeding`
: 'Continue to image upload'
}
>
Continue to Images
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,186 @@
/**
* ValidationToolbar Component
*
* Provides filtering, template selection, and action buttons.
*
* PERFORMANCE NOTE: This component must NOT subscribe to the rows array!
* Using useRows() or hooks that depend on it (like useValidationActions,
* useSelectedRowIndices) causes re-render on EVERY cell change, making
* dropdowns extremely slow. Instead, use getState() for one-time reads.
*/
import { useMemo, useCallback, useState } from 'react';
import { Search, Plus, FolderPlus, Edit3 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { useValidationStore } from '../store/validationStore';
import {
useFilters,
useSelectedRowCount,
useFields,
} from '../store/selectors';
import { CreateProductCategoryDialog, type CreatedCategoryInfo } from '../../../CreateProductCategoryDialog';
import { SearchProductTemplateDialog } from '@/components/templates/SearchProductTemplateDialog';
import { useTemplateManagement } from '../hooks/useTemplateManagement';
interface ValidationToolbarProps {
rowCount: number;
errorCount: number;
rowsWithErrors: number;
}
export const ValidationToolbar = ({
rowCount,
errorCount,
rowsWithErrors,
}: ValidationToolbarProps) => {
const filters = useFilters();
const selectedRowCount = useSelectedRowCount();
const fields = useFields();
// State for the product search template dialog
const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false);
// Get template management hook for reloading templates
const { loadTemplates } = useTemplateManagement();
// Store actions - get directly, no subscription needed
const setSearchText = useValidationStore((state) => state.setSearchText);
const setShowErrorsOnly = useValidationStore((state) => state.setShowErrorsOnly);
const setProductLines = useValidationStore((state) => state.setProductLines);
const setSublines = useValidationStore((state) => state.setSublines);
// Extract companies from field options
const companyOptions = useMemo(() => {
const companyField = fields.find((f) => f.key === 'company');
if (!companyField || companyField.fieldType.type !== 'select') return [];
const opts = companyField.fieldType.options || [];
return opts.map((opt) => ({
label: opt.label,
value: opt.value,
}));
}, [fields]);
// PERFORMANCE: Get addRow at action time
const handleAddRow = useCallback(() => {
useValidationStore.getState().addRow();
}, []);
// Handle category creation - refresh the cache
const handleCategoryCreated = useCallback(
async (info: CreatedCategoryInfo) => {
if (info.type === 'line') {
// A new product line was created under a company
// Refresh the productLinesCache for that company
const companyId = info.parentId;
const { productLinesCache } = useValidationStore.getState();
const existingLines = productLinesCache.get(companyId) || [];
// Add the new line to the cache
const newOption = {
label: info.name,
value: info.id || info.name,
};
setProductLines(companyId, [...existingLines, newOption]);
} else {
// A new subline was created under a line
// Refresh the sublinesCache for that line
const lineId = info.parentId;
const { sublinesCache } = useValidationStore.getState();
const existingSublines = sublinesCache.get(lineId) || [];
// Add the new subline to the cache
const newOption = {
label: info.name,
value: info.id || info.name,
};
setSublines(lineId, [...existingSublines, newOption]);
}
},
[setProductLines, setSublines]
);
return (
<div className="border-b bg-background px-4 py-3">
{/* Top row: Search and stats */}
<div className="flex items-center gap-4 mb-3">
{/* Search */}
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search products..."
value={filters.searchText}
onChange={(e) => setSearchText(e.target.value)}
className="pl-9"
/>
</div>
{/* Error filter toggle */}
<div className="flex items-center gap-2">
<Switch
id="show-errors"
checked={filters.showErrorsOnly}
onCheckedChange={setShowErrorsOnly}
/>
<Label htmlFor="show-errors" className="text-sm cursor-pointer">
Show errors only
</Label>
</div>
{/* Stats */}
<div className="flex items-center gap-3 text-sm text-muted-foreground ml-auto">
<span>{rowCount} products</span>
{errorCount > 0 && (
<Badge variant="destructive">
{errorCount} errors in {rowsWithErrors} rows
</Badge>
)}
{selectedRowCount > 0 && (
<Badge variant="secondary">{selectedRowCount} selected</Badge>
)}
</div>
</div>
{/* Bottom row: Actions */}
<div className="flex items-center gap-2">
{/* Add row */}
<Button variant="outline" size="sm" onClick={() => handleAddRow()}>
<Plus className="h-4 w-4 mr-1" />
Add Row
</Button>
{/* Create template from existing product */}
<Button
variant="outline"
size="sm"
onClick={() => setIsSearchDialogOpen(true)}
>
<Edit3 className="h-4 w-4 mr-1" />
New Template
</Button>
{/* Create product line/subline */}
<CreateProductCategoryDialog
trigger={
<Button variant="outline" size="sm">
<FolderPlus className="h-4 w-4 mr-1" />
New Line/Subline
</Button>
}
companies={companyOptions}
onCreated={handleCategoryCreated}
/>
</div>
{/* Product Search Template Dialog */}
<SearchProductTemplateDialog
isOpen={isSearchDialogOpen}
onClose={() => setIsSearchDialogOpen(false)}
onTemplateCreated={loadTemplates}
/>
</div>
);
};

View File

@@ -0,0 +1,119 @@
/**
* CheckboxCell Component
*
* Checkbox cell for boolean values.
* Memoized to prevent unnecessary re-renders when parent table updates.
*/
import { useState, useEffect, useCallback, memo } from 'react';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
import type { Field, SelectOption } from '../../../../types';
import type { ValidationError } from '../../store/types';
interface CheckboxCellProps {
value: unknown;
field: Field<string>;
options?: SelectOption[];
rowIndex: number;
isValidating: boolean;
errors: ValidationError[];
onChange: (value: unknown) => void;
onBlur: (value: unknown) => void;
onFetchOptions?: () => void;
booleanMatches?: Record<string, boolean>;
}
const CheckboxCellComponent = ({
field,
value,
isValidating,
errors,
onChange,
onBlur,
booleanMatches = {},
}: CheckboxCellProps) => {
const [checked, setChecked] = useState(false);
// Initialize checkbox state
useEffect(() => {
if (value === undefined || value === null) {
setChecked(false);
return;
}
if (typeof value === 'boolean') {
setChecked(value);
return;
}
// Handle string values using booleanMatches
if (typeof value === 'string') {
// First try the field's booleanMatches
const fieldBooleanMatches =
field.fieldType.type === 'checkbox' ? field.fieldType.booleanMatches || {} : {};
// Merge with the provided booleanMatches, with the provided ones taking precedence
const allMatches = { ...fieldBooleanMatches, ...booleanMatches };
// Try to find the value in the matches
const matchEntry = Object.entries(allMatches).find(
([k]) => k.toLowerCase() === value.toLowerCase()
);
if (matchEntry) {
setChecked(matchEntry[1]);
return;
}
// If no match found, use common true/false strings
const trueStrings = ['yes', 'true', '1', 'y'];
const falseStrings = ['no', 'false', '0', 'n'];
if (trueStrings.includes(value.toLowerCase())) {
setChecked(true);
return;
}
if (falseStrings.includes(value.toLowerCase())) {
setChecked(false);
return;
}
}
// For any other values, try to convert to boolean
setChecked(!!value);
}, [value, field.fieldType, booleanMatches]);
// Handle checkbox change
const handleChange = useCallback(
(checked: boolean) => {
setChecked(checked);
onChange(checked);
onBlur(checked);
},
[onChange, onBlur]
);
const hasError = errors.length > 0;
return (
<div
className={cn(
'flex items-center justify-center h-8 w-full rounded-md border',
hasError ? 'bg-red-50 border-destructive' : 'border-input',
isValidating && 'opacity-50'
)}
>
<Checkbox
checked={checked}
onCheckedChange={handleChange}
disabled={isValidating}
className={cn(hasError ? 'border-destructive' : '')}
/>
</div>
);
};
// Memoize to prevent re-renders when parent table state changes
export const CheckboxCell = memo(CheckboxCellComponent);

View File

@@ -0,0 +1,169 @@
/**
* ComboboxCell Component
*
* Combobox-style dropdown for fields with many options (50+).
* Uses Command (cmdk) with built-in fuzzy search filtering.
*
* PERFORMANCE: Unlike SelectCell which renders ALL options upfront,
* ComboboxCell only renders filtered results. When a user types,
* cmdk filters the list and only matching items are rendered.
* This dramatically improves performance for 100+ option lists.
*/
import { useState, useCallback, useRef, memo } from 'react';
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import type { Field, SelectOption } from '../../../../types';
import type { ValidationError } from '../../store/types';
interface ComboboxCellProps {
value: unknown;
field: Field<string>;
options?: SelectOption[];
rowIndex: number;
isValidating: boolean;
errors: ValidationError[];
onChange: (value: unknown) => void;
onBlur: (value: unknown) => void;
onFetchOptions?: () => Promise<SelectOption[]>;
}
const ComboboxCellComponent = ({
value,
field,
options = [],
isValidating,
errors,
onChange: _onChange, // Unused - onBlur handles both update and validation
onBlur,
onFetchOptions,
}: ComboboxCellProps) => {
const [open, setOpen] = useState(false);
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
const hasFetchedRef = useRef(false);
const stringValue = String(value ?? '');
const hasError = errors.length > 0;
const errorMessage = errors[0]?.message;
// Find display label for current value
const selectedOption = options.find((opt) => opt.value === stringValue);
const displayLabel = selectedOption?.label || stringValue;
// Handle popover open - trigger fetch if needed
const handleOpenChange = useCallback(
(isOpen: boolean) => {
setOpen(isOpen);
if (isOpen && onFetchOptions && options.length === 0 && !hasFetchedRef.current) {
hasFetchedRef.current = true;
setIsLoadingOptions(true);
onFetchOptions().finally(() => {
setIsLoadingOptions(false);
});
}
},
[onFetchOptions, options.length]
);
// Handle selection
const handleSelect = useCallback(
(selectedValue: string) => {
onBlur(selectedValue);
setOpen(false);
},
[onBlur]
);
// Handle wheel scroll in dropdown - stop propagation to prevent table scroll
const handleWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
e.stopPropagation();
e.currentTarget.scrollTop += e.deltaY;
}, []);
return (
<div className="relative w-full">
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
'w-full h-8 justify-between text-sm font-normal',
hasError && 'border-destructive focus:ring-destructive',
isValidating && 'opacity-50',
!stringValue && 'text-muted-foreground'
)}
disabled={isValidating}
title={errorMessage}
>
<span className="truncate">
{displayLabel || field.label}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0" align="start">
<Command>
<CommandInput placeholder={`Search ${field.label.toLowerCase()}...`} />
<CommandList>
{isLoadingOptions ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<>
<CommandEmpty>No {field.label.toLowerCase()} found.</CommandEmpty>
<div
className="max-h-[200px] overflow-y-auto overscroll-contain"
onWheel={handleWheel}
>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.label} // cmdk filters by this value
onSelect={() => handleSelect(option.value)}
>
<Check
className={cn(
'mr-2 h-4 w-4',
stringValue === option.value ? 'opacity-100' : 'opacity-0'
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</div>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{isValidating && (
<div className="absolute right-8 top-1/2 -translate-y-1/2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
);
};
// Memoize to prevent re-renders when parent table state changes
export const ComboboxCell = memo(ComboboxCellComponent);

View File

@@ -0,0 +1,155 @@
/**
* InputCell Component
*
* Editable input cell for text, numbers, and price values.
* Memoized to prevent unnecessary re-renders when parent table updates.
*/
import { useState, useCallback, useEffect, useRef, memo } from 'react';
import { Input } from '@/components/ui/input';
import { Loader2, AlertCircle } from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import type { Field, SelectOption } from '../../../../types';
import type { ValidationError } from '../../store/types';
import { ErrorType } from '../../store/types';
interface InputCellProps {
value: unknown;
field: Field<string>;
options?: SelectOption[];
rowIndex: number;
isValidating: boolean;
errors: ValidationError[];
onChange: (value: unknown) => void;
onBlur: (value: unknown) => void;
onFetchOptions?: () => void;
}
const InputCellComponent = ({
value,
field,
isValidating,
errors,
onBlur,
}: InputCellProps) => {
const [localValue, setLocalValue] = useState(String(value ?? ''));
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// Sync local value with prop value when not focused
useEffect(() => {
if (!isFocused) {
setLocalValue(String(value ?? ''));
}
}, [value, isFocused]);
// PERFORMANCE: Only update local state while typing, NOT the store
// The store is updated on blur, which prevents thousands of subscription
// checks per keystroke
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
let newValue = e.target.value;
// Handle price formatting
if ('price' in field.fieldType && field.fieldType.price) {
// Allow only numbers and decimal point
newValue = newValue.replace(/[^0-9.]/g, '');
}
// Only update local state - store will be updated on blur
setLocalValue(newValue);
},
[field]
);
const handleFocus = useCallback(() => {
setIsFocused(true);
}, []);
// Update store only on blur - this is when validation runs too
// Round price fields to 2 decimal places
const handleBlur = useCallback(() => {
setIsFocused(false);
let valueToSave = localValue;
// Round price fields to 2 decimal places
if ('price' in field.fieldType && field.fieldType.price && localValue) {
const numValue = parseFloat(localValue);
if (!isNaN(numValue)) {
valueToSave = numValue.toFixed(2);
setLocalValue(valueToSave);
}
}
onBlur(valueToSave);
}, [localValue, onBlur, field.fieldType]);
// Process errors - show icon only for non-required errors when field has value
// Don't show error icon while user is actively editing (focused)
const hasError = errors.length > 0;
const isRequiredError = errors.length === 1 && errors[0]?.type === ErrorType.Required;
const valueIsEmpty = !localValue;
const showErrorIcon = !isFocused && hasError && !(valueIsEmpty && isRequiredError);
const errorMessage = errors.filter(e => !(valueIsEmpty && e.type === ErrorType.Required))
.map(e => e.message).join('\n');
// Show skeleton while validating
if (isValidating) {
return (
<div className={cn(
'h-8 w-full flex items-center border rounded-md px-2',
hasError && 'border-destructive'
)}>
<Skeleton className="h-4 w-full" />
</div>
);
}
return (
<div className="relative w-full">
<Input
ref={inputRef}
value={localValue}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={isValidating}
className={cn(
'h-8 text-sm',
hasError && 'border-destructive focus-visible:ring-destructive',
isValidating && 'opacity-50',
showErrorIcon && 'pr-8'
)}
/>
{/* Error icon with tooltip - only for non-required errors */}
{showErrorIcon && (
<div className="absolute right-2 top-1/2 -translate-y-1/2 z-10">
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<div className="cursor-help">
<AlertCircle className="h-4 w-4 text-destructive" />
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<p className="whitespace-pre-wrap">{errorMessage}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</div>
);
};
// Memoize to prevent re-renders when parent table state changes
export const InputCell = memo(InputCellComponent);

View File

@@ -0,0 +1,290 @@
/**
* MultiSelectCell Component
*
* Multi-select cell for choosing multiple values.
* Memoized to prevent unnecessary re-renders when parent table updates.
*
* PERFORMANCE: Uses uncontrolled open state for Popover.
* Controlled open state can cause delays due to React state processing.
*/
import { useCallback, useMemo, memo, useState } from 'react';
import { Check, ChevronsUpDown, AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import type { Field, SelectOption } from '../../../../types';
import type { ValidationError } from '../../store/types';
import { ErrorType } from '../../store/types';
// Extended option type to include hex color values
interface MultiSelectOption extends SelectOption {
hex?: string;
hexColor?: string;
hex_color?: string;
}
interface MultiSelectCellProps {
value: unknown;
field: Field<string>;
options?: SelectOption[];
rowIndex: number;
isValidating: boolean;
errors: ValidationError[];
onChange: (value: unknown) => void;
onBlur: (value: unknown) => void;
onFetchOptions?: () => void;
}
/**
* Helper to extract hex color from option
* Supports hex, hexColor, and hex_color field names
*/
const getOptionHex = (option: MultiSelectOption): string | undefined => {
if (option.hex) return option.hex.startsWith('#') ? option.hex : `#${option.hex}`;
if (option.hexColor) return option.hexColor.startsWith('#') ? option.hexColor : `#${option.hexColor}`;
if (option.hex_color) return option.hex_color.startsWith('#') ? option.hex_color : `#${option.hex_color}`;
return undefined;
};
/**
* Check if a color is white or near-white (needs border)
*/
const isWhiteColor = (hex: string): boolean => {
const normalized = hex.toLowerCase();
return normalized === '#ffffff' || normalized === '#fff' || normalized === 'ffffff' || normalized === 'fff';
};
const MultiSelectCellComponent = ({
value,
field,
options = [],
isValidating,
errors,
onChange: _onChange, // Unused - onBlur handles both update and validation
onBlur,
}: MultiSelectCellProps) => {
const [open, setOpen] = useState(false);
// Handle wheel scroll in dropdown - stop propagation to prevent table scroll
const handleWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
e.stopPropagation();
e.currentTarget.scrollTop += e.deltaY;
}, []);
// Parse value to array
const selectedValues = useMemo(() => {
if (Array.isArray(value)) return value.map(String);
if (typeof value === 'string' && value) {
const separator = 'separator' in field.fieldType ? field.fieldType.separator : ',';
return value.split(separator || ',').map((v) => v.trim()).filter(Boolean);
}
return [];
}, [value, field.fieldType]);
// Process errors - show icon only for non-required errors when field has value
const hasError = errors.length > 0;
const isRequiredError = errors.length === 1 && errors[0]?.type === ErrorType.Required;
const valueIsEmpty = selectedValues.length === 0;
const showErrorIcon = hasError && !(valueIsEmpty && isRequiredError);
const errorMessage = errors.filter(e => !(valueIsEmpty && e.type === ErrorType.Required))
.map(e => e.message).join('\n');
// Only call onBlur - it handles both the cell update AND validation
// Calling onChange separately would cause a redundant store update
const handleSelect = useCallback(
(optionValue: string) => {
let newValues: string[];
if (selectedValues.includes(optionValue)) {
newValues = selectedValues.filter((v) => v !== optionValue);
} else {
newValues = [...selectedValues, optionValue];
}
onBlur(newValues);
},
[selectedValues, onBlur]
);
// Get labels for selected values (including hex color for colors field)
const selectedLabels = useMemo(() => {
return selectedValues.map((val) => {
const option = options.find((opt) => opt.value === val) as MultiSelectOption | undefined;
const hexColor = field.key === 'colors' && option ? getOptionHex(option) : undefined;
return { value: val, label: option?.label || val, hex: hexColor };
});
}, [selectedValues, options, field.key]);
// Show loading skeleton when validating or when we have values but no options (not yet loaded)
const showLoadingSkeleton = isValidating || (selectedValues.length > 0 && options.length === 0);
if (showLoadingSkeleton) {
return (
<div className={cn(
'h-8 w-full flex items-center border rounded-md px-2',
hasError && 'border-destructive'
)}>
<Skeleton className="h-4 w-full" />
</div>
);
}
return (
<div className="relative w-full">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={isValidating}
className={cn(
'h-8 w-full justify-between text-sm font-normal',
hasError && 'border-destructive focus:ring-destructive',
isValidating && 'opacity-50',
selectedValues.length === 0 && 'text-muted-foreground',
showErrorIcon && 'pr-8'
)}
>
<div className="flex items-center w-full justify-between overflow-hidden">
<div className="flex items-center gap-2 overflow-hidden">
{selectedLabels.length === 0 ? (
<span className="text-muted-foreground truncate">Select...</span>
) : selectedLabels.length === 1 ? (
<div className="flex items-center gap-1.5 truncate">
{field.key === 'colors' && selectedLabels[0].hex && (
<span
className={cn(
'inline-block h-3 w-3 rounded-full flex-shrink-0',
isWhiteColor(selectedLabels[0].hex) && 'border border-black'
)}
style={{ backgroundColor: selectedLabels[0].hex }}
/>
)}
<span className="truncate">{selectedLabels[0].label}</span>
</div>
) : (
<>
<Badge variant="secondary" className="shrink-0 whitespace-nowrap">
{selectedLabels.length} selected
</Badge>
<div className="flex items-center gap-1 truncate">
{field.key === 'colors' ? (
// Show color swatches for colors field
selectedLabels.slice(0, 5).map((v, i) => (
v.hex ? (
<span
key={i}
className={cn(
'inline-block h-3 w-3 rounded-full flex-shrink-0',
isWhiteColor(v.hex) && 'border border-black'
)}
style={{ backgroundColor: v.hex }}
title={v.label}
/>
) : null
))
) : (
<span className="truncate">
{selectedLabels.map((v) => v.label).join(', ')}
</span>
)}
</div>
</>
)}
</div>
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput placeholder={`Search ${field.label}...`} />
<CommandList>
<CommandEmpty>No options found.</CommandEmpty>
<div
className="max-h-[200px] overflow-y-auto overscroll-contain"
onWheel={handleWheel}
>
<CommandGroup>
{options.map((option) => {
const hexColor = field.key === 'colors' ? getOptionHex(option as MultiSelectOption) : undefined;
const isWhite = hexColor ? isWhiteColor(hexColor) : false;
return (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => handleSelect(option.value)}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedValues.includes(option.value)
? 'opacity-100'
: 'opacity-0'
)}
/>
{/* Color circle for colors field */}
{field.key === 'colors' && hexColor && (
<span
className={cn(
'inline-block h-3.5 w-3.5 rounded-full mr-2 flex-shrink-0',
isWhite && 'border border-black'
)}
style={{ backgroundColor: hexColor }}
/>
)}
{option.label}
</CommandItem>
);
})}
</CommandGroup>
</div>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* Error icon with tooltip - only for non-required errors */}
{showErrorIcon && (
<div className="absolute right-2 top-1/2 -translate-y-1/2 z-10">
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<div className="cursor-help">
<AlertCircle className="h-4 w-4 text-destructive" />
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<p className="whitespace-pre-wrap">{errorMessage}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</div>
);
};
// Memoize to prevent re-renders when parent table state changes
export const MultiSelectCell = memo(MultiSelectCellComponent);

View File

@@ -0,0 +1,174 @@
/**
* MultilineInput Component
*
* Expandable textarea cell for long text content.
* Memoized to prevent unnecessary re-renders when parent table updates.
*/
import { useState, useCallback, useRef, useEffect, memo } from 'react';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover';
import { X, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { Field, SelectOption } from '../../../../types';
import type { ValidationError } from '../../store/types';
interface MultilineInputProps {
value: unknown;
field: Field<string>;
options?: SelectOption[];
rowIndex: number;
isValidating: boolean;
errors: ValidationError[];
onChange: (value: unknown) => void;
onBlur: (value: unknown) => void;
onFetchOptions?: () => void;
}
const MultilineInputComponent = ({
field,
value,
isValidating,
errors,
onChange,
onBlur,
}: MultilineInputProps) => {
const [popoverOpen, setPopoverOpen] = useState(false);
const [editValue, setEditValue] = useState('');
const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null);
const cellRef = useRef<HTMLDivElement>(null);
const preventReopenRef = useRef(false);
const hasError = errors.length > 0;
const errorMessage = errors[0]?.message;
// Initialize localDisplayValue on mount and when value changes externally
useEffect(() => {
const strValue = String(value ?? '');
if (localDisplayValue === null || strValue !== localDisplayValue) {
setLocalDisplayValue(strValue);
}
}, [value, localDisplayValue]);
// Handle trigger click to toggle the popover
const handleTriggerClick = useCallback(
(e: React.MouseEvent) => {
if (preventReopenRef.current) {
e.preventDefault();
e.stopPropagation();
preventReopenRef.current = false;
return;
}
// Only process if not already open
if (!popoverOpen) {
setPopoverOpen(true);
// Initialize edit value from the current display
setEditValue(localDisplayValue || String(value ?? ''));
}
},
[popoverOpen, value, localDisplayValue]
);
// Handle immediate close of popover
const handleClosePopover = useCallback(() => {
// Only process if we have changes
if (editValue !== value || editValue !== localDisplayValue) {
// Update local display immediately
setLocalDisplayValue(editValue);
// Queue up the change
onChange(editValue);
onBlur(editValue);
}
// Immediately close popover
setPopoverOpen(false);
// Prevent reopening
preventReopenRef.current = true;
setTimeout(() => {
preventReopenRef.current = false;
}, 100);
}, [editValue, value, localDisplayValue, onChange, onBlur]);
// Handle popover open/close
const handlePopoverOpenChange = useCallback(
(open: boolean) => {
if (!open && popoverOpen) {
handleClosePopover();
} else if (open && !popoverOpen) {
setEditValue(localDisplayValue || String(value ?? ''));
setPopoverOpen(true);
}
},
[value, popoverOpen, handleClosePopover, localDisplayValue]
);
// Handle direct input change
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setEditValue(e.target.value);
}, []);
// Calculate display value
const displayValue = localDisplayValue !== null ? localDisplayValue : String(value ?? '');
return (
<div className="w-full relative" ref={cellRef}>
<Popover open={popoverOpen} onOpenChange={handlePopoverOpenChange}>
<PopoverTrigger asChild>
<div
onClick={handleTriggerClick}
className={cn(
'px-2 py-1 h-8 rounded-md text-sm w-full cursor-pointer',
'overflow-hidden whitespace-nowrap text-ellipsis',
'border',
hasError ? 'border-destructive bg-destructive/5' : 'border-input',
isValidating && 'opacity-50'
)}
title={errorMessage || displayValue}
>
{displayValue}
</div>
</PopoverTrigger>
<PopoverContent
className="p-0 shadow-lg rounded-md"
style={{ width: Math.max(cellRef.current?.offsetWidth || 300, 300) }}
align="start"
side="bottom"
alignOffset={0}
sideOffset={4}
onInteractOutside={handleClosePopover}
>
<div className="flex flex-col relative">
<Button
size="icon"
variant="ghost"
onClick={handleClosePopover}
className="h-6 w-6 text-muted-foreground absolute top-0.5 right-0.5 z-10"
>
<X className="h-3 w-3" />
</Button>
<Textarea
value={editValue}
onChange={handleChange}
className="min-h-[150px] border-none focus-visible:ring-0 rounded-md p-2 pr-8"
placeholder={`Enter ${field.label || 'text'}...`}
autoFocus
/>
</div>
</PopoverContent>
</Popover>
{isValidating && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
);
};
// Memoize to prevent re-renders when parent table state changes
export const MultilineInput = memo(MultilineInputComponent);

View File

@@ -0,0 +1,232 @@
/**
* SelectCell Component
*
* Searchable dropdown select cell for single-value selection.
* Uses Command component for built-in search filtering.
* Memoized to prevent unnecessary re-renders when parent table updates.
*/
import { useState, useCallback, useRef, useMemo, memo } from 'react';
import { Check, ChevronsUpDown, Loader2, AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import type { Field, SelectOption } from '../../../../types';
import type { ValidationError } from '../../store/types';
import { ErrorType } from '../../store/types';
interface SelectCellProps {
value: unknown;
field: Field<string>;
options?: SelectOption[];
rowIndex: number;
isValidating: boolean;
errors: ValidationError[];
onChange: (value: unknown) => void;
onBlur: (value: unknown) => void;
onFetchOptions?: () => Promise<SelectOption[]>;
isLoadingOptions?: boolean; // External loading state (e.g., when company changes)
}
const SelectCellComponent = ({
value,
field,
options = [],
isValidating,
errors,
onChange: _onChange,
onBlur,
onFetchOptions,
isLoadingOptions: externalLoadingOptions = false,
}: SelectCellProps) => {
const [open, setOpen] = useState(false);
const [isFetchingOptions, setIsFetchingOptions] = useState(false);
const hasFetchedRef = useRef(false);
// Combined loading state - either internal fetch or external loading
const isLoadingOptions = isFetchingOptions || externalLoadingOptions;
const stringValue = String(value ?? '');
// Process errors - show icon only for non-required errors when field has value
const hasError = errors.length > 0;
const isRequiredError = errors.length === 1 && errors[0]?.type === ErrorType.Required;
const valueIsEmpty = !stringValue;
const showErrorIcon = hasError && !(valueIsEmpty && isRequiredError);
const errorMessage = errors.filter(e => !(valueIsEmpty && e.type === ErrorType.Required))
.map(e => e.message).join('\n');
// Handle opening the dropdown - fetch options if needed
const handleOpenChange = useCallback(
async (isOpen: boolean) => {
if (isOpen && onFetchOptions && options.length === 0 && !hasFetchedRef.current) {
hasFetchedRef.current = true;
setIsFetchingOptions(true);
try {
await onFetchOptions();
} finally {
setIsFetchingOptions(false);
}
}
setOpen(isOpen);
},
[onFetchOptions, options.length]
);
// Handle selection
const handleSelect = useCallback(
(selectedValue: string) => {
onBlur(selectedValue);
setOpen(false);
},
[onBlur]
);
// Handle wheel scroll in dropdown - stop propagation to prevent table scroll
const handleWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
e.stopPropagation();
e.currentTarget.scrollTop += e.deltaY;
}, []);
// Find display label for current value
// IMPORTANT: We need to match against both string and number value types
const displayLabel = useMemo(() => {
if (!stringValue) return '';
// Try exact string match first, then loose match
const found = options.find((opt) =>
String(opt.value) === stringValue || opt.value === stringValue
);
return found?.label ?? null; // Return null if not found (don't fallback to ID)
}, [options, stringValue]);
// Check if we have a value but couldn't find its label in options
// This indicates options haven't loaded yet
const valueWithoutLabel = stringValue && displayLabel === null;
// Show loading skeleton when:
// - Validating
// - Have a value but no options and couldn't find label
// - External loading state (e.g., fetching lines after company change)
const showLoadingSkeleton = isValidating || (valueWithoutLabel && options.length === 0) || externalLoadingOptions;
if (showLoadingSkeleton) {
return (
<div className={cn(
'h-8 w-full flex items-center border rounded-md px-2',
hasError && 'border-destructive'
)}>
<Skeleton className="h-4 w-full" />
</div>
);
}
return (
<div className="relative w-full">
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={isValidating}
className={cn(
'h-8 w-full justify-between text-sm font-normal',
hasError && 'border-destructive focus:ring-destructive',
isValidating && 'opacity-50',
!stringValue && 'text-muted-foreground',
showErrorIcon && 'pr-8'
)}
>
<span className="truncate">
{/* Show label if found, placeholder if no value, or loading indicator if value but no label */}
{displayLabel ? displayLabel : !stringValue ? field.label : (
<span className="text-muted-foreground italic">Loading...</span>
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0 w-[var(--radix-popover-trigger-width)]"
align="start"
sideOffset={4}
>
<Command shouldFilter={true}>
<CommandInput placeholder="Search..." className="h-9" />
<CommandList>
{isLoadingOptions ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<>
<CommandEmpty>No results found.</CommandEmpty>
<div
className="max-h-[200px] overflow-y-auto overscroll-contain"
onWheel={handleWheel}
>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => handleSelect(option.value)}
className="cursor-pointer"
>
{option.label}
{String(option.value) === stringValue && (
<Check className="ml-auto h-4 w-4" />
)}
</CommandItem>
))}
</CommandGroup>
</div>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* Error icon with tooltip - only for non-required errors */}
{showErrorIcon && (
<div className="absolute right-2 top-1/2 -translate-y-1/2 z-10">
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<div className="cursor-help">
<AlertCircle className="h-4 w-4 text-destructive" />
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<p className="whitespace-pre-wrap">{errorMessage}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</div>
);
};
// Memoize to prevent re-renders when parent table state changes
export const SelectCell = memo(SelectCellComponent);

View File

@@ -0,0 +1,125 @@
/**
* AiDebugDialog Component
*
* Shows the AI validation prompt for debugging purposes.
* Only visible to users with admin:debug permission.
*/
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Loader2 } from 'lucide-react';
import type { AiDebugPromptResponse } from '../hooks/useAiValidation';
interface AiDebugDialogProps {
open: boolean;
onClose: () => void;
debugData: AiDebugPromptResponse | null;
isLoading?: boolean;
}
export const AiDebugDialog = ({
open,
onClose,
debugData,
isLoading = false,
}: AiDebugDialogProps) => {
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden">
<DialogHeader>
<DialogTitle>AI Validation Prompt</DialogTitle>
<DialogDescription>
Debug view of the prompt that will be sent to the AI for validation
</DialogDescription>
</DialogHeader>
<div className="flex flex-col h-[calc(90vh-120px)] overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : debugData ? (
<ScrollArea className="flex-1 rounded-md border">
<div className="p-4 space-y-4">
{/* Token/Character Stats */}
{debugData.promptLength && (
<div className="grid grid-cols-2 gap-4 p-3 bg-muted/50 rounded-md text-sm">
<div>
<span className="text-muted-foreground">Prompt Length:</span>
<span className="ml-2 font-medium">
{debugData.promptLength.toLocaleString()} chars
</span>
</div>
<div>
<span className="text-muted-foreground">Est. Tokens:</span>
<span className="ml-2 font-medium">
~{Math.round(debugData.promptLength / 4).toLocaleString()}
</span>
</div>
</div>
)}
{/* Base Prompt */}
{debugData.basePrompt && (
<div>
<h4 className="font-medium mb-2">Base Prompt</h4>
<pre className="p-3 bg-muted rounded-md text-xs overflow-x-auto whitespace-pre-wrap">
{debugData.basePrompt}
</pre>
</div>
)}
{/* Sample Full Prompt */}
{debugData.sampleFullPrompt && (
<div>
<h4 className="font-medium mb-2">Sample Full Prompt (First 5 Products)</h4>
<pre className="p-3 bg-muted rounded-md text-xs overflow-x-auto whitespace-pre-wrap">
{debugData.sampleFullPrompt}
</pre>
</div>
)}
{/* Taxonomy Stats */}
{debugData.taxonomyStats && (
<div>
<h4 className="font-medium mb-2">Taxonomy Stats</h4>
<div className="grid grid-cols-4 gap-2 p-3 bg-muted/50 rounded-md text-sm">
{Object.entries(debugData.taxonomyStats).map(([key, value]) => (
<div key={key}>
<span className="text-muted-foreground capitalize">
{key.replace(/([A-Z])/g, ' $1').trim()}:
</span>
<span className="ml-1 font-medium">{value}</span>
</div>
))}
</div>
</div>
)}
{/* API Format */}
{debugData.apiFormat && (
<div>
<h4 className="font-medium mb-2">API Format</h4>
<pre className="p-3 bg-muted rounded-md text-xs overflow-x-auto whitespace-pre-wrap">
{JSON.stringify(debugData.apiFormat, null, 2)}
</pre>
</div>
)}
</div>
</ScrollArea>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
No debug data available
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,74 @@
/**
* AiValidationProgressDialog Component
*
* Shows progress while AI validation is running.
*/
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Loader2 } from 'lucide-react';
import type { AiValidationProgress } from '../store/types';
interface AiValidationProgressDialogProps {
progress: AiValidationProgress;
onCancel: () => void;
}
const formatTime = (ms: number): string => {
if (ms < 1000) return 'less than a second';
const seconds = Math.ceil(ms / 1000);
if (seconds < 60) return `${seconds} second${seconds === 1 ? '' : 's'}`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
};
export const AiValidationProgressDialog = ({
progress,
onCancel,
}: AiValidationProgressDialogProps) => {
const progressPercent = progress.total > 0
? Math.round((progress.current / progress.total) * 100)
: 0;
return (
<Dialog open={true}>
<DialogContent className="sm:max-w-md" onPointerDownOutside={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
AI Validation in Progress
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">{progress.message}</span>
<span className="font-medium">{progressPercent}%</span>
</div>
<Progress value={progressPercent} className="h-2" />
</div>
{progress.estimatedTimeRemaining !== undefined && progress.estimatedTimeRemaining > 0 && (
<p className="text-sm text-muted-foreground text-center">
Estimated time remaining: {formatTime(progress.estimatedTimeRemaining)}
</p>
)}
</div>
<div className="flex justify-end">
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,151 @@
/**
* AiValidationResultsDialog Component
*
* Shows AI validation results and allows reverting changes.
*/
import { useMemo } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Check, Undo2, Sparkles } from 'lucide-react';
import type { AiValidationResults } from '../store/types';
interface AiValidationResultsDialogProps {
results: AiValidationResults;
revertedChanges: Set<string>;
onRevert: (productIndex: number, fieldKey: string) => void;
onDismiss: () => void;
}
export const AiValidationResultsDialog = ({
results,
revertedChanges,
onRevert,
onDismiss,
}: AiValidationResultsDialogProps) => {
// Group changes by product
const changesByProduct = useMemo(() => {
const grouped = new Map<number, typeof results.changes>();
results.changes.forEach((change) => {
const existing = grouped.get(change.productIndex) || [];
existing.push(change);
grouped.set(change.productIndex, existing);
});
return grouped;
}, [results.changes]);
const activeChangesCount = results.changes.filter(
(c) => !revertedChanges.has(`${c.productIndex}:${c.fieldKey}`)
).length;
return (
<Dialog open={true} onOpenChange={() => onDismiss()}>
<DialogContent className="sm:max-w-2xl max-h-[80vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
AI Validation Complete
</DialogTitle>
</DialogHeader>
<div className="py-4">
{/* Summary */}
<div className="flex items-center gap-4 mb-4 p-4 bg-muted rounded-lg">
<div className="text-center">
<div className="text-2xl font-bold">{results.totalProducts}</div>
<div className="text-xs text-muted-foreground">Products</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-primary">{results.productsWithChanges}</div>
<div className="text-xs text-muted-foreground">With Changes</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold">{activeChangesCount}</div>
<div className="text-xs text-muted-foreground">Active Changes</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold">{(results.processingTime / 1000).toFixed(1)}s</div>
<div className="text-xs text-muted-foreground">Processing Time</div>
</div>
</div>
{/* Changes list */}
{results.changes.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Check className="h-12 w-12 mx-auto mb-2 text-green-500" />
<p>No corrections needed - all data looks good!</p>
</div>
) : (
<ScrollArea className="h-[300px]">
<div className="space-y-4">
{Array.from(changesByProduct.entries()).map(([productIndex, changes]) => (
<div key={productIndex} className="border rounded-lg p-3">
<div className="font-medium mb-2">Product {productIndex + 1}</div>
<div className="space-y-2">
{changes.map((change) => {
const isReverted = revertedChanges.has(
`${change.productIndex}:${change.fieldKey}`
);
return (
<div
key={`${change.productIndex}-${change.fieldKey}`}
className={`flex items-center justify-between p-2 rounded ${
isReverted ? 'bg-muted opacity-50' : 'bg-primary/5'
}`}
>
<div className="flex-1">
<div className="font-medium text-sm">{change.fieldKey}</div>
<div className="text-xs text-muted-foreground">
<span className="line-through">
{String(change.originalValue || '(empty)')}
</span>
<span className="mx-2"></span>
<span className="text-primary font-medium">
{String(change.correctedValue)}
</span>
</div>
</div>
{isReverted ? (
<Badge variant="outline">Reverted</Badge>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => onRevert(change.productIndex, change.fieldKey)}
>
<Undo2 className="h-4 w-4 mr-1" />
Revert
</Button>
)}
</div>
);
})}
</div>
</div>
))}
</div>
</ScrollArea>
)}
{results.tokenUsage && (
<div className="mt-4 text-xs text-muted-foreground text-center">
Token usage: {results.tokenUsage.input} input, {results.tokenUsage.output} output
</div>
)}
</div>
<DialogFooter>
<Button onClick={onDismiss}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,204 @@
/**
* useAiValidation - Main AI Validation Orchestrator
*
* Coordinates AI validation flow using sub-hooks for API, progress, and transformation.
* This is the public interface for AI validation functionality.
*
* PERFORMANCE NOTE: This hook must NOT subscribe to the rows array!
* Subscribing to rows causes re-render on EVERY cell change.
* Instead, use getState() to read rows at action time.
*/
import { useCallback, useState } from 'react';
import { useValidationStore } from '../../store/validationStore';
import { useAiValidation, useIsAiValidating } from '../../store/selectors';
import { useAiProgress } from './useAiProgress';
import { useAiTransform } from './useAiTransform';
import {
runAiValidation,
getAiDebugPrompt,
prepareProductsForAi,
extractAiSupplementalColumns,
type AiDebugPromptResponse,
} from './useAiApi';
import { toast } from 'sonner';
import type { AiValidationChange } from '../../store/types';
export { useAiProgress } from './useAiProgress';
export { useAiTransform } from './useAiTransform';
export * from './useAiApi';
/**
* Main hook for AI validation operations
*/
export const useAiValidationFlow = () => {
// PERFORMANCE: Only subscribe to AI-specific state, NOT rows or fields
// Rows and fields are read via getState() at action time
const aiValidation = useAiValidation();
const isValidating = useIsAiValidating();
// Sub-hooks
const { startProgress, updateProgress, completeProgress, setError, clearProgress } = useAiProgress();
const { applyAiChanges, buildResults, saveResults } = useAiTransform();
// Store actions
const revertAiChange = useValidationStore((state) => state.revertAiChange);
const clearAiValidation = useValidationStore((state) => state.clearAiValidation);
// Local state for debug prompt preview
const [debugPrompt, setDebugPrompt] = useState<AiDebugPromptResponse | null>(null);
const [showDebugDialog, setShowDebugDialog] = useState(false);
/**
* Run AI validation on all rows
* PERFORMANCE: Uses getState() to read rows/fields at action time
*/
const validate = useCallback(async () => {
if (isValidating) {
toast.error('AI validation is already running');
return;
}
// Get rows and fields at action time
const { rows, fields } = useValidationStore.getState();
if (rows.length === 0) {
toast.error('No products to validate');
return;
}
const startTime = Date.now();
try {
// Start progress tracking
startProgress(rows.length);
// Prepare data for API
updateProgress(0, rows.length, 'preparing', 'Preparing data...');
const products = prepareProductsForAi(rows, fields);
const aiSupplementalColumns = extractAiSupplementalColumns(rows);
// Call AI validation API
updateProgress(0, rows.length, 'validating', 'Running AI validation...');
const response = await runAiValidation({
products,
aiSupplementalColumns,
});
if (!response.success || !response.results) {
throw new Error(response.error || 'AI validation failed');
}
// Process results
updateProgress(rows.length, rows.length, 'processing', 'Processing results...');
const changes: AiValidationChange[] = response.results.changes || [];
// Apply changes to rows
if (changes.length > 0) {
applyAiChanges(response.results.products, changes);
}
// Build and save results
const processingTime = Date.now() - startTime;
const results = buildResults(changes, response.results.tokenUsage, processingTime);
saveResults(results);
// Complete progress
completeProgress();
// Show summary toast
if (changes.length > 0) {
toast.success(`AI made ${changes.length} corrections across ${results.productsWithChanges} products`);
} else {
toast.info('AI validation complete - no corrections needed');
}
} catch (error) {
console.error('AI validation error:', error);
const message = error instanceof Error ? error.message : 'Unknown error';
setError(message);
toast.error(`AI validation failed: ${message}`);
}
}, [
isValidating,
startProgress,
updateProgress,
completeProgress,
setError,
applyAiChanges,
buildResults,
saveResults,
]);
/**
* Revert a specific AI change
*/
const revertChange = useCallback(
(productIndex: number, fieldKey: string) => {
revertAiChange(productIndex, fieldKey);
toast.success('Change reverted');
},
[revertAiChange]
);
/**
* Dismiss AI validation results
*/
const dismissResults = useCallback(() => {
clearAiValidation();
}, [clearAiValidation]);
/**
* Show debug prompt dialog
* PERFORMANCE: Uses getState() to read rows/fields at action time
*/
const showPromptPreview = useCallback(async () => {
const { rows, fields } = useValidationStore.getState();
const products = prepareProductsForAi(rows, fields);
const aiSupplementalColumns = extractAiSupplementalColumns(rows);
const prompt = await getAiDebugPrompt(products, aiSupplementalColumns);
if (prompt) {
setDebugPrompt(prompt);
setShowDebugDialog(true);
} else {
toast.error('Failed to load prompt preview');
}
}, []);
/**
* Close debug prompt dialog
*/
const closePromptPreview = useCallback(() => {
setShowDebugDialog(false);
setDebugPrompt(null);
}, []);
/**
* Cancel ongoing validation
*/
const cancel = useCallback(() => {
clearProgress();
toast.info('AI validation cancelled');
}, [clearProgress]);
return {
// State
isValidating,
progress: aiValidation.progress,
results: aiValidation.results,
revertedChanges: aiValidation.revertedChanges,
// Debug dialog
debugPrompt,
showDebugDialog,
// Actions
validate,
revertChange,
dismissResults,
cancel,
showPromptPreview,
closePromptPreview,
};
};

View File

@@ -0,0 +1,147 @@
/**
* useAiApi - AI Validation API Calls
*
* Handles all API communication for AI validation.
*/
import config from '@/config';
import type { RowData } from '../../store/types';
import type { Field } from '../../../../types';
export interface AiValidationRequest {
products: Record<string, unknown>[];
aiSupplementalColumns: string[];
options?: {
batchSize?: number;
temperature?: number;
};
}
export interface AiValidationResponse {
success: boolean;
results?: {
products: Record<string, unknown>[];
changes: Array<{
productIndex: number;
fieldKey: string;
originalValue: unknown;
correctedValue: unknown;
confidence?: number;
}>;
tokenUsage?: {
input: number;
output: number;
};
};
error?: string;
}
export interface AiDebugPromptResponse {
prompt: string;
systemPrompt: string;
estimatedTokens: number;
}
/**
* Prepare products data for AI validation
*/
export const prepareProductsForAi = (
rows: RowData[],
fields: Field<string>[]
): Record<string, unknown>[] => {
return rows.map((row, index) => {
const product: Record<string, unknown> = {
_index: index, // Track original index for applying changes
};
// Include all field values
fields.forEach((field) => {
const value = row[field.key];
if (value !== undefined && value !== null && value !== '') {
product[field.key] = value;
}
});
// Include AI supplemental columns if present
if (row.__aiSupplemental && Array.isArray(row.__aiSupplemental)) {
row.__aiSupplemental.forEach((col) => {
if (row[col] !== undefined) {
product[`_supplemental_${col}`] = row[col];
}
});
}
return product;
});
};
/**
* Extract AI supplemental columns from rows
*/
export const extractAiSupplementalColumns = (rows: RowData[]): string[] => {
const columns = new Set<string>();
rows.forEach((row) => {
if (row.__aiSupplemental && Array.isArray(row.__aiSupplemental)) {
row.__aiSupplemental.forEach((col) => columns.add(col));
}
});
return Array.from(columns);
};
/**
* Run AI validation on products
*/
export const runAiValidation = async (
request: AiValidationRequest
): Promise<AiValidationResponse> => {
try {
const response = await fetch(`${config.apiUrl}/ai-validation/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `API error (${response.status})`);
}
return await response.json();
} catch (error) {
console.error('AI validation API error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
};
/**
* Get AI debug prompt (for preview)
*/
export const getAiDebugPrompt = async (
products: Record<string, unknown>[],
aiSupplementalColumns: string[]
): Promise<AiDebugPromptResponse | null> => {
try {
const response = await fetch(`${config.apiUrl}/ai-validation/debug`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
products: products.slice(0, 5), // Only send first 5 for preview
aiSupplementalColumns,
}),
});
if (!response.ok) {
throw new Error(`API error (${response.status})`);
}
return await response.json();
} catch (error) {
console.error('AI debug prompt error:', error);
return null;
}
};

View File

@@ -0,0 +1,132 @@
/**
* useAiProgress - AI Validation Progress Tracking
*
* Manages progress state and time estimation for AI validation.
*/
import { useCallback, useRef, useEffect } from 'react';
import { useValidationStore } from '../../store/validationStore';
import type { AiValidationProgress } from '../../store/types';
// Average time per product (based on historical data)
const AVG_MS_PER_PRODUCT = 150;
const MIN_ESTIMATED_TIME = 2000; // Minimum 2 seconds
/**
* Hook for managing AI validation progress
*/
export const useAiProgress = () => {
const setAiValidationProgress = useValidationStore((state) => state.setAiValidationProgress);
const setAiValidationRunning = useValidationStore((state) => state.setAiValidationRunning);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const startTimeRef = useRef<number>(0);
/**
* Start progress tracking
*/
const startProgress = useCallback(
(totalProducts: number) => {
startTimeRef.current = Date.now();
const initialProgress: AiValidationProgress = {
current: 0,
total: totalProducts,
status: 'preparing',
message: 'Preparing data for AI validation...',
startTime: startTimeRef.current,
estimatedTimeRemaining: Math.max(
totalProducts * AVG_MS_PER_PRODUCT,
MIN_ESTIMATED_TIME
),
};
setAiValidationRunning(true);
setAiValidationProgress(initialProgress);
},
[setAiValidationProgress, setAiValidationRunning]
);
/**
* Update progress
*/
const updateProgress = useCallback(
(current: number, total: number, status: AiValidationProgress['status'], message?: string) => {
const elapsed = Date.now() - startTimeRef.current;
const rate = current > 0 ? elapsed / current : AVG_MS_PER_PRODUCT;
const remaining = (total - current) * rate;
setAiValidationProgress({
current,
total,
status,
message,
startTime: startTimeRef.current,
estimatedTimeRemaining: Math.max(remaining, 0),
});
},
[setAiValidationProgress]
);
/**
* Complete progress
*/
const completeProgress = useCallback(() => {
const elapsed = Date.now() - startTimeRef.current;
setAiValidationProgress({
current: 1,
total: 1,
status: 'complete',
message: `Validation complete in ${(elapsed / 1000).toFixed(1)}s`,
startTime: startTimeRef.current,
estimatedTimeRemaining: 0,
});
}, [setAiValidationProgress]);
/**
* Set error state
*/
const setError = useCallback(
(message: string) => {
setAiValidationProgress({
current: 0,
total: 0,
status: 'error',
message,
startTime: startTimeRef.current,
estimatedTimeRemaining: 0,
});
},
[setAiValidationProgress]
);
/**
* Clear progress and stop tracking
*/
const clearProgress = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
setAiValidationProgress(null);
setAiValidationRunning(false);
}, [setAiValidationProgress, setAiValidationRunning]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}, []);
return {
startProgress,
updateProgress,
completeProgress,
setError,
clearProgress,
};
};

View File

@@ -0,0 +1,166 @@
/**
* useAiTransform - AI Validation Data Transformation
*
* Handles applying AI changes to row data with type coercion.
*
* PERFORMANCE NOTE: This hook must NOT subscribe to the rows array!
* Subscribing to rows causes re-render on EVERY cell change.
* Instead, use getState() to read rows at action time.
*/
import { useCallback } from 'react';
import { useValidationStore } from '../../store/validationStore';
import type { AiValidationChange, AiValidationResults } from '../../store/types';
import type { Field, SelectOption } from '../../../../types';
/**
* Coerce a value to match the expected field type
*/
const coerceValue = (value: unknown, field: Field<string>): unknown => {
if (value === null || value === undefined) return value;
const fieldType = field.fieldType.type;
switch (fieldType) {
case 'checkbox':
// Convert to boolean
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
const lower = value.toLowerCase();
if (['true', 'yes', '1', 'on'].includes(lower)) return true;
if (['false', 'no', '0', 'off'].includes(lower)) return false;
}
return Boolean(value);
case 'multi-select':
case 'multi-input':
// Convert to array
if (Array.isArray(value)) return value;
if (typeof value === 'string') {
const separator = field.fieldType.separator || ',';
return value.split(separator).map((v) => v.trim()).filter(Boolean);
}
return [value];
case 'select':
// Ensure it's a valid option value
if ('options' in field.fieldType) {
const strValue = String(value);
const validOption = field.fieldType.options.find(
(opt: SelectOption) => opt.value === strValue || opt.label === strValue
);
return validOption?.value || strValue;
}
return String(value);
case 'input':
// Handle price fields
if ('price' in field.fieldType && field.fieldType.price) {
const strValue = String(value).replace(/[$,\s]/g, '');
const num = parseFloat(strValue);
return isNaN(num) ? value : num.toFixed(2);
}
return String(value);
default:
return value;
}
};
/**
* Hook for transforming and applying AI validation results
* PERFORMANCE: Uses getState() to read rows/fields at action time
*/
export const useAiTransform = () => {
// PERFORMANCE: Only get store actions, no state subscriptions
const storeOriginalValues = useValidationStore((state) => state.storeOriginalValues);
const setAiValidationResults = useValidationStore((state) => state.setAiValidationResults);
/**
* Apply AI changes to rows
* PERFORMANCE: Uses getState() to read rows/fields at action time
*/
const applyAiChanges = useCallback(
(_aiProducts: Record<string, unknown>[], changes: AiValidationChange[]) => {
// Get current state at action time
const { rows, fields, updateRow } = useValidationStore.getState();
// Store original values for revert functionality
storeOriginalValues();
// Group changes by product index for batch updates
const changesByProduct = new Map<number, Record<string, unknown>>();
changes.forEach((change) => {
const productChanges = changesByProduct.get(change.productIndex) || {};
const field = fields.find((f) => f.key === change.fieldKey);
if (field) {
productChanges[change.fieldKey] = coerceValue(change.correctedValue, field);
} else {
productChanges[change.fieldKey] = change.correctedValue;
}
changesByProduct.set(change.productIndex, productChanges);
});
// Apply changes to rows
changesByProduct.forEach((productChanges, productIndex) => {
if (productIndex >= 0 && productIndex < rows.length) {
// Mark which fields were changed by AI
const __changes: Record<string, boolean> = {};
Object.keys(productChanges).forEach((key) => {
__changes[key] = true;
});
updateRow(productIndex, {
...productChanges,
__changes,
__corrected: productChanges,
});
}
});
},
[storeOriginalValues]
);
/**
* Build results summary from AI response
* PERFORMANCE: Uses getState() to read row count at action time
*/
const buildResults = useCallback(
(
changes: AiValidationChange[],
tokenUsage: { input: number; output: number } | undefined,
processingTime: number
): AiValidationResults => {
const { rows } = useValidationStore.getState();
const affectedProducts = new Set(changes.map((c) => c.productIndex));
return {
totalProducts: rows.length,
productsWithChanges: affectedProducts.size,
changes,
tokenUsage,
processingTime,
};
},
[]
);
/**
* Set validation results in store
*/
const saveResults = useCallback(
(results: AiValidationResults) => {
setAiValidationResults(results);
},
[setAiValidationResults]
);
return {
applyAiChanges,
buildResults,
saveResults,
};
};

View File

@@ -0,0 +1,76 @@
/**
* useFieldOptions Hook
*
* Manages fetching and caching of field options from the API.
* Options are used to populate select dropdowns in the validation table.
*/
import { useQuery } from '@tanstack/react-query';
import config from '@/config';
import type { SelectOption } from '../../../types';
export interface FieldOptionsResponse {
suppliers: SelectOption[];
companies: SelectOption[];
taxCategories: SelectOption[];
artists: SelectOption[];
shippingRestrictions: SelectOption[]; // API returns 'shippingRestrictions'
sizes: SelectOption[]; // API returns 'sizes'
categories: SelectOption[];
themes: SelectOption[];
colors: SelectOption[];
}
/**
* Fetch field options from the API
*/
const fetchFieldOptions = async (): Promise<FieldOptionsResponse> => {
const response = await fetch(`${config.apiUrl}/import/field-options`);
if (!response.ok) {
throw new Error('Failed to fetch field options');
}
return response.json();
};
/**
* Hook to fetch and cache field options
*/
export const useFieldOptions = () => {
return useQuery({
queryKey: ['field-options'],
queryFn: fetchFieldOptions,
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
gcTime: 10 * 60 * 1000, // Keep in garbage collection for 10 minutes
retry: 2,
refetchOnWindowFocus: false,
});
};
/**
* Get options for a specific field key
*/
export const getOptionsForField = (
fieldOptions: FieldOptionsResponse | undefined,
fieldKey: string
): SelectOption[] => {
if (!fieldOptions) return [];
const fieldToOptionsMap: Record<string, keyof FieldOptionsResponse> = {
supplier: 'suppliers',
company: 'companies',
tax_cat: 'taxCategories',
artist: 'artists',
ship_restrictions: 'shippingRestrictions', // API returns 'shippingRestrictions'
size_cat: 'sizes', // API returns 'sizes'
categories: 'categories',
themes: 'themes',
colors: 'colors',
};
const optionsKey = fieldToOptionsMap[fieldKey];
if (optionsKey && fieldOptions[optionsKey]) {
return fieldOptions[optionsKey];
}
return [];
};

View File

@@ -0,0 +1,238 @@
/**
* useProductLines Hook
*
* Manages fetching and caching of product lines and sublines.
* Lines are hierarchical: Company -> Line -> Subline
*
* PERFORMANCE NOTE: This hook has NO subscriptions to avoid re-renders.
* All state is read via getState() at call-time inside callbacks.
* This prevents components using this hook from re-rendering when cache updates.
*/
import { useCallback, useRef } from 'react';
import { useValidationStore } from '../store/validationStore';
import type { SelectOption } from '../../../types';
import axios from 'axios';
/**
* Fetch product lines for a company
*/
const fetchProductLinesApi = async (companyId: string): Promise<SelectOption[]> => {
const response = await axios.get(`/api/import/product-lines/${companyId}`);
const lines = response.data;
return lines.map((line: { name?: string; label?: string; value?: string | number; id?: string | number }) => ({
label: line.name || line.label || String(line.value || line.id),
value: String(line.value || line.id),
}));
};
/**
* Fetch sublines for a product line
*/
const fetchSublinesApi = async (lineId: string): Promise<SelectOption[]> => {
const response = await axios.get(`/api/import/sublines/${lineId}`);
const sublines = response.data;
return sublines.map((subline: { name?: string; label?: string; value?: string | number; id?: string | number }) => ({
label: subline.name || subline.label || String(subline.value || subline.id),
value: String(subline.value || subline.id),
}));
};
/**
* Hook for product lines operations
*
* PERFORMANCE: This hook has NO subscriptions to cache state.
* All cache reads use getState() at call-time inside callbacks.
* This prevents re-renders when cache updates.
*/
export const useProductLines = () => {
// Store actions (these are stable from Zustand)
const setProductLines = useValidationStore((state) => state.setProductLines);
const setSublines = useValidationStore((state) => state.setSublines);
const setLoadingProductLines = useValidationStore((state) => state.setLoadingProductLines);
const setLoadingSublines = useValidationStore((state) => state.setLoadingSublines);
// Track pending requests to prevent duplicates
const pendingCompanyRequests = useRef(new Set<string>());
const pendingLineRequests = useRef(new Set<string>());
/**
* Fetch product lines for a company
* IMPORTANT: This callback is stable - no subscriptions, only store actions
*/
const fetchProductLines = useCallback(
async (companyId: string): Promise<SelectOption[]> => {
if (!companyId) return [];
// Check cache first via getState()
const cached = useValidationStore.getState().productLinesCache.get(companyId);
if (cached) return cached;
// Check if already fetching
if (pendingCompanyRequests.current.has(companyId)) {
// Wait for the pending request
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
const result = useValidationStore.getState().productLinesCache.get(companyId);
if (result !== undefined) {
clearInterval(checkInterval);
resolve(result);
}
}, 100);
// Timeout after 10 seconds
setTimeout(() => {
clearInterval(checkInterval);
resolve([]);
}, 10000);
});
}
pendingCompanyRequests.current.add(companyId);
setLoadingProductLines(companyId, true);
try {
const lines = await fetchProductLinesApi(companyId);
setProductLines(companyId, lines);
return lines;
} catch (error) {
console.error(`Error fetching product lines for company ${companyId}:`, error);
setProductLines(companyId, []); // Cache empty to prevent retries
return [];
} finally {
pendingCompanyRequests.current.delete(companyId);
setLoadingProductLines(companyId, false);
}
},
[setProductLines, setLoadingProductLines] // Only stable store actions as deps
);
/**
* Fetch sublines for a product line
* IMPORTANT: This callback is stable - no subscriptions, only store actions
*/
const fetchSublines = useCallback(
async (lineId: string): Promise<SelectOption[]> => {
if (!lineId) return [];
// Check cache first via getState()
const cached = useValidationStore.getState().sublinesCache.get(lineId);
if (cached) return cached;
// Check if already fetching
if (pendingLineRequests.current.has(lineId)) {
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
const result = useValidationStore.getState().sublinesCache.get(lineId);
if (result !== undefined) {
clearInterval(checkInterval);
resolve(result);
}
}, 100);
setTimeout(() => {
clearInterval(checkInterval);
resolve([]);
}, 10000);
});
}
pendingLineRequests.current.add(lineId);
setLoadingSublines(lineId, true);
try {
const sublines = await fetchSublinesApi(lineId);
setSublines(lineId, sublines);
return sublines;
} catch (error) {
console.error(`Error fetching sublines for line ${lineId}:`, error);
setSublines(lineId, []); // Cache empty to prevent retries
return [];
} finally {
pendingLineRequests.current.delete(lineId);
setLoadingSublines(lineId, false);
}
},
[setSublines, setLoadingSublines] // Only stable store actions as deps
);
/**
* Prefetch product lines and sublines for all rows
* PERFORMANCE: Uses getState() to read rows at action time
*/
const prefetchAllLines = useCallback(async () => {
// Get rows at action time via getState()
const currentRows = useValidationStore.getState().rows;
// Collect unique company IDs
const companyIds = new Set<string>();
const lineIds = new Set<string>();
currentRows.forEach((row) => {
if (row.company) companyIds.add(String(row.company));
if (row.line) lineIds.add(String(row.line));
});
// Fetch all product lines in parallel
await Promise.all(
Array.from(companyIds).map((companyId) => fetchProductLines(companyId))
);
// Fetch all sublines in parallel
await Promise.all(
Array.from(lineIds).map((lineId) => fetchSublines(lineId))
);
}, [fetchProductLines, fetchSublines]);
/**
* Get product lines for a company (from cache)
*/
const getProductLines = useCallback(
(companyId: string): SelectOption[] => {
return useValidationStore.getState().productLinesCache.get(companyId) || [];
},
[]
);
/**
* Get sublines for a line (from cache)
*/
const getSublines = useCallback(
(lineId: string): SelectOption[] => {
return useValidationStore.getState().sublinesCache.get(lineId) || [];
},
[]
);
/**
* Check if product lines are loading for a company
*/
const isLoadingProductLines = useCallback(
(companyId: string): boolean => {
return pendingCompanyRequests.current.has(companyId);
},
[]
);
/**
* Check if sublines are loading for a line
*/
const isLoadingSublines = useCallback(
(lineId: string): boolean => {
return pendingLineRequests.current.has(lineId);
},
[]
);
return {
fetchProductLines,
fetchSublines,
prefetchAllLines,
getProductLines,
getSublines,
isLoadingProductLines,
isLoadingSublines,
};
};

View File

@@ -0,0 +1,334 @@
/**
* useTemplateManagement Hook
*
* Manages template loading, applying, and saving.
* Templates provide pre-filled values that can be applied to rows.
*
* PERFORMANCE NOTE: This hook must NOT subscribe to the rows array!
* Using useRows() or useSelectedRowIndices() causes re-render on EVERY cell
* change. Instead, use getState() to read rows at action time.
*/
import { useCallback } from 'react';
import { useValidationStore } from '../store/validationStore';
import {
useTemplates,
useTemplatesLoading,
useTemplateState,
} from '../store/selectors';
import { toast } from 'sonner';
import config from '@/config';
import type { Template, RowData } from '../store/types';
// Fields to exclude from template data
const TEMPLATE_EXCLUDE_FIELDS = [
'id',
'__index',
'__meta',
'__template',
'__original',
'__corrected',
'__changes',
'__aiSupplemental',
];
/**
* Hook for template management operations
*/
export const useTemplateManagement = () => {
// PERFORMANCE: Only subscribe to templates state, NOT rows!
// Rows are read via getState() at action time
const templates = useTemplates();
const templatesLoading = useTemplatesLoading();
const templateState = useTemplateState();
// Store actions
const setTemplates = useValidationStore((state) => state.setTemplates);
const setTemplatesLoading = useValidationStore((state) => state.setTemplatesLoading);
const setTemplateState = useValidationStore((state) => state.setTemplateState);
/**
* Load templates from API
*/
const loadTemplates = useCallback(async () => {
setTemplatesLoading(true);
try {
const response = await fetch(`${config.apiUrl}/templates`);
if (!response.ok) throw new Error('Failed to fetch templates');
const data = await response.json();
const validTemplates = data.filter(
(t: Template) => t && typeof t === 'object' && t.id && t.company && t.product_type
);
setTemplates(validTemplates);
} catch (error) {
console.error('Error fetching templates:', error);
toast.error('Failed to load templates');
} finally {
setTemplatesLoading(false);
}
}, [setTemplates, setTemplatesLoading]);
/**
* Apply template to specific rows
* PERFORMANCE: Uses getState() to read rows at action time, avoiding subscriptions
*/
const applyTemplate = useCallback(
async (templateId: string, rowIndexes: number[]) => {
const template = templates.find((t) => t.id.toString() === templateId);
if (!template) {
toast.error('Template not found');
return;
}
// Get current state at action time
const {
rows,
updateRow,
clearRowErrors,
setRowValidationStatus,
} = useValidationStore.getState();
// Validate row indexes
const validIndexes = rowIndexes.filter(
(index) => index >= 0 && index < rows.length && Number.isInteger(index)
);
if (validIndexes.length === 0) {
toast.error('No valid rows to update');
return;
}
// Extract template fields
const templateFields = Object.entries(template).filter(
([key]) => !TEMPLATE_EXCLUDE_FIELDS.includes(key)
);
// Apply template to each row
const rowsNeedingUpcValidation: { rowIndex: number; supplierId: string; upcValue: string }[] = [];
for (const rowIndex of validIndexes) {
// Build updates object
const updates: Partial<RowData> = {
__template: templateId,
};
for (const [key, value] of templateFields) {
updates[key] = value;
}
// Apply updates
updateRow(rowIndex, updates);
// Clear validation errors (template values are presumed valid)
clearRowErrors(rowIndex);
setRowValidationStatus(rowIndex, 'validated');
// Check if row needs UPC validation
const row = rows[rowIndex];
const hasUpc = updates.upc || row?.upc;
const hasSupplier = updates.supplier || row?.supplier;
if (hasUpc && hasSupplier) {
rowsNeedingUpcValidation.push({
rowIndex,
supplierId: String(updates.supplier || row?.supplier || ''),
upcValue: String(updates.upc || row?.upc || ''),
});
}
}
// Show success toast
if (validIndexes.length === 1) {
toast.success('Template applied');
} else {
toast.success(`Template applied to ${validIndexes.length} rows`);
}
// Trigger UPC validation for affected rows in background
// Note: We captured the values above to avoid re-reading stale state
for (const { rowIndex, supplierId, upcValue } of rowsNeedingUpcValidation) {
if (supplierId && upcValue) {
// Don't await - let it run in background
// UPC validation uses its own hooks which will update the store
fetch(`/api/import/validate-upc/${supplierId}/${encodeURIComponent(upcValue)}`)
.then((res) => res.json())
.then((result) => {
if (result.itemNumber) {
const { updateCell, setUpcStatus } = useValidationStore.getState();
updateCell(rowIndex, 'item_number', result.itemNumber);
setUpcStatus(rowIndex, 'done');
}
})
.catch(console.error);
}
}
},
[templates]
);
/**
* Apply template to selected rows
* PERFORMANCE: Gets selected row indices at action time via getState()
*/
const applyTemplateToSelected = useCallback(
(templateId: string) => {
if (!templateId) return;
setTemplateState({ selectedTemplateId: templateId });
// Get selected row indices at action time
const { rows, selectedRows } = useValidationStore.getState();
const selectedRowIndices: number[] = [];
rows.forEach((row, index) => {
if (selectedRows.has(row.__index)) {
selectedRowIndices.push(index);
}
});
if (selectedRowIndices.length === 0) {
toast.error('No rows selected');
return;
}
applyTemplate(templateId, selectedRowIndices);
},
[applyTemplate, setTemplateState]
);
/**
* Save a new template from a row
* PERFORMANCE: Uses getState() to read rows at action time
*/
const saveTemplate = useCallback(
async (name: string, type: string, sourceRowIndex: number) => {
// Get row data at action time
const { rows, updateRow } = useValidationStore.getState();
const row = rows[sourceRowIndex];
if (!row) {
toast.error('Invalid row selected');
return;
}
// Extract data for template, excluding metadata
const templateData: Record<string, unknown> = {};
for (const [key, value] of Object.entries(row)) {
if (TEMPLATE_EXCLUDE_FIELDS.includes(key)) continue;
// Clean price values
if (typeof value === 'string' && value.includes('$')) {
templateData[key] = value.replace(/[$,\s]/g, '').trim();
} else {
templateData[key] = value;
}
}
try {
const response = await fetch(`${config.apiUrl}/templates`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...templateData,
company: name,
product_type: type,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || errorData.details || 'Failed to save template');
}
const newTemplate = await response.json();
// Add to templates list
setTemplates([...templates, newTemplate]);
// Mark row as using this template
updateRow(sourceRowIndex, { __template: newTemplate.id.toString() });
toast.success(`Template "${name}" saved successfully`);
// Reset dialog state
setTemplateState({
showSaveDialog: false,
newTemplateName: '',
newTemplateType: '',
});
} catch (error) {
console.error('Error saving template:', error);
toast.error(error instanceof Error ? error.message : 'Failed to save template');
}
},
[templates, setTemplates, setTemplateState]
);
/**
* Open save template dialog
*/
const openSaveDialog = useCallback(() => {
setTemplateState({ showSaveDialog: true });
}, [setTemplateState]);
/**
* Close save template dialog
*/
const closeSaveDialog = useCallback(() => {
setTemplateState({
showSaveDialog: false,
newTemplateName: '',
newTemplateType: '',
});
}, [setTemplateState]);
/**
* Update dialog form fields
*/
const updateDialogField = useCallback(
(field: 'newTemplateName' | 'newTemplateType', value: string) => {
setTemplateState({ [field]: value });
},
[setTemplateState]
);
/**
* Get display text for a template (e.g., "Brand - Product Type")
*/
const getTemplateDisplayText = useCallback(
(templateId: string | null): string => {
if (!templateId) return '';
const template = templates.find((t) => t.id.toString() === templateId);
if (!template) return '';
// Return "Brand - Product Type" format
const company = template.company || 'Unknown';
const productType = template.product_type || 'Unknown';
return `${company} - ${productType}`;
},
[templates]
);
return {
// State
templates,
templatesLoading,
templateState,
// Actions
loadTemplates,
applyTemplate,
applyTemplateToSelected,
saveTemplate,
// Helpers
getTemplateDisplayText,
// Dialog
openSaveDialog,
closeSaveDialog,
updateDialogField,
};
};

View File

@@ -0,0 +1,309 @@
/**
* useUpcValidation Hook
*
* Handles UPC validation and item number generation.
* Validates UPCs against the API and caches results for efficiency.
*
* PERFORMANCE NOTE: This hook must NOT subscribe to the rows array!
* Subscribing to rows causes re-render on EVERY cell change.
* Uses getState() to read rows at action time instead.
*/
import { useCallback, useRef } from 'react';
import { useValidationStore } from '../store/validationStore';
import { useInitialUpcValidationDone } from '../store/selectors';
import { ErrorSource, ErrorType, type UpcValidationResult } from '../store/types';
import { correctUpcValue } from '../utils/upcUtils';
import config from '@/config';
const BATCH_SIZE = 50;
const BATCH_DELAY = 100; // ms between batches
/**
* Fetch product by UPC from API
*/
const fetchProductByUpc = async (
supplierId: string,
upcValue: string
): Promise<UpcValidationResult> => {
try {
const response = await fetch(
`${config.apiUrl}/import/check-upc-and-generate-sku?upc=${encodeURIComponent(upcValue)}&supplierId=${encodeURIComponent(supplierId)}`
);
let payload: Record<string, unknown> | null = null;
try {
payload = await response.json();
} catch {
// Non-JSON responses handled below
}
if (response.status === 409) {
return {
success: false,
error: (payload?.error as string) || 'UPC already exists',
code: 'conflict',
};
}
if (!response.ok) {
return {
success: false,
error: (payload?.error as string) || `API error (${response.status})`,
code: 'http_error',
};
}
if (!payload?.success) {
return {
success: false,
error: (payload?.message as string) || 'Unknown error',
code: 'invalid_response',
};
}
return {
success: true,
itemNumber: (payload.itemNumber as string) || '',
};
} catch (error) {
console.error('Network error validating UPC:', error);
return {
success: false,
error: 'Network error',
code: 'network_error',
};
}
};
/**
* Hook for UPC validation operations
* PERFORMANCE: Does NOT subscribe to rows - uses getState() at action time
*/
export const useUpcValidation = () => {
// PERFORMANCE: Only subscribe to initialValidationDone, NOT rows
const initialValidationDone = useInitialUpcValidationDone();
// Store actions (these are stable from Zustand)
const setUpcStatus = useValidationStore((state) => state.setUpcStatus);
const setGeneratedItemNumber = useValidationStore((state) => state.setGeneratedItemNumber);
const cacheUpcResult = useValidationStore((state) => state.cacheUpcResult);
const getCachedItemNumber = useValidationStore((state) => state.getCachedItemNumber);
const setInitialUpcValidationDone = useValidationStore((state) => state.setInitialUpcValidationDone);
const updateCell = useValidationStore((state) => state.updateCell);
const setError = useValidationStore((state) => state.setError);
const clearFieldError = useValidationStore((state) => state.clearFieldError);
const startValidatingCell = useValidationStore((state) => state.startValidatingCell);
const stopValidatingCell = useValidationStore((state) => state.stopValidatingCell);
const setInitPhase = useValidationStore((state) => state.setInitPhase);
// Track active validations to prevent duplicates
const activeValidationsRef = useRef(new Set<string>());
const initialValidationStartedRef = useRef(false);
/**
* Validate a single UPC for a row
* IMPORTANT: This callback is stable due to only using store actions as deps
*/
const validateUpc = useCallback(
async (rowIndex: number, supplierId: string, upcValue: string) => {
const validationKey = `${rowIndex}-${supplierId}-${upcValue}`;
// Cancel any previous validation for this row
const prevKeys = Array.from(activeValidationsRef.current).filter((k) =>
k.startsWith(`${rowIndex}-`)
);
prevKeys.forEach((k) => activeValidationsRef.current.delete(k));
activeValidationsRef.current.add(validationKey);
// Set loading state
setUpcStatus(rowIndex, 'validating');
startValidatingCell(rowIndex, 'item_number');
try {
// Check cache first
const cached = getCachedItemNumber(supplierId, upcValue);
if (cached) {
setGeneratedItemNumber(rowIndex, cached);
clearFieldError(rowIndex, 'upc');
setUpcStatus(rowIndex, 'done');
return { success: true, itemNumber: cached };
}
// Make API call
const result = await fetchProductByUpc(supplierId, upcValue);
// Check if this validation is still relevant
if (!activeValidationsRef.current.has(validationKey)) {
return { success: false };
}
if (result.success && result.itemNumber) {
// Cache and apply result
cacheUpcResult(supplierId, upcValue, result.itemNumber);
setGeneratedItemNumber(rowIndex, result.itemNumber);
clearFieldError(rowIndex, 'upc');
setUpcStatus(rowIndex, 'done');
return result;
} else {
// Clear item number on error
updateCell(rowIndex, 'item_number', '');
setUpcStatus(rowIndex, 'error');
// Set specific error for conflicts
if (result.code === 'conflict') {
setError(rowIndex, 'upc', {
message: 'UPC already exists in database',
level: 'error',
source: ErrorSource.Upc,
type: ErrorType.Unique,
});
}
return result;
}
} catch (error) {
console.error('Error validating UPC:', error);
setUpcStatus(rowIndex, 'error');
return { success: false, error: 'Validation error' };
} finally {
stopValidatingCell(rowIndex, 'item_number');
activeValidationsRef.current.delete(validationKey);
}
},
[
getCachedItemNumber,
setUpcStatus,
setGeneratedItemNumber,
cacheUpcResult,
updateCell,
setError,
clearFieldError,
startValidatingCell,
stopValidatingCell,
]
);
/**
* Validate all UPCs in batch during initialization
* PERFORMANCE: Uses getState() to read rows at action time
*/
const validateAllUpcs = useCallback(async () => {
if (initialValidationStartedRef.current) return;
initialValidationStartedRef.current = true;
// Get current rows at action time via getState()
const currentRows = useValidationStore.getState().rows;
console.log('[UPC Validation] Starting batch validation for', currentRows.length, 'rows');
// Find rows that need UPC validation
const rowsToValidate = currentRows
.map((row, index) => ({ row, index }))
.filter(({ row }) => row.supplier && (row.upc || row.barcode));
console.log('[UPC Validation] Found', rowsToValidate.length, 'rows with supplier and UPC/barcode');
if (rowsToValidate.length === 0) {
console.log('[UPC Validation] No rows to validate, skipping to next phase');
setInitialUpcValidationDone(true);
setInitPhase('validating-fields');
return;
}
// Process in batches
for (let i = 0; i < rowsToValidate.length; i += BATCH_SIZE) {
const batch = rowsToValidate.slice(i, i + BATCH_SIZE);
// Process batch in parallel
await Promise.all(
batch.map(async ({ row, index }) => {
const supplierId = String(row.supplier);
const rawUpc = String(row.upc || row.barcode);
// Apply UPC check digit correction before validating
// This handles imported data with incorrect/missing check digits
const { corrected: upcValue, changed: upcCorrected } = correctUpcValue(rawUpc);
// If UPC was corrected, update the cell value
if (upcCorrected) {
updateCell(index, 'upc', upcValue);
}
// Mark as validating
setUpcStatus(index, 'validating');
startValidatingCell(index, 'item_number');
try {
// Check cache first
const cached = getCachedItemNumber(supplierId, upcValue);
if (cached) {
setGeneratedItemNumber(index, cached);
setUpcStatus(index, 'done');
return;
}
// Make API call
const result = await fetchProductByUpc(supplierId, upcValue);
console.log(`[UPC Validation] Row ${index}: supplierId=${supplierId}, upc=${upcValue}, result=`, result);
if (result.success && result.itemNumber) {
console.log(`[UPC Validation] Row ${index}: Setting item_number to "${result.itemNumber}"`);
cacheUpcResult(supplierId, upcValue, result.itemNumber);
setGeneratedItemNumber(index, result.itemNumber);
clearFieldError(index, 'upc');
setUpcStatus(index, 'done');
} else {
console.log(`[UPC Validation] Row ${index}: No item number returned, error code=${result.code}`);
updateCell(index, 'item_number', '');
setUpcStatus(index, 'error');
if (result.code === 'conflict') {
setError(index, 'upc', {
message: 'UPC already exists in database',
level: 'error',
source: ErrorSource.Upc,
type: ErrorType.Unique,
});
}
}
} catch (error) {
console.error(`Error validating UPC for row ${index}:`, error);
setUpcStatus(index, 'error');
} finally {
stopValidatingCell(index, 'item_number');
}
})
);
// Small delay between batches for UI responsiveness
if (i + BATCH_SIZE < rowsToValidate.length) {
await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY));
}
}
setInitialUpcValidationDone(true);
setInitPhase('validating-fields');
}, [
// Only stable store actions as dependencies
getCachedItemNumber,
setUpcStatus,
setGeneratedItemNumber,
cacheUpcResult,
updateCell,
setError,
clearFieldError,
startValidatingCell,
stopValidatingCell,
setInitialUpcValidationDone,
setInitPhase,
]);
return {
validateUpc,
validateAllUpcs,
initialValidationDone,
};
};

View File

@@ -0,0 +1,498 @@
/**
* useValidationActions Hook
*
* Provides validation logic and wraps store actions.
* Handles field validation, unique validation, and row operations.
*
* PERFORMANCE NOTE: Uses getState() inside callbacks instead of subscriptions.
* This is critical because subscribing to rows/fields/errors would cause the
* parent component to re-render on EVERY cell change, cascading to all children.
* By using getState(), we read fresh values at call-time without subscriptions.
*/
import { useCallback } from 'react';
import { useValidationStore } from '../store/validationStore';
import { ErrorSource, ErrorType, type ValidationError, type RowData } from '../store/types';
import type { Field, Validation } from '../../../types';
// Debounce cache for validation
const validationCache = new Map<string, NodeJS.Timeout>();
/**
* Check if a value is empty
*/
const isEmpty = (value: unknown): boolean => {
if (value === undefined || value === null) return true;
if (typeof value === 'string' && value.trim() === '') return true;
if (Array.isArray(value) && value.length === 0) return true;
return false;
};
/**
* Validate a single field value against its validation rules
*/
const validateFieldValue = (
value: unknown,
field: Field<string>,
allRows: RowData[],
currentRowIndex: number
): ValidationError | null => {
const validations = field.validations || [];
for (const validation of validations) {
// Required validation
if (validation.rule === 'required') {
if (isEmpty(value)) {
return {
message: validation.errorMessage || 'This field is required',
level: validation.level || 'error',
source: ErrorSource.Row,
type: ErrorType.Required,
};
}
}
// Regex validation
if (validation.rule === 'regex' && !isEmpty(value)) {
const regex = new RegExp(validation.value, validation.flags);
if (!regex.test(String(value))) {
return {
message: validation.errorMessage || 'Invalid format',
level: validation.level || 'error',
source: ErrorSource.Row,
type: ErrorType.Regex,
};
}
}
// Unique validation
if (validation.rule === 'unique') {
// Skip if empty and allowEmpty is true
if (isEmpty(value) && validation.allowEmpty) continue;
// Check for duplicates in other rows
const stringValue = String(value ?? '').toLowerCase().trim();
const isDuplicate = allRows.some((row, index) => {
if (index === currentRowIndex) return false;
const otherValue = String(row[field.key] ?? '').toLowerCase().trim();
return otherValue === stringValue && stringValue !== '';
});
if (isDuplicate) {
return {
message: validation.errorMessage || 'Must be unique',
level: validation.level || 'error',
source: ErrorSource.Table,
type: ErrorType.Unique,
};
}
}
}
return null;
};
/**
* Hook providing validation actions
*
* PERFORMANCE: This hook has NO subscriptions to rows/fields/errors.
* All state is read via getState() at call-time inside callbacks.
* This prevents re-renders of components using this hook when cells change.
*/
export const useValidationActions = () => {
// Store actions (these are stable from Zustand)
const updateCell = useValidationStore((state) => state.updateCell);
const updateRow = useValidationStore((state) => state.updateRow);
const deleteRows = useValidationStore((state) => state.deleteRows);
const addRow = useValidationStore((state) => state.addRow);
const copyDown = useValidationStore((state) => state.copyDown);
const setError = useValidationStore((state) => state.setError);
const clearFieldError = useValidationStore((state) => state.clearFieldError);
const setRowValidationStatus = useValidationStore((state) => state.setRowValidationStatus);
const startValidatingCell = useValidationStore((state) => state.startValidatingCell);
const stopValidatingCell = useValidationStore((state) => state.stopValidatingCell);
const startEditingCell = useValidationStore((state) => state.startEditingCell);
const stopEditingCell = useValidationStore((state) => state.stopEditingCell);
/**
* Validate a specific field for a row
* Uses getState() to access current state at call-time
*/
const validateField = useCallback(
async (rowIndex: number, fieldKey: string) => {
const { rows: currentRows, fields: currentFields } = useValidationStore.getState();
const field = currentFields.find((f: Field<string>) => f.key === fieldKey);
if (!field) return;
const row = currentRows[rowIndex];
if (!row) return;
const value = row[fieldKey];
const error = validateFieldValue(value, field, currentRows, rowIndex);
if (error) {
setError(rowIndex, fieldKey, error);
} else {
clearFieldError(rowIndex, fieldKey);
}
},
[setError, clearFieldError] // Only stable store actions as deps
);
/**
* Validate all fields for a row
*/
const validateRow = useCallback(
async (rowIndex: number, specificFields?: string[]) => {
const { rows: currentRows, fields: currentFields } = useValidationStore.getState();
const row = currentRows[rowIndex];
if (!row) return;
setRowValidationStatus(rowIndex, 'validating');
const fieldsToValidate = specificFields
? currentFields.filter((f: Field<string>) => specificFields.includes(f.key))
: currentFields;
for (const field of fieldsToValidate) {
const value = row[field.key];
const error = validateFieldValue(value, field, currentRows, rowIndex);
if (error) {
setError(rowIndex, field.key, error);
} else {
clearFieldError(rowIndex, field.key);
}
}
// Determine final status based on errors - re-read to get latest
const latestErrors = useValidationStore.getState().errors;
const rowErrors = latestErrors.get(rowIndex);
const hasErrors = rowErrors && Object.keys(rowErrors).length > 0;
setRowValidationStatus(rowIndex, hasErrors ? 'error' : 'validated');
},
[setError, clearFieldError, setRowValidationStatus]
);
/**
* Validate all rows in a SINGLE BATCH operation.
*
* PERFORMANCE CRITICAL: This collects ALL errors first, then sets them in ONE
* store update. This avoids the catastrophic performance of Immer cloning
* Maps/Sets on every individual setError() call.
*
* With 100 rows × 30 fields, the old approach would trigger ~3000 individual
* set() calls, each cloning the entire errors Map. This approach triggers ONE.
*
* Also handles:
* - Rounding currency fields to 2 decimal places
*/
const validateAllRows = useCallback(async () => {
const { rows: currentRows, fields: currentFields, errors: existingErrors, setBulkValidationResults, updateCell: updateCellAction } = useValidationStore.getState();
// Collect ALL errors in plain JS Maps (no Immer overhead)
const allErrors = new Map<number, Record<string, ValidationError[]>>();
const allStatuses = new Map<number, 'pending' | 'validating' | 'validated' | 'error'>();
// Identify price fields for currency rounding
const priceFields = currentFields.filter((f: Field<string>) =>
'price' in f.fieldType && f.fieldType.price
).map((f: Field<string>) => f.key);
// Process all rows - collect errors without touching the store
for (let rowIndex = 0; rowIndex < currentRows.length; rowIndex++) {
const row = currentRows[rowIndex];
if (!row) continue;
// IMPORTANT: Preserve existing UPC errors (from UPC validation phase)
// These have source: ErrorSource.Upc and would otherwise be overwritten
const existingRowErrors = existingErrors.get(rowIndex);
const rowErrors: Record<string, ValidationError[]> = {};
// Copy over any existing UPC-sourced errors
if (existingRowErrors) {
Object.entries(existingRowErrors).forEach(([fieldKey, errors]) => {
const upcErrors = errors.filter(e => e.source === ErrorSource.Upc);
if (upcErrors.length > 0) {
rowErrors[fieldKey] = upcErrors;
}
});
}
// Round currency fields to 2 decimal places on initial load
for (const priceFieldKey of priceFields) {
const value = row[priceFieldKey];
if (value !== undefined && value !== null && value !== '') {
const numValue = parseFloat(String(value));
if (!isNaN(numValue)) {
const rounded = numValue.toFixed(2);
if (String(value) !== rounded) {
// Update the cell with rounded value (batched later)
updateCellAction(rowIndex, priceFieldKey, rounded);
}
}
}
}
// Validate each field
for (const field of currentFields) {
const value = row[field.key];
const error = validateFieldValue(value, field, currentRows, rowIndex);
if (error) {
// Merge with existing errors (e.g., UPC errors) rather than replacing
const existingFieldErrors = rowErrors[field.key] || [];
rowErrors[field.key] = [...existingFieldErrors, error];
}
}
// Store results
if (Object.keys(rowErrors).length > 0) {
allErrors.set(rowIndex, rowErrors);
allStatuses.set(rowIndex, 'error');
} else {
allStatuses.set(rowIndex, 'validated');
}
}
// Also handle unique field validation in the same batch
const uniqueFields = currentFields.filter((f: Field<string>) =>
f.validations?.some((v: Validation) => v.rule === 'unique')
);
for (const field of uniqueFields) {
const valueMap = new Map<string, number[]>();
// Build map of values to row indices
currentRows.forEach((row: RowData, index: number) => {
const value = String(row[field.key] ?? '').toLowerCase().trim();
if (value === '') return;
const indices = valueMap.get(value) || [];
indices.push(index);
valueMap.set(value, indices);
});
// Mark duplicates
valueMap.forEach((indices) => {
if (indices.length > 1) {
const validation = field.validations?.find((v: Validation) => v.rule === 'unique') as
| { rule: 'unique'; errorMessage?: string; level?: 'error' | 'warning' | 'info' }
| undefined;
indices.forEach((rowIndex) => {
// Get or create row errors
let rowErrors = allErrors.get(rowIndex);
if (!rowErrors) {
rowErrors = {};
allErrors.set(rowIndex, rowErrors);
}
rowErrors[field.key] = [{
message: validation?.errorMessage || 'Must be unique',
level: validation?.level || 'error',
source: ErrorSource.Table,
type: ErrorType.Unique,
}];
allStatuses.set(rowIndex, 'error');
});
}
});
}
// SINGLE store update with all validation results
setBulkValidationResults(allErrors, allStatuses);
}, []); // No dependencies - reads everything from getState()
/**
* Validate unique fields across all rows (for table-level validation)
* Used for incremental updates after individual cell changes
*/
const validateUniqueFields = useCallback(() => {
const { rows: currentRows, fields: currentFields, errors: currentErrors } = useValidationStore.getState();
const uniqueFields = currentFields.filter((f: Field<string>) =>
f.validations?.some((v: Validation) => v.rule === 'unique')
);
uniqueFields.forEach((field: Field<string>) => {
const valueMap = new Map<string, number[]>();
// Build map of values to row indices
currentRows.forEach((row: RowData, index: number) => {
const value = String(row[field.key] ?? '').toLowerCase().trim();
if (value === '') return;
const indices = valueMap.get(value) || [];
indices.push(index);
valueMap.set(value, indices);
});
// Mark duplicates
valueMap.forEach((indices) => {
if (indices.length > 1) {
// Multiple rows have the same value - mark all as duplicates
indices.forEach((rowIndex) => {
const validation = field.validations?.find((v: Validation) => v.rule === 'unique') as
| { rule: 'unique'; errorMessage?: string; level?: 'error' | 'warning' | 'info' }
| undefined;
setError(rowIndex, field.key, {
message: validation?.errorMessage || 'Must be unique',
level: validation?.level || 'error',
source: ErrorSource.Table,
type: ErrorType.Unique,
});
});
} else {
// Only one row has this value - clear any unique error
const rowIndex = indices[0];
const rowErrors = currentErrors.get(rowIndex);
const fieldErrors = rowErrors?.[field.key];
if (fieldErrors?.some((e: ValidationError) => e.type === ErrorType.Unique)) {
// Only clear if it was a unique error
clearFieldError(rowIndex, field.key);
}
}
});
});
}, [setError, clearFieldError]);
/**
* Handle cell value change with debounced validation
* IMPORTANT: This callback is stable - no subscriptions, only store actions
*/
const handleCellChange = useCallback(
(rowIndex: number, fieldKey: string, value: unknown) => {
// Update the cell value immediately
updateCell(rowIndex, fieldKey, value);
// Cancel any pending validation for this cell
const cacheKey = `${rowIndex}-${fieldKey}`;
const existingTimeout = validationCache.get(cacheKey);
if (existingTimeout) {
clearTimeout(existingTimeout);
}
// Debounce validation
const timeout = setTimeout(() => {
validateField(rowIndex, fieldKey);
validationCache.delete(cacheKey);
}, 300);
validationCache.set(cacheKey, timeout);
},
[updateCell, validateField]
);
/**
* Handle cell edit start
*/
const handleCellEditStart = useCallback(
(rowIndex: number, fieldKey: string) => {
startEditingCell(rowIndex, fieldKey);
},
[startEditingCell]
);
/**
* Handle cell edit end with immediate validation
* IMPORTANT: This callback is stable - no subscriptions, only store actions
*/
const handleCellEditEnd = useCallback(
(rowIndex: number, fieldKey: string, value: unknown) => {
const { fields: currentFields } = useValidationStore.getState();
stopEditingCell(rowIndex, fieldKey);
// Update value and validate immediately
updateCell(rowIndex, fieldKey, value);
validateField(rowIndex, fieldKey);
// Check unique validation for this field
const field = currentFields.find((f: Field<string>) => f.key === fieldKey);
if (field?.validations?.some((v: Validation) => v.rule === 'unique')) {
validateUniqueFields();
}
},
[stopEditingCell, updateCell, validateField, validateUniqueFields]
);
/**
* Handle row deletion with confirmation
*/
const handleDeleteRows = useCallback(
(rowIndexes: number[]) => {
deleteRows(rowIndexes);
// Re-validate unique fields after deletion
setTimeout(() => validateUniqueFields(), 0);
},
[deleteRows, validateUniqueFields]
);
/**
* Handle adding a new row
*/
const handleAddRow = useCallback(
(rowData?: Partial<RowData>) => {
addRow(rowData);
},
[addRow]
);
/**
* Handle copy down operation
*/
const handleCopyDown = useCallback(
(fromRowIndex: number, fieldKey: string, toRowIndex?: number) => {
const { rows: currentRows, fields: currentFields } = useValidationStore.getState();
copyDown(fromRowIndex, fieldKey, toRowIndex);
// Validate affected rows
const endIndex = toRowIndex ?? currentRows.length - 1;
for (let i = fromRowIndex + 1; i <= endIndex; i++) {
validateField(i, fieldKey);
}
// Check unique validation if applicable
const field = currentFields.find((f: Field<string>) => f.key === fieldKey);
if (field?.validations?.some((v: Validation) => v.rule === 'unique')) {
validateUniqueFields();
}
},
[copyDown, validateField, validateUniqueFields]
);
return {
// Basic operations
updateCell,
updateRow,
handleCellChange,
handleCellEditStart,
handleCellEditEnd,
// Row operations
handleDeleteRows,
handleAddRow,
handleCopyDown,
// Validation
validateField,
validateRow,
validateAllRows,
validateUniqueFields,
// Cell state
startValidatingCell,
stopValidatingCell,
startEditingCell,
stopEditingCell,
// Error management
setError,
clearFieldError,
};
};

View File

@@ -0,0 +1,278 @@
/**
* ValidationStep Entry Point
*
* The main entry component for the validation step. This component:
* 1. Initializes the Zustand store with incoming data
* 2. Loads field options from the API
* 3. Orchestrates the initialization phases
* 4. Renders the ValidationContainer once initialized
*/
import { useEffect, useRef, useDeferredValue } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useValidationStore } from './store/validationStore';
import { useInitPhase, useIsReady } from './store/selectors';
import { ValidationContainer } from './components/ValidationContainer';
import { InitializingOverlay } from './components/InitializingOverlay';
import { useTemplateManagement } from './hooks/useTemplateManagement';
import { useUpcValidation } from './hooks/useUpcValidation';
import { useValidationActions } from './hooks/useValidationActions';
import { useProductLines } from './hooks/useProductLines';
import { BASE_IMPORT_FIELDS } from '../../config';
import config from '@/config';
import type { ValidationStepProps } from './store/types';
import type { Field, SelectOption } from '../../types';
/**
* Fetch field options from the API
*/
const fetchFieldOptions = async () => {
const response = await fetch(`${config.apiUrl}/import/field-options`);
if (!response.ok) {
throw new Error('Failed to fetch field options');
}
return response.json();
};
/**
* Normalize option values to strings
* API may return numeric values (e.g., supplier IDs from MySQL) but our
* SelectOption type expects string values for consistent comparison
*/
const normalizeOptions = (options: SelectOption[]): SelectOption[] => {
return options.map((opt) => ({
...opt,
value: String(opt.value),
}));
};
/**
* Merge API options into base field definitions
*/
const mergeFieldOptions = (
baseFields: typeof BASE_IMPORT_FIELDS,
options: Record<string, SelectOption[]>
): Field<string>[] => {
return baseFields.map((field) => {
// Map field keys to option keys in the API response
// Note: API returns 'sizes' and 'shippingRestrictions' not 'sizeCategories' and 'shipRestrictions'
const optionKeyMap: Record<string, string> = {
supplier: 'suppliers',
company: 'companies',
tax_cat: 'taxCategories',
artist: 'artists',
ship_restrictions: 'shippingRestrictions', // API returns 'shippingRestrictions'
size_cat: 'sizes', // API returns 'sizes'
categories: 'categories',
themes: 'themes',
colors: 'colors',
};
const optionKey = optionKeyMap[field.key];
if (optionKey && options[optionKey]) {
return {
...field,
fieldType: {
...field.fieldType,
// Normalize option values to strings for consistent type handling
options: normalizeOptions(options[optionKey]),
},
} as Field<string>;
}
return field as Field<string>;
});
};
export const ValidationStep = ({
initialData,
file,
onBack,
onNext,
isFromScratch,
}: ValidationStepProps) => {
const initPhase = useInitPhase();
const isReady = useIsReady();
// PERFORMANCE: Defer the ready state to allow React to render heavy content
// in the background while keeping the loading overlay visible.
// This prevents the UI from freezing when ValidationContainer first mounts.
const deferredIsReady = useDeferredValue(isReady);
const isTransitioning = isReady && !deferredIsReady;
const initStartedRef = useRef(false);
const templatesLoadedRef = useRef(false);
const upcValidationStartedRef = useRef(false);
const fieldValidationStartedRef = useRef(false);
// Debug logging
console.log('[ValidationStep] Render - initPhase:', initPhase, 'isReady:', isReady);
// Store actions
const initialize = useValidationStore((state) => state.initialize);
const setFields = useValidationStore((state) => state.setFields);
const setFieldOptionsLoaded = useValidationStore((state) => state.setFieldOptionsLoaded);
const setInitPhase = useValidationStore((state) => state.setInitPhase);
// Initialization hooks
const { loadTemplates } = useTemplateManagement();
const { validateAllUpcs } = useUpcValidation();
const { validateAllRows } = useValidationActions();
const { prefetchAllLines } = useProductLines();
// Fetch field options
const { data: fieldOptions, isLoading: optionsLoading, error: optionsError } = useQuery({
queryKey: ['field-options'],
queryFn: fetchFieldOptions,
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
retry: 2,
});
// Initialize store with data
useEffect(() => {
console.log('[ValidationStep] Init effect running, initStartedRef:', initStartedRef.current, 'initPhase:', initPhase);
// Skip if already initialized (check both ref AND store state)
// The ref prevents double-init within the same mount cycle
// Checking initPhase handles StrictMode remounts where store was initialized but ref persisted
if (initStartedRef.current && initPhase !== 'idle') {
console.log('[ValidationStep] Skipping init - already initialized');
return;
}
initStartedRef.current = true;
console.log('[ValidationStep] Starting initialization with', initialData.length, 'rows');
// Convert initialData to RowData format
const rowData = initialData.map((row, index) => ({
...row,
__index: row.__index || `row-${index}-${Date.now()}`,
}));
// Start with base fields
console.log('[ValidationStep] Calling initialize()');
initialize(rowData, BASE_IMPORT_FIELDS as unknown as Field<string>[], file);
console.log('[ValidationStep] initialize() called');
}, [initialData, file, initialize, initPhase]);
// Update fields when options are loaded
// CRITICAL: Check store state (not ref) because initialize() resets the store
// In StrictMode, effects double-run. If we used a ref:
// 1. First pass: merge runs, ref = true, phase advances
// 2. Second pass: initialize() resets store (phase back to loading-options), but ref is still true, so merge SKIPS
// 3. Phase stuck at loading-options!
// By checking fieldOptionsLoaded from store, we re-run merge after store reset.
useEffect(() => {
const { fieldOptionsLoaded } = useValidationStore.getState();
console.log('[ValidationStep] Field options effect - fieldOptions:', !!fieldOptions, 'initPhase:', initPhase, 'fieldOptionsLoaded:', fieldOptionsLoaded);
// Skip if no options or already loaded in store
if (!fieldOptions || fieldOptionsLoaded) return;
console.log('[ValidationStep] Merging field options');
const mergedFields = mergeFieldOptions(BASE_IMPORT_FIELDS, fieldOptions);
setFields(mergedFields);
setFieldOptionsLoaded(true);
// Move to next phase - use current store state to avoid race condition
// The initPhase variable may be stale if initialization ran in the same cycle
// CRITICAL: Also handle 'idle' phase for when React Query returns cached data
// before the initialization effect has a chance to run
const currentPhase = useValidationStore.getState().initPhase;
console.log('[ValidationStep] Checking phase transition - currentPhase:', currentPhase);
if (currentPhase === 'loading-options' || currentPhase === 'idle') {
console.log('[ValidationStep] Transitioning to loading-templates');
setInitPhase('loading-templates');
}
}, [fieldOptions, initPhase, setFields, setFieldOptionsLoaded, setInitPhase]);
// Note: We intentionally do NOT reset the store on unmount.
// React StrictMode double-mounts components, and resetting on unmount
// causes the store to be cleared before the second mount, while refs
// persist, causing initialization to be skipped.
// The store will be reset when initialize() is called on the next import.
// Load templates when entering loading-templates phase
useEffect(() => {
if (initPhase === 'loading-templates' && !templatesLoadedRef.current) {
templatesLoadedRef.current = true;
loadTemplates().then(() => {
setInitPhase('validating-upcs');
});
}
}, [initPhase, loadTemplates, setInitPhase]);
// Run UPC validation when entering validating-upcs phase
useEffect(() => {
if (initPhase === 'validating-upcs' && !upcValidationStartedRef.current) {
upcValidationStartedRef.current = true;
validateAllUpcs();
}
}, [initPhase, validateAllUpcs]);
// Run field validation when entering validating-fields phase
useEffect(() => {
if (initPhase === 'validating-fields' && !fieldValidationStartedRef.current) {
fieldValidationStartedRef.current = true;
// Run field validation (includes unique validation in batch) and prefetch lines
// NOTE: validateAllRows now handles unique field validation in its batch,
// so we don't call validateUniqueFields() separately here.
Promise.all([
validateAllRows(),
prefetchAllLines(),
]).then(() => {
setInitPhase('ready');
});
}
}, [initPhase, validateAllRows, prefetchAllLines, setInitPhase]);
// Show error state if options failed to load
if (optionsError) {
return (
<div className="flex flex-col items-center justify-center h-[calc(100vh-9.5rem)] gap-4">
<div className="text-destructive text-lg">Failed to load field options</div>
<div className="text-muted-foreground text-sm">
{optionsError instanceof Error ? optionsError.message : 'Unknown error'}
</div>
{onBack && (
<button
onClick={onBack}
className="px-4 py-2 text-sm bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/80"
>
Go Back
</button>
)}
</div>
);
}
// Show loading overlay while initializing
// Use deferredIsReady to keep showing loading state while React processes
// the heavy ValidationContainer in the background
if (!deferredIsReady || optionsLoading) {
return (
<InitializingOverlay
phase={initPhase}
message={
optionsLoading
? 'Loading field options...'
: isTransitioning
? 'Preparing table...'
: undefined
}
/>
);
}
return (
<ValidationContainer
onBack={onBack}
onNext={onNext}
isFromScratch={isFromScratch}
/>
);
};
export default ValidationStep;

View File

@@ -0,0 +1,484 @@
/**
* ValidationStep Store Selectors
*
* Memoized selectors for derived state. Components use these to subscribe
* only to the state they need, preventing unnecessary re-renders.
*/
import { useMemo, useCallback } from 'react';
import { useValidationStore } from './validationStore';
import type { RowData, ValidationError } from './types';
// =============================================================================
// Basic State Selectors
// =============================================================================
export const useRows = () => useValidationStore((state) => state.rows);
export const useFields = () => useValidationStore((state) => state.fields);
export const useErrors = () => useValidationStore((state) => state.errors);
export const useFilters = () => useValidationStore((state) => state.filters);
export const useTemplates = () => useValidationStore((state) => state.templates);
export const useTemplatesLoading = () => useValidationStore((state) => state.templatesLoading);
export const useTemplateState = () => useValidationStore((state) => state.templateState);
export const useSelectedRows = () => useValidationStore((state) => state.selectedRows);
export const useInitPhase = () => useValidationStore((state) => state.initPhase);
export const useAiValidation = () => useValidationStore((state) => state.aiValidation);
export const useFieldOptionsLoaded = () => useValidationStore((state) => state.fieldOptionsLoaded);
// =============================================================================
// Cell State Selectors
// =============================================================================
export const useValidatingCells = () => useValidationStore((state) => state.validatingCells);
export const useEditingCells = () => useValidationStore((state) => state.editingCells);
export const useIsCellValidating = (rowIndex: number, field: string) =>
useValidationStore((state) => state.validatingCells.has(`${rowIndex}-${field}`));
export const useIsCellEditing = (rowIndex: number, field: string) =>
useValidationStore((state) => state.editingCells.has(`${rowIndex}-${field}`));
// =============================================================================
// UPC Selectors
// =============================================================================
export const useUpcStatus = () => useValidationStore((state) => state.upcStatus);
export const useGeneratedItemNumbers = () => useValidationStore((state) => state.generatedItemNumbers);
export const useInitialUpcValidationDone = () => useValidationStore((state) => state.initialUpcValidationDone);
export const useRowUpcStatus = (rowIndex: number) =>
useValidationStore((state) => state.upcStatus.get(rowIndex) || 'idle');
export const useRowItemNumber = (rowIndex: number) =>
useValidationStore((state) => state.generatedItemNumbers.get(rowIndex));
// =============================================================================
// Product Lines Selectors
// =============================================================================
export const useProductLinesCache = () => useValidationStore((state) => state.productLinesCache);
export const useSublinesCache = () => useValidationStore((state) => state.sublinesCache);
export const useProductLinesForCompany = (companyId: string) =>
useValidationStore((state) => state.productLinesCache.get(companyId) || []);
export const useSublinesForLine = (lineId: string) =>
useValidationStore((state) => state.sublinesCache.get(lineId) || []);
export const useIsLoadingProductLines = (companyId: string) =>
useValidationStore((state) => state.loadingProductLines.has(companyId));
export const useIsLoadingSublines = (lineId: string) =>
useValidationStore((state) => state.loadingSublines.has(lineId));
// =============================================================================
// Derived State Selectors (Memoized)
// =============================================================================
/**
* Get filtered rows based on current filter state
*
* PERFORMANCE NOTE: Only subscribes to errors when showErrorsOnly filter is active.
* This prevents re-renders when errors change but we're not filtering by them.
*/
export const useFilteredRows = () => {
const rows = useRows();
const filters = useFilters();
// Only subscribe to errors when we need to filter by them
const showErrorsOnly = filters.showErrorsOnly;
const errors = useValidationStore((state) =>
showErrorsOnly ? state.errors : null
);
return useMemo(() => {
// No filtering needed if no filters active
if (!filters.searchText && !showErrorsOnly) {
return rows;
}
// Filter with index tracking to correctly apply errors filter
const result: RowData[] = [];
rows.forEach((row, originalIndex) => {
// Apply search filter
if (filters.searchText) {
const searchLower = filters.searchText.toLowerCase();
const matches = Object.values(row).some((value) =>
String(value ?? '').toLowerCase().includes(searchLower)
);
if (!matches) return;
}
// Apply errors-only filter using the ORIGINAL index (not filtered position)
if (showErrorsOnly && errors) {
const rowErrors = errors.get(originalIndex);
if (!rowErrors || Object.keys(rowErrors).length === 0) return;
}
result.push(row);
});
return result;
}, [rows, filters, showErrorsOnly, errors]);
};
/**
* Get filtered row indices (useful for mapping back to original data)
*
* PERFORMANCE NOTE: Only subscribes to errors when showErrorsOnly filter is active.
*/
export const useFilteredRowIndices = () => {
const rows = useRows();
const filters = useFilters();
// Only subscribe to errors when we need to filter by them
const showErrorsOnly = filters.showErrorsOnly;
const errors = useValidationStore((state) =>
showErrorsOnly ? state.errors : null
);
return useMemo(() => {
// No filtering needed if no filters active - return all indices
if (!filters.searchText && !showErrorsOnly) {
return rows.map((_, index) => index);
}
const indices: number[] = [];
rows.forEach((row, index) => {
// Apply search filter
if (filters.searchText) {
const searchLower = filters.searchText.toLowerCase();
const matches = Object.values(row).some((value) =>
String(value ?? '').toLowerCase().includes(searchLower)
);
if (!matches) return;
}
// Apply errors-only filter
if (showErrorsOnly && errors) {
const rowErrors = errors.get(index);
if (!rowErrors || Object.keys(rowErrors).length === 0) return;
}
indices.push(index);
});
return indices;
}, [rows, filters, showErrorsOnly, errors]);
};
/**
* Get total error count across all rows
*/
export const useTotalErrorCount = () => {
const errors = useErrors();
return useMemo(() => {
let count = 0;
errors.forEach((rowErrors) => {
count += Object.keys(rowErrors).length;
});
return count;
}, [errors]);
};
/**
* Get rows with errors count
*/
export const useRowsWithErrorsCount = () => {
const errors = useErrors();
return useMemo(() => {
let count = 0;
errors.forEach((rowErrors) => {
if (Object.keys(rowErrors).length > 0) {
count++;
}
});
return count;
}, [errors]);
};
// Stable empty array for cells with no errors
const EMPTY_CELL_ERRORS: ValidationError[] = [];
// Stable empty object for rows with no errors
const EMPTY_ROW_ERRORS: Record<string, ValidationError[]> = {};
/**
* Get errors for a specific row
* Uses stable empty object to prevent re-renders
*/
export const useRowErrors = (rowIndex: number) =>
useValidationStore((state) => state.errors.get(rowIndex) || EMPTY_ROW_ERRORS);
/**
* Get errors for a specific cell
* Uses stable empty array to prevent re-renders
*/
export const useCellErrors = (rowIndex: number, field: string): ValidationError[] => {
return useValidationStore((state) => {
const rowErrors = state.errors.get(rowIndex);
if (!rowErrors) return EMPTY_CELL_ERRORS;
return rowErrors[field] || EMPTY_CELL_ERRORS;
});
};
/**
* Check if a row has any errors
*/
export const useRowHasErrors = (rowIndex: number) => {
const rowErrors = useRowErrors(rowIndex);
return Object.keys(rowErrors).length > 0;
};
/**
* Get row data by index
*/
export const useRowData = (rowIndex: number): RowData | undefined =>
useValidationStore((state) => state.rows[rowIndex]);
/**
* Get row data by __index (UUID)
*/
export const useRowByIndex = (index: string): RowData | undefined =>
useValidationStore((state) => state.rows.find((row) => row.__index === index));
/**
* Check if a row is selected
*/
export const useIsRowSelected = (rowId: string) =>
useValidationStore((state) => state.selectedRows.has(rowId));
/**
* Get selected row count
*/
export const useSelectedRowCount = () =>
useValidationStore((state) => state.selectedRows.size);
/**
* Get selected row data
*/
export const useSelectedRowData = () => {
const rows = useRows();
const selectedRows = useSelectedRows();
return useMemo(() => {
return rows.filter((row) => selectedRows.has(row.__index));
}, [rows, selectedRows]);
};
/**
* Get selected row indices (numeric)
*/
export const useSelectedRowIndices = () => {
const rows = useRows();
const selectedRows = useSelectedRows();
return useMemo(() => {
const indices: number[] = [];
rows.forEach((row, index) => {
if (selectedRows.has(row.__index)) {
indices.push(index);
}
});
return indices;
}, [rows, selectedRows]);
};
/**
* Check if all rows are selected
*/
export const useAllRowsSelected = () => {
const rows = useRows();
const selectedRows = useSelectedRows();
return useMemo(() => {
if (rows.length === 0) return false;
return selectedRows.size === rows.length;
}, [rows, selectedRows]);
};
/**
* Check if some (but not all) rows are selected
*/
export const useSomeRowsSelected = () => {
const rows = useRows();
const selectedRows = useSelectedRows();
return useMemo(() => {
return selectedRows.size > 0 && selectedRows.size < rows.length;
}, [rows, selectedRows]);
};
// =============================================================================
// Initialization Selectors
// =============================================================================
export const useIsInitializing = () =>
useValidationStore((state) => state.initPhase !== 'ready' && state.initPhase !== 'idle');
export const useIsReady = () =>
useValidationStore((state) => state.initPhase === 'ready');
// =============================================================================
// AI Validation Selectors
// =============================================================================
export const useIsAiValidating = () =>
useValidationStore((state) => state.aiValidation.isRunning);
export const useAiValidationProgress = () =>
useValidationStore((state) => state.aiValidation.progress);
export const useAiValidationResults = () =>
useValidationStore((state) => state.aiValidation.results);
export const useIsAiChangeReverted = (productIndex: number, fieldKey: string) =>
useValidationStore((state) =>
state.aiValidation.revertedChanges.has(`${productIndex}:${fieldKey}`)
);
/**
* Get count of AI changes (not reverted)
*/
export const useActiveAiChangesCount = () => {
const aiValidation = useAiValidation();
return useMemo(() => {
if (!aiValidation.results) return 0;
return aiValidation.results.changes.filter(
(change) => !aiValidation.revertedChanges.has(`${change.productIndex}:${change.fieldKey}`)
).length;
}, [aiValidation]);
};
// =============================================================================
// Field Selectors
// =============================================================================
/**
* Get field definition by key
*/
export const useFieldByKey = (key: string) => {
const fields = useFields();
return useMemo(() => fields.find((f) => f.key === key), [fields, key]);
};
/**
* Get field options for a select field
*/
export const useFieldOptions = (fieldKey: string) => {
const field = useFieldByKey(fieldKey);
return useMemo(() => {
if (!field) return [];
if (field.fieldType.type === 'select' || field.fieldType.type === 'multi-select') {
return field.fieldType.options || [];
}
return [];
}, [field]);
};
// =============================================================================
// Copy-Down Mode Selectors
// =============================================================================
/**
* Get the full copy-down mode state
*/
export const useCopyDownMode = () =>
useValidationStore((state) => state.copyDownMode);
/**
* Check if copy-down mode is active (returns boolean for minimal subscription)
*/
export const useIsCopyDownActive = () =>
useValidationStore((state) => state.copyDownMode.isActive);
/**
* Check if a specific cell is the copy-down source
* Returns boolean - minimal subscription, only re-renders when this specific cell's status changes
*/
export const useIsCopyDownSource = (rowIndex: number, fieldKey: string) =>
useValidationStore((state) =>
state.copyDownMode.isActive &&
state.copyDownMode.sourceRowIndex === rowIndex &&
state.copyDownMode.sourceFieldKey === fieldKey
);
/**
* Check if a cell is a valid copy-down target (below source row, same field)
* Returns boolean - minimal subscription
*/
export const useIsCopyDownTarget = (rowIndex: number, fieldKey: string) =>
useValidationStore((state) => {
if (!state.copyDownMode.isActive) return false;
if (state.copyDownMode.sourceFieldKey !== fieldKey) return false;
const sourceIndex = state.copyDownMode.sourceRowIndex;
if (sourceIndex === null) return false;
return rowIndex > sourceIndex;
});
/**
* Check if a cell is within the current copy-down hover range
* Returns boolean - for highlighting rows that will be affected
*/
export const useIsInCopyDownRange = (rowIndex: number, fieldKey: string) =>
useValidationStore((state) => {
if (!state.copyDownMode.isActive) return false;
if (state.copyDownMode.sourceFieldKey !== fieldKey) return false;
const sourceIndex = state.copyDownMode.sourceRowIndex;
const targetIndex = state.copyDownMode.targetRowIndex;
if (sourceIndex === null || targetIndex === null) return false;
return rowIndex > sourceIndex && rowIndex <= targetIndex;
});
// =============================================================================
// Dialog Selectors
// =============================================================================
/**
* Get full dialog state
*/
export const useDialogs = () =>
useValidationStore((state) => state.dialogs);
/**
* Check if template form dialog is open
*/
export const useIsTemplateFormOpen = () =>
useValidationStore((state) => state.dialogs.templateFormOpen);
/**
* Get template form initial data
*/
export const useTemplateFormData = () =>
useValidationStore((state) => state.dialogs.templateFormData);
/**
* Check if create category dialog is open
*/
export const useIsCreateCategoryOpen = () =>
useValidationStore((state) => state.dialogs.createCategoryOpen);
// =============================================================================
// Selection Helper Selectors
// =============================================================================
/**
* Check if there is any selection (returns boolean)
*/
export const useHasSelection = () =>
useValidationStore((state) => state.selectedRows.size > 0);
/**
* Check if exactly one row is selected (for "Save as Template")
*/
export const useHasSingleRowSelected = () =>
useValidationStore((state) => state.selectedRows.size === 1);
/**
* Get row count (useful for copy-down to check if there are rows below)
*/
export const useRowCount = () =>
useValidationStore((state) => state.rows.length);

View File

@@ -0,0 +1,401 @@
/**
* ValidationStep Store Types
*
* Comprehensive type definitions for the Zustand-based validation store.
* These types define the shape of all state and actions in the validation flow.
*/
import type { Field, SelectOption, ErrorLevel } from '../../../types';
// =============================================================================
// Core Data Types
// =============================================================================
/**
* Extended row data with metadata fields used during validation.
* The __ prefixed fields are internal and stripped before output.
*/
export interface RowData {
__index: string; // Unique row identifier (UUID)
__template?: string; // Applied template ID
__original?: Record<string, unknown>; // Original values before AI changes
__corrected?: Record<string, unknown>; // AI-corrected values
__changes?: Record<string, boolean>; // Fields changed by AI
__aiSupplemental?: string[]; // AI supplemental columns from MatchColumnsStep
// Standard fields (from config.ts)
supplier?: string;
company?: string;
line?: string;
subline?: string;
upc?: string;
item_number?: string;
supplier_no?: string;
notions_no?: string;
name?: string;
msrp?: string;
qty_per_unit?: string;
cost_each?: string;
case_qty?: string;
tax_cat?: string;
artist?: string;
eta?: string;
weight?: string;
length?: string;
width?: string;
height?: string;
ship_restrictions?: string;
coo?: string;
hts_code?: string;
size_cat?: string;
description?: string;
priv_notes?: string;
categories?: string | string[];
themes?: string | string[];
colors?: string | string[];
product_images?: string;
// Allow dynamic field access
[key: string]: unknown;
}
/**
* Clean row data without metadata (output format for ImageUploadStep)
*/
export type CleanRowData = Omit<RowData, '__index' | '__template' | '__original' | '__corrected' | '__changes' | '__aiSupplemental'>;
// =============================================================================
// Validation Types
// =============================================================================
export enum ErrorSource {
Row = 'row',
Table = 'table',
Api = 'api',
Upc = 'upc'
}
export enum ErrorType {
Required = 'required',
Regex = 'regex',
Unique = 'unique',
Custom = 'custom',
Api = 'api'
}
export interface ValidationError {
message: string;
level: ErrorLevel;
source: ErrorSource;
type: ErrorType;
}
/**
* Validation errors organized by row and field
* Structure: Map<rowIndex, Record<fieldKey, ValidationError[]>>
*/
export type ValidationErrors = Map<number, Record<string, ValidationError[]>>;
export type RowValidationStatus = 'pending' | 'validating' | 'validated' | 'error';
// =============================================================================
// UPC Validation Types
// =============================================================================
export type UpcValidationStatus = 'idle' | 'validating' | 'done' | 'error';
export interface UpcValidationResult {
success: boolean;
itemNumber?: string;
error?: string;
code?: 'conflict' | 'http_error' | 'invalid_response' | 'network_error';
}
// =============================================================================
// Template Types
// =============================================================================
export interface Template {
id: number;
company: string;
product_type: string;
[key: string]: string | number | boolean | undefined;
}
export interface TemplateState {
selectedTemplateId: string | null;
showSaveDialog: boolean;
newTemplateName: string;
newTemplateType: string;
}
// =============================================================================
// Filter Types
// =============================================================================
export interface FilterState {
searchText: string;
showErrorsOnly: boolean;
filterField: string | null;
filterValue: string | null;
}
// =============================================================================
// Copy-Down Mode Types
// =============================================================================
export interface CopyDownState {
isActive: boolean;
sourceRowIndex: number | null;
sourceFieldKey: string | null;
targetRowIndex: number | null; // Hover preview - which row the user is hovering on
}
// =============================================================================
// Dialog State Types
// =============================================================================
export interface DialogState {
templateFormOpen: boolean;
templateFormData: Record<string, unknown> | null;
createCategoryOpen: boolean;
}
// =============================================================================
// AI Validation Types
// =============================================================================
export interface AiValidationProgress {
current: number;
total: number;
status: 'preparing' | 'validating' | 'processing' | 'complete' | 'error';
message?: string;
startTime: number;
estimatedTimeRemaining?: number;
}
export interface AiValidationChange {
productIndex: number;
fieldKey: string;
originalValue: unknown;
correctedValue: unknown;
confidence?: number;
}
export interface AiValidationResults {
totalProducts: number;
productsWithChanges: number;
changes: AiValidationChange[];
tokenUsage?: {
input: number;
output: number;
};
processingTime: number;
}
export interface AiValidationState {
isRunning: boolean;
progress: AiValidationProgress | null;
results: AiValidationResults | null;
revertedChanges: Set<string>; // Format: "productIndex:fieldKey"
}
// =============================================================================
// Initialization Types
// =============================================================================
export type InitPhase =
| 'idle'
| 'loading-options'
| 'loading-templates'
| 'validating-upcs'
| 'validating-fields'
| 'ready';
// =============================================================================
// Field Options Types (from API)
// =============================================================================
export interface FieldOptionsData {
suppliers: SelectOption[];
companies: SelectOption[];
taxCategories: SelectOption[];
artists: SelectOption[];
shipRestrictions: SelectOption[];
sizeCategories: SelectOption[];
categories: SelectOption[];
themes: SelectOption[];
colors: SelectOption[];
}
// =============================================================================
// Store State Interface
// =============================================================================
export interface ValidationState {
// === Core Data ===
rows: RowData[];
originalRows: RowData[]; // For AI revert functionality
// === Field Configuration ===
fields: Field<string>[];
fieldOptionsLoaded: boolean;
// === Validation ===
errors: ValidationErrors;
rowValidationStatus: Map<number, RowValidationStatus>;
// === Cell States ===
validatingCells: Set<string>; // Format: "rowIndex-fieldKey"
editingCells: Set<string>;
// === UPC Validation ===
upcStatus: Map<number, UpcValidationStatus>;
generatedItemNumbers: Map<number, string>;
upcCache: Map<string, string>; // Format: "supplierId-upc" -> itemNumber
initialUpcValidationDone: boolean;
// === Product Lines (hierarchical) ===
productLinesCache: Map<string, SelectOption[]>; // companyId -> lines
sublinesCache: Map<string, SelectOption[]>; // lineId -> sublines
loadingProductLines: Set<string>; // companyIds being loaded
loadingSublines: Set<string>; // lineIds being loaded
// === Templates ===
templates: Template[];
templatesLoading: boolean;
templateState: TemplateState;
// === Filters ===
filters: FilterState;
// === Row Selection ===
selectedRows: Set<string>; // Uses __index as key
// === Copy-Down Mode ===
copyDownMode: CopyDownState;
// === Dialogs ===
dialogs: DialogState;
// === Initialization ===
initPhase: InitPhase;
// === AI Validation ===
aiValidation: AiValidationState;
// === File (for output) ===
file: File | null;
}
// =============================================================================
// Store Actions Interface
// =============================================================================
export interface ValidationActions {
// === Initialization ===
initialize: (data: RowData[], fields: Field<string>[], file?: File) => Promise<void>;
setFields: (fields: Field<string>[]) => void;
setFieldOptionsLoaded: (loaded: boolean) => void;
// === Row Operations ===
updateCell: (rowIndex: number, field: string, value: unknown) => void;
updateRow: (rowIndex: number, updates: Partial<RowData>) => void;
deleteRows: (rowIndexes: number[]) => void;
addRow: (rowData?: Partial<RowData>) => void;
copyDown: (fromRowIndex: number, fieldKey: string, toRowIndex?: number) => void;
setRows: (rows: RowData[]) => void;
// === Validation ===
setError: (rowIndex: number, field: string, error: ValidationError | null) => void;
setErrors: (rowIndex: number, errors: Record<string, ValidationError[]>) => void;
setBulkValidationResults: (
allErrors: Map<number, Record<string, ValidationError[]>>,
allStatuses: Map<number, RowValidationStatus>
) => void;
clearRowErrors: (rowIndex: number) => void;
clearFieldError: (rowIndex: number, field: string) => void;
setRowValidationStatus: (rowIndex: number, status: RowValidationStatus) => void;
// === Cell States ===
startValidatingCell: (rowIndex: number, field: string) => void;
stopValidatingCell: (rowIndex: number, field: string) => void;
startEditingCell: (rowIndex: number, field: string) => void;
stopEditingCell: (rowIndex: number, field: string) => void;
// === UPC ===
setUpcStatus: (rowIndex: number, status: UpcValidationStatus) => void;
setGeneratedItemNumber: (rowIndex: number, itemNumber: string) => void;
cacheUpcResult: (supplierId: string, upc: string, itemNumber: string) => void;
getCachedItemNumber: (supplierId: string, upc: string) => string | undefined;
setInitialUpcValidationDone: (done: boolean) => void;
// === Product Lines ===
setProductLines: (companyId: string, lines: SelectOption[]) => void;
setSublines: (lineId: string, sublines: SelectOption[]) => void;
setLoadingProductLines: (companyId: string, loading: boolean) => void;
setLoadingSublines: (lineId: string, loading: boolean) => void;
// === Templates ===
setTemplates: (templates: Template[]) => void;
setTemplatesLoading: (loading: boolean) => void;
setTemplateState: (state: Partial<TemplateState>) => void;
// === Filters ===
setSearchText: (text: string) => void;
setShowErrorsOnly: (value: boolean) => void;
setFilters: (filters: Partial<FilterState>) => void;
// === Row Selection ===
setSelectedRows: (rows: Set<string>) => void;
toggleRowSelection: (rowId: string) => void;
selectAllRows: () => void;
clearSelection: () => void;
// === Copy-Down Mode ===
setCopyDownMode: (mode: Partial<CopyDownState>) => void;
startCopyDown: (rowIndex: number, fieldKey: string) => void;
cancelCopyDown: () => void;
completeCopyDown: (targetRowIndex: number) => void;
setTargetRowHover: (rowIndex: number | null) => void;
// === Dialogs ===
setDialogs: (updates: Partial<DialogState>) => void;
openTemplateForm: (initialData: Record<string, unknown>) => void;
closeTemplateForm: () => void;
// === Initialization Phase ===
setInitPhase: (phase: InitPhase) => void;
// === AI Validation ===
setAiValidationRunning: (running: boolean) => void;
setAiValidationProgress: (progress: AiValidationProgress | null) => void;
setAiValidationResults: (results: AiValidationResults | null) => void;
revertAiChange: (productIndex: number, fieldKey: string) => void;
clearAiValidation: () => void;
storeOriginalValues: () => void;
// === Output ===
getCleanedData: () => CleanRowData[];
// === Reset ===
reset: () => void;
}
// =============================================================================
// Combined Store Type
// =============================================================================
export type ValidationStore = ValidationState & ValidationActions;
// =============================================================================
// Component Props Types
// =============================================================================
export interface ValidationStepProps {
initialData: RowData[];
file?: File;
onBack?: () => void;
onNext?: (data: CleanRowData[]) => void;
isFromScratch?: boolean;
}

View File

@@ -0,0 +1,735 @@
/**
* ValidationStep Zustand Store
*
* Single source of truth for all validation state.
* Uses immer for immutable updates and subscribeWithSelector for efficient subscriptions.
*/
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import { subscribeWithSelector } from 'zustand/middleware';
import { enableMapSet } from 'immer';
import { v4 as uuidv4 } from 'uuid';
// Enable Map and Set support in Immer
enableMapSet();
import type {
ValidationStore,
ValidationState,
RowData,
CleanRowData,
ValidationError,
RowValidationStatus,
UpcValidationStatus,
Template,
TemplateState,
FilterState,
InitPhase,
AiValidationProgress,
AiValidationResults,
CopyDownState,
DialogState,
} from './types';
import type { Field, SelectOption } from '../../../types';
// =============================================================================
// Initial State
// =============================================================================
const initialTemplateState: TemplateState = {
selectedTemplateId: null,
showSaveDialog: false,
newTemplateName: '',
newTemplateType: '',
};
const initialFilterState: FilterState = {
searchText: '',
showErrorsOnly: false,
filterField: null,
filterValue: null,
};
const initialCopyDownState: CopyDownState = {
isActive: false,
sourceRowIndex: null,
sourceFieldKey: null,
targetRowIndex: null,
};
const initialDialogState: DialogState = {
templateFormOpen: false,
templateFormData: null,
createCategoryOpen: false,
};
const getInitialState = (): ValidationState => ({
// Core Data
rows: [],
originalRows: [],
// Field Configuration
fields: [],
fieldOptionsLoaded: false,
// Validation
errors: new Map(),
rowValidationStatus: new Map(),
// Cell States
validatingCells: new Set(),
editingCells: new Set(),
// UPC Validation
upcStatus: new Map(),
generatedItemNumbers: new Map(),
upcCache: new Map(),
initialUpcValidationDone: false,
// Product Lines
productLinesCache: new Map(),
sublinesCache: new Map(),
loadingProductLines: new Set(),
loadingSublines: new Set(),
// Templates
templates: [],
templatesLoading: false,
templateState: { ...initialTemplateState },
// Filters
filters: { ...initialFilterState },
// Row Selection
selectedRows: new Set(),
// Copy-Down Mode
copyDownMode: { ...initialCopyDownState },
// Dialogs
dialogs: { ...initialDialogState },
// Initialization
initPhase: 'idle',
// AI Validation
aiValidation: {
isRunning: false,
progress: null,
results: null,
revertedChanges: new Set(),
},
// File
file: null,
});
// =============================================================================
// Store Creation
// =============================================================================
export const useValidationStore = create<ValidationStore>()(
subscribeWithSelector(
immer((set, get) => ({
...getInitialState(),
// =========================================================================
// Initialization
// =========================================================================
initialize: async (data: RowData[], fields: Field<string>[], file?: File) => {
console.log('[ValidationStore] initialize() called with', data.length, 'rows');
// First, get a fresh initial state to ensure clean slate
const freshState = getInitialState();
set((state) => {
console.log('[ValidationStore] Inside set callback, current initPhase:', state.initPhase);
// Apply fresh state first (clean slate)
Object.assign(state, freshState);
// Then set up with new data
state.rows = data.map((row) => ({
...row,
__index: row.__index || uuidv4(),
}));
state.originalRows = JSON.parse(JSON.stringify(state.rows));
// Cast to bypass immer's strict readonly type checking
state.fields = fields as unknown as typeof state.fields;
state.file = file || null;
state.initPhase = 'loading-options';
console.log('[ValidationStore] Set initPhase to loading-options');
});
console.log('[ValidationStore] initialize() complete, new initPhase:', get().initPhase);
},
setFields: (fields: Field<string>[]) => {
set((state) => {
// Cast to bypass immer's strict readonly type checking
state.fields = fields as unknown as typeof state.fields;
});
},
setFieldOptionsLoaded: (loaded: boolean) => {
set((state) => {
state.fieldOptionsLoaded = loaded;
});
},
// =========================================================================
// Row Operations
// =========================================================================
updateCell: (rowIndex: number, field: string, value: unknown) => {
set((state) => {
if (state.rows[rowIndex]) {
state.rows[rowIndex][field] = value;
}
});
},
updateRow: (rowIndex: number, updates: Partial<RowData>) => {
set((state) => {
if (state.rows[rowIndex]) {
Object.assign(state.rows[rowIndex], updates);
}
});
},
deleteRows: (rowIndexes: number[]) => {
set((state) => {
// Sort descending to delete from end first (preserves indices)
const sorted = [...rowIndexes].sort((a, b) => b - a);
sorted.forEach((index) => {
if (index >= 0 && index < state.rows.length) {
state.rows.splice(index, 1);
state.errors.delete(index);
state.rowValidationStatus.delete(index);
state.upcStatus.delete(index);
state.generatedItemNumbers.delete(index);
}
});
// Reindex remaining Maps after deletion
// This is necessary because we use numeric indices as keys
const reindexMap = <T>(map: Map<number, T>): Map<number, T> => {
const entries = Array.from(map.entries())
.filter(([idx]) => !rowIndexes.includes(idx))
.sort(([a], [b]) => a - b);
const newMap = new Map<number, T>();
let newIndex = 0;
entries.forEach(([, value]) => {
newMap.set(newIndex++, value);
});
return newMap;
};
state.errors = reindexMap(state.errors) as typeof state.errors;
state.rowValidationStatus = reindexMap(state.rowValidationStatus);
state.upcStatus = reindexMap(state.upcStatus);
state.generatedItemNumbers = reindexMap(state.generatedItemNumbers);
});
},
addRow: (rowData?: Partial<RowData>) => {
set((state) => {
const newRow: RowData = {
__index: uuidv4(),
...rowData,
};
state.rows.push(newRow);
});
},
copyDown: (fromRowIndex: number, fieldKey: string, toRowIndex?: number) => {
set((state) => {
const sourceValue = state.rows[fromRowIndex]?.[fieldKey];
if (sourceValue === undefined) return;
const endIndex = toRowIndex ?? state.rows.length - 1;
for (let i = fromRowIndex + 1; i <= endIndex; i++) {
if (state.rows[i]) {
state.rows[i][fieldKey] = sourceValue;
}
}
});
},
setRows: (rows: RowData[]) => {
set((state) => {
state.rows = rows;
});
},
// =========================================================================
// Validation
// =========================================================================
setError: (rowIndex: number, field: string, error: ValidationError | null) => {
set((state) => {
if (!state.errors.has(rowIndex)) {
state.errors.set(rowIndex, {});
}
const rowErrors = state.errors.get(rowIndex)!;
if (error === null) {
delete rowErrors[field];
if (Object.keys(rowErrors).length === 0) {
state.errors.delete(rowIndex);
}
} else {
rowErrors[field] = [error];
}
});
},
setErrors: (rowIndex: number, errors: Record<string, ValidationError[]>) => {
set((state) => {
if (Object.keys(errors).length === 0) {
state.errors.delete(rowIndex);
} else {
state.errors.set(rowIndex, errors);
}
});
},
/**
* PERFORMANCE: Batch set all errors and row statuses in a single store update.
* This avoids the O(n) Immer Map/Set cloning that happens on every individual set() call.
* Use this for bulk validation operations like validateAllRows.
*/
setBulkValidationResults: (
allErrors: Map<number, Record<string, ValidationError[]>>,
allStatuses: Map<number, RowValidationStatus>
) => {
set((state) => {
// Replace entire errors map
state.errors = allErrors as typeof state.errors;
// Replace entire status map
state.rowValidationStatus = allStatuses;
});
},
clearRowErrors: (rowIndex: number) => {
set((state) => {
state.errors.delete(rowIndex);
});
},
clearFieldError: (rowIndex: number, field: string) => {
set((state) => {
const rowErrors = state.errors.get(rowIndex);
if (rowErrors) {
delete rowErrors[field];
if (Object.keys(rowErrors).length === 0) {
state.errors.delete(rowIndex);
}
}
});
},
setRowValidationStatus: (rowIndex: number, status: RowValidationStatus) => {
set((state) => {
state.rowValidationStatus.set(rowIndex, status);
});
},
// =========================================================================
// Cell States
// =========================================================================
startValidatingCell: (rowIndex: number, field: string) => {
set((state) => {
state.validatingCells.add(`${rowIndex}-${field}`);
});
},
stopValidatingCell: (rowIndex: number, field: string) => {
set((state) => {
state.validatingCells.delete(`${rowIndex}-${field}`);
});
},
startEditingCell: (rowIndex: number, field: string) => {
set((state) => {
state.editingCells.add(`${rowIndex}-${field}`);
});
},
stopEditingCell: (rowIndex: number, field: string) => {
set((state) => {
state.editingCells.delete(`${rowIndex}-${field}`);
});
},
// =========================================================================
// UPC Validation
// =========================================================================
setUpcStatus: (rowIndex: number, status: UpcValidationStatus) => {
set((state) => {
state.upcStatus.set(rowIndex, status);
});
},
setGeneratedItemNumber: (rowIndex: number, itemNumber: string) => {
set((state) => {
state.generatedItemNumbers.set(rowIndex, itemNumber);
// Also update the row data
if (state.rows[rowIndex]) {
state.rows[rowIndex].item_number = itemNumber;
}
// Clear any validation errors for item_number since we just set a valid value
const rowErrors = state.errors.get(rowIndex);
if (rowErrors && rowErrors.item_number) {
const { item_number: _, ...remainingErrors } = rowErrors;
if (Object.keys(remainingErrors).length === 0) {
state.errors.delete(rowIndex);
} else {
state.errors.set(rowIndex, remainingErrors);
}
}
});
},
cacheUpcResult: (supplierId: string, upc: string, itemNumber: string) => {
set((state) => {
state.upcCache.set(`${supplierId}-${upc}`, itemNumber);
});
},
getCachedItemNumber: (supplierId: string, upc: string) => {
return get().upcCache.get(`${supplierId}-${upc}`);
},
setInitialUpcValidationDone: (done: boolean) => {
set((state) => {
state.initialUpcValidationDone = done;
});
},
// =========================================================================
// Product Lines
// =========================================================================
setProductLines: (companyId: string, lines: SelectOption[]) => {
set((state) => {
state.productLinesCache.set(companyId, lines);
});
},
setSublines: (lineId: string, sublines: SelectOption[]) => {
set((state) => {
state.sublinesCache.set(lineId, sublines);
});
},
setLoadingProductLines: (companyId: string, loading: boolean) => {
set((state) => {
if (loading) {
state.loadingProductLines.add(companyId);
} else {
state.loadingProductLines.delete(companyId);
}
});
},
setLoadingSublines: (lineId: string, loading: boolean) => {
set((state) => {
if (loading) {
state.loadingSublines.add(lineId);
} else {
state.loadingSublines.delete(lineId);
}
});
},
// =========================================================================
// Templates
// =========================================================================
setTemplates: (templates: Template[]) => {
set((state) => {
state.templates = templates;
});
},
setTemplatesLoading: (loading: boolean) => {
set((state) => {
state.templatesLoading = loading;
});
},
setTemplateState: (updates: Partial<TemplateState>) => {
set((state) => {
Object.assign(state.templateState, updates);
});
},
// =========================================================================
// Filters
// =========================================================================
setSearchText: (text: string) => {
set((state) => {
state.filters.searchText = text;
});
},
setShowErrorsOnly: (value: boolean) => {
set((state) => {
state.filters.showErrorsOnly = value;
});
},
setFilters: (updates: Partial<FilterState>) => {
set((state) => {
Object.assign(state.filters, updates);
});
},
// =========================================================================
// Row Selection
// =========================================================================
setSelectedRows: (rows: Set<string>) => {
set((state) => {
state.selectedRows = rows;
});
},
toggleRowSelection: (rowId: string) => {
set((state) => {
if (state.selectedRows.has(rowId)) {
state.selectedRows.delete(rowId);
} else {
state.selectedRows.add(rowId);
}
});
},
selectAllRows: () => {
set((state) => {
state.selectedRows = new Set(state.rows.map((row: RowData) => row.__index));
});
},
clearSelection: () => {
set((state) => {
state.selectedRows = new Set();
});
},
// =========================================================================
// Copy-Down Mode
// =========================================================================
setCopyDownMode: (mode: Partial<CopyDownState>) => {
set((state) => {
Object.assign(state.copyDownMode, mode);
});
},
startCopyDown: (rowIndex: number, fieldKey: string) => {
set((state) => {
state.copyDownMode = {
isActive: true,
sourceRowIndex: rowIndex,
sourceFieldKey: fieldKey,
targetRowIndex: null,
};
});
},
cancelCopyDown: () => {
set((state) => {
state.copyDownMode = { ...initialCopyDownState };
});
},
completeCopyDown: (targetRowIndex: number) => {
const { copyDownMode } = get();
if (!copyDownMode.isActive || copyDownMode.sourceRowIndex === null || !copyDownMode.sourceFieldKey) {
return;
}
const fieldKey = copyDownMode.sourceFieldKey;
const sourceRowIndex = copyDownMode.sourceRowIndex;
// First, perform the copy operation
set((state) => {
const sourceValue = state.rows[sourceRowIndex]?.[fieldKey];
if (sourceValue === undefined) return;
// Clone value for arrays/objects to prevent reference sharing
const cloneValue = (val: unknown): unknown => {
if (Array.isArray(val)) return [...val];
if (val && typeof val === 'object') return { ...val };
return val;
};
// Check if value is non-empty (for clearing required errors)
const hasValue = sourceValue !== null && sourceValue !== '' &&
!(Array.isArray(sourceValue) && sourceValue.length === 0);
for (let i = sourceRowIndex + 1; i <= targetRowIndex; i++) {
if (state.rows[i]) {
state.rows[i][fieldKey] = cloneValue(sourceValue);
// Clear validation errors for this field if value is non-empty
if (hasValue) {
const rowErrors = state.errors.get(i);
if (rowErrors && rowErrors[fieldKey]) {
// Remove errors for this field
const { [fieldKey]: _, ...remainingErrors } = rowErrors;
if (Object.keys(remainingErrors).length === 0) {
state.errors.delete(i);
} else {
state.errors.set(i, remainingErrors);
}
}
}
}
}
// Reset copy-down mode
state.copyDownMode = { ...initialCopyDownState };
});
},
setTargetRowHover: (rowIndex: number | null) => {
set((state) => {
if (state.copyDownMode.isActive) {
state.copyDownMode.targetRowIndex = rowIndex;
}
});
},
// =========================================================================
// Dialogs
// =========================================================================
setDialogs: (updates: Partial<DialogState>) => {
set((state) => {
Object.assign(state.dialogs, updates);
});
},
openTemplateForm: (initialData: Record<string, unknown>) => {
set((state) => {
state.dialogs.templateFormOpen = true;
state.dialogs.templateFormData = initialData;
});
},
closeTemplateForm: () => {
set((state) => {
state.dialogs.templateFormOpen = false;
state.dialogs.templateFormData = null;
});
},
// =========================================================================
// Initialization Phase
// =========================================================================
setInitPhase: (phase: InitPhase) => {
set((state) => {
state.initPhase = phase;
});
},
// =========================================================================
// AI Validation
// =========================================================================
setAiValidationRunning: (running: boolean) => {
set((state) => {
state.aiValidation.isRunning = running;
});
},
setAiValidationProgress: (progress: AiValidationProgress | null) => {
set((state) => {
state.aiValidation.progress = progress;
});
},
setAiValidationResults: (results: AiValidationResults | null) => {
set((state) => {
state.aiValidation.results = results;
});
},
revertAiChange: (productIndex: number, fieldKey: string) => {
set((state) => {
const key = `${productIndex}:${fieldKey}`;
const row = state.rows[productIndex];
if (row && row.__original && fieldKey in row.__original) {
// Revert to original value
row[fieldKey] = row.__original[fieldKey];
// Mark as reverted
state.aiValidation.revertedChanges.add(key);
// Clear the change marker
if (row.__changes) {
delete row.__changes[fieldKey];
}
}
});
},
clearAiValidation: () => {
set((state) => {
state.aiValidation = {
isRunning: false,
progress: null,
results: null,
revertedChanges: new Set(),
};
});
},
storeOriginalValues: () => {
set((state) => {
state.rows.forEach((row: RowData) => {
row.__original = { ...row };
});
});
},
// =========================================================================
// Output
// =========================================================================
getCleanedData: (): CleanRowData[] => {
const { rows } = get();
return rows.map((row) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { __index, __template, __original, __corrected, __changes, __aiSupplemental, ...cleanRow } = row;
return cleanRow as CleanRowData;
});
},
// =========================================================================
// Reset
// =========================================================================
reset: () => {
console.log('[ValidationStore] reset() called');
console.trace('[ValidationStore] reset trace');
set(getInitialState());
},
}))
)
);
// =============================================================================
// Store Selectors (for use outside React components)
// =============================================================================
export const getRows = () => useValidationStore.getState().rows;
export const getErrors = () => useValidationStore.getState().errors;
export const getFields = () => useValidationStore.getState().fields;

View File

@@ -0,0 +1,111 @@
/**
* AI Validation utility functions
*
* Helper functions for processing AI validation data and managing progress
*/
import type { Fields } from '@/components/product-import/types';
/**
* Clean data for AI validation by including all fields
*
* Ensures every field is present in the data sent to the API,
* converting undefined values to empty strings
*/
export function prepareDataForAiValidation<T extends string>(
data: any[],
fields: Fields<T>
): Record<string, any>[] {
return data.map(item => {
const { __index, __aiSupplemental, ...rest } = item as Record<string, any>;
const withAllKeys: Record<string, any> = {};
fields.forEach((f) => {
const k = String(f.key);
if (Array.isArray(rest[k])) {
withAllKeys[k] = rest[k];
} else if (rest[k] === undefined) {
withAllKeys[k] = "";
} else {
withAllKeys[k] = rest[k];
}
});
if (typeof __aiSupplemental === 'object' && __aiSupplemental !== null) {
withAllKeys.aiSupplementalInfo = __aiSupplemental;
}
return withAllKeys;
});
}
/**
* Process AI-corrected data to handle multi-select and select fields
*
* Converts comma-separated strings to arrays for multi-select fields
* and handles label-to-value conversions for select fields
*/
export function processAiCorrectedData<T extends string>(
correctedData: any[],
originalData: any[],
fields: Fields<T>
): any[] {
return correctedData.map((corrected: any, index: number) => {
// Start with original data to preserve metadata like __index
const original = originalData[index] || {};
const processed = { ...original, ...corrected };
// Process each field according to its type
Object.keys(processed).forEach(key => {
if (key.startsWith('__')) return; // Skip metadata fields
const fieldConfig = fields.find(f => String(f.key) === key);
if (!fieldConfig) return;
// Handle multi-select fields (comma-separated values → array)
if (fieldConfig?.fieldType.type === 'multi-select' && typeof processed[key] === 'string') {
processed[key] = processed[key]
.split(',')
.map((v: string) => v.trim())
.filter(Boolean);
}
});
return processed;
});
}
/**
* Calculate progress percentage based on elapsed time and estimates
*
* @param step - Current step number (1-5)
* @param elapsedSeconds - Time elapsed since start
* @param estimatedSeconds - Estimated total time (optional)
* @returns Progress percentage (0-95, never reaches 100 until complete)
*/
export function calculateProgressPercent(
step: number,
elapsedSeconds: number,
estimatedSeconds?: number
): number {
if (estimatedSeconds && estimatedSeconds > 0) {
// Time-based progress
return Math.min(95, (elapsedSeconds / estimatedSeconds) * 100);
}
// Step-based progress with time adjustment
const baseProgress = (step / 5) * 100;
const timeAdjustment = step === 1 ? Math.min(20, elapsedSeconds * 0.5) : 0;
return Math.min(95, baseProgress + timeAdjustment);
}
/**
* Extract base status message by removing time information
*
* Removes patterns like "(5s remaining)" or "(1m 30s elapsed)"
*/
export function extractBaseStatus(status: string): string {
return status
.replace(/\s\(\d+[ms].+\)$/, '')
.replace(/\s\(\d+m \d+s.+\)$/, '');
}

View File

@@ -0,0 +1,66 @@
/**
* Country code normalization utilities
*
* Converts various country code formats and country names to ISO 3166-1 alpha-2 codes
*/
/**
* Normalizes country codes and names to ISO 3166-1 alpha-2 format (2-letter codes)
*
* Supports:
* - ISO 3166-1 alpha-2 codes (e.g., "US", "GB")
* - ISO 3166-1 alpha-3 codes (e.g., "USA", "GBR")
* - Common country names (e.g., "United States", "China")
*
* @param input - Country code or name to normalize
* @returns ISO 3166-1 alpha-2 code or null if not recognized
*
* @example
* normalizeCountryCode("USA") // "US"
* normalizeCountryCode("United States") // "US"
* normalizeCountryCode("US") // "US"
* normalizeCountryCode("invalid") // null
*/
export function normalizeCountryCode(input: string): string | null {
if (!input) return null;
const s = input.trim();
const upper = s.toUpperCase();
// Already in ISO 3166-1 alpha-2 format
if (/^[A-Z]{2}$/.test(upper)) return upper;
// ISO 3166-1 alpha-3 to alpha-2 mapping
const iso3to2: Record<string, string> = {
USA: "US", GBR: "GB", UK: "GB", CHN: "CN", DEU: "DE", FRA: "FR", ITA: "IT", ESP: "ES",
CAN: "CA", MEX: "MX", AUS: "AU", NZL: "NZ", JPN: "JP", KOR: "KR", PRK: "KP", TWN: "TW",
VNM: "VN", THA: "TH", IDN: "ID", IND: "IN", BRA: "BR", ARG: "AR", CHL: "CL", PER: "PE",
ZAF: "ZA", RUS: "RU", UKR: "UA", NLD: "NL", BEL: "BE", CHE: "CH", SWE: "SE", NOR: "NO",
DNK: "DK", POL: "PL", AUT: "AT", PRT: "PT", GRC: "GR", CZE: "CZ", HUN: "HU", IRL: "IE",
ISR: "IL", PAK: "PK", BGD: "BD", PHL: "PH", MYS: "MY", SGP: "SG", HKG: "HK", MAC: "MO"
};
if (iso3to2[upper]) return iso3to2[upper];
// Country name to ISO 3166-1 alpha-2 mapping
const nameMap: Record<string, string> = {
"UNITED STATES": "US", "UNITED STATES OF AMERICA": "US", "AMERICA": "US", "U.S.": "US", "U.S.A": "US", "USA": "US",
"UNITED KINGDOM": "GB", "UK": "GB", "GREAT BRITAIN": "GB", "ENGLAND": "GB",
"CHINA": "CN", "PEOPLE'S REPUBLIC OF CHINA": "CN", "PRC": "CN",
"CANADA": "CA", "MEXICO": "MX", "JAPAN": "JP", "SOUTH KOREA": "KR", "KOREA, REPUBLIC OF": "KR",
"TAIWAN": "TW", "VIETNAM": "VN", "THAILAND": "TH", "INDONESIA": "ID", "INDIA": "IN",
"GERMANY": "DE", "FRANCE": "FR", "ITALY": "IT", "SPAIN": "ES", "NETHERLANDS": "NL", "BELGIUM": "BE",
"SWITZERLAND": "CH", "SWEDEN": "SE", "NORWAY": "NO", "DENMARK": "DK", "POLAND": "PL", "AUSTRIA": "AT",
"PORTUGAL": "PT", "GREECE": "GR", "CZECH REPUBLIC": "CZ", "CZECHIA": "CZ", "HUNGARY": "HU", "IRELAND": "IE",
"RUSSIA": "RU", "UKRAINE": "UA", "AUSTRALIA": "AU", "NEW ZEALAND": "NZ",
"BRAZIL": "BR", "ARGENTINA": "AR", "CHILE": "CL", "PERU": "PE", "SOUTH AFRICA": "ZA",
"ISRAEL": "IL", "PAKISTAN": "PK", "BANGLADESH": "BD", "PHILIPPINES": "PH", "MALAYSIA": "MY", "SINGAPORE": "SG",
"HONG KONG": "HK", "MACAU": "MO"
};
// Normalize input: remove dots, trim, uppercase
const normalizedName = s.replace(/\./g, "").trim().toUpperCase();
if (nameMap[normalizedName]) return nameMap[normalizedName];
return null;
}

View File

@@ -0,0 +1,142 @@
import type { Data, Fields, Info, RowHook, TableHook } from "../../../types"
import type { Meta, Errors } from "../../ValidationStepNew/types"
import { v4 } from "uuid"
import { ErrorSources, ErrorType } from "../../../types"
type DataWithMeta<T extends string> = Data<T> & Meta & {
__index?: string;
}
export const addErrorsAndRunHooks = async <T extends string>(
data: (Data<T> & Partial<Meta>)[],
fields: Fields<T>,
rowHook?: RowHook<T>,
tableHook?: TableHook<T>,
changedRowIndexes?: number[],
): Promise<DataWithMeta<T>[]> => {
const errors: Errors = {}
const addError = (source: ErrorSources, rowIndex: number, fieldKey: string, error: Info, type: ErrorType = ErrorType.Custom) => {
errors[rowIndex] = {
...errors[rowIndex],
[fieldKey]: { ...error, source, type },
}
}
let processedData = [...data] as DataWithMeta<T>[]
if (tableHook) {
const tableResults = await tableHook(processedData)
processedData = tableResults.map((result, index) => ({
...processedData[index],
...result
}))
}
if (rowHook) {
if (changedRowIndexes) {
for (const index of changedRowIndexes) {
const rowResult = await rowHook(processedData[index], index, processedData)
processedData[index] = {
...processedData[index],
...rowResult
}
}
} else {
const rowResults = await Promise.all(
processedData.map(async (value, index) => {
const result = await rowHook(value, index, processedData)
return {
...value,
...result
}
})
)
processedData = rowResults
}
}
fields.forEach((field) => {
const fieldKey = field.key as string
field.validations?.forEach((validation) => {
switch (validation.rule) {
case "unique": {
const values = processedData.map((entry) => {
const value = entry[fieldKey as keyof typeof entry]
return value
})
const taken = new Set() // Set of items used at least once
const duplicates = new Set() // Set of items used multiple times
values.forEach((value) => {
if (validation.allowEmpty && !value) {
// If allowEmpty is set, we will not validate falsy fields such as undefined or empty string.
return
}
if (taken.has(value)) {
duplicates.add(value)
} else {
taken.add(value)
}
})
values.forEach((value, index) => {
if (duplicates.has(value)) {
addError(ErrorSources.Table, index, fieldKey, {
level: validation.level || "error",
message: validation.errorMessage || "Field must be unique",
}, ErrorType.Unique)
}
})
break
}
case "required": {
const dataToValidate = changedRowIndexes ? changedRowIndexes.map((index) => processedData[index]) : processedData
dataToValidate.forEach((entry, index) => {
const realIndex = changedRowIndexes ? changedRowIndexes[index] : index
const value = entry[fieldKey as keyof typeof entry]
if (value === null || value === undefined || value === "") {
addError(ErrorSources.Row, realIndex, fieldKey, {
level: validation.level || "error",
message: validation.errorMessage || "Field is required",
}, ErrorType.Required)
}
})
break
}
case "regex": {
const dataToValidate = changedRowIndexes ? changedRowIndexes.map((index) => processedData[index]) : processedData
const regex = new RegExp(validation.value, validation.flags)
dataToValidate.forEach((entry, index) => {
const realIndex = changedRowIndexes ? changedRowIndexes[index] : index
const value = entry[fieldKey as keyof typeof entry]
const stringValue = value?.toString() ?? ""
if (!stringValue.match(regex)) {
addError(ErrorSources.Row, realIndex, fieldKey, {
level: validation.level || "error",
message:
validation.errorMessage || `Field did not match the regex /${validation.value}/${validation.flags} `,
}, ErrorType.Regex)
}
})
break
}
}
})
})
return processedData.map((value) => {
// This is required only for table. Mutates to prevent needless rerenders
const result: DataWithMeta<T> = { ...value }
if (!result.__index) {
result.__index = v4()
}
// We no longer store errors in the row data
// The errors are now only stored in the validationErrors Map
return result
})
}

View File

@@ -0,0 +1,62 @@
/**
* Price field cleaning and formatting utilities
*/
/**
* Cleans a price field by removing currency symbols and formatting to 2 decimal places
*
* - Removes dollar signs ($) and commas (,)
* - Converts to number and formats with 2 decimal places
* - Returns original value if conversion fails
*
* @param value - Price value to clean (string or number)
* @returns Cleaned price string formatted to 2 decimals, or original value if invalid
*
* @example
* cleanPriceField("$1,234.56") // "1234.56"
* cleanPriceField("$99.9") // "99.90"
* cleanPriceField(123.456) // "123.46"
* cleanPriceField("invalid") // "invalid"
*/
export function cleanPriceField(value: string | number): string {
if (typeof value === "string") {
const cleaned = value.replace(/[$,]/g, "");
const numValue = parseFloat(cleaned);
if (!isNaN(numValue)) {
return numValue.toFixed(2);
}
return value;
}
if (typeof value === "number") {
return value.toFixed(2);
}
return String(value);
}
/**
* Cleans multiple price fields in a data object
*
* @param data - Object containing price fields
* @param priceFields - Array of field keys to clean
* @returns New object with cleaned price fields
*
* @example
* cleanPriceFields({ msrp: "$99.99", cost_each: "$50.00" }, ["msrp", "cost_each"])
* // { msrp: "99.99", cost_each: "50.00" }
*/
export function cleanPriceFields<T extends Record<string, any>>(
data: T,
priceFields: (keyof T)[]
): T {
const cleaned = { ...data };
for (const field of priceFields) {
if (cleaned[field] !== undefined && cleaned[field] !== null) {
cleaned[field] = cleanPriceField(cleaned[field]) as any;
}
}
return cleaned;
}

View File

@@ -0,0 +1,63 @@
const NUMERIC_REGEX = /^\d+$/;
export function calculateUpcCheckDigit(upcBody: string): number {
if (!NUMERIC_REGEX.test(upcBody) || upcBody.length !== 11) {
throw new Error('UPC body must be 11 numeric characters');
}
const digits = upcBody.split('').map((d) => Number.parseInt(d, 10));
let sum = 0;
for (let i = 0; i < digits.length; i += 1) {
sum += (i % 2 === 0 ? digits[i] * 3 : digits[i]);
}
const mod = sum % 10;
return mod === 0 ? 0 : 10 - mod;
}
export function calculateEanCheckDigit(eanBody: string): number {
if (!NUMERIC_REGEX.test(eanBody) || eanBody.length !== 12) {
throw new Error('EAN body must be 12 numeric characters');
}
const digits = eanBody.split('').map((d) => Number.parseInt(d, 10));
let sum = 0;
for (let i = 0; i < digits.length; i += 1) {
sum += (i % 2 === 0 ? digits[i] : digits[i] * 3);
}
const mod = sum % 10;
return mod === 0 ? 0 : 10 - mod;
}
export function correctUpcValue(rawValue: unknown): { corrected: string; changed: boolean } {
const value = rawValue ?? '';
const str = typeof value === 'string' ? value.trim() : String(value);
if (str === '' || !NUMERIC_REGEX.test(str)) {
return { corrected: str, changed: false };
}
if (str.length === 11) {
const check = calculateUpcCheckDigit(str);
return { corrected: `${str}${check}`, changed: true };
}
if (str.length === 12) {
const body = str.slice(0, 11);
const check = calculateUpcCheckDigit(body);
const corrected = `${body}${check}`;
return { corrected, changed: corrected !== str };
}
if (str.length === 13) {
const body = str.slice(0, 12);
const check = calculateEanCheckDigit(body);
const corrected = `${body}${check}`;
return { corrected, changed: corrected !== str };
}
return { corrected: str, changed: false };
}

View File

@@ -7,6 +7,7 @@ const StepTypeToStepRecord: Record<StepType, (typeof steps)[number]> = {
[StepType.selectHeader]: "selectHeaderStep",
[StepType.matchColumns]: "matchColumnsStep",
[StepType.validateData]: "validationStep",
[StepType.validateDataNew]: "validationStep",
[StepType.imageUpload]: "imageUploadStep",
}
const StepToStepTypeRecord: Record<(typeof steps)[number], StepType> = {

View File

@@ -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;
}
}

View File

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

View File

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

View File

@@ -68,7 +68,28 @@ export default {
'2': 'hsl(var(--chart-2))',
'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))',

File diff suppressed because one or more lines are too long

View File

@@ -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);