Compare commits
4 Commits
0ffd02e22e
...
630945e901
| Author | SHA1 | Date | |
|---|---|---|---|
| 630945e901 | |||
| 54ddaa0492 | |||
| 262890a7be | |||
| ef50aec33c |
11
inventory/package-lock.json
generated
11
inventory/package-lock.json
generated
@@ -52,6 +52,7 @@
|
||||
"date-fns": "^3.6.0",
|
||||
"diff": "^7.0.0",
|
||||
"framer-motion": "^12.4.4",
|
||||
"immer": "^11.1.3",
|
||||
"input-otp": "^1.4.1",
|
||||
"js-levenshtein": "^1.1.6",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -5749,6 +5750,16 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "11.1.3",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
|
||||
"integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build:deploy": "tsc -b && COPY_BUILD=true vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"mount": "../mountremote.command"
|
||||
@@ -55,6 +56,7 @@
|
||||
"date-fns": "^3.6.0",
|
||||
"diff": "^7.0.0",
|
||||
"framer-motion": "^12.4.4",
|
||||
"immer": "^11.1.3",
|
||||
"input-otp": "^1.4.1",
|
||||
"js-levenshtein": "^1.1.6",
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
// components/AircallDashboard.jsx
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -14,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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
||||
|
||||
const CraftsIcon = () => (
|
||||
<svg viewBox="0 0 2687 3338" className="w-6 h-6" aria-hidden="true">
|
||||
@@ -289,7 +290,7 @@ const Header = () => {
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"w-full bg-white dark:bg-gray-900 shadow-sm",
|
||||
`w-full ${CARD_STYLES.solid} shadow-sm`,
|
||||
isStuck ? "rounded-b-lg border-b-1" : "border-b-0 rounded-b-none"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import 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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -22,10 +22,10 @@ import {
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { EventDialog } from "./EventFeed.jsx";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DashboardErrorState } from "@/components/dashboard/shared";
|
||||
|
||||
const METRIC_IDS = {
|
||||
PLACED_ORDER: "Y8cqcF",
|
||||
@@ -439,13 +439,7 @@ const MiniEventFeed = ({
|
||||
{loading && !events.length ? (
|
||||
<LoadingState />
|
||||
) : error ? (
|
||||
<Alert variant="destructive" className="mx-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>
|
||||
Failed to load event feed: {error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<DashboardErrorState error={`Failed to load event feed: ${error}`} className="mx-4" />
|
||||
) : !events || events.length === 0 ? (
|
||||
<div className="px-4">
|
||||
<EmptyState />
|
||||
|
||||
@@ -11,41 +11,11 @@ import {
|
||||
import { AlertTriangle, Users, Activity } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
summaryCard,
|
||||
SkeletonSummaryCard,
|
||||
SkeletonBarChart,
|
||||
processBasicData,
|
||||
} from "./RealtimeAnalytics";
|
||||
import { processBasicData } from "./RealtimeAnalytics";
|
||||
import { DashboardStatCardMini, DashboardStatCardMiniSkeleton, TOOLTIP_THEMES } from "@/components/dashboard/shared";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { METRIC_COLORS } from "@/lib/dashboard/designTokens";
|
||||
|
||||
const SkeletonCard = ({ colorScheme = "sky" }) => (
|
||||
<Card className={`w-full h-[150px] bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm`}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
||||
<CardTitle>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300/20`} />
|
||||
</div>
|
||||
</CardTitle>
|
||||
<div className="relative p-2">
|
||||
<div className={`absolute inset-0 rounded-full bg-${colorScheme}-300/20`} />
|
||||
<div className="h-5 w-5 relative rounded-full bg-${colorScheme}-300/20" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className={`h-8 w-32 bg-${colorScheme}-300/20`} />
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="space-y-1">
|
||||
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300/20`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const MiniRealtimeAnalytics = () => {
|
||||
const [basicData, setBasicData] = useState({
|
||||
@@ -119,8 +89,8 @@ const MiniRealtimeAnalytics = () => {
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-2 mt-1 mb-2">
|
||||
<SkeletonCard colorScheme="sky" />
|
||||
<SkeletonCard colorScheme="sky" />
|
||||
<DashboardStatCardMiniSkeleton gradient="sky" />
|
||||
<DashboardStatCardMiniSkeleton gradient="sky" />
|
||||
</div>
|
||||
|
||||
<Card className="bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm">
|
||||
@@ -168,34 +138,22 @@ const MiniRealtimeAnalytics = () => {
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-2 mt-1 mb-2">
|
||||
{summaryCard(
|
||||
"Last 30 Minutes",
|
||||
"Active users",
|
||||
basicData.last30MinUsers,
|
||||
{
|
||||
colorClass: "text-sky-200",
|
||||
titleClass: "text-sky-100 font-bold text-md",
|
||||
descriptionClass: "pt-2 text-sky-200 text-md font-semibold",
|
||||
background: "h-[150px] pt-2 bg-gradient-to-br from-sky-900 to-sky-800",
|
||||
icon: Users,
|
||||
iconColor: "text-sky-900",
|
||||
iconBackground: "bg-sky-300"
|
||||
}
|
||||
)}
|
||||
{summaryCard(
|
||||
"Last 5 Minutes",
|
||||
"Active users",
|
||||
basicData.last5MinUsers,
|
||||
{
|
||||
colorClass: "text-sky-200",
|
||||
titleClass: "text-sky-100 font-bold text-md",
|
||||
descriptionClass: "pt-2 text-sky-200 text-md font-semibold",
|
||||
background: "h-[150px] pt-2 bg-gradient-to-br from-sky-900 to-sky-800",
|
||||
icon: Activity,
|
||||
iconColor: "text-sky-900",
|
||||
iconBackground: "bg-sky-300"
|
||||
}
|
||||
)}
|
||||
<DashboardStatCardMini
|
||||
title="Last 30 Minutes"
|
||||
value={basicData.last30MinUsers}
|
||||
description="Active users"
|
||||
gradient="sky"
|
||||
icon={Users}
|
||||
iconBackground="bg-sky-300"
|
||||
/>
|
||||
<DashboardStatCardMini
|
||||
title="Last 5 Minutes"
|
||||
value={basicData.last5MinUsers}
|
||||
description="Active users"
|
||||
gradient="sky"
|
||||
icon={Activity}
|
||||
iconBackground="bg-sky-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="bg-gradient-to-br from-sky-900 to-sky-800 backdrop-blur-sm">
|
||||
@@ -219,28 +177,25 @@ const MiniRealtimeAnalytics = () => {
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const styles = TOOLTIP_THEMES.sky;
|
||||
return (
|
||||
<Card className="p-2 shadow-lg bg-sky-800 border-none">
|
||||
<CardContent className="p-0 space-y-1">
|
||||
<p className="font-medium text-sm text-sky-100 border-b border-sky-700 pb-1 mb-1">
|
||||
<div className={styles.container}>
|
||||
<p className={styles.header}>
|
||||
{payload[0].payload.timestamp}
|
||||
</p>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-sky-200">
|
||||
Active Users:
|
||||
</span>
|
||||
<span className="font-medium ml-4 text-sky-100">
|
||||
{payload[0].value}
|
||||
</span>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.name}>Active Users</span>
|
||||
<span className={styles.value}>{payload[0].value}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="users" fill="#0EA5E9" />
|
||||
<Bar dataKey="users" fill={METRIC_COLORS.secondary} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import React, { useState, useEffect, useCallback, memo } from "react";
|
||||
import axios from "axios";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { acotService } from "@/services/dashboard/acotService";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
LineChart,
|
||||
@@ -17,141 +13,16 @@ import {
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { DateTime } from "luxon";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { AlertCircle, TrendingUp, DollarSign, ShoppingCart, Truck, PiggyBank, ArrowUp,ArrowDown, Banknote, Package } from "lucide-react";
|
||||
import { formatCurrency, CustomTooltip, processData, StatCard } from "./SalesChart.jsx";
|
||||
|
||||
const SkeletonChart = () => (
|
||||
<div className="h-[216px]">
|
||||
<div className="h-full w-full relative">
|
||||
{/* Grid lines */}
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute w-full h-px bg-slate-600"
|
||||
style={{ top: `${(i + 1) * 20}%` }}
|
||||
/>
|
||||
))}
|
||||
{/* Y-axis labels */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-3 w-6 bg-slate-600 rounded-sm" />
|
||||
))}
|
||||
</div>
|
||||
{/* X-axis labels */}
|
||||
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-3 w-8 bg-slate-600 rounded-sm" />
|
||||
))}
|
||||
</div>
|
||||
{/* Chart lines */}
|
||||
<div className="absolute inset-x-8 bottom-6 top-4">
|
||||
<div className="h-full w-full relative">
|
||||
<div
|
||||
className="absolute inset-0 bg-slate-600 rounded-sm"
|
||||
style={{
|
||||
opacity: 0.5,
|
||||
clipPath: "polygon(0 50%, 100% 20%, 100% 100%, 0 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const MiniStatCard = memo(({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
colorClass,
|
||||
iconColor,
|
||||
iconBackground,
|
||||
background,
|
||||
previousValue,
|
||||
trend,
|
||||
trendValue,
|
||||
onClick,
|
||||
active = true,
|
||||
titleClass = "text-sm font-bold text-gray-100",
|
||||
descriptionClass = "text-sm font-semibold text-gray-200"
|
||||
}) => (
|
||||
<Card
|
||||
className={`w-full bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm ${
|
||||
onClick ? 'cursor-pointer transition-all hover:brightness-110' : ''
|
||||
} ${!active ? 'opacity-50' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
||||
<CardTitle className={titleClass}>
|
||||
{title}
|
||||
</CardTitle>
|
||||
{Icon && (
|
||||
<div className="relative p-2">
|
||||
<div className={`absolute inset-0 rounded-full ${iconBackground}`} />
|
||||
<Icon className={`h-5 w-5 ${iconColor} relative`} />
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<div className={`text-3xl font-extrabold ${colorClass}`}>
|
||||
{value}
|
||||
</div>
|
||||
<div className="mt-2 items-center justify-between flex">
|
||||
<span className={descriptionClass}>Prev: {previousValue}</span>
|
||||
{trend && (
|
||||
<span
|
||||
className={`flex items-center gap-0 px-1 py-0.5 rounded-full ${
|
||||
trend === 'up'
|
||||
? 'text-sm font-bold bg-emerald-300 text-emerald-900'
|
||||
: 'text-sm font-bold bg-rose-300 text-rose-900'
|
||||
}`}
|
||||
>
|
||||
{trend === "up" ? (
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ArrowDown className="w-4 h-4" />
|
||||
)}
|
||||
{trendValue}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
));
|
||||
|
||||
MiniStatCard.displayName = "MiniStatCard";
|
||||
|
||||
const SkeletonCard = ({ colorScheme = "emerald" }) => (
|
||||
<Card className="w-full h-[150px] bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
||||
<CardTitle>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300`} />
|
||||
</div>
|
||||
</CardTitle>
|
||||
<div className="relative p-2">
|
||||
<div className={`absolute inset-0 rounded-full bg-${colorScheme}-300`} />
|
||||
<Skeleton className={`h-5 w-5 bg-${colorScheme}-300 relative rounded-full`} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className={`h-8 w-20 bg-${colorScheme}-300`} />
|
||||
<div className="flex justify-between items-center">
|
||||
<Skeleton className={`h-4 w-24 bg-${colorScheme}-300`} />
|
||||
<Skeleton className={`h-4 w-12 bg-${colorScheme}-300 rounded-full`} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
import { AlertCircle, PiggyBank, Truck } from "lucide-react";
|
||||
import { formatCurrency, processData } from "./SalesChart.jsx";
|
||||
import { METRIC_COLORS } from "@/lib/dashboard/designTokens";
|
||||
import {
|
||||
DashboardStatCardMini,
|
||||
DashboardStatCardMiniSkeleton,
|
||||
ChartSkeleton,
|
||||
TOOLTIP_THEMES,
|
||||
} from "@/components/dashboard/shared";
|
||||
|
||||
const MiniSalesChart = ({ className = "" }) => {
|
||||
const [data, setData] = useState([]);
|
||||
@@ -269,19 +140,46 @@ const MiniSalesChart = ({ className = "" }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to calculate trend direction
|
||||
const getRevenueTrend = () => {
|
||||
const current = summaryStats.periodProgress < 100
|
||||
? (projection?.projectedRevenue || summaryStats.totalRevenue)
|
||||
: summaryStats.totalRevenue;
|
||||
return current >= summaryStats.prevRevenue ? "up" : "down";
|
||||
};
|
||||
|
||||
const getRevenueTrendValue = () => {
|
||||
const current = summaryStats.periodProgress < 100
|
||||
? (projection?.projectedRevenue || summaryStats.totalRevenue)
|
||||
: summaryStats.totalRevenue;
|
||||
return `${Math.abs(Math.round((current - summaryStats.prevRevenue) / summaryStats.prevRevenue * 100))}%`;
|
||||
};
|
||||
|
||||
const getOrdersTrend = () => {
|
||||
const projected = Math.round(summaryStats.totalOrders * (100 / summaryStats.periodProgress));
|
||||
const current = summaryStats.periodProgress < 100 ? projected : summaryStats.totalOrders;
|
||||
return current >= summaryStats.prevOrders ? "up" : "down";
|
||||
};
|
||||
|
||||
const getOrdersTrendValue = () => {
|
||||
const projected = Math.round(summaryStats.totalOrders * (100 / summaryStats.periodProgress));
|
||||
const current = summaryStats.periodProgress < 100 ? projected : summaryStats.totalOrders;
|
||||
return `${Math.abs(Math.round((current - summaryStats.prevOrders) / summaryStats.prevOrders * 100))}%`;
|
||||
};
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Stat Cards */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<SkeletonCard colorScheme="emerald" />
|
||||
<SkeletonCard colorScheme="blue" />
|
||||
<DashboardStatCardMiniSkeleton gradient="slate" />
|
||||
<DashboardStatCardMiniSkeleton gradient="slate" />
|
||||
</div>
|
||||
|
||||
{/* Chart Card */}
|
||||
<Card className="bg-gradient-to-br from-slate-700 to-slate-600 backdrop-blur-sm">
|
||||
<CardContent className="p-4">
|
||||
<SkeletonChart />
|
||||
<ChartSkeleton height="sm" withCard={false} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -294,56 +192,38 @@ const MiniSalesChart = ({ className = "" }) => {
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{loading ? (
|
||||
<>
|
||||
<SkeletonCard colorScheme="emerald" />
|
||||
<SkeletonCard colorScheme="blue" />
|
||||
<DashboardStatCardMiniSkeleton gradient="slate" />
|
||||
<DashboardStatCardMiniSkeleton gradient="slate" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MiniStatCard
|
||||
<DashboardStatCardMini
|
||||
title="30 Days Revenue"
|
||||
value={formatCurrency(summaryStats.totalRevenue, false)}
|
||||
previousValue={formatCurrency(summaryStats.prevRevenue, false)}
|
||||
trend={
|
||||
summaryStats.periodProgress < 100
|
||||
? ((projection?.projectedRevenue || summaryStats.totalRevenue) >= summaryStats.prevRevenue ? "up" : "down")
|
||||
: (summaryStats.totalRevenue >= summaryStats.prevRevenue ? "up" : "down")
|
||||
}
|
||||
trendValue={
|
||||
summaryStats.periodProgress < 100
|
||||
? `${Math.abs(Math.round(((projection?.projectedRevenue || summaryStats.totalRevenue) - summaryStats.prevRevenue) / summaryStats.prevRevenue * 100))}%`
|
||||
: `${Math.abs(Math.round(((summaryStats.totalRevenue - summaryStats.prevRevenue) / summaryStats.prevRevenue) * 100))}%`
|
||||
}
|
||||
colorClass="text-emerald-300"
|
||||
titleClass="text-emerald-300 font-bold text-md"
|
||||
descriptionClass="text-emerald-300 text-md font-semibold pb-1"
|
||||
description={`Prev: ${formatCurrency(summaryStats.prevRevenue, false)}`}
|
||||
trend={{
|
||||
direction: getRevenueTrend(),
|
||||
value: getRevenueTrendValue(),
|
||||
}}
|
||||
icon={PiggyBank}
|
||||
iconColor="text-emerald-900"
|
||||
iconBackground="bg-emerald-300"
|
||||
gradient="slate"
|
||||
className={!visibleMetrics.revenue ? 'opacity-50' : ''}
|
||||
onClick={() => toggleMetric('revenue')}
|
||||
active={visibleMetrics.revenue}
|
||||
/>
|
||||
<MiniStatCard
|
||||
<DashboardStatCardMini
|
||||
title="30 Days Orders"
|
||||
value={summaryStats.totalOrders.toLocaleString()}
|
||||
previousValue={summaryStats.prevOrders.toLocaleString()}
|
||||
trend={
|
||||
summaryStats.periodProgress < 100
|
||||
? ((Math.round(summaryStats.totalOrders * (100 / summaryStats.periodProgress))) >= summaryStats.prevOrders ? "up" : "down")
|
||||
: (summaryStats.totalOrders >= summaryStats.prevOrders ? "up" : "down")
|
||||
}
|
||||
trendValue={
|
||||
summaryStats.periodProgress < 100
|
||||
? `${Math.abs(Math.round(((Math.round(summaryStats.totalOrders * (100 / summaryStats.periodProgress))) - summaryStats.prevOrders) / summaryStats.prevOrders * 100))}%`
|
||||
: `${Math.abs(Math.round(((summaryStats.totalOrders - summaryStats.prevOrders) / summaryStats.prevOrders) * 100))}%`
|
||||
}
|
||||
colorClass="text-blue-300"
|
||||
titleClass="text-blue-300 font-bold text-md"
|
||||
descriptionClass="text-blue-300 text-md font-semibold pb-1"
|
||||
description={`Prev: ${summaryStats.prevOrders.toLocaleString()}`}
|
||||
trend={{
|
||||
direction: getOrdersTrend(),
|
||||
value: getOrdersTrendValue(),
|
||||
}}
|
||||
icon={Truck}
|
||||
iconColor="text-blue-900"
|
||||
iconBackground="bg-blue-300"
|
||||
gradient="slate"
|
||||
className={!visibleMetrics.orders ? 'opacity-50' : ''}
|
||||
onClick={() => toggleMetric('orders')}
|
||||
active={visibleMetrics.orders}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -354,40 +234,7 @@ const MiniSalesChart = ({ className = "" }) => {
|
||||
<CardContent className="p-4">
|
||||
<div className="h-[216px]">
|
||||
{loading ? (
|
||||
<div className="h-full w-full relative">
|
||||
{/* Grid lines */}
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute w-full h-px bg-slate-600"
|
||||
style={{ top: `${(i + 1) * 20}%` }}
|
||||
/>
|
||||
))}
|
||||
{/* Y-axis labels */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-3 w-6 bg-slate-600 rounded-sm" />
|
||||
))}
|
||||
</div>
|
||||
{/* X-axis labels */}
|
||||
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-3 w-8 bg-slate-600 rounded-sm" />
|
||||
))}
|
||||
</div>
|
||||
{/* Chart lines */}
|
||||
<div className="absolute inset-x-8 bottom-6 top-4">
|
||||
<div className="h-full w-full relative">
|
||||
<div
|
||||
className="absolute inset-0 bg-slate-600 rounded-sm"
|
||||
style={{
|
||||
opacity: 0.5,
|
||||
clipPath: "polygon(0 50%, 100% 20%, 100% 100%, 0 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChartSkeleton height="sm" withCard={false} />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
@@ -421,32 +268,33 @@ const MiniSalesChart = ({ className = "" }) => {
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const timestamp = new Date(payload[0].payload.timestamp);
|
||||
const styles = TOOLTIP_THEMES.stone;
|
||||
return (
|
||||
<Card className="p-2 shadow-lg bg-stone-800 border-none">
|
||||
<CardContent className="p-0 space-y-1">
|
||||
<p className="font-medium text-sm text-stone-100 border-b border-stone-700 pb-1 mb-1">
|
||||
<div className={styles.container}>
|
||||
<p className={styles.header}>
|
||||
{timestamp.toLocaleDateString([], {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric"
|
||||
})}
|
||||
</p>
|
||||
<div className={styles.content}>
|
||||
{payload
|
||||
.filter(entry => visibleMetrics[entry.dataKey])
|
||||
.map((entry, index) => (
|
||||
<div key={index} className="flex justify-between items-center text-sm">
|
||||
<span className="text-stone-200">
|
||||
{entry.name}:
|
||||
<div key={index} className={styles.row}>
|
||||
<span className={styles.name}>
|
||||
{entry.name}
|
||||
</span>
|
||||
<span className="font-medium ml-4 text-stone-100">
|
||||
<span className={styles.value}>
|
||||
{entry.dataKey === 'revenue'
|
||||
? formatCurrency(entry.value)
|
||||
: entry.value.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -458,7 +306,7 @@ const MiniSalesChart = ({ className = "" }) => {
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
name="Revenue"
|
||||
stroke="#10b981"
|
||||
stroke={METRIC_COLORS.revenue}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
@@ -469,7 +317,7 @@ const MiniSalesChart = ({ className = "" }) => {
|
||||
type="monotone"
|
||||
dataKey="orders"
|
||||
name="Orders"
|
||||
stroke="#3b82f6"
|
||||
stroke={METRIC_COLORS.orders}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
import React, { useState, useEffect, useCallback, memo } from "react";
|
||||
import axios from "axios";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { acotService } from "@/services/dashboard/acotService";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -22,7 +13,6 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DateTime } from "luxon";
|
||||
import { TIME_RANGES } from "@/lib/dashboard/constants";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
DollarSign,
|
||||
@@ -30,23 +20,7 @@ import {
|
||||
Package,
|
||||
AlertCircle,
|
||||
CircleDollarSign,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
TooltipProvider,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
// Import the detail view components and utilities from StatCards
|
||||
import {
|
||||
@@ -54,163 +28,28 @@ import {
|
||||
OrdersDetails,
|
||||
AverageOrderDetails,
|
||||
ShippingDetails,
|
||||
StatCard,
|
||||
DetailDialog,
|
||||
formatCurrency,
|
||||
formatPercentage,
|
||||
SkeletonCard,
|
||||
} from "./StatCards";
|
||||
import {
|
||||
DashboardStatCardMini,
|
||||
DashboardStatCardMiniSkeleton,
|
||||
ChartSkeleton,
|
||||
TableSkeleton,
|
||||
DashboardErrorState,
|
||||
} from "@/components/dashboard/shared";
|
||||
|
||||
// Mini skeleton components
|
||||
const MiniSkeletonChart = ({ type = "line" }) => (
|
||||
<div className={`h-[230px] w-full ${
|
||||
type === 'revenue' ? 'bg-emerald-50/10' :
|
||||
type === 'orders' ? 'bg-blue-50/10' :
|
||||
type === 'average_order' ? 'bg-violet-50/10' :
|
||||
'bg-orange-50/10'
|
||||
} rounded-lg p-4`}>
|
||||
<div className="h-full relative">
|
||||
{/* Grid lines */}
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`absolute w-full h-px ${
|
||||
type === 'revenue' ? 'bg-emerald-200/20' :
|
||||
type === 'orders' ? 'bg-blue-200/20' :
|
||||
type === 'average_order' ? 'bg-violet-200/20' :
|
||||
'bg-orange-200/20'
|
||||
}`}
|
||||
style={{ top: `${(i + 1) * 20}%` }}
|
||||
/>
|
||||
))}
|
||||
{/* Y-axis labels */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-8 flex flex-col justify-between py-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className={`h-3 w-6 ${
|
||||
type === 'revenue' ? 'bg-emerald-200/20' :
|
||||
type === 'orders' ? 'bg-blue-200/20' :
|
||||
type === 'average_order' ? 'bg-violet-200/20' :
|
||||
'bg-orange-200/20'
|
||||
} rounded-sm`} />
|
||||
))}
|
||||
</div>
|
||||
{/* X-axis labels */}
|
||||
<div className="absolute left-8 right-4 bottom-0 flex justify-between">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Skeleton key={i} className={`h-3 w-8 ${
|
||||
type === 'revenue' ? 'bg-emerald-200/20' :
|
||||
type === 'orders' ? 'bg-blue-200/20' :
|
||||
type === 'average_order' ? 'bg-violet-200/20' :
|
||||
'bg-orange-200/20'
|
||||
} rounded-sm`} />
|
||||
))}
|
||||
</div>
|
||||
{type === "bar" ? (
|
||||
<div className="absolute inset-x-8 bottom-6 top-4 flex items-end justify-between gap-1">
|
||||
{[...Array(24)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-2 ${
|
||||
type === 'revenue' ? 'bg-emerald-200/20' :
|
||||
type === 'orders' ? 'bg-blue-200/20' :
|
||||
type === 'average_order' ? 'bg-violet-200/20' :
|
||||
'bg-orange-200/20'
|
||||
} rounded-sm`}
|
||||
style={{ height: `${Math.random() * 80 + 10}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute inset-x-8 bottom-6 top-4">
|
||||
<div className="h-full w-full relative">
|
||||
<div
|
||||
className={`absolute inset-0 ${
|
||||
type === 'revenue' ? 'bg-emerald-200/20' :
|
||||
type === 'orders' ? 'bg-blue-200/20' :
|
||||
type === 'average_order' ? 'bg-violet-200/20' :
|
||||
'bg-orange-200/20'
|
||||
} rounded-sm`}
|
||||
style={{
|
||||
opacity: 0.5,
|
||||
clipPath: "polygon(0 50%, 100% 20%, 100% 100%, 0 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const MiniSkeletonTable = ({ rows = 8, colorScheme = "orange" }) => (
|
||||
<div className={`rounded-lg border ${
|
||||
colorScheme === 'orange' ? 'bg-orange-50/10 border-orange-200/20' :
|
||||
colorScheme === 'emerald' ? 'bg-emerald-50/10 border-emerald-200/20' :
|
||||
colorScheme === 'blue' ? 'bg-blue-50/10 border-blue-200/20' :
|
||||
'bg-violet-50/10 border-violet-200/20'
|
||||
}`}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<Skeleton className={`h-4 w-32 ${
|
||||
colorScheme === 'orange' ? 'bg-orange-200/20' :
|
||||
colorScheme === 'emerald' ? 'bg-emerald-200/20' :
|
||||
colorScheme === 'blue' ? 'bg-blue-200/20' :
|
||||
'bg-violet-200/20'
|
||||
} rounded-sm`} />
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<Skeleton className={`h-4 w-24 ml-auto ${
|
||||
colorScheme === 'orange' ? 'bg-orange-200/20' :
|
||||
colorScheme === 'emerald' ? 'bg-emerald-200/20' :
|
||||
colorScheme === 'blue' ? 'bg-blue-200/20' :
|
||||
'bg-violet-200/20'
|
||||
} rounded-sm`} />
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<Skeleton className={`h-4 w-24 ml-auto ${
|
||||
colorScheme === 'orange' ? 'bg-orange-200/20' :
|
||||
colorScheme === 'emerald' ? 'bg-emerald-200/20' :
|
||||
colorScheme === 'blue' ? 'bg-blue-200/20' :
|
||||
'bg-violet-200/20'
|
||||
} rounded-sm`} />
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[...Array(rows)].map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<Skeleton className={`h-4 w-48 ${
|
||||
colorScheme === 'orange' ? 'bg-orange-200/20' :
|
||||
colorScheme === 'emerald' ? 'bg-emerald-200/20' :
|
||||
colorScheme === 'blue' ? 'bg-blue-200/20' :
|
||||
'bg-violet-200/20'
|
||||
} rounded-sm`} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Skeleton className={`h-4 w-16 ml-auto ${
|
||||
colorScheme === 'orange' ? 'bg-orange-200/20' :
|
||||
colorScheme === 'emerald' ? 'bg-emerald-200/20' :
|
||||
colorScheme === 'blue' ? 'bg-blue-200/20' :
|
||||
'bg-violet-200/20'
|
||||
} rounded-sm`} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Skeleton className={`h-4 w-16 ml-auto ${
|
||||
colorScheme === 'orange' ? 'bg-orange-200/20' :
|
||||
colorScheme === 'emerald' ? 'bg-emerald-200/20' :
|
||||
colorScheme === 'blue' ? 'bg-blue-200/20' :
|
||||
'bg-violet-200/20'
|
||||
} rounded-sm`} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
// Helper to map metric to colorVariant
|
||||
const getColorVariant = (metric) => {
|
||||
switch (metric) {
|
||||
case 'revenue': return 'emerald';
|
||||
case 'orders': return 'blue';
|
||||
case 'average_order': return 'violet';
|
||||
case 'shipping': return 'orange';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const MiniStatCards = ({
|
||||
timeRange: initialTimeRange = "today",
|
||||
@@ -421,101 +260,16 @@ const MiniStatCards = ({
|
||||
if (loading && !stats) {
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<Card className="h-[150px] bg-gradient-to-br from-emerald-900 to-emerald-800 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
||||
<CardTitle className="text-emerald-100 font-bold text-md">
|
||||
<Skeleton className="h-4 w-24 bg-emerald-700" />
|
||||
</CardTitle>
|
||||
<div className="relative p-2">
|
||||
<div className="absolute inset-0 rounded-full bg-emerald-300" />
|
||||
<Skeleton className="h-5 w-5 bg-emerald-700 relative rounded-full" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-20 bg-emerald-700" />
|
||||
<div className="flex justify-between items-center">
|
||||
<Skeleton className="h-4 w-24 bg-emerald-700" />
|
||||
<Skeleton className="h-4 w-12 bg-emerald-700 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="h-[150px] bg-gradient-to-br from-blue-900 to-blue-800 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
||||
<CardTitle className="text-blue-100 font-bold text-md">
|
||||
<Skeleton className="h-4 w-24 bg-blue-700" />
|
||||
</CardTitle>
|
||||
<div className="relative p-2">
|
||||
<div className="absolute inset-0 rounded-full bg-blue-300" />
|
||||
<Skeleton className="h-5 w-5 bg-blue-700 relative rounded-full" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-20 bg-blue-700" />
|
||||
<div className="flex justify-between items-center">
|
||||
<Skeleton className="h-4 w-24 bg-blue-700" />
|
||||
<Skeleton className="h-4 w-12 bg-blue-700 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="h-[150px] bg-gradient-to-br from-violet-900 to-violet-800 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
||||
<CardTitle className="text-violet-100 font-bold text-md">
|
||||
<Skeleton className="h-4 w-24 bg-violet-700" />
|
||||
</CardTitle>
|
||||
<div className="relative p-2">
|
||||
<div className="absolute inset-0 rounded-full bg-violet-300" />
|
||||
<Skeleton className="h-5 w-5 bg-violet-700 relative rounded-full" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-20 bg-violet-700" />
|
||||
<div className="flex justify-between items-center">
|
||||
<Skeleton className="h-4 w-24 bg-violet-700" />
|
||||
<Skeleton className="h-4 w-12 bg-violet-700 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="h-[150px] bg-gradient-to-br from-orange-900 to-orange-800 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
||||
<CardTitle className="text-orange-100 font-bold text-md">
|
||||
<Skeleton className="h-4 w-24 bg-orange-700" />
|
||||
</CardTitle>
|
||||
<div className="relative p-2">
|
||||
<div className="absolute inset-0 rounded-full bg-orange-300" />
|
||||
<Skeleton className="h-5 w-5 bg-orange-700 relative rounded-full" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-20 bg-orange-700" />
|
||||
<div className="flex justify-between items-center">
|
||||
<Skeleton className="h-4 w-24 bg-orange-700" />
|
||||
<Skeleton className="h-4 w-12 bg-orange-700 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DashboardStatCardMiniSkeleton gradient="emerald" className="h-[150px]" />
|
||||
<DashboardStatCardMiniSkeleton gradient="blue" className="h-[150px]" />
|
||||
<DashboardStatCardMiniSkeleton gradient="violet" className="h-[150px]" />
|
||||
<DashboardStatCardMiniSkeleton gradient="orange" className="h-[150px]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>Failed to load stats: {error}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
return <DashboardErrorState error={`Failed to load stats: ${error}`} />;
|
||||
}
|
||||
|
||||
if (!stats) return null;
|
||||
@@ -527,100 +281,68 @@ const MiniStatCards = ({
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<StatCard
|
||||
<DashboardStatCardMini
|
||||
title="Today's Revenue"
|
||||
value={formatCurrency(stats?.revenue || 0)}
|
||||
description={
|
||||
stats?.periodProgress < 100 ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Proj: </span>
|
||||
{projectionLoading ? (
|
||||
<div className="w-20">
|
||||
<Skeleton className="h-4 w-15 bg-emerald-700" />
|
||||
</div>
|
||||
) : (
|
||||
formatCurrency(
|
||||
projection?.projectedRevenue || stats.projectedRevenue
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
stats?.periodProgress < 100
|
||||
? `Proj: ${formatCurrency(projection?.projectedRevenue || stats.projectedRevenue)}`
|
||||
: undefined
|
||||
}
|
||||
progress={stats?.periodProgress < 100 ? stats.periodProgress : undefined}
|
||||
trend={projectionLoading && stats?.periodProgress < 100 ? undefined : revenueTrend?.trend}
|
||||
trendValue={
|
||||
projectionLoading && stats?.periodProgress < 100 ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Skeleton className="h-4 w-4 bg-emerald-700 rounded-full" />
|
||||
<Skeleton className="h-4 w-8 bg-emerald-700" />
|
||||
</div>
|
||||
) : revenueTrend?.value ? (
|
||||
formatPercentage(revenueTrend.value)
|
||||
) : null
|
||||
trend={
|
||||
revenueTrend?.trend && !projectionLoading
|
||||
? { direction: revenueTrend.trend, value: formatPercentage(revenueTrend.value) }
|
||||
: undefined
|
||||
}
|
||||
colorClass="text-emerald-200"
|
||||
titleClass="text-emerald-100 font-bold text-md"
|
||||
descriptionClass="text-emerald-200 text-md font-semibold"
|
||||
icon={DollarSign}
|
||||
iconColor="text-emerald-900"
|
||||
iconBackground="bg-emerald-300"
|
||||
onDetailsClick={() => setSelectedMetric("revenue")}
|
||||
isLoading={loading || !stats}
|
||||
variant="mini"
|
||||
background="h-[150px] bg-gradient-to-br from-emerald-900 to-emerald-800"
|
||||
gradient="emerald"
|
||||
className="h-[150px]"
|
||||
onClick={() => setSelectedMetric("revenue")}
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
<DashboardStatCardMini
|
||||
title="Today's Orders"
|
||||
value={stats?.orderCount}
|
||||
description={`${stats?.itemCount} total items`}
|
||||
trend={orderTrend?.trend}
|
||||
trendValue={orderTrend?.value ? formatPercentage(orderTrend.value) : null}
|
||||
colorClass="text-blue-200"
|
||||
titleClass="text-blue-100 font-bold text-md"
|
||||
descriptionClass="text-blue-200 text-md font-semibold"
|
||||
trend={
|
||||
orderTrend?.trend
|
||||
? { direction: orderTrend.trend, value: formatPercentage(orderTrend.value) }
|
||||
: undefined
|
||||
}
|
||||
icon={ShoppingCart}
|
||||
iconColor="text-blue-900"
|
||||
iconBackground="bg-blue-300"
|
||||
onDetailsClick={() => setSelectedMetric("orders")}
|
||||
isLoading={loading || !stats}
|
||||
variant="mini"
|
||||
background="h-[150px] bg-gradient-to-br from-blue-900 to-blue-800"
|
||||
gradient="blue"
|
||||
className="h-[150px]"
|
||||
onClick={() => setSelectedMetric("orders")}
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
<DashboardStatCardMini
|
||||
title="Today's AOV"
|
||||
value={stats?.averageOrderValue?.toFixed(2)}
|
||||
valuePrefix="$"
|
||||
description={`${stats?.averageItemsPerOrder?.toFixed(1)} items per order`}
|
||||
trend={aovTrend?.trend}
|
||||
trendValue={aovTrend?.value ? formatPercentage(aovTrend.value) : null}
|
||||
colorClass="text-violet-200"
|
||||
titleClass="text-violet-100 font-bold text-md"
|
||||
descriptionClass="text-violet-200 text-md font-semibold"
|
||||
trend={
|
||||
aovTrend?.trend
|
||||
? { direction: aovTrend.trend, value: formatPercentage(aovTrend.value) }
|
||||
: undefined
|
||||
}
|
||||
icon={CircleDollarSign}
|
||||
iconColor="text-violet-900"
|
||||
iconBackground="bg-violet-300"
|
||||
onDetailsClick={() => setSelectedMetric("average_order")}
|
||||
isLoading={loading || !stats}
|
||||
variant="mini"
|
||||
background="h-[150px] bg-gradient-to-br from-violet-900 to-violet-800"
|
||||
gradient="violet"
|
||||
className="h-[150px]"
|
||||
onClick={() => setSelectedMetric("average_order")}
|
||||
/>
|
||||
|
||||
<StatCard
|
||||
<DashboardStatCardMini
|
||||
title="Shipped Today"
|
||||
value={stats?.shipping?.shippedCount || 0}
|
||||
description={`${stats?.shipping?.locations?.total || 0} locations`}
|
||||
colorClass="text-orange-200"
|
||||
titleClass="text-orange-100 font-bold text-md"
|
||||
descriptionClass="text-orange-200 text-md font-semibold"
|
||||
icon={Package}
|
||||
iconColor="text-orange-900"
|
||||
iconBackground="bg-orange-300"
|
||||
onDetailsClick={() => setSelectedMetric("shipping")}
|
||||
isLoading={loading || !stats}
|
||||
variant="mini"
|
||||
background="h-[150px] bg-gradient-to-br from-orange-900 to-orange-800"
|
||||
gradient="orange"
|
||||
className="h-[150px]"
|
||||
onClick={() => setSelectedMetric("shipping")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -633,7 +355,7 @@ const MiniStatCards = ({
|
||||
selectedMetric === 'orders' ? 'bg-blue-50 dark:bg-blue-950/30' :
|
||||
selectedMetric === 'average_order' ? 'bg-violet-50 dark:bg-violet-950/30' :
|
||||
selectedMetric === 'shipping' ? 'bg-orange-50 dark:bg-orange-950/30' :
|
||||
'bg-white dark:bg-gray-950'
|
||||
'bg-card'
|
||||
} backdrop-blur-md border-none`}>
|
||||
<div className="transform scale-[2] origin-top-left h-[40vh] w-[40vw]">
|
||||
<div className="h-full w-full p-6">
|
||||
@@ -657,20 +379,18 @@ const MiniStatCards = ({
|
||||
{detailDataLoading[selectedMetric] ? (
|
||||
<div className="space-y-4 h-full">
|
||||
{selectedMetric === "shipping" ? (
|
||||
<MiniSkeletonTable
|
||||
<TableSkeleton
|
||||
rows={8}
|
||||
colorScheme={
|
||||
selectedMetric === 'revenue' ? 'emerald' :
|
||||
selectedMetric === 'orders' ? 'blue' :
|
||||
selectedMetric === 'average_order' ? 'violet' :
|
||||
'orange'
|
||||
}
|
||||
columns={3}
|
||||
colorVariant={getColorVariant(selectedMetric)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<MiniSkeletonChart
|
||||
<ChartSkeleton
|
||||
type={selectedMetric === "orders" ? "bar" : "line"}
|
||||
metric={selectedMetric}
|
||||
height="sm"
|
||||
withCard={false}
|
||||
colorVariant={getColorVariant(selectedMetric)}
|
||||
/>
|
||||
{selectedMetric === "orders" && (
|
||||
<div className="mt-8">
|
||||
@@ -683,7 +403,12 @@ const MiniStatCards = ({
|
||||
}`}>
|
||||
Hourly Distribution
|
||||
</h3>
|
||||
<MiniSkeletonChart type="bar" metric={selectedMetric} />
|
||||
<ChartSkeleton
|
||||
type="bar"
|
||||
height="sm"
|
||||
withCard={false}
|
||||
colorVariant={getColorVariant(selectedMetric)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -226,7 +226,7 @@ const Navigation = () => {
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
"w-full bg-white dark:bg-gray-900 transition-all duration-200",
|
||||
"w-full bg-background transition-all duration-200",
|
||||
isStuck
|
||||
? "rounded-lg mt-2 shadow-md"
|
||||
: "shadow-sm rounded-t-none border-t-0 -mt-6 pb-2"
|
||||
@@ -261,7 +261,7 @@ const Navigation = () => {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -right-2.5 top-0 bottom-0 flex items-center bg-white dark:bg-gray-900 pl-1 pr-0">
|
||||
<div className="absolute -right-2.5 top-0 bottom-0 flex items-center bg-background pl-1 pr-0">
|
||||
<Button
|
||||
variant="icon"
|
||||
size="sm"
|
||||
|
||||
@@ -30,7 +30,8 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { CARD_STYLES, TYPOGRAPHY } from "@/lib/dashboard/designTokens";
|
||||
import { DashboardEmptyState, DashboardErrorState } from "@/components/dashboard/shared";
|
||||
|
||||
const ProductGrid = ({
|
||||
timeRange = "today",
|
||||
@@ -127,8 +128,8 @@ const ProductGrid = ({
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="hover:bg-transparent">
|
||||
<th className="p-1.5 text-left font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 w-[50px] min-w-[50px] border-b dark:border-gray-800" />
|
||||
<th className="p-1.5 text-left font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 min-w-[200px] border-b dark:border-gray-800">
|
||||
<th className="p-1.5 text-left font-medium sticky top-0 bg-card z-10 w-[50px] min-w-[50px] border-b border-border/50" />
|
||||
<th className="p-1.5 text-left font-medium sticky top-0 bg-card z-10 min-w-[200px] border-b border-border/50">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full p-2 justify-start h-8 pointer-events-none"
|
||||
@@ -137,7 +138,7 @@ const ProductGrid = ({
|
||||
<Skeleton className="h-4 w-16 bg-muted rounded-sm" />
|
||||
</Button>
|
||||
</th>
|
||||
<th className="p-1.5 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 border-b dark:border-gray-800">
|
||||
<th className="p-1.5 font-medium text-center sticky top-0 bg-card z-10 border-b border-border/50">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full p-2 justify-center h-8 pointer-events-none"
|
||||
@@ -146,7 +147,7 @@ const ProductGrid = ({
|
||||
<Skeleton className="h-4 w-12 bg-muted rounded-sm" />
|
||||
</Button>
|
||||
</th>
|
||||
<th className="p-1.5 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 border-b dark:border-gray-800">
|
||||
<th className="p-1.5 font-medium text-center sticky top-0 bg-card z-10 border-b border-border/50">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full p-2 justify-center h-8 pointer-events-none"
|
||||
@@ -155,7 +156,7 @@ const ProductGrid = ({
|
||||
<Skeleton className="h-4 w-12 bg-muted rounded-sm" />
|
||||
</Button>
|
||||
</th>
|
||||
<th className="p-1.5 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 border-b dark:border-gray-800">
|
||||
<th className="p-1.5 font-medium text-center sticky top-0 bg-card z-10 border-b border-border/50">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full p-2 justify-center h-8 pointer-events-none"
|
||||
@@ -166,7 +167,7 @@ const ProductGrid = ({
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{[...Array(20)].map((_, i) => (
|
||||
<SkeletonProduct key={i} />
|
||||
))}
|
||||
@@ -178,12 +179,12 @@ const ProductGrid = ({
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="flex flex-col h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<Card className={`flex flex-col h-full ${CARD_STYLES.base}`}>
|
||||
<CardHeader className="p-6 pb-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
<CardTitle className={TYPOGRAPHY.sectionTitle}>
|
||||
<Skeleton className="h-6 w-32 bg-muted rounded-sm" />
|
||||
</CardTitle>
|
||||
{description && (
|
||||
@@ -210,14 +211,14 @@ const ProductGrid = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col h-full bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<Card className={`flex flex-col h-full ${CARD_STYLES.base}`}>
|
||||
<CardHeader className="p-6 pb-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100">{title}</CardTitle>
|
||||
<CardTitle className={TYPOGRAPHY.sectionTitle}>{title}</CardTitle>
|
||||
{description && (
|
||||
<CardDescription className="mt-1 text-muted-foreground">{description}</CardDescription>
|
||||
<CardDescription className={`mt-1 ${TYPOGRAPHY.cardDescription}`}>{description}</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -279,27 +280,22 @@ const ProductGrid = ({
|
||||
<CardContent className="p-6 pt-0 flex-1 overflow-hidden -mt-1">
|
||||
<div className="h-full">
|
||||
{error ? (
|
||||
<Alert variant="destructive" className="bg-white dark:bg-gray-900/60 backdrop-blur-sm">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>
|
||||
Failed to load products: {error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<DashboardErrorState error={`Failed to load products: ${error}`} className="mx-0 my-0" />
|
||||
) : !products?.length ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Package className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="font-medium mb-2 text-gray-900 dark:text-gray-100">No product data available</p>
|
||||
<p className="text-sm text-muted-foreground">Try selecting a different time range</p>
|
||||
</div>
|
||||
<DashboardEmptyState
|
||||
icon={Package}
|
||||
title="No product data available"
|
||||
description="Try selecting a different time range"
|
||||
height="sm"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full">
|
||||
<div className="overflow-y-auto h-full">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="hover:bg-transparent">
|
||||
<th className="p-1 text-left font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 h-[50px] min-h-[50px] w-[50px] min-w-[35px] border-b dark:border-gray-800" />
|
||||
<th className="p-1 text-left font-medium sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 border-b dark:border-gray-800">
|
||||
<th className="p-1 text-left font-medium sticky top-0 bg-card z-10 h-[50px] min-h-[50px] w-[50px] min-w-[35px] border-b border-border/50" />
|
||||
<th className="p-1 text-left font-medium sticky top-0 bg-card z-10 border-b border-border/50">
|
||||
<Button
|
||||
variant={sorting.column === "name" ? "default" : "ghost"}
|
||||
onClick={() => handleSort("name")}
|
||||
@@ -308,7 +304,7 @@ const ProductGrid = ({
|
||||
Product
|
||||
</Button>
|
||||
</th>
|
||||
<th className="p-1 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 border-b dark:border-gray-800">
|
||||
<th className="p-1 font-medium text-center sticky top-0 bg-card z-10 border-b border-border/50">
|
||||
<Button
|
||||
variant={sorting.column === "totalQuantity" ? "default" : "ghost"}
|
||||
onClick={() => handleSort("totalQuantity")}
|
||||
@@ -317,7 +313,7 @@ const ProductGrid = ({
|
||||
Sold
|
||||
</Button>
|
||||
</th>
|
||||
<th className="p-1 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 border-b dark:border-gray-800">
|
||||
<th className="p-1 font-medium text-center sticky top-0 bg-card z-10 border-b border-border/50">
|
||||
<Button
|
||||
variant={sorting.column === "totalRevenue" ? "default" : "ghost"}
|
||||
onClick={() => handleSort("totalRevenue")}
|
||||
@@ -326,7 +322,7 @@ const ProductGrid = ({
|
||||
Rev
|
||||
</Button>
|
||||
</th>
|
||||
<th className="p-1 font-medium text-center sticky top-0 bg-white dark:bg-gray-900/60 backdrop-blur-sm z-10 border-b dark:border-gray-800">
|
||||
<th className="p-1 font-medium text-center sticky top-0 bg-card z-10 border-b border-border/50">
|
||||
<Button
|
||||
variant={sorting.column === "orderCount" ? "default" : "ghost"}
|
||||
onClick={() => handleSort("orderCount")}
|
||||
@@ -337,7 +333,7 @@ const ProductGrid = ({
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{filteredProducts.map((product) => (
|
||||
<tr
|
||||
key={product.id}
|
||||
@@ -364,7 +360,7 @@ const ProductGrid = ({
|
||||
href={`https://backend.acherryontop.com/product/${product.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm hover:underline line-clamp-2 text-gray-900 dark:text-gray-100"
|
||||
className="text-sm hover:underline line-clamp-2 text-foreground"
|
||||
>
|
||||
{product.name}
|
||||
</a>
|
||||
@@ -376,7 +372,7 @@ const ProductGrid = ({
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-1 align-middle text-center text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
<td className="p-1 align-middle text-center text-sm font-medium text-foreground">
|
||||
{product.totalQuantity}
|
||||
</td>
|
||||
<td className="p-1 align-middle text-center text-emerald-600 dark:text-emerald-400 text-sm font-medium">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
@@ -7,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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
246
inventory/src/components/dashboard/shared/DashboardBadge.tsx
Normal file
246
inventory/src/components/dashboard/shared/DashboardBadge.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* DashboardBadge
|
||||
*
|
||||
* Standardized badge component with consistent color variants for dashboard use.
|
||||
* Uses the BADGE_STYLES tokens from designTokens.
|
||||
*
|
||||
* @example
|
||||
* // Basic usage
|
||||
* <DashboardBadge variant="success">Active</DashboardBadge>
|
||||
*
|
||||
* @example
|
||||
* // With different colors
|
||||
* <DashboardBadge variant="blue">New</DashboardBadge>
|
||||
* <DashboardBadge variant="purple">Premium</DashboardBadge>
|
||||
* <DashboardBadge variant="yellow">Pending</DashboardBadge>
|
||||
*
|
||||
* @example
|
||||
* // Small size
|
||||
* <DashboardBadge variant="green" size="sm">✓</DashboardBadge>
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export type BadgeVariant =
|
||||
| "default"
|
||||
| "blue"
|
||||
| "green"
|
||||
| "emerald"
|
||||
| "red"
|
||||
| "yellow"
|
||||
| "amber"
|
||||
| "orange"
|
||||
| "purple"
|
||||
| "violet"
|
||||
| "pink"
|
||||
| "indigo"
|
||||
| "cyan"
|
||||
| "teal"
|
||||
// Semantic variants
|
||||
| "success"
|
||||
| "warning"
|
||||
| "error"
|
||||
| "info";
|
||||
|
||||
export type BadgeSize = "sm" | "default" | "lg";
|
||||
|
||||
export interface DashboardBadgeProps {
|
||||
/** Color variant */
|
||||
variant?: BadgeVariant;
|
||||
/** Size variant */
|
||||
size?: BadgeSize;
|
||||
/** Additional className */
|
||||
className?: string;
|
||||
/** Badge content */
|
||||
children: React.ReactNode;
|
||||
/** Make the badge pill-shaped (more rounded) */
|
||||
pill?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COLOR VARIANTS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Color classes for each variant
|
||||
* Format: background (light mode) / background (dark mode) / text (light) / text (dark)
|
||||
*/
|
||||
const VARIANT_CLASSES: Record<BadgeVariant, string> = {
|
||||
// Neutral
|
||||
default: "bg-muted text-muted-foreground",
|
||||
|
||||
// Colors
|
||||
blue: "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300",
|
||||
green: "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300",
|
||||
emerald: "bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300",
|
||||
red: "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300",
|
||||
yellow: "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300",
|
||||
amber: "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300",
|
||||
orange: "bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300",
|
||||
purple: "bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300",
|
||||
violet: "bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300",
|
||||
pink: "bg-pink-100 dark:bg-pink-900/30 text-pink-700 dark:text-pink-300",
|
||||
indigo: "bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300",
|
||||
cyan: "bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-300",
|
||||
teal: "bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300",
|
||||
|
||||
// Semantic (maps to colors)
|
||||
success: "bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300",
|
||||
warning: "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300",
|
||||
error: "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300",
|
||||
info: "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300",
|
||||
};
|
||||
|
||||
const SIZE_CLASSES: Record<BadgeSize, string> = {
|
||||
sm: "px-1.5 py-0.5 text-[10px]",
|
||||
default: "px-2.5 py-0.5 text-xs",
|
||||
lg: "px-3 py-1 text-sm",
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
export const DashboardBadge: React.FC<DashboardBadgeProps> = ({
|
||||
variant = "default",
|
||||
size = "default",
|
||||
className,
|
||||
children,
|
||||
pill = true,
|
||||
}) => {
|
||||
const roundedClass = pill ? "rounded-full" : "rounded-md";
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center font-medium",
|
||||
roundedClass,
|
||||
VARIANT_CLASSES[variant],
|
||||
SIZE_CLASSES[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// STATUS BADGE (for common status patterns)
|
||||
// =============================================================================
|
||||
|
||||
export type StatusType = "active" | "inactive" | "pending" | "completed" | "failed" | "cancelled";
|
||||
|
||||
const STATUS_CONFIG: Record<StatusType, { variant: BadgeVariant; label: string }> = {
|
||||
active: { variant: "success", label: "Active" },
|
||||
inactive: { variant: "default", label: "Inactive" },
|
||||
pending: { variant: "warning", label: "Pending" },
|
||||
completed: { variant: "success", label: "Completed" },
|
||||
failed: { variant: "error", label: "Failed" },
|
||||
cancelled: { variant: "default", label: "Cancelled" },
|
||||
};
|
||||
|
||||
export interface StatusBadgeProps {
|
||||
status: StatusType;
|
||||
/** Override the default label */
|
||||
label?: string;
|
||||
/** Size variant */
|
||||
size?: BadgeSize;
|
||||
/** Additional className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const StatusBadge: React.FC<StatusBadgeProps> = ({
|
||||
status,
|
||||
label,
|
||||
size = "default",
|
||||
className,
|
||||
}) => {
|
||||
const config = STATUS_CONFIG[status];
|
||||
|
||||
return (
|
||||
<DashboardBadge variant={config.variant} size={size} className={className}>
|
||||
{label || config.label}
|
||||
</DashboardBadge>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// METRIC BADGE (for showing numbers with context)
|
||||
// =============================================================================
|
||||
|
||||
export interface MetricBadgeProps {
|
||||
/** The metric value */
|
||||
value: number;
|
||||
/** Suffix to show after value (e.g., "%", "K") */
|
||||
suffix?: string;
|
||||
/** Prefix to show before value (e.g., "+", "$") */
|
||||
prefix?: string;
|
||||
/** Color variant */
|
||||
variant?: BadgeVariant;
|
||||
/** Size variant */
|
||||
size?: BadgeSize;
|
||||
/** Additional className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const MetricBadge: React.FC<MetricBadgeProps> = ({
|
||||
value,
|
||||
suffix = "",
|
||||
prefix = "",
|
||||
variant = "default",
|
||||
size = "default",
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<DashboardBadge variant={variant} size={size} className={cn("tabular-nums", className)}>
|
||||
{prefix}{value.toLocaleString()}{suffix}
|
||||
</DashboardBadge>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// TREND BADGE (for showing positive/negative changes)
|
||||
// =============================================================================
|
||||
|
||||
export interface TrendBadgeProps {
|
||||
/** The trend value (positive or negative) */
|
||||
value: number;
|
||||
/** Whether higher is better (affects color) */
|
||||
moreIsBetter?: boolean;
|
||||
/** Show as percentage */
|
||||
asPercentage?: boolean;
|
||||
/** Size variant */
|
||||
size?: BadgeSize;
|
||||
/** Additional className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const TrendBadge: React.FC<TrendBadgeProps> = ({
|
||||
value,
|
||||
moreIsBetter = true,
|
||||
asPercentage = true,
|
||||
size = "default",
|
||||
className,
|
||||
}) => {
|
||||
const isPositive = value > 0;
|
||||
const isGood = isPositive === moreIsBetter;
|
||||
const variant: BadgeVariant = value === 0 ? "default" : isGood ? "success" : "error";
|
||||
|
||||
const displayValue = asPercentage
|
||||
? `${value > 0 ? "+" : ""}${value.toFixed(1)}%`
|
||||
: `${value > 0 ? "+" : ""}${value.toLocaleString()}`;
|
||||
|
||||
return (
|
||||
<DashboardBadge variant={variant} size={size} className={cn("tabular-nums", className)}>
|
||||
{displayValue}
|
||||
</DashboardBadge>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardBadge;
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* DashboardChartTooltip
|
||||
*
|
||||
* Standardized tooltip components for Recharts across the dashboard.
|
||||
* Provides consistent styling and formatting for all chart tooltips.
|
||||
*
|
||||
* @example
|
||||
* // Basic usage with Recharts
|
||||
* <RechartsTooltip content={<DashboardChartTooltip />} />
|
||||
*
|
||||
* @example
|
||||
* // Currency tooltip
|
||||
* <RechartsTooltip content={<CurrencyTooltip />} />
|
||||
*
|
||||
* @example
|
||||
* // Custom formatter
|
||||
* <RechartsTooltip
|
||||
* content={
|
||||
* <DashboardChartTooltip
|
||||
* valueFormatter={(value) => `${value} units`}
|
||||
* labelFormatter={(label) => `Week of ${label}`}
|
||||
* />
|
||||
* }
|
||||
* />
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TOOLTIP_STYLES } from "@/lib/dashboard/chartConfig";
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface TooltipPayloadItem {
|
||||
name: string;
|
||||
value: number | string;
|
||||
color?: string;
|
||||
dataKey?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
fill?: string;
|
||||
stroke?: string;
|
||||
}
|
||||
|
||||
export interface DashboardChartTooltipProps {
|
||||
/** Whether the tooltip is currently active (provided by Recharts) */
|
||||
active?: boolean;
|
||||
/** The payload data (provided by Recharts) */
|
||||
payload?: TooltipPayloadItem[];
|
||||
/** The label for the tooltip header (provided by Recharts) */
|
||||
label?: string;
|
||||
/** Custom formatter for the value */
|
||||
valueFormatter?: (value: number | string, name: string) => string;
|
||||
/** Custom formatter for the label/header */
|
||||
labelFormatter?: (label: string) => string;
|
||||
/** Custom formatter for item names */
|
||||
nameFormatter?: (name: string) => string;
|
||||
/** Hide the header/label */
|
||||
hideLabel?: boolean;
|
||||
/** Additional className for the container */
|
||||
className?: string;
|
||||
/** Show a divider between items */
|
||||
showDivider?: boolean;
|
||||
/** Custom render function for each item */
|
||||
itemRenderer?: (item: TooltipPayloadItem, index: number) => React.ReactNode;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FORMATTERS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Format a number as currency
|
||||
*/
|
||||
export const formatCurrency = (value: number | string): string => {
|
||||
const num = typeof value === "string" ? parseFloat(value) : value;
|
||||
if (isNaN(num)) return "$0";
|
||||
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(num);
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a number as percentage
|
||||
*/
|
||||
export const formatPercentage = (value: number | string): string => {
|
||||
const num = typeof value === "string" ? parseFloat(value) : value;
|
||||
if (isNaN(num)) return "0%";
|
||||
return `${num.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a number with commas
|
||||
*/
|
||||
export const formatNumber = (value: number | string): string => {
|
||||
const num = typeof value === "string" ? parseFloat(value) : value;
|
||||
if (isNaN(num)) return "0";
|
||||
return num.toLocaleString();
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a duration in seconds to human-readable format
|
||||
*/
|
||||
export const formatDuration = (seconds: number | string): string => {
|
||||
const num = typeof seconds === "string" ? parseFloat(seconds) : seconds;
|
||||
if (isNaN(num)) return "0s";
|
||||
|
||||
const hours = Math.floor(num / 3600);
|
||||
const minutes = Math.floor((num % 3600) / 60);
|
||||
const secs = Math.floor(num % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${secs}s`;
|
||||
}
|
||||
return `${secs}s`;
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
export const DashboardChartTooltip: React.FC<DashboardChartTooltipProps> = ({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
valueFormatter,
|
||||
labelFormatter,
|
||||
nameFormatter,
|
||||
hideLabel = false,
|
||||
className,
|
||||
showDivider = false,
|
||||
itemRenderer,
|
||||
}) => {
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formattedLabel = labelFormatter ? labelFormatter(String(label)) : label;
|
||||
|
||||
return (
|
||||
<div className={cn(TOOLTIP_STYLES.container, className)}>
|
||||
{!hideLabel && formattedLabel && (
|
||||
<p className={TOOLTIP_STYLES.header}>{formattedLabel}</p>
|
||||
)}
|
||||
<div className={TOOLTIP_STYLES.content}>
|
||||
{payload.map((item, index) => {
|
||||
// Allow custom item rendering
|
||||
if (itemRenderer) {
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
{itemRenderer(item, index)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const itemColor = item.color || item.fill || item.stroke || "#888";
|
||||
const itemName = nameFormatter ? nameFormatter(item.name) : item.name;
|
||||
const itemValue = valueFormatter
|
||||
? valueFormatter(item.value, item.name)
|
||||
: String(item.value);
|
||||
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
{showDivider && index > 0 && (
|
||||
<div className={TOOLTIP_STYLES.divider} />
|
||||
)}
|
||||
<div className={TOOLTIP_STYLES.row}>
|
||||
<div className={TOOLTIP_STYLES.rowLabel}>
|
||||
<span
|
||||
className={TOOLTIP_STYLES.dot}
|
||||
style={{ backgroundColor: itemColor }}
|
||||
/>
|
||||
<span className={TOOLTIP_STYLES.name}>{itemName}</span>
|
||||
</div>
|
||||
<span className={TOOLTIP_STYLES.value}>{itemValue}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// SPECIALIZED TOOLTIPS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Currency-formatted tooltip
|
||||
*/
|
||||
export const CurrencyTooltip: React.FC<Omit<DashboardChartTooltipProps, "valueFormatter">> = (
|
||||
props
|
||||
) => {
|
||||
return <DashboardChartTooltip {...props} valueFormatter={formatCurrency} />;
|
||||
};
|
||||
|
||||
/**
|
||||
* Percentage-formatted tooltip
|
||||
*/
|
||||
export const PercentageTooltip: React.FC<Omit<DashboardChartTooltipProps, "valueFormatter">> = (
|
||||
props
|
||||
) => {
|
||||
return <DashboardChartTooltip {...props} valueFormatter={formatPercentage} />;
|
||||
};
|
||||
|
||||
/**
|
||||
* Number-formatted tooltip (with commas)
|
||||
*/
|
||||
export const NumberTooltip: React.FC<Omit<DashboardChartTooltipProps, "valueFormatter">> = (
|
||||
props
|
||||
) => {
|
||||
return <DashboardChartTooltip {...props} valueFormatter={formatNumber} />;
|
||||
};
|
||||
|
||||
/**
|
||||
* Duration-formatted tooltip (for call times, etc.)
|
||||
*/
|
||||
export const DurationTooltip: React.FC<Omit<DashboardChartTooltipProps, "valueFormatter">> = (
|
||||
props
|
||||
) => {
|
||||
return <DashboardChartTooltip {...props} valueFormatter={formatDuration} />;
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// SIMPLE TOOLTIP (for single-value charts)
|
||||
// =============================================================================
|
||||
|
||||
export interface SimpleTooltipProps {
|
||||
active?: boolean;
|
||||
payload?: TooltipPayloadItem[];
|
||||
label?: string;
|
||||
valueFormatter?: (value: number | string) => string;
|
||||
labelFormatter?: (label: string) => string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified tooltip for single-series charts
|
||||
*/
|
||||
export const SimpleTooltip: React.FC<SimpleTooltipProps> = ({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
valueFormatter = formatNumber,
|
||||
labelFormatter,
|
||||
className,
|
||||
}) => {
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = payload[0];
|
||||
const formattedLabel = labelFormatter ? labelFormatter(String(label)) : label;
|
||||
const formattedValue = valueFormatter(item.value);
|
||||
|
||||
return (
|
||||
<div className={cn(TOOLTIP_STYLES.container, className)}>
|
||||
{formattedLabel && (
|
||||
<p className={TOOLTIP_STYLES.header}>{formattedLabel}</p>
|
||||
)}
|
||||
<p className={TOOLTIP_STYLES.value}>{formattedValue}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardChartTooltip;
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* DashboardSectionHeader
|
||||
*
|
||||
* A reusable header component for dashboard sections/cards.
|
||||
* Provides consistent layout for title, description, time selectors, and action buttons.
|
||||
*
|
||||
* @example
|
||||
* // Basic usage
|
||||
* <DashboardSectionHeader title="Sales Overview" />
|
||||
*
|
||||
* @example
|
||||
* // With time selector and last updated
|
||||
* <DashboardSectionHeader
|
||||
* title="Revenue Analytics"
|
||||
* description="Track your daily revenue performance"
|
||||
* lastUpdated={new Date()}
|
||||
* timeSelector={
|
||||
* <Select value={timeRange} onValueChange={setTimeRange}>
|
||||
* <SelectTrigger className="w-[130px] h-9">
|
||||
* <SelectValue />
|
||||
* </SelectTrigger>
|
||||
* <SelectContent>
|
||||
* {TIME_RANGES.map(range => (
|
||||
* <SelectItem key={range.value} value={range.value}>
|
||||
* {range.label}
|
||||
* </SelectItem>
|
||||
* ))}
|
||||
* </SelectContent>
|
||||
* </Select>
|
||||
* }
|
||||
* />
|
||||
*
|
||||
* @example
|
||||
* // With actions
|
||||
* <DashboardSectionHeader
|
||||
* title="Orders"
|
||||
* actions={
|
||||
* <>
|
||||
* <Button variant="outline" size="sm">Export</Button>
|
||||
* <Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||
* <RefreshCcw className="h-4 w-4" />
|
||||
* </Button>
|
||||
* </>
|
||||
* }
|
||||
* />
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TYPOGRAPHY } from "@/lib/dashboard/designTokens";
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface DashboardSectionHeaderProps {
|
||||
/** Section title */
|
||||
title: string;
|
||||
/** Optional description shown below title */
|
||||
description?: string;
|
||||
/** Last updated timestamp - shown as "Last updated: HH:MM AM/PM" */
|
||||
lastUpdated?: Date | null;
|
||||
/** Custom format function for lastUpdated */
|
||||
lastUpdatedFormat?: (date: Date) => string;
|
||||
/** Loading state - shows skeletons */
|
||||
loading?: boolean;
|
||||
/** Time/period selector component (flexible - accepts any selector UI) */
|
||||
timeSelector?: React.ReactNode;
|
||||
/** Action buttons or other controls */
|
||||
actions?: React.ReactNode;
|
||||
/** Additional className for the header */
|
||||
className?: string;
|
||||
/** Use compact padding */
|
||||
compact?: boolean;
|
||||
/** Size variant for title */
|
||||
size?: "default" | "large";
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
const defaultLastUpdatedFormat = (date: Date): string => {
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
export const DashboardSectionHeader: React.FC<DashboardSectionHeaderProps> = ({
|
||||
title,
|
||||
description,
|
||||
lastUpdated,
|
||||
lastUpdatedFormat = defaultLastUpdatedFormat,
|
||||
loading = false,
|
||||
timeSelector,
|
||||
actions,
|
||||
className,
|
||||
compact = false,
|
||||
size = "default",
|
||||
}) => {
|
||||
const paddingClass = compact ? "p-4 pb-2" : "p-6 pb-4";
|
||||
const titleClass = size === "large"
|
||||
? "text-xl font-semibold text-foreground"
|
||||
: "text-lg font-semibold text-foreground";
|
||||
|
||||
// Loading skeleton
|
||||
if (loading) {
|
||||
return (
|
||||
<CardHeader className={cn(paddingClass, className)}>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-6 w-40 bg-muted" />
|
||||
{description && <Skeleton className="h-4 w-56 bg-muted" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{timeSelector && <Skeleton className="h-9 w-[130px] bg-muted rounded-md" />}
|
||||
{actions && <Skeleton className="h-9 w-20 bg-muted rounded-md" />}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
);
|
||||
}
|
||||
|
||||
const hasRightContent = timeSelector || actions || (lastUpdated && !loading);
|
||||
|
||||
return (
|
||||
<CardHeader className={cn(paddingClass, className)}>
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
{/* Left side: Title and description */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className={titleClass}>{title}</CardTitle>
|
||||
{description && (
|
||||
<CardDescription className={cn(TYPOGRAPHY.cardDescription, "mt-1")}>
|
||||
{description}
|
||||
</CardDescription>
|
||||
)}
|
||||
{lastUpdated && !loading && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Last updated: {lastUpdatedFormat(lastUpdated)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side: Time selector and actions */}
|
||||
{hasRightContent && (
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{timeSelector}
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// SKELETON VARIANT
|
||||
// =============================================================================
|
||||
|
||||
export interface DashboardSectionHeaderSkeletonProps {
|
||||
hasDescription?: boolean;
|
||||
hasTimeSelector?: boolean;
|
||||
hasActions?: boolean;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const DashboardSectionHeaderSkeleton: React.FC<DashboardSectionHeaderSkeletonProps> = ({
|
||||
hasDescription = false,
|
||||
hasTimeSelector = true,
|
||||
hasActions = false,
|
||||
compact = false,
|
||||
className,
|
||||
}) => {
|
||||
const paddingClass = compact ? "p-4 pb-2" : "p-6 pb-4";
|
||||
|
||||
return (
|
||||
<CardHeader className={cn(paddingClass, className)}>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-6 w-40 bg-muted" />
|
||||
{hasDescription && <Skeleton className="h-4 w-56 bg-muted" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasTimeSelector && <Skeleton className="h-9 w-[130px] bg-muted rounded-md" />}
|
||||
{hasActions && <Skeleton className="h-9 w-20 bg-muted rounded-md" />}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardSectionHeader;
|
||||
538
inventory/src/components/dashboard/shared/DashboardSkeleton.tsx
Normal file
538
inventory/src/components/dashboard/shared/DashboardSkeleton.tsx
Normal 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,
|
||||
};
|
||||
344
inventory/src/components/dashboard/shared/DashboardStatCard.tsx
Normal file
344
inventory/src/components/dashboard/shared/DashboardStatCard.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* DashboardStatCard
|
||||
*
|
||||
* A reusable stat/metric card component for the dashboard.
|
||||
* Supports icons, trend indicators, tooltips, and multiple size variants.
|
||||
*
|
||||
* @example
|
||||
* // Basic usage
|
||||
* <DashboardStatCard
|
||||
* title="Total Revenue"
|
||||
* value="$12,345"
|
||||
* subtitle="Last 30 days"
|
||||
* />
|
||||
*
|
||||
* @example
|
||||
* // With icon and trend
|
||||
* <DashboardStatCard
|
||||
* title="Orders"
|
||||
* value={1234}
|
||||
* trend={{ value: 12.5, label: "vs last month" }}
|
||||
* icon={ShoppingCart}
|
||||
* iconColor="blue"
|
||||
* />
|
||||
*
|
||||
* @example
|
||||
* // With prefix/suffix and tooltip
|
||||
* <DashboardStatCard
|
||||
* title="Average Order Value"
|
||||
* value={85.50}
|
||||
* valuePrefix="$"
|
||||
* valueSuffix="/order"
|
||||
* tooltip="Calculated as total revenue divided by number of orders"
|
||||
* />
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { TrendingUp, TrendingDown, Minus, Info, type LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CARD_STYLES,
|
||||
TYPOGRAPHY,
|
||||
STAT_ICON_STYLES,
|
||||
getTrendColor,
|
||||
} from "@/lib/dashboard/designTokens";
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export type TrendDirection = "up" | "down" | "neutral";
|
||||
export type CardSize = "default" | "compact" | "large";
|
||||
export type IconColor = keyof typeof STAT_ICON_STYLES.colors;
|
||||
|
||||
export interface TrendProps {
|
||||
/** The percentage or absolute change value */
|
||||
value: number;
|
||||
/** Optional label to show after the trend (e.g., "vs last month") */
|
||||
label?: string;
|
||||
/** Whether a higher value is better (affects color). Defaults to true. */
|
||||
moreIsBetter?: boolean;
|
||||
/** Suffix for the trend value (defaults to "%"). Use "" for no suffix. */
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
export interface DashboardStatCardProps {
|
||||
/** Card title/label */
|
||||
title: string;
|
||||
/** The main value to display (can be string for formatted values or number) */
|
||||
value: string | number;
|
||||
/** Optional prefix for the value (e.g., "$") */
|
||||
valuePrefix?: string;
|
||||
/** Optional suffix for the value (e.g., "/day", "%") */
|
||||
valueSuffix?: string;
|
||||
/** Optional subtitle or description (can be string or JSX) */
|
||||
subtitle?: React.ReactNode;
|
||||
/** Optional trend indicator */
|
||||
trend?: TrendProps;
|
||||
/** Optional icon component */
|
||||
icon?: LucideIcon;
|
||||
/** Icon color variant */
|
||||
iconColor?: IconColor;
|
||||
/** Card size variant */
|
||||
size?: CardSize;
|
||||
/** Additional className for the card */
|
||||
className?: string;
|
||||
/** Click handler for interactive cards */
|
||||
onClick?: () => void;
|
||||
/** Loading state */
|
||||
loading?: boolean;
|
||||
/** Tooltip text shown via info icon next to title */
|
||||
tooltip?: string;
|
||||
/** Additional content to render below the main value */
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPER COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
interface TrendIndicatorProps {
|
||||
value: number;
|
||||
label?: string;
|
||||
moreIsBetter?: boolean;
|
||||
suffix?: string;
|
||||
size?: CardSize;
|
||||
}
|
||||
|
||||
const TrendIndicator: React.FC<TrendIndicatorProps> = ({
|
||||
value,
|
||||
label,
|
||||
moreIsBetter = true,
|
||||
suffix = "%",
|
||||
size = "default",
|
||||
}) => {
|
||||
const colors = getTrendColor(value, moreIsBetter);
|
||||
const direction: TrendDirection =
|
||||
value > 0 ? "up" : value < 0 ? "down" : "neutral";
|
||||
|
||||
const IconComponent =
|
||||
direction === "up"
|
||||
? TrendingUp
|
||||
: direction === "down"
|
||||
? TrendingDown
|
||||
: Minus;
|
||||
|
||||
const iconSize = size === "compact" ? "h-3 w-3" : "h-4 w-4";
|
||||
const textSize = size === "compact" ? "text-xs" : "text-sm";
|
||||
|
||||
// Format the value - use fixed decimal for percentages, integer for absolute values
|
||||
const formattedValue = suffix === "%"
|
||||
? value.toFixed(1)
|
||||
: Math.abs(value).toString();
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1", colors.text)}>
|
||||
<IconComponent className={iconSize} />
|
||||
<span className={cn("font-medium", textSize)}>
|
||||
{value > 0 && suffix === "%" ? "+" : ""}
|
||||
{formattedValue}{suffix}
|
||||
</span>
|
||||
{label && (
|
||||
<span className={cn("text-muted-foreground", textSize)}>{label}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IconContainerProps {
|
||||
icon: LucideIcon;
|
||||
color?: IconColor;
|
||||
size?: CardSize;
|
||||
}
|
||||
|
||||
const IconContainer: React.FC<IconContainerProps> = ({
|
||||
icon: Icon,
|
||||
color = "blue",
|
||||
size = "default",
|
||||
}) => {
|
||||
const colorStyles = STAT_ICON_STYLES.colors[color] || STAT_ICON_STYLES.colors.blue;
|
||||
const containerSize = size === "compact" ? "p-1.5" : "p-2";
|
||||
const iconSize = size === "compact" ? "h-3.5 w-3.5" : size === "large" ? "h-5 w-5" : "h-4 w-4";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
STAT_ICON_STYLES.container,
|
||||
containerSize,
|
||||
colorStyles.container
|
||||
)}
|
||||
>
|
||||
<Icon className={cn(iconSize, colorStyles.icon)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
export const DashboardStatCard: React.FC<DashboardStatCardProps> = ({
|
||||
title,
|
||||
value,
|
||||
valuePrefix,
|
||||
valueSuffix,
|
||||
subtitle,
|
||||
trend,
|
||||
icon,
|
||||
iconColor = "blue",
|
||||
size = "default",
|
||||
className,
|
||||
onClick,
|
||||
loading = false,
|
||||
tooltip,
|
||||
children,
|
||||
}) => {
|
||||
// Size-based styling
|
||||
const sizeStyles = {
|
||||
default: {
|
||||
header: CARD_STYLES.header,
|
||||
value: TYPOGRAPHY.cardValue,
|
||||
title: TYPOGRAPHY.cardTitle,
|
||||
content: CARD_STYLES.content,
|
||||
},
|
||||
compact: {
|
||||
header: CARD_STYLES.headerCompact,
|
||||
value: TYPOGRAPHY.cardValueSmall,
|
||||
title: "text-xs font-medium text-muted-foreground",
|
||||
content: "px-4 pt-0 pb-3",
|
||||
},
|
||||
large: {
|
||||
header: CARD_STYLES.header,
|
||||
value: TYPOGRAPHY.cardValueLarge,
|
||||
title: TYPOGRAPHY.cardTitle,
|
||||
content: CARD_STYLES.contentPadded,
|
||||
},
|
||||
};
|
||||
|
||||
const styles = sizeStyles[size];
|
||||
const cardClass = onClick ? CARD_STYLES.interactive : CARD_STYLES.base;
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className={cn(cardClass, className)}>
|
||||
<CardHeader className={cn(styles.header, "space-y-0")}>
|
||||
<div className="h-4 w-24 bg-muted animate-pulse rounded" />
|
||||
{icon && <div className="h-8 w-8 bg-muted animate-pulse rounded-lg" />}
|
||||
</CardHeader>
|
||||
<CardContent className={styles.content}>
|
||||
<div className="h-8 w-32 bg-muted animate-pulse rounded mb-2" />
|
||||
{subtitle && <div className="h-4 w-20 bg-muted animate-pulse rounded" />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Format the display value with prefix/suffix
|
||||
const formattedValue = (
|
||||
<>
|
||||
{valuePrefix && <span className="text-muted-foreground">{valuePrefix}</span>}
|
||||
{typeof value === "number" ? value.toLocaleString() : value}
|
||||
{valueSuffix && <span className="text-muted-foreground text-lg">{valueSuffix}</span>}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(cardClass, onClick && "cursor-pointer", className)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardHeader className={cn(styles.header, "space-y-0")}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CardTitle className={styles.title}>{title}</CardTitle>
|
||||
{tooltip && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<p className="text-sm">{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{icon && <IconContainer icon={icon} color={iconColor} size={size} />}
|
||||
</CardHeader>
|
||||
<CardContent className={styles.content}>
|
||||
<div className={cn(styles.value, "text-foreground")}>
|
||||
{formattedValue}
|
||||
</div>
|
||||
{(subtitle || trend) && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mt-1">
|
||||
{subtitle && (
|
||||
<span className={TYPOGRAPHY.cardDescription}>{subtitle}</span>
|
||||
)}
|
||||
{trend && (
|
||||
<TrendIndicator
|
||||
value={trend.value}
|
||||
label={trend.label}
|
||||
moreIsBetter={trend.moreIsBetter}
|
||||
suffix={trend.suffix}
|
||||
size={size}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// SKELETON VARIANT
|
||||
// =============================================================================
|
||||
|
||||
export interface DashboardStatCardSkeletonProps {
|
||||
size?: CardSize;
|
||||
hasIcon?: boolean;
|
||||
hasSubtitle?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const DashboardStatCardSkeleton: React.FC<DashboardStatCardSkeletonProps> = ({
|
||||
size = "default",
|
||||
hasIcon = true,
|
||||
hasSubtitle = true,
|
||||
className,
|
||||
}) => {
|
||||
const sizeStyles = {
|
||||
default: { header: CARD_STYLES.header, content: CARD_STYLES.content },
|
||||
compact: { header: CARD_STYLES.headerCompact, content: "px-4 pt-0 pb-3" },
|
||||
large: { header: CARD_STYLES.header, content: CARD_STYLES.contentPadded },
|
||||
};
|
||||
|
||||
const styles = sizeStyles[size];
|
||||
|
||||
return (
|
||||
<Card className={cn(CARD_STYLES.base, className)}>
|
||||
<CardHeader className={cn(styles.header, "space-y-0")}>
|
||||
<div className="h-4 w-24 bg-muted animate-pulse rounded" />
|
||||
{hasIcon && <div className="h-8 w-8 bg-muted animate-pulse rounded-lg" />}
|
||||
</CardHeader>
|
||||
<CardContent className={styles.content}>
|
||||
<div className="h-8 w-32 bg-muted animate-pulse rounded mb-2" />
|
||||
{hasSubtitle && <div className="h-4 w-20 bg-muted animate-pulse rounded" />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardStatCard;
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* DashboardStatCardMini
|
||||
*
|
||||
* A compact, visually prominent stat card with gradient backgrounds.
|
||||
* Designed for hero metrics or dashboard headers where cards need to stand out.
|
||||
*
|
||||
* @example
|
||||
* <DashboardStatCardMini
|
||||
* title="Total Revenue"
|
||||
* value="$12,345"
|
||||
* gradient="emerald"
|
||||
* icon={DollarSign}
|
||||
* />
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { TrendingUp, TrendingDown, type LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export type GradientVariant =
|
||||
| "slate"
|
||||
| "emerald"
|
||||
| "blue"
|
||||
| "purple"
|
||||
| "violet"
|
||||
| "amber"
|
||||
| "orange"
|
||||
| "rose"
|
||||
| "cyan"
|
||||
| "sky"
|
||||
| "custom";
|
||||
|
||||
export interface DashboardStatCardMiniProps {
|
||||
/** Card title/label */
|
||||
title: string;
|
||||
/** The main value to display */
|
||||
value: string | number;
|
||||
/** Optional prefix for the value (e.g., "$") */
|
||||
valuePrefix?: string;
|
||||
/** Optional suffix for the value (e.g., "%") */
|
||||
valueSuffix?: string;
|
||||
/** Optional description text or element */
|
||||
description?: React.ReactNode;
|
||||
/** Trend direction and value */
|
||||
trend?: {
|
||||
direction: "up" | "down";
|
||||
value: string;
|
||||
};
|
||||
/** Optional icon component */
|
||||
icon?: LucideIcon;
|
||||
/** Icon background color class (e.g., "bg-emerald-500/20") */
|
||||
iconBackground?: string;
|
||||
/** Gradient preset or "custom" to use className */
|
||||
gradient?: GradientVariant;
|
||||
/** Additional className (use with gradient="custom" for custom backgrounds) */
|
||||
className?: string;
|
||||
/** Click handler */
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GRADIENT PRESETS
|
||||
// =============================================================================
|
||||
|
||||
const GRADIENT_PRESETS: Record<GradientVariant, string> = {
|
||||
slate: "bg-gradient-to-br from-slate-700 to-slate-600",
|
||||
emerald: "bg-gradient-to-br from-emerald-900 to-emerald-800",
|
||||
blue: "bg-gradient-to-br from-blue-900 to-blue-800",
|
||||
purple: "bg-gradient-to-br from-purple-800 to-purple-900",
|
||||
violet: "bg-gradient-to-br from-violet-900 to-violet-800",
|
||||
amber: "bg-gradient-to-br from-amber-700 to-amber-900",
|
||||
orange: "bg-gradient-to-br from-orange-900 to-orange-800",
|
||||
rose: "bg-gradient-to-br from-rose-800 to-rose-900",
|
||||
cyan: "bg-gradient-to-br from-cyan-800 to-cyan-900",
|
||||
sky: "bg-gradient-to-br from-sky-900 to-sky-800",
|
||||
custom: "",
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
export const DashboardStatCardMini: React.FC<DashboardStatCardMiniProps> = ({
|
||||
title,
|
||||
value,
|
||||
valuePrefix,
|
||||
valueSuffix,
|
||||
description,
|
||||
trend,
|
||||
icon: Icon,
|
||||
iconBackground,
|
||||
gradient = "slate",
|
||||
className,
|
||||
onClick,
|
||||
}) => {
|
||||
const gradientClass = gradient === "custom" ? "" : GRADIENT_PRESETS[gradient];
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
gradientClass,
|
||||
"backdrop-blur-md border-white/10",
|
||||
onClick && "cursor-pointer transition-all hover:brightness-110",
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
||||
<CardTitle className="text-sm font-bold text-gray-100">
|
||||
{title}
|
||||
</CardTitle>
|
||||
{Icon && (
|
||||
<div className="relative p-2">
|
||||
{iconBackground && (
|
||||
<div
|
||||
className={cn("absolute inset-0 rounded-full", iconBackground)}
|
||||
/>
|
||||
)}
|
||||
<Icon className="h-5 w-5 text-white relative" />
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="text-3xl font-extrabold text-white">
|
||||
{valuePrefix}
|
||||
{typeof value === "number" ? value.toLocaleString() : value}
|
||||
{valueSuffix && (
|
||||
<span className="text-xl text-gray-300">{valueSuffix}</span>
|
||||
)}
|
||||
</div>
|
||||
{(description || trend) && (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{trend && (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-sm font-semibold",
|
||||
trend.direction === "up"
|
||||
? "text-emerald-300"
|
||||
: "text-rose-300"
|
||||
)}
|
||||
>
|
||||
{trend.direction === "up" ? (
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4" />
|
||||
)}
|
||||
{trend.value}
|
||||
</span>
|
||||
)}
|
||||
{description && (
|
||||
<span className="text-sm font-semibold text-gray-200">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// SKELETON VARIANT
|
||||
// =============================================================================
|
||||
|
||||
export interface DashboardStatCardMiniSkeletonProps {
|
||||
gradient?: GradientVariant;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const DashboardStatCardMiniSkeleton: React.FC<
|
||||
DashboardStatCardMiniSkeletonProps
|
||||
> = ({ gradient = "slate", className }) => {
|
||||
const gradientClass = gradient === "custom" ? "" : GRADIENT_PRESETS[gradient];
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
gradientClass,
|
||||
"backdrop-blur-md border-white/10",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
||||
<div className="h-4 w-20 bg-white/20 animate-pulse rounded" />
|
||||
<div className="h-9 w-9 bg-white/20 animate-pulse rounded-full" />
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="h-9 w-28 bg-white/20 animate-pulse rounded mb-2" />
|
||||
<div className="h-4 w-24 bg-white/10 animate-pulse rounded" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardStatCardMini;
|
||||
268
inventory/src/components/dashboard/shared/DashboardStates.tsx
Normal file
268
inventory/src/components/dashboard/shared/DashboardStates.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* DashboardStates
|
||||
*
|
||||
* Reusable empty and error state components for dashboard sections.
|
||||
* Provides consistent appearance for "no data" and error scenarios.
|
||||
*
|
||||
* @example
|
||||
* // Empty state
|
||||
* {!data.length && !loading && (
|
||||
* <DashboardEmptyState
|
||||
* icon={TrendingUp}
|
||||
* title="No analytics data available"
|
||||
* description="Try selecting a different time range"
|
||||
* />
|
||||
* )}
|
||||
*
|
||||
* @example
|
||||
* // Error state with retry
|
||||
* {error && (
|
||||
* <DashboardErrorState
|
||||
* error={error}
|
||||
* onRetry={() => refetch()}
|
||||
* />
|
||||
* )}
|
||||
*
|
||||
* @example
|
||||
* // Error state as inline alert
|
||||
* <DashboardErrorState
|
||||
* error="Failed to load data"
|
||||
* variant="inline"
|
||||
* />
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertCircle, RefreshCcw, type LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TYPOGRAPHY } from "@/lib/dashboard/designTokens";
|
||||
|
||||
// =============================================================================
|
||||
// EMPTY STATE
|
||||
// =============================================================================
|
||||
|
||||
export interface DashboardEmptyStateProps {
|
||||
/** Icon to display (from lucide-react) */
|
||||
icon?: LucideIcon;
|
||||
/** Main message/title */
|
||||
title: string;
|
||||
/** Supporting description text */
|
||||
description?: string;
|
||||
/** Optional action button */
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
/** Height of the container */
|
||||
height?: "sm" | "md" | "default" | "lg";
|
||||
/** Additional className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const HEIGHT_MAP = {
|
||||
sm: "h-[200px]",
|
||||
md: "h-[300px]",
|
||||
default: "h-[400px]",
|
||||
lg: "h-[500px]",
|
||||
};
|
||||
|
||||
export const DashboardEmptyState: React.FC<DashboardEmptyStateProps> = ({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
height = "default",
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center text-muted-foreground",
|
||||
HEIGHT_MAP[height],
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="text-center max-w-sm px-4">
|
||||
{Icon && (
|
||||
<Icon className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
)}
|
||||
<div className="font-medium mb-2 text-foreground">{title}</div>
|
||||
{description && (
|
||||
<p className={cn(TYPOGRAPHY.cardDescription, "mb-4")}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{action && (
|
||||
<Button variant="outline" size="sm" onClick={action.onClick}>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ERROR STATE
|
||||
// =============================================================================
|
||||
|
||||
export interface DashboardErrorStateProps {
|
||||
/** Error message or Error object */
|
||||
error: string | Error;
|
||||
/** Title shown above the error message */
|
||||
title?: string;
|
||||
/** Callback for retry button */
|
||||
onRetry?: () => void;
|
||||
/** Retry button label */
|
||||
retryLabel?: string;
|
||||
/** Visual variant */
|
||||
variant?: "default" | "destructive" | "warning" | "inline";
|
||||
/** Additional className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const DashboardErrorState: React.FC<DashboardErrorStateProps> = ({
|
||||
error,
|
||||
title = "Error",
|
||||
onRetry,
|
||||
retryLabel = "Try Again",
|
||||
variant = "destructive",
|
||||
className,
|
||||
}) => {
|
||||
const errorMessage = error instanceof Error ? error.message : error;
|
||||
|
||||
// Inline variant - simple text display
|
||||
if (variant === "inline") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center h-[200px] text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="text-center">
|
||||
<AlertCircle className="h-8 w-8 mx-auto mb-2 text-destructive" />
|
||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||
{onRetry && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRetry}
|
||||
className="mt-3"
|
||||
>
|
||||
<RefreshCcw className="h-3.5 w-3.5 mr-1.5" />
|
||||
{retryLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Alert variant - only "default" and "destructive" are supported by shadcn/ui Alert
|
||||
// For "warning", we use custom styling
|
||||
const alertVariant = variant === "destructive" ? "destructive" : "default";
|
||||
const warningClass = variant === "warning"
|
||||
? "border-amber-500/50 text-amber-700 dark:text-amber-400 [&>svg]:text-amber-600"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Alert variant={alertVariant} className={cn("mx-4 my-4", warningClass, className)}>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{title}</AlertTitle>
|
||||
<AlertDescription className="flex items-center justify-between">
|
||||
<span>{errorMessage}</span>
|
||||
{onRetry && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRetry}
|
||||
className="ml-4 shrink-0"
|
||||
>
|
||||
<RefreshCcw className="h-3.5 w-3.5 mr-1.5" />
|
||||
{retryLabel}
|
||||
</Button>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// LOADING STATE (bonus - for consistency)
|
||||
// =============================================================================
|
||||
|
||||
export interface DashboardLoadingStateProps {
|
||||
/** Message to display */
|
||||
message?: string;
|
||||
/** Height of the container */
|
||||
height?: "sm" | "md" | "default" | "lg";
|
||||
/** Additional className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const DashboardLoadingState: React.FC<DashboardLoadingStateProps> = ({
|
||||
message = "Loading...",
|
||||
height = "default",
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center",
|
||||
HEIGHT_MAP[height],
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="h-8 w-8 mx-auto mb-3 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-sm text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// NO PERMISSION STATE (bonus - for protected content)
|
||||
// =============================================================================
|
||||
|
||||
export interface DashboardNoPermissionStateProps {
|
||||
/** Feature name that requires permission */
|
||||
feature?: string;
|
||||
/** Additional className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const DashboardNoPermissionState: React.FC<DashboardNoPermissionStateProps> = ({
|
||||
feature,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center h-[300px] text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="h-12 w-12 mx-auto mb-4 rounded-full bg-muted flex items-center justify-center">
|
||||
<AlertCircle className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="font-medium mb-2 text-foreground">Access Restricted</div>
|
||||
<p className="text-sm text-muted-foreground max-w-xs">
|
||||
{feature
|
||||
? `You don't have permission to view ${feature}.`
|
||||
: "You don't have permission to view this content."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
Empty: DashboardEmptyState,
|
||||
Error: DashboardErrorState,
|
||||
Loading: DashboardLoadingState,
|
||||
NoPermission: DashboardNoPermissionState,
|
||||
};
|
||||
415
inventory/src/components/dashboard/shared/DashboardTable.tsx
Normal file
415
inventory/src/components/dashboard/shared/DashboardTable.tsx
Normal 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;
|
||||
199
inventory/src/components/dashboard/shared/index.ts
Normal file
199
inventory/src/components/dashboard/shared/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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 [];
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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.+\)$/, '');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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> = {
|
||||
|
||||
@@ -58,6 +58,26 @@
|
||||
--sidebar-border: 220 13% 91%;
|
||||
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
|
||||
/* Dashboard card with glass effect */
|
||||
--card-glass: 0 0% 100%;
|
||||
--card-glass-foreground: 222.2 84% 4.9%;
|
||||
|
||||
/* Semantic chart colors - EXACT values for consistency */
|
||||
--chart-revenue: 160.1 84.1% 39.4%; /* #10b981 - emerald-500 */
|
||||
--chart-orders: 217.2 91.2% 59.8%; /* #3b82f6 - blue-500 */
|
||||
--chart-aov: 258.3 89.5% 66.3%; /* #8b5cf6 - violet-500 */
|
||||
--chart-comparison: 37.7 92.1% 50.2%; /* #f59e0b - amber-500 */
|
||||
--chart-expense: 24.6 95% 53.1%; /* #f97316 - orange-500 */
|
||||
--chart-profit: 142.1 76.2% 45.7%; /* #22c55e - green-500 */
|
||||
--chart-secondary: 187.9 85.7% 53.3%; /* #06b6d4 - cyan-500 */
|
||||
--chart-tertiary: 330.4 81.2% 60.4%; /* #ec4899 - pink-500 */
|
||||
|
||||
/* Trend colors */
|
||||
--trend-positive: 160.1 84.1% 39.4%; /* emerald-500 */
|
||||
--trend-positive-muted: 158.1 64.4% 91.6%; /* emerald-100 */
|
||||
--trend-negative: 346.8 77.2% 49.8%; /* rose-500 */
|
||||
--trend-negative-muted: 355.7 100% 94.7%; /* rose-100 */
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -106,6 +126,26 @@
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
|
||||
/* Dashboard card with glass effect - semi-transparent in dark mode */
|
||||
--card-glass: 222.2 47.4% 11.2%;
|
||||
--card-glass-foreground: 210 40% 98%;
|
||||
|
||||
/* Semantic chart colors - slightly brighter in dark mode for visibility */
|
||||
--chart-revenue: 158.1 64.4% 51.6%; /* emerald-400 */
|
||||
--chart-orders: 213.1 93.9% 67.8%; /* blue-400 */
|
||||
--chart-aov: 255.1 91.7% 76.3%; /* violet-400 */
|
||||
--chart-comparison: 43.3 96.4% 56.3%; /* amber-400 */
|
||||
--chart-expense: 27.0 96.0% 61.0%; /* orange-400 */
|
||||
--chart-profit: 141.9 69.2% 58%; /* green-400 */
|
||||
--chart-secondary: 186.0 93.5% 55.7%; /* cyan-400 */
|
||||
--chart-tertiary: 328.6 85.5% 70.2%; /* pink-400 */
|
||||
|
||||
/* Trend colors - brighter in dark mode */
|
||||
--trend-positive: 158.1 64.4% 51.6%; /* emerald-400 */
|
||||
--trend-positive-muted: 163.1 88.1% 19.6%; /* emerald-900 */
|
||||
--trend-negative: 351.3 94.5% 71.4%; /* rose-400 */
|
||||
--trend-negative-muted: 343.1 87.7% 15.9%; /* rose-950 */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,3 +166,26 @@
|
||||
@apply bg-background text-foreground font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Dashboard card with glass effect */
|
||||
.card-glass {
|
||||
@apply bg-card-glass/100 dark:bg-card-glass/60 backdrop-blur-sm border border-border/50 shadow-sm rounded-xl text-card-glass-foreground;
|
||||
}
|
||||
|
||||
/* Dashboard scrollable container with custom scrollbar */
|
||||
.dashboard-scroll {
|
||||
@apply overflow-y-auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(229 231 235) transparent;
|
||||
}
|
||||
.dark .dashboard-scroll {
|
||||
scrollbar-color: rgb(55 65 81) transparent;
|
||||
}
|
||||
.dashboard-scroll:hover {
|
||||
scrollbar-color: rgb(209 213 219) transparent;
|
||||
}
|
||||
.dark .dashboard-scroll:hover {
|
||||
scrollbar-color: rgb(75 85 99) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
395
inventory/src/lib/dashboard/chartConfig.ts
Normal file
395
inventory/src/lib/dashboard/chartConfig.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* Chart Configuration
|
||||
*
|
||||
* Centralized Recharts configuration for consistent chart appearance
|
||||
* across all dashboard components.
|
||||
*/
|
||||
|
||||
import { METRIC_COLORS, FINANCIAL_COLORS } from "./designTokens";
|
||||
|
||||
// =============================================================================
|
||||
// CHART LAYOUT CONFIGURATION
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Standard chart margins
|
||||
* Negative left margin accounts for Y-axis label width
|
||||
*/
|
||||
export const CHART_MARGINS = {
|
||||
default: { top: 10, right: 10, left: -15, bottom: 5 },
|
||||
withLegend: { top: 10, right: 10, left: -15, bottom: 25 },
|
||||
compact: { top: 5, right: 5, left: -20, bottom: 5 },
|
||||
spacious: { top: 20, right: 20, left: 0, bottom: 10 },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Standard chart heights
|
||||
*/
|
||||
export const CHART_HEIGHTS = {
|
||||
small: 200,
|
||||
medium: 300,
|
||||
default: 400,
|
||||
large: 500,
|
||||
} as const;
|
||||
|
||||
// =============================================================================
|
||||
// GRID CONFIGURATION
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* CartesianGrid styling
|
||||
*/
|
||||
export const GRID_CONFIG = {
|
||||
strokeDasharray: "3 3",
|
||||
className: "stroke-border/40",
|
||||
stroke: "hsl(var(--border) / 0.4)",
|
||||
vertical: false, // Horizontal lines only for cleaner look
|
||||
} as const;
|
||||
|
||||
// =============================================================================
|
||||
// AXIS CONFIGURATION
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* X-Axis default configuration
|
||||
*/
|
||||
export const X_AXIS_CONFIG = {
|
||||
className: "text-xs fill-muted-foreground",
|
||||
tickLine: false,
|
||||
axisLine: false,
|
||||
tick: { fontSize: 11 },
|
||||
dy: 10,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Y-Axis default configuration
|
||||
*/
|
||||
export const Y_AXIS_CONFIG = {
|
||||
className: "text-xs fill-muted-foreground",
|
||||
tickLine: false,
|
||||
axisLine: false,
|
||||
tick: { fontSize: 11 },
|
||||
width: 60,
|
||||
} as const;
|
||||
|
||||
// =============================================================================
|
||||
// LINE/BAR CONFIGURATION
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Line chart series configuration
|
||||
*/
|
||||
export const LINE_CONFIG = {
|
||||
strokeWidth: 2,
|
||||
dot: false,
|
||||
activeDot: {
|
||||
r: 4,
|
||||
strokeWidth: 2,
|
||||
className: "fill-background stroke-current",
|
||||
},
|
||||
type: "monotone" as const,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Area chart series configuration
|
||||
*/
|
||||
export const AREA_CONFIG = {
|
||||
strokeWidth: 2,
|
||||
fillOpacity: 0.1,
|
||||
type: "monotone" as const,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Bar chart series configuration
|
||||
*/
|
||||
export const BAR_CONFIG = {
|
||||
radius: [4, 4, 0, 0] as [number, number, number, number],
|
||||
maxBarSize: 50,
|
||||
} as const;
|
||||
|
||||
// =============================================================================
|
||||
// TOOLTIP CONFIGURATION
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Tooltip styling - comprehensive styles for consistent tooltip appearance
|
||||
*
|
||||
* Structure:
|
||||
* <div className={container}>
|
||||
* <p className={header}>{date/label}</p>
|
||||
* <div className={content}>
|
||||
* {items.map(item => (
|
||||
* <div className={row}>
|
||||
* <div className={rowLabel}>
|
||||
* <span className={dot} style={{backgroundColor: color}} />
|
||||
* <span className={name}>{name}</span>
|
||||
* </div>
|
||||
* <span className={value}>{value}</span>
|
||||
* </div>
|
||||
* ))}
|
||||
* </div>
|
||||
* </div>
|
||||
*/
|
||||
export const TOOLTIP_STYLES = {
|
||||
// Outer container
|
||||
container: "rounded-lg border border-border/50 bg-popover px-3 py-2 shadow-lg",
|
||||
|
||||
// Header/label at top of tooltip
|
||||
header: "font-medium text-sm text-foreground pb-1.5 mb-1.5 border-b border-border/50",
|
||||
label: "font-medium text-sm text-foreground pb-1.5 mb-1.5 border-b border-border/50",
|
||||
|
||||
// Content wrapper
|
||||
content: "space-y-1",
|
||||
|
||||
// Individual row
|
||||
row: "flex justify-between items-center gap-4",
|
||||
|
||||
// Left side of row (dot + name)
|
||||
rowLabel: "flex items-center gap-2",
|
||||
|
||||
// Color indicator dot
|
||||
dot: "h-2.5 w-2.5 rounded-full shrink-0",
|
||||
|
||||
// Metric name / item label
|
||||
name: "text-sm text-muted-foreground",
|
||||
item: "text-sm text-muted-foreground",
|
||||
|
||||
// Value on right side
|
||||
value: "text-sm font-medium text-foreground",
|
||||
|
||||
// Divider between sections (if needed)
|
||||
divider: "border-t border-border/50 my-1.5",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Themed tooltip variants for special contexts (e.g., SmallDashboard TV display)
|
||||
* These maintain the same structure as TOOLTIP_STYLES but with different color schemes
|
||||
*/
|
||||
export const TOOLTIP_THEMES = {
|
||||
/** Stone/neutral dark theme - for MiniSalesChart */
|
||||
stone: {
|
||||
container: "rounded-lg border-none bg-stone-800 px-3 py-2 shadow-lg",
|
||||
header: "font-medium text-sm text-stone-100 pb-1.5 mb-1.5 border-b border-stone-700",
|
||||
content: "space-y-1",
|
||||
row: "flex justify-between items-center gap-4",
|
||||
rowLabel: "flex items-center gap-2",
|
||||
dot: "h-2.5 w-2.5 rounded-full shrink-0",
|
||||
name: "text-sm text-stone-200",
|
||||
value: "text-sm font-medium text-stone-100",
|
||||
},
|
||||
/** Sky/blue dark theme - for MiniRealtimeAnalytics */
|
||||
sky: {
|
||||
container: "rounded-lg border-none bg-sky-800 px-3 py-2 shadow-lg",
|
||||
header: "font-medium text-sm text-sky-100 pb-1.5 mb-1.5 border-b border-sky-700",
|
||||
content: "space-y-1",
|
||||
row: "flex justify-between items-center gap-4",
|
||||
rowLabel: "flex items-center gap-2",
|
||||
dot: "h-2.5 w-2.5 rounded-full shrink-0",
|
||||
name: "text-sm text-sky-200",
|
||||
value: "text-sm font-medium text-sky-100",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Tooltip cursor styling (the vertical line that follows the mouse)
|
||||
*/
|
||||
export const TOOLTIP_CURSOR = {
|
||||
stroke: "hsl(var(--border))",
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: "4 4",
|
||||
} as const;
|
||||
|
||||
// =============================================================================
|
||||
// LEGEND CONFIGURATION
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Legend styling
|
||||
*/
|
||||
export const LEGEND_CONFIG = {
|
||||
wrapperStyle: {
|
||||
paddingTop: "10px",
|
||||
},
|
||||
iconType: "circle" as const,
|
||||
iconSize: 8,
|
||||
formatter: (value: string) => (
|
||||
`<span class="text-xs text-muted-foreground ml-1">${value}</span>`
|
||||
),
|
||||
} as const;
|
||||
|
||||
// =============================================================================
|
||||
// COLOR PALETTES
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Standard chart color palette
|
||||
* Use these in order for multi-series charts
|
||||
*/
|
||||
export const CHART_PALETTE = [
|
||||
METRIC_COLORS.revenue, // Emerald
|
||||
METRIC_COLORS.orders, // Blue
|
||||
METRIC_COLORS.aov, // Purple
|
||||
METRIC_COLORS.comparison, // Amber
|
||||
METRIC_COLORS.secondary, // Cyan
|
||||
METRIC_COLORS.tertiary, // Pink
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Financial chart palette
|
||||
* Preserves accounting conventions
|
||||
*/
|
||||
export const FINANCIAL_PALETTE = {
|
||||
income: FINANCIAL_COLORS.income,
|
||||
cogs: FINANCIAL_COLORS.expense,
|
||||
profit: FINANCIAL_COLORS.profit,
|
||||
margin: FINANCIAL_COLORS.margin,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Comparison chart colors (current vs previous period)
|
||||
*/
|
||||
export const COMPARISON_PALETTE = {
|
||||
current: METRIC_COLORS.revenue,
|
||||
previous: METRIC_COLORS.comparison,
|
||||
} as const;
|
||||
|
||||
// =============================================================================
|
||||
// FORMATTERS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Currency formatter for chart labels
|
||||
*/
|
||||
export const formatChartCurrency = (value: number): string => {
|
||||
if (value >= 1000000) {
|
||||
return `$${(value / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (value >= 1000) {
|
||||
return `$${(value / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return `$${value.toFixed(0)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Number formatter for chart labels
|
||||
*/
|
||||
export const formatChartNumber = (value: number): string => {
|
||||
if (value >= 1000000) {
|
||||
return `${(value / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (value >= 1000) {
|
||||
return `${(value / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return value.toFixed(0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Percentage formatter for chart labels
|
||||
*/
|
||||
export const formatChartPercent = (value: number): string => {
|
||||
return `${value.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Date formatter for X-axis labels
|
||||
*/
|
||||
export const formatChartDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
};
|
||||
|
||||
/**
|
||||
* Time formatter for real-time charts
|
||||
*/
|
||||
export const formatChartTime = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// PRESET CONFIGURATIONS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Ready-to-use chart configurations for common use cases
|
||||
*/
|
||||
export const CHART_PRESETS = {
|
||||
/** Revenue/financial line chart */
|
||||
revenue: {
|
||||
margins: CHART_MARGINS.default,
|
||||
height: CHART_HEIGHTS.default,
|
||||
grid: GRID_CONFIG,
|
||||
xAxis: X_AXIS_CONFIG,
|
||||
yAxis: { ...Y_AXIS_CONFIG, tickFormatter: formatChartCurrency },
|
||||
line: { ...LINE_CONFIG, stroke: METRIC_COLORS.revenue },
|
||||
},
|
||||
|
||||
/** Order count bar chart */
|
||||
orders: {
|
||||
margins: CHART_MARGINS.default,
|
||||
height: CHART_HEIGHTS.default,
|
||||
grid: GRID_CONFIG,
|
||||
xAxis: X_AXIS_CONFIG,
|
||||
yAxis: { ...Y_AXIS_CONFIG, tickFormatter: formatChartNumber },
|
||||
bar: { ...BAR_CONFIG, fill: METRIC_COLORS.orders },
|
||||
},
|
||||
|
||||
/** Comparison chart (current vs previous) */
|
||||
comparison: {
|
||||
margins: CHART_MARGINS.withLegend,
|
||||
height: CHART_HEIGHTS.default,
|
||||
grid: GRID_CONFIG,
|
||||
xAxis: X_AXIS_CONFIG,
|
||||
yAxis: Y_AXIS_CONFIG,
|
||||
colors: COMPARISON_PALETTE,
|
||||
},
|
||||
|
||||
/** Real-time metrics chart */
|
||||
realtime: {
|
||||
margins: CHART_MARGINS.compact,
|
||||
height: CHART_HEIGHTS.small,
|
||||
grid: { ...GRID_CONFIG, vertical: false },
|
||||
xAxis: { ...X_AXIS_CONFIG, tickFormatter: formatChartTime },
|
||||
yAxis: Y_AXIS_CONFIG,
|
||||
},
|
||||
|
||||
/** Financial overview chart */
|
||||
financial: {
|
||||
margins: CHART_MARGINS.withLegend,
|
||||
height: CHART_HEIGHTS.default,
|
||||
grid: GRID_CONFIG,
|
||||
xAxis: X_AXIS_CONFIG,
|
||||
yAxis: { ...Y_AXIS_CONFIG, tickFormatter: formatChartCurrency },
|
||||
colors: FINANCIAL_PALETTE,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// =============================================================================
|
||||
// RESPONSIVE BREAKPOINTS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Breakpoints for responsive chart sizing
|
||||
*/
|
||||
export const CHART_BREAKPOINTS = {
|
||||
sm: 640,
|
||||
md: 768,
|
||||
lg: 1024,
|
||||
xl: 1280,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get responsive chart height based on container width
|
||||
*/
|
||||
export function getResponsiveHeight(
|
||||
containerWidth: number,
|
||||
baseHeight: number = CHART_HEIGHTS.default
|
||||
): number {
|
||||
if (containerWidth < CHART_BREAKPOINTS.sm) {
|
||||
return Math.round(baseHeight * 0.6);
|
||||
}
|
||||
if (containerWidth < CHART_BREAKPOINTS.md) {
|
||||
return Math.round(baseHeight * 0.75);
|
||||
}
|
||||
return baseHeight;
|
||||
}
|
||||
364
inventory/src/lib/dashboard/designTokens.ts
Normal file
364
inventory/src/lib/dashboard/designTokens.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* Dashboard Design Tokens
|
||||
*
|
||||
* Centralized styling constants for consistent dashboard appearance.
|
||||
* These tokens are divided into two categories:
|
||||
*
|
||||
* 1. STRUCTURAL - Visual consistency (safe to change)
|
||||
* 2. SEMANTIC - Convey meaning (preserve carefully)
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// STRUCTURAL TOKENS - Visual consistency across components
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Standard card styling classes
|
||||
* Uses the glass effect by default for dashboard cards
|
||||
*/
|
||||
export const CARD_STYLES = {
|
||||
/** Base card appearance with glass effect (default for dashboard) */
|
||||
base: "card-glass",
|
||||
/** Card with subtle hover effect */
|
||||
interactive: "card-glass transition-shadow hover:shadow-md",
|
||||
/** Solid card without glass effect (use sparingly) */
|
||||
solid: "bg-card border border-border/50 shadow-sm rounded-xl",
|
||||
/** Card header layout */
|
||||
header: "flex flex-row items-center justify-between p-4 pb-2",
|
||||
/** Compact header for stat cards */
|
||||
headerCompact: "flex flex-row items-center justify-between px-4 pt-4 pb-2",
|
||||
/** Card content area */
|
||||
content: "p-4 pt-0",
|
||||
/** Card content with extra bottom padding */
|
||||
contentPadded: "p-4 pt-0 pb-4",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Typography scale for dashboard elements
|
||||
*/
|
||||
export const TYPOGRAPHY = {
|
||||
/** Card/section titles */
|
||||
cardTitle: "text-sm font-medium text-muted-foreground",
|
||||
/** Primary metric values */
|
||||
cardValue: "text-2xl font-semibold tracking-tight",
|
||||
/** Large hero metrics (real-time, attention-grabbing) */
|
||||
cardValueLarge: "text-3xl font-bold tracking-tight",
|
||||
/** Small metric values */
|
||||
cardValueSmall: "text-xl font-semibold tracking-tight",
|
||||
/** Supporting descriptions */
|
||||
cardDescription: "text-sm text-muted-foreground",
|
||||
/** Section headings within cards */
|
||||
sectionTitle: "text-base font-semibold",
|
||||
/** Table headers */
|
||||
tableHeader: "text-xs font-medium text-muted-foreground uppercase tracking-wider",
|
||||
/** Table cells */
|
||||
tableCell: "text-sm",
|
||||
/** Small labels */
|
||||
label: "text-xs font-medium text-muted-foreground",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Spacing and layout constants
|
||||
*/
|
||||
export const SPACING = {
|
||||
/** Gap between cards in a grid */
|
||||
cardGap: "gap-4",
|
||||
/** Standard card padding */
|
||||
cardPadding: "p-4",
|
||||
/** Inner content spacing */
|
||||
contentGap: "space-y-4",
|
||||
/** Tight content spacing */
|
||||
contentGapTight: "space-y-2",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Border radius tokens
|
||||
*/
|
||||
export const RADIUS = {
|
||||
card: "rounded-xl",
|
||||
button: "rounded-lg",
|
||||
badge: "rounded-full",
|
||||
input: "rounded-md",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Scrollable container styling
|
||||
* Uses the dashboard-scroll utility class defined in index.css
|
||||
*/
|
||||
export const SCROLL_STYLES = {
|
||||
/** Standard scrollable container with custom scrollbar */
|
||||
container: "dashboard-scroll",
|
||||
/** Scrollable with max height variants */
|
||||
sm: "dashboard-scroll max-h-[300px]",
|
||||
md: "dashboard-scroll max-h-[400px]",
|
||||
lg: "dashboard-scroll max-h-[540px]",
|
||||
xl: "dashboard-scroll max-h-[700px]",
|
||||
} as const;
|
||||
|
||||
// =============================================================================
|
||||
// SEMANTIC TOKENS - Colors that convey meaning (preserve carefully!)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Trend indicator colors
|
||||
* Used for showing positive/negative changes
|
||||
* Uses CSS variables for consistency (see index.css)
|
||||
*/
|
||||
export const TREND_COLORS = {
|
||||
positive: {
|
||||
text: "text-trend-positive",
|
||||
bg: "bg-trend-positive-muted",
|
||||
border: "border-trend-positive/20",
|
||||
},
|
||||
negative: {
|
||||
text: "text-trend-negative",
|
||||
bg: "bg-trend-negative-muted",
|
||||
border: "border-trend-negative/20",
|
||||
},
|
||||
neutral: {
|
||||
text: "text-muted-foreground",
|
||||
bg: "bg-muted",
|
||||
border: "border-border",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Event type colors - PRESERVE THESE
|
||||
* Each color has semantic meaning for quick visual recognition
|
||||
*/
|
||||
export const EVENT_COLORS = {
|
||||
orderPlaced: {
|
||||
bg: "bg-emerald-500 dark:bg-emerald-600",
|
||||
text: "text-emerald-600 dark:text-emerald-400",
|
||||
bgSubtle: "bg-emerald-500/10",
|
||||
},
|
||||
orderShipped: {
|
||||
bg: "bg-blue-500 dark:bg-blue-600",
|
||||
text: "text-blue-600 dark:text-blue-400",
|
||||
bgSubtle: "bg-blue-500/10",
|
||||
},
|
||||
newAccount: {
|
||||
bg: "bg-purple-500 dark:bg-purple-600",
|
||||
text: "text-purple-600 dark:text-purple-400",
|
||||
bgSubtle: "bg-purple-500/10",
|
||||
},
|
||||
orderCanceled: {
|
||||
bg: "bg-red-500 dark:bg-red-600",
|
||||
text: "text-red-600 dark:text-red-400",
|
||||
bgSubtle: "bg-red-500/10",
|
||||
},
|
||||
paymentRefunded: {
|
||||
bg: "bg-orange-500 dark:bg-orange-600",
|
||||
text: "text-orange-600 dark:text-orange-400",
|
||||
bgSubtle: "bg-orange-500/10",
|
||||
},
|
||||
blogPost: {
|
||||
bg: "bg-indigo-500 dark:bg-indigo-600",
|
||||
text: "text-indigo-600 dark:text-indigo-400",
|
||||
bgSubtle: "bg-indigo-500/10",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Financial chart colors - PRESERVE THESE
|
||||
* Follow accounting visualization conventions
|
||||
*/
|
||||
export const FINANCIAL_COLORS = {
|
||||
income: "#3b82f6", // Blue - Revenue streams
|
||||
expense: "#f97316", // Orange - Costs/Expenses (COGS)
|
||||
profit: "#10b981", // Green - Positive financial outcome
|
||||
margin: "#8b5cf6", // Purple - Percentage metrics
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Standard metric colors for charts
|
||||
* Used across multiple dashboard components
|
||||
*
|
||||
* For CSS/Tailwind classes, use: text-chart-revenue, bg-chart-orders, etc.
|
||||
* For Recharts/JS, use these hex values directly.
|
||||
*
|
||||
* IMPORTANT: These MUST match the CSS variables in index.css
|
||||
*/
|
||||
export const METRIC_COLORS = {
|
||||
revenue: "#10b981", // Emerald - Primary positive metric (--chart-revenue)
|
||||
orders: "#3b82f6", // Blue - Count/volume metrics (--chart-orders)
|
||||
aov: "#8b5cf6", // Purple/Violet - Calculated/derived metrics (--chart-aov)
|
||||
comparison: "#f59e0b", // Amber - Previous period comparison (--chart-comparison)
|
||||
expense: "#f97316", // Orange - Costs/expenses (--chart-expense)
|
||||
profit: "#22c55e", // Green - Profit metrics (--chart-profit)
|
||||
secondary: "#06b6d4", // Cyan - Secondary metrics (--chart-secondary)
|
||||
tertiary: "#ec4899", // Pink - Tertiary metrics (--chart-tertiary)
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Metric colors as HSL for CSS variable compatibility
|
||||
* Use these when you need HSL format (e.g., for opacity variations)
|
||||
*/
|
||||
export const METRIC_COLORS_HSL = {
|
||||
revenue: "hsl(var(--chart-revenue))",
|
||||
orders: "hsl(var(--chart-orders))",
|
||||
aov: "hsl(var(--chart-aov))",
|
||||
comparison: "hsl(var(--chart-comparison))",
|
||||
expense: "hsl(var(--chart-expense))",
|
||||
profit: "hsl(var(--chart-profit))",
|
||||
secondary: "hsl(var(--chart-secondary))",
|
||||
tertiary: "hsl(var(--chart-tertiary))",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Status indicator colors
|
||||
*/
|
||||
export const STATUS_COLORS = {
|
||||
success: {
|
||||
text: "text-emerald-600 dark:text-emerald-400",
|
||||
bg: "bg-emerald-500/10",
|
||||
border: "border-emerald-500/20",
|
||||
icon: "text-emerald-500",
|
||||
},
|
||||
warning: {
|
||||
text: "text-amber-600 dark:text-amber-400",
|
||||
bg: "bg-amber-500/10",
|
||||
border: "border-amber-500/20",
|
||||
icon: "text-amber-500",
|
||||
},
|
||||
error: {
|
||||
text: "text-red-600 dark:text-red-400",
|
||||
bg: "bg-red-500/10",
|
||||
border: "border-red-500/20",
|
||||
icon: "text-red-500",
|
||||
},
|
||||
info: {
|
||||
text: "text-blue-600 dark:text-blue-400",
|
||||
bg: "bg-blue-500/10",
|
||||
border: "border-blue-500/20",
|
||||
icon: "text-blue-500",
|
||||
},
|
||||
} as const;
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENT-SPECIFIC TOKENS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Stat card icon styling
|
||||
*/
|
||||
export const STAT_ICON_STYLES = {
|
||||
container: "p-2 rounded-lg",
|
||||
icon: "h-4 w-4",
|
||||
// Color variants for icons
|
||||
colors: {
|
||||
emerald: {
|
||||
container: "bg-emerald-500/10",
|
||||
icon: "text-emerald-600 dark:text-emerald-400",
|
||||
},
|
||||
green: {
|
||||
container: "bg-green-500/10",
|
||||
icon: "text-green-600 dark:text-green-400",
|
||||
},
|
||||
blue: {
|
||||
container: "bg-blue-500/10",
|
||||
icon: "text-blue-600 dark:text-blue-400",
|
||||
},
|
||||
purple: {
|
||||
container: "bg-purple-500/10",
|
||||
icon: "text-purple-600 dark:text-purple-400",
|
||||
},
|
||||
amber: {
|
||||
container: "bg-amber-500/10",
|
||||
icon: "text-amber-600 dark:text-amber-400",
|
||||
},
|
||||
yellow: {
|
||||
container: "bg-yellow-500/10",
|
||||
icon: "text-yellow-600 dark:text-yellow-400",
|
||||
},
|
||||
orange: {
|
||||
container: "bg-orange-500/10",
|
||||
icon: "text-orange-600 dark:text-orange-400",
|
||||
},
|
||||
red: {
|
||||
container: "bg-red-500/10",
|
||||
icon: "text-red-600 dark:text-red-400",
|
||||
},
|
||||
rose: {
|
||||
container: "bg-rose-500/10",
|
||||
icon: "text-rose-600 dark:text-rose-400",
|
||||
},
|
||||
pink: {
|
||||
container: "bg-pink-500/10",
|
||||
icon: "text-pink-600 dark:text-pink-400",
|
||||
},
|
||||
teal: {
|
||||
container: "bg-teal-500/10",
|
||||
icon: "text-teal-600 dark:text-teal-400",
|
||||
},
|
||||
cyan: {
|
||||
container: "bg-cyan-500/10",
|
||||
icon: "text-cyan-600 dark:text-cyan-400",
|
||||
},
|
||||
indigo: {
|
||||
container: "bg-indigo-500/10",
|
||||
icon: "text-indigo-600 dark:text-indigo-400",
|
||||
},
|
||||
violet: {
|
||||
container: "bg-violet-500/10",
|
||||
icon: "text-violet-600 dark:text-violet-400",
|
||||
},
|
||||
lime: {
|
||||
container: "bg-lime-500/10",
|
||||
icon: "text-lime-600 dark:text-lime-400",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Table styling tokens
|
||||
*/
|
||||
export const TABLE_STYLES = {
|
||||
container: "rounded-lg border border-border/50 overflow-hidden",
|
||||
header: "bg-muted/30",
|
||||
headerCell: "text-xs font-medium text-muted-foreground uppercase tracking-wider",
|
||||
row: "border-b border-border/50 last:border-0",
|
||||
rowHover: "hover:bg-muted/50 transition-colors",
|
||||
cell: "text-sm",
|
||||
cellNumeric: "text-sm tabular-nums text-right",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Badge styling tokens
|
||||
*/
|
||||
export const BADGE_STYLES = {
|
||||
base: "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
|
||||
variants: {
|
||||
default: "bg-muted text-muted-foreground",
|
||||
success: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
||||
warning: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
|
||||
error: "bg-red-500/10 text-red-600 dark:text-red-400",
|
||||
info: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
|
||||
},
|
||||
} as const;
|
||||
|
||||
// =============================================================================
|
||||
// UTILITY FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get trend color based on direction and whether more is better
|
||||
*/
|
||||
export function getTrendColor(
|
||||
value: number,
|
||||
moreIsBetter: boolean = true
|
||||
): typeof TREND_COLORS.positive | typeof TREND_COLORS.negative | typeof TREND_COLORS.neutral {
|
||||
if (value === 0) return TREND_COLORS.neutral;
|
||||
const isPositive = value > 0;
|
||||
const isGood = isPositive === moreIsBetter;
|
||||
return isGood ? TREND_COLORS.positive : TREND_COLORS.negative;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon color variant by name
|
||||
*/
|
||||
export function getIconColorVariant(
|
||||
color: keyof typeof STAT_ICON_STYLES.colors
|
||||
) {
|
||||
return STAT_ICON_STYLES.colors[color] || STAT_ICON_STYLES.colors.blue;
|
||||
}
|
||||
@@ -68,7 +68,28 @@ export default {
|
||||
'2': 'hsl(var(--chart-2))',
|
||||
'3': 'hsl(var(--chart-3))',
|
||||
'4': 'hsl(var(--chart-4))',
|
||||
'5': 'hsl(var(--chart-5))'
|
||||
'5': 'hsl(var(--chart-5))',
|
||||
// Semantic chart colors for dashboard
|
||||
revenue: 'hsl(var(--chart-revenue))',
|
||||
orders: 'hsl(var(--chart-orders))',
|
||||
aov: 'hsl(var(--chart-aov))',
|
||||
comparison: 'hsl(var(--chart-comparison))',
|
||||
expense: 'hsl(var(--chart-expense))',
|
||||
profit: 'hsl(var(--chart-profit))',
|
||||
secondary: 'hsl(var(--chart-secondary))',
|
||||
tertiary: 'hsl(var(--chart-tertiary))'
|
||||
},
|
||||
// Dashboard glass effect card
|
||||
'card-glass': {
|
||||
DEFAULT: 'hsl(var(--card-glass))',
|
||||
foreground: 'hsl(var(--card-glass-foreground))'
|
||||
},
|
||||
// Trend indicator colors
|
||||
trend: {
|
||||
positive: 'hsl(var(--trend-positive))',
|
||||
'positive-muted': 'hsl(var(--trend-positive-muted))',
|
||||
negative: 'hsl(var(--trend-negative))',
|
||||
'negative-muted': 'hsl(var(--trend-negative-muted))'
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: 'hsl(var(--sidebar-background))',
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -15,7 +15,7 @@ export default defineConfig(({ mode }) => {
|
||||
{
|
||||
name: 'copy-build',
|
||||
closeBundle: async () => {
|
||||
if (!isDev) {
|
||||
if (!isDev && process.env.COPY_BUILD === 'true') {
|
||||
const sourcePath = path.resolve(__dirname, 'build');
|
||||
const targetPath = path.resolve(__dirname, '../inventory-server/frontend/build');
|
||||
|
||||
@@ -23,6 +23,7 @@ export default defineConfig(({ mode }) => {
|
||||
await fs.ensureDir(path.dirname(targetPath));
|
||||
await fs.remove(targetPath);
|
||||
await fs.copy(sourcePath, targetPath);
|
||||
console.log('✓ Build copied to inventory-server/frontend/build');
|
||||
} catch (error) {
|
||||
console.error('Error copying build files:', error);
|
||||
process.exit(1);
|
||||
|
||||
Reference in New Issue
Block a user