diff --git a/dashboard-COPY b/dashboard-COPY deleted file mode 160000 index 1200d26..0000000 --- a/dashboard-COPY +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 1200d268665ae1effbbd67511f13a0fd4d99d851 diff --git a/inventory/package-lock.json b/inventory/package-lock.json index 898953e..870470d 100644 --- a/inventory/package-lock.json +++ b/inventory/package-lock.json @@ -52,9 +52,11 @@ "date-fns": "^3.6.0", "diff": "^7.0.0", "framer-motion": "^12.4.4", + "input-otp": "^1.4.1", "js-levenshtein": "^1.1.6", "lodash": "^4.17.21", "lucide-react": "^0.469.0", + "luxon": "^3.5.0", "motion": "^11.18.0", "next-themes": "^0.4.4", "react": "^18.3.1", @@ -75,7 +77,8 @@ "uuid": "^11.0.5", "vaul": "^1.1.2", "xlsx": "^0.18.5", - "zod": "^3.24.2" + "zod": "^3.24.2", + "zustand": "^5.0.2" }, "devDependencies": { "@eslint/js": "^9.17.0", @@ -5732,6 +5735,16 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/input-otp": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz", + "integrity": "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -6104,6 +6117,15 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/luxon": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz", + "integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -8316,6 +8338,35 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zustand": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.5.tgz", + "integrity": "sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/inventory/package.json b/inventory/package.json index db6a0a2..9487769 100644 --- a/inventory/package.json +++ b/inventory/package.json @@ -54,9 +54,11 @@ "date-fns": "^3.6.0", "diff": "^7.0.0", "framer-motion": "^12.4.4", + "input-otp": "^1.4.1", "js-levenshtein": "^1.1.6", "lodash": "^4.17.21", "lucide-react": "^0.469.0", + "luxon": "^3.5.0", "motion": "^11.18.0", "next-themes": "^0.4.4", "react": "^18.3.1", @@ -77,7 +79,8 @@ "uuid": "^11.0.5", "vaul": "^1.1.2", "xlsx": "^0.18.5", - "zod": "^3.24.2" + "zod": "^3.24.2", + "zustand": "^5.0.2" }, "devDependencies": { "@eslint/js": "^9.17.0", diff --git a/inventory/src/App.tsx b/inventory/src/App.tsx index 26f6592..a8dc7a7 100644 --- a/inventory/src/App.tsx +++ b/inventory/src/App.tsx @@ -2,7 +2,7 @@ import { Routes, Route, useNavigate, Navigate, useLocation } from 'react-router- import { MainLayout } from './components/layout/MainLayout'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Products } from './pages/Products'; -import { Dashboard } from './pages/Dashboard'; +import { Overview } from './pages/Overview'; import { Settings } from './pages/Settings'; import { Analytics } from './pages/Analytics'; import { Toaster } from '@/components/ui/sonner'; @@ -20,6 +20,8 @@ import { Protected } from './components/auth/Protected'; import { FirstAccessiblePage } from './components/auth/FirstAccessiblePage'; import { Brands } from '@/pages/Brands'; import { Chat } from '@/pages/Chat'; +import { Dashboard } from '@/pages/Dashboard'; +import { SmallDashboard } from '@/pages/SmallDashboard'; const queryClient = new QueryClient(); function App() { @@ -74,6 +76,7 @@ function App() { } /> + } /> @@ -81,12 +84,12 @@ function App() { }> }> - + } /> - + } /> } /> + + + + } /> } /> diff --git a/inventory/src/components/chat/ChatRoom.tsx b/inventory/src/components/chat/ChatRoom.tsx index 33821f9..0a6e9f6 100644 --- a/inventory/src/components/chat/ChatRoom.tsx +++ b/inventory/src/components/chat/ChatRoom.tsx @@ -1,9 +1,8 @@ -import React, { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { Loader2, Hash, Lock, MessageSquare, ChevronUp, Search, ExternalLink, FileText, Image, Download, MessageCircle, Users2 } from 'lucide-react'; +import { Loader2, Hash, MessageSquare, ChevronUp, Search, ExternalLink, FileText, Image, Download, MessageCircle, Users2 } from 'lucide-react'; import { Input } from '@/components/ui/input'; import config from '@/config'; import { convertEmojiShortcodes } from '@/utils/emojiUtils'; @@ -63,7 +62,6 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) { const [error, setError] = useState(null); const [hasMore, setHasMore] = useState(true); const [searchQuery, setSearchQuery] = useState(''); - const [searchResults, setSearchResults] = useState([]); const [showSearch, setShowSearch] = useState(false); const messagesEndRef = useRef(null); @@ -180,7 +178,7 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) { const data = await response.json(); if (data.status === 'success') { - setSearchResults(data.results); + // Handle search results } } catch (err) { console.error('Error searching messages:', err); diff --git a/inventory/src/components/chat/ChatTest.tsx b/inventory/src/components/chat/ChatTest.tsx index 7630a1a..82627c8 100644 --- a/inventory/src/components/chat/ChatTest.tsx +++ b/inventory/src/components/chat/ChatTest.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import { useState, useEffect } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Loader2, Hash, Lock, Users, MessageSquare } from 'lucide-react'; diff --git a/inventory/src/components/chat/RoomList.tsx b/inventory/src/components/chat/RoomList.tsx index bafa04e..44f4b2a 100644 --- a/inventory/src/components/chat/RoomList.tsx +++ b/inventory/src/components/chat/RoomList.tsx @@ -1,9 +1,7 @@ import React, { useState, useEffect } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { Loader2, Hash, Lock, Users, MessageSquare, Search, MessageCircle, Users2 } from 'lucide-react'; -import { Input } from '@/components/ui/input'; +import { Loader2, Hash, Users, MessageSquare, MessageCircle, Users2 } from 'lucide-react'; import config from '@/config'; interface Room { @@ -38,7 +36,7 @@ export function RoomList({ selectedUserId, selectedRoomId, onRoomSelect }: RoomL const [filteredRooms, setFilteredRooms] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [searchFilter, setSearchFilter] = useState(''); + const [searchFilter] = useState(''); useEffect(() => { if (!selectedUserId) { diff --git a/inventory/src/components/chat/SearchResults.tsx b/inventory/src/components/chat/SearchResults.tsx index 73c2c34..3a033c7 100644 --- a/inventory/src/components/chat/SearchResults.tsx +++ b/inventory/src/components/chat/SearchResults.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; diff --git a/inventory/src/components/dashboard/AcotTest.jsx b/inventory/src/components/dashboard/AcotTest.jsx new file mode 100644 index 0000000..8fa503f --- /dev/null +++ b/inventory/src/components/dashboard/AcotTest.jsx @@ -0,0 +1,133 @@ +import React, { useState, useEffect } from "react"; +import axios from "axios"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/dashboard/ui/card"; +import { Button } from "@/components/dashboard/ui/button"; +import { Alert, AlertDescription, AlertTitle } from "@/components/dashboard/ui/alert"; +import { Loader2, AlertCircle, CheckCircle, RefreshCw } from "lucide-react"; + +const AcotTest = () => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + const [connectionStatus, setConnectionStatus] = useState(null); + + const testConnection = async () => { + setLoading(true); + setError(null); + try { + const response = await axios.get("/api/acot/test/test-connection"); + setConnectionStatus(response.data); + } catch (err) { + setError(err.response?.data?.error || err.message); + setConnectionStatus(null); + } finally { + setLoading(false); + } + }; + + const fetchOrderCount = async () => { + setLoading(true); + setError(null); + try { + const response = await axios.get("/api/acot/test/order-count"); + setData(response.data.data); + } catch (err) { + setError(err.response?.data?.error || err.message); + setData(null); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + testConnection(); + }, []); + + return ( + + + + ACOT Server Test + + + + + {/* Connection Status */} +
+

Connection Status

+ {connectionStatus?.success ? ( + + + Connected + + {connectionStatus.message} + + + ) : error ? ( + + + Connection Failed + {error} + + ) : ( +
+ Testing connection... +
+ )} +
+ + {/* Order Count */} + {connectionStatus?.success && ( +
+ + + {data && ( +
+
+ Total Orders in Database +
+
+ {data.orderCount?.toLocaleString()} +
+
+ Last updated: {new Date(data.timestamp).toLocaleTimeString()} +
+
+ )} +
+ )} +
+
+ ); +}; + +export default AcotTest; \ No newline at end of file diff --git a/inventory/src/components/dashboard/AircallDashboard.jsx b/inventory/src/components/dashboard/AircallDashboard.jsx new file mode 100644 index 0000000..2790994 --- /dev/null +++ b/inventory/src/components/dashboard/AircallDashboard.jsx @@ -0,0 +1,608 @@ +// components/AircallDashboard.jsx +import React, { useState, useEffect } from "react"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/dashboard/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/dashboard/ui/select"; +import { Alert, AlertDescription } from "@/components/dashboard/ui/alert"; +import { Skeleton } from "@/components/dashboard/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/dashboard/ui/table"; +import { + PhoneCall, + PhoneMissed, + Clock, + UserCheck, + PhoneIncoming, + PhoneOutgoing, + ArrowUpDown, + Timer, + Loader2, + Download, + Search, +} from "lucide-react"; +import { Button } from "@/components/dashboard/ui/button"; +import { Input } from "@/components/dashboard/ui/input"; +import { Progress } from "@/components/dashboard/ui/progress"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/dashboard/ui/tooltip"; +import { + LineChart, + Line, + 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 +}; + +const TIME_RANGES = [ + { label: "Today", value: "today" }, + { label: "Yesterday", value: "yesterday" }, + { label: "Last 7 Days", value: "last7days" }, + { label: "Last 30 Days", value: "last30days" }, + { label: "Last 90 Days", value: "last90days" }, +]; + +const REFRESH_INTERVAL = 5 * 60 * 1000; + +const formatDuration = (seconds) => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainingSeconds = seconds % 60; + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + return `${minutes}m ${remainingSeconds}s`; +}; + +const MetricCard = ({ title, value, subtitle, icon: Icon, iconColor }) => ( + + + {title} + + + +
{value}
+ {subtitle && ( +

{subtitle}

+ )} +
+
+); + +const CustomTooltip = ({ active, payload, label }) => { + if (active && payload && payload.length) { + return ( + + +

{label}

+ {payload.map((entry, index) => ( +

+ {`${entry.name}: ${entry.value}`} +

+ ))} +
+
+ ); + } + 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 ( + + + + Agent + handleSort("total")}>Total Calls + handleSort("answered")}>Answered + handleSort("missed")}>Missed + handleSort("average_duration")}>Average Duration + + + + {agents.map((agent) => ( + + {agent.name} + {agent.total} + {agent.answered} + {agent.missed} + {formatDuration(agent.average_duration)} + + ))} + +
+ ); +}; + +const SkeletonMetricCard = () => ( + + + + +
+ + +
+
+
+); + +const SkeletonChart = ({ type = "line" }) => ( +
+
+
+ {type === "bar" ? ( +
+ {[...Array(24)].map((_, i) => ( +
+ ))} +
+ ) : ( +
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+
+ )} +
+
+
+); + +const SkeletonTable = ({ rows = 5 }) => ( + + + + + + + + + + + + {[...Array(rows)].map((_, i) => ( + + + + + + + + ))} + +
+); + +const AircallDashboard = () => { + const [timeRange, setTimeRange] = useState("last7days"); + const [metrics, setMetrics] = useState(null); + const [lastUpdated, setLastUpdated] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [agentSort, setAgentSort] = useState({ + key: "total", + direction: "desc", + }); + + 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) => { + const multiplier = agentSort.direction === "desc" ? -1 : 1; + return multiplier * (a[agentSort.key] - b[agentSort.key]); + }) + : []; + + 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) => ({ + hour: new Date(2000, 0, 1, hour).toLocaleString('en-US', { + hour: 'numeric', + hour12: true + }).toUpperCase(), + calls: count || 0, + })) + : [], + + missedReasons: metrics?.by_missed_reason + ? Object.entries(metrics.by_missed_reason).map(([reason, count]) => ({ + reason: (reason || "").replace(/_/g, " "), + count: count || 0, + })) + : [], + + daily: safeArray(metrics?.daily_data).map((day) => ({ + ...day, + inbound: day.inbound || 0, + outbound: day.outbound || 0, + date: new Date(day.date).toLocaleString('en-US', { + month: 'short', + day: 'numeric' + }), + })), + }; + + const peakHour = metrics?.by_hour + ? metrics.by_hour.indexOf(Math.max(...metrics.by_hour)) + : null; + + const busyAgent = sortedAgents?.length > 0 ? sortedAgents[0] : null; + + const bestAnswerRate = sortedAgents + ?.filter((agent) => agent.total > 0) + ?.sort((a, b) => b.answered / b.total - a.answered / a.total)[0]; + + const fetchData = async () => { + try { + setIsLoading(true); + const response = await fetch(`/api/aircall/metrics/${timeRange}`); + if (!response.ok) throw new Error("Failed to fetch metrics"); + const data = await response.json(); + setMetrics(data); + setLastUpdated(data._meta?.generatedAt); + setError(null); + } catch (err) { + setError(err.message); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchData(); + const interval = setInterval(fetchData, REFRESH_INTERVAL); + return () => clearInterval(interval); + }, [timeRange]); + + if (error) { + return ( + + +
+ Error loading call data: {error} +
+
+
+ ); + } + + return ( +
+ + +
+
+ Calls +
+ + +
+
+ + + {/* Metric Cards */} +
+ {isLoading ? ( + [...Array(4)].map((_, i) => ( + + )) + ) : metrics ? ( + <> + + + Total Calls +
{metrics.total}
+
+
+ ↑ {metrics.by_direction.inbound} inbound +
+
+ ↓ {metrics.by_direction.outbound} outbound +
+
+
+
+ + + Answer Rate +
+ {`${((metrics.by_status.answered / metrics.total) * 100).toFixed(1)}%`} +
+
+
+ {metrics.by_status.answered} answered +
+
+ {metrics.by_status.missed} missed +
+
+
+
+ + + Peak Hour +
+ {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'} +
+
+ Busiest Agent: {sortedAgents[0]?.name || "N/A"} +
+
+
+ + + Avg Duration + + + +
+
+ {formatDuration(metrics.average_duration)} +
+
+ {metrics?.daily_data?.length > 0 + ? `${Math.round(metrics.total / metrics.daily_data.length)} calls/day` + : "N/A"} +
+
+
+ +
+

Duration Distribution

+ {metrics?.duration_distribution?.map((d, i) => ( +
+ {d.range} + {d.count} calls +
+ ))} +
+
+
+
+
+
+ + ) : null} +
+ + {/* Charts and Tables Section */} +
+ {/* Charts Row */} +
+ {/* Daily Call Volume */} + + + Daily Call Volume + + + {isLoading ? ( + + ) : ( + + + + + + } /> + + + + + + )} + + + + {/* Hourly Distribution */} + + + Hourly Distribution + + + {isLoading ? ( + + ) : ( + + + + + + } /> + + + + )} + + +
+ + {/* Tables Row */} +
+ {/* Agent Performance */} + + + Agent Performance + + + {isLoading ? ( + + ) : ( +
+ setAgentSort({ key, direction })} + /> +
+ )} +
+
+ + {/* Missed Call Reasons Table */} + + + Missed Call Reasons + + + {isLoading ? ( + + ) : ( +
+ + + + Reason + Count + + + + {chartData.missedReasons.map((reason, index) => ( + + + {reason.reason} + + + {reason.count} + + + ))} + +
+
+ )} +
+
+
+
+
+
+
+ ); +}; + +export default AircallDashboard; diff --git a/inventory/src/components/dashboard/AnalyticsDashboard.jsx b/inventory/src/components/dashboard/AnalyticsDashboard.jsx new file mode 100644 index 0000000..9ee54bc --- /dev/null +++ b/inventory/src/components/dashboard/AnalyticsDashboard.jsx @@ -0,0 +1,597 @@ +import React, { useState, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/dashboard/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/dashboard/ui/select"; +import { Button } from "@/components/dashboard/ui/button"; +import { Separator } from "@/components/dashboard/ui/separator"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, + ReferenceLine, +} from "recharts"; +import { Loader2, TrendingUp, AlertCircle } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/dashboard/ui/alert"; +import { Skeleton } from "@/components/dashboard/ui/skeleton"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/dashboard/ui/dialog"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/dashboard/ui/table"; + +// 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 = () => ( +
+
+
+ {/* Grid lines */} + {[...Array(5)].map((_, i) => ( +
+ ))} + {/* Y-axis labels */} +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ {/* X-axis labels */} +
+ {[...Array(6)].map((_, i) => ( + + ))} +
+ {/* Chart line */} +
+
+
+
+
+
+
+
+); + +const SkeletonStats = () => ( +
+ {[...Array(4)].map((_, i) => ( + + + + + + + + + + ))} +
+); + +const SkeletonButtons = () => ( +
+ {[...Array(4)].map((_, i) => ( + + ))} +
+); + +// Add StatCard component +const StatCard = ({ + title, + value, + description, + trend, + trendValue, + colorClass = "text-gray-900 dark:text-gray-100", +}) => ( + + + {title} + {trend && ( + + {trendValue} + + )} + + +
{value}
+ {description && ( +
{description}
+ )} +
+
+); + +// Add color constants +const METRIC_COLORS = { + activeUsers: { + color: "#8b5cf6", + className: "text-purple-600 dark:text-purple-400", + }, + newUsers: { + color: "#10b981", + className: "text-emerald-600 dark:text-emerald-400", + }, + pageViews: { + color: "#f59e0b", + className: "text-amber-600 dark:text-amber-400", + }, + conversions: { + color: "#3b82f6", + className: "text-blue-600 dark:text-blue-400", + }, +}; + +export const AnalyticsDashboard = () => { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [timeRange, setTimeRange] = useState("30"); + const [metrics, setMetrics] = useState({ + activeUsers: true, + newUsers: true, + pageViews: true, + conversions: true, + }); + + useEffect(() => { + const fetchData = async () => { + setLoading(true); + try { + const response = await fetch( + `/api/dashboard-analytics/metrics?startDate=${timeRange}daysAgo`, + { + credentials: "include", + } + ); + + if (!response.ok) { + throw new Error("Failed to fetch metrics"); + } + + const result = await response.json(); + + if (!result?.data?.rows) { + console.log("No result data received"); + return; + } + + const processedData = result.data.rows.map((row) => ({ + date: formatGADate(row.dimensionValues[0].value), + activeUsers: parseInt(row.metricValues[0].value), + newUsers: parseInt(row.metricValues[1].value), + avgSessionDuration: parseFloat(row.metricValues[2].value), + pageViews: parseInt(row.metricValues[3].value), + bounceRate: parseFloat(row.metricValues[4].value) * 100, + conversions: parseInt(row.metricValues[5].value), + })); + + const sortedData = processedData.sort((a, b) => a.date - b.date); + setData(sortedData); + } catch (error) { + console.error("Failed to fetch analytics:", error); + } finally { + setLoading(false); + } + }; + fetchData(); + }, [timeRange]); + + const formatGADate = (gaDate) => { + const year = gaDate.substring(0, 4); + const month = gaDate.substring(4, 6); + const day = gaDate.substring(6, 8); + return new Date(year, month - 1, day); + }; + + const formatXAxis = (date) => { + if (!date) return ""; + return date.toLocaleDateString([], { month: "short", day: "numeric" }); + }; + + const calculateSummaryStats = () => { + if (!data.length) return null; + + const totals = data.reduce( + (acc, day) => ({ + activeUsers: acc.activeUsers + day.activeUsers, + newUsers: acc.newUsers + day.newUsers, + pageViews: acc.pageViews + day.pageViews, + conversions: acc.conversions + day.conversions, + }), + { + activeUsers: 0, + newUsers: 0, + pageViews: 0, + conversions: 0, + } + ); + + const averages = { + activeUsers: totals.activeUsers / data.length, + newUsers: totals.newUsers / data.length, + pageViews: totals.pageViews / data.length, + conversions: totals.conversions / data.length, + }; + + return { totals, averages }; + }; + + const summaryStats = calculateSummaryStats(); + + const CustomTooltip = ({ active, payload, label }) => { + if (active && payload && payload.length) { + return ( + + +

+ {label instanceof Date ? label.toLocaleDateString() : label} +

+
+ {payload.map((entry, index) => ( +
+ {entry.name}: + + {entry.value.toLocaleString()} + +
+ ))} +
+
+
+ ); + } + return null; + }; + + return ( + + +
+
+
+ + Analytics Overview + +
+
+ {loading ? ( + + ) : ( + <> + + + + + + + Daily Details +
+
+ {Object.entries(metrics).map(([key, value]) => ( + + ))} +
+
+
+
+
+ + + + Date + {metrics.activeUsers && ( + Active Users + )} + {metrics.newUsers && ( + New Users + )} + {metrics.pageViews && ( + Page Views + )} + {metrics.conversions && ( + Conversions + )} + + + + {data.map((day) => ( + + {formatXAxis(day.date)} + {metrics.activeUsers && ( + + {day.activeUsers.toLocaleString()} + + )} + {metrics.newUsers && ( + + {day.newUsers.toLocaleString()} + + )} + {metrics.pageViews && ( + + {day.pageViews.toLocaleString()} + + )} + {metrics.conversions && ( + + {day.conversions.toLocaleString()} + + )} + + ))} + +
+
+
+
+
+ + + )} +
+
+ + {loading ? ( + + ) : summaryStats ? ( +
+ + + + +
+ ) : null} + +
+
+ + + + +
+
+
+
+ + + {loading ? ( + + ) : !data.length ? ( +
+
+ +
No analytics data available
+
+ Try selecting a different time range +
+
+
+ ) : ( +
+ + + + + + + } /> + + {metrics.activeUsers && ( + + )} + {metrics.newUsers && ( + + )} + {metrics.pageViews && ( + + )} + {metrics.conversions && ( + + )} + + +
+ )} +
+
+ ); +}; + +export default AnalyticsDashboard; \ No newline at end of file diff --git a/inventory/src/components/dashboard/DateTime.jsx b/inventory/src/components/dashboard/DateTime.jsx new file mode 100644 index 0000000..e5cd1cc --- /dev/null +++ b/inventory/src/components/dashboard/DateTime.jsx @@ -0,0 +1,456 @@ +import React, { useState, useEffect } from 'react'; +import { Card, CardContent } from '@/components/dashboard/ui/card'; +import { Calendar as CalendarComponent } from '@/components/dashboard/ui/calendaredit'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/dashboard/ui/popover'; +import { Alert, AlertDescription } from '@/components/dashboard/ui/alert'; +import { + Sun, + Cloud, + CloudRain, + CloudDrizzle, + CloudSnow, + CloudLightning, + CloudFog, + CloudSun, + CircleAlert, + Tornado, + Haze, + Moon, + Wind, + Droplets, + ThermometerSun, + ThermometerSnowflake, + Sunrise, + Sunset, + AlertTriangle, + Umbrella, + ChevronLeft, + ChevronRight +} from 'lucide-react'; +import { cn } from "@/lib/utils"; + +const DateTimeWeatherDisplay = ({ scaleFactor = 1 }) => { + const [datetime, setDatetime] = useState(new Date()); + const [prevTime, setPrevTime] = useState(getTimeComponents(new Date())); + const [isTimeChanging, setIsTimeChanging] = useState(false); + const [mounted, setMounted] = useState(false); + const [weather, setWeather] = useState(null); + const [forecast, setForecast] = useState(null); + + useEffect(() => { + setTimeout(() => setMounted(true), 150); + + const timer = setInterval(() => { + const newDate = new Date(); + const newTime = getTimeComponents(newDate); + + if (newTime.minutes !== prevTime.minutes) { + setIsTimeChanging(true); + setTimeout(() => setIsTimeChanging(false), 200); + } + + setPrevTime(newTime); + setDatetime(newDate); + }, 1000); + return () => clearInterval(timer); + }, [prevTime]); + + useEffect(() => { + const fetchWeatherData = async () => { + try { + const API_KEY = import.meta.env.VITE_OPENWEATHER_API_KEY; + const [weatherResponse, forecastResponse] = await Promise.all([ + fetch( + `https://api.openweathermap.org/data/2.5/weather?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial` + ), + fetch( + `https://api.openweathermap.org/data/2.5/forecast?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial` + ) + ]); + + const weatherData = await weatherResponse.json(); + const forecastData = await forecastResponse.json(); + + setWeather(weatherData); + + // Process forecast data to get daily forecasts with precipitation + const dailyForecasts = forecastData.list.reduce((acc, item) => { + const date = new Date(item.dt * 1000).toLocaleDateString(); + if (!acc[date]) { + acc[date] = { + ...item, + precipitation: item.rain?.['3h'] || item.snow?.['3h'] || 0, + pop: item.pop * 100 // Probability of precipitation as percentage + }; + } + return acc; + }, {}); + + setForecast(Object.values(dailyForecasts).slice(0, 5)); + } catch (error) { + console.error("Error fetching weather:", error); + } + }; + + fetchWeatherData(); + const weatherTimer = setInterval(fetchWeatherData, 300000); + return () => clearInterval(weatherTimer); + }, []); + + function getTimeComponents(date) { + let hours = date.getHours(); + const minutes = date.getMinutes(); + const ampm = hours >= 12 ? 'PM' : 'AM'; + hours = hours % 12; + hours = hours ? hours : 12; + return { + hours: hours.toString(), + minutes: minutes.toString().padStart(2, '0'), + ampm + }; + } + + const formatDate = (date) => { + return { + weekday: date.toLocaleDateString('en-US', { weekday: 'long' }), + month: date.toLocaleDateString('en-US', { month: 'long' }), + day: date.getDate() + }; + }; + + const getWeatherIcon = (weatherCode, currentTime, small = false) => { + if (!weatherCode) return ; + const code = parseInt(weatherCode, 10); + const iconProps = small ? "w-8 h-8" : "w-12 h-12"; + const isNight = currentTime.getHours() >= 18 || currentTime.getHours() < 6; + + switch (true) { + case code >= 200 && code < 300: + return ; + case code >= 300 && code < 500: + return ; + case code >= 500 && code < 600: + return ; + case code >= 600 && code < 700: + return ; + case code >= 700 && code < 721: + return ; + case code === 721: + return ; + case code >= 722 && code < 781: + return ; + case code === 781: + return ; + case code === 800: + return currentTime.getHours() >= 6 && currentTime.getHours() < 18 ? ( + + ) : ( + + ); + case code >= 800 && code < 803: + return ; + case code >= 803: + return ; + default: + return ; + } + }; + + const getWeatherBackground = (weatherCode, isNight) => { + const code = parseInt(weatherCode, 10); + + // Thunderstorm (200-299) + if (code >= 200 && code < 300) { + return "bg-gradient-to-br from-slate-900 to-purple-800"; + } + + // Drizzle (300-399) + if (code >= 300 && code < 400) { + return "bg-gradient-to-br from-slate-800 to-blue-800"; + } + + // Rain (500-599) + if (code >= 500 && code < 600) { + return "bg-gradient-to-br from-slate-800 to-blue-800"; + } + + // Snow (600-699) + if (code >= 600 && code < 700) { + return "bg-gradient-to-br from-slate-700 to-blue-800"; + } + + // Atmosphere (700-799: mist, smoke, haze, fog, etc.) + if (code >= 700 && code < 800) { + return "bg-gradient-to-br from-slate-700 to-slate-500"; + } + + // Clear (800) + if (code === 800) { + if (isNight) { + return "bg-gradient-to-br from-slate-900 to-blue-900"; + } + return "bg-gradient-to-br from-blue-600 to-sky-400"; + } + + // Clouds (801-804) + if (code > 800) { + if (isNight) { + return "bg-gradient-to-br from-slate-800 to-slate-600"; + } + return "bg-gradient-to-br from-slate-600 to-slate-400"; + } + + // Default fallback + return "bg-gradient-to-br from-slate-700 to-slate-500"; + }; + + const getTemperatureColor = (weatherCode, isNight) => { + const code = parseInt(weatherCode, 10); + + // Snow - dark background, light text + if (code >= 600 && code < 700) { + return "text-white"; + } + + // Clear day - light background, dark text + if (code === 800 && !isNight) { + return "text-white"; + } + + // Cloudy day - medium background, ensure contrast + if (code > 800 && !isNight) { + return "text-white"; + } + + // All other cases (darker backgrounds) + return "text-white"; + }; + + const { hours, minutes, ampm } = getTimeComponents(datetime); + const dateInfo = formatDate(datetime); + + const formatTime = (timestamp) => { + if (!timestamp) return '--:--'; + const date = new Date(timestamp * 1000); + return date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + }; + + const WeatherDetails = () => ( +
+
+ +
+ +
+ High + {Math.round(weather.main.temp_max)}°F +
+
+
+ + +
+ +
+ Low + {Math.round(weather.main.temp_min)}°F +
+
+
+ + +
+ +
+ Humidity + {weather.main.humidity}% +
+
+
+ + +
+ +
+ Wind + {Math.round(weather.wind.speed)} mph +
+
+
+ + +
+ +
+ Sunrise + {formatTime(weather.sys?.sunrise)} +
+
+
+ + +
+ +
+ Sunset + {formatTime(weather.sys?.sunset)} +
+
+
+
+ + {forecast && ( +
+
+ {forecast.map((day, index) => { + const forecastTime = new Date(day.dt * 1000); + const isNight = forecastTime.getHours() >= 18 || forecastTime.getHours() < 6; + return ( + +
+ + {forecastTime.toLocaleDateString('en-US', { weekday: 'short' })} + + {getWeatherIcon(day.weather[0].id, forecastTime, true)} +
+ + {Math.round(day.main.temp_max)}° + + + {Math.round(day.main.temp_min)}° + +
+
+ {day.rain?.['3h'] > 0 && ( +
+ + {day.rain['3h'].toFixed(2)}" +
+ )} + {day.snow?.['3h'] > 0 && ( +
+ + {day.snow['3h'].toFixed(2)}" +
+ )} + {!day.rain?.['3h'] && !day.snow?.['3h'] && ( +
+ + 0" +
+ )} +
+
+
+ ); + })} +
+
+ )} +
+ ); + + return ( +
+ {/* Time Display */} + + +
+
+ {hours} + : + {minutes} + {ampm} +
+
+
+
+ + {/* Date and Weather Display */} +
+ + +
+ + {dateInfo.day} + + + {dateInfo.weekday} + +
+
+
+ + {weather?.main && ( + + + = 18 || datetime.getHours() < 6 + ), + "flex items-center justify-center cursor-pointer hover:brightness-110 transition-all relative backdrop-blur-sm" + )}> + +
+ {getWeatherIcon(weather.weather[0]?.id, datetime)} + + {Math.round(weather.main.temp)}° + +
+
+ {weather.alerts && ( +
+ +
+ )} +
+
+ + {weather.alerts && ( + + + + {weather.alerts[0].event} + + + )} + + +
+ )} +
+ + {/* Calendar Display */} + + + + + +
+ ); +}; + +export default DateTimeWeatherDisplay; \ No newline at end of file diff --git a/inventory/src/components/dashboard/EventFeed.jsx b/inventory/src/components/dashboard/EventFeed.jsx new file mode 100644 index 0000000..1d2211c --- /dev/null +++ b/inventory/src/components/dashboard/EventFeed.jsx @@ -0,0 +1,1622 @@ +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import axios from "axios"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/dashboard/ui/card"; +import { Badge } from "@/components/dashboard/ui/badge"; +import { ScrollArea } from "@/components/dashboard/ui/scroll-area"; +import { + Package, + Truck, + UserPlus, + XCircle, + DollarSign, + ChevronRight, + Tag, + Box, + Activity, + RefreshCcw, + FileText, + AlertCircle, +} from "lucide-react"; +import { format } from "date-fns"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/dashboard/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/dashboard/ui/select"; +import { Skeleton } from "@/components/dashboard/ui/skeleton"; +import { Button } from "@/components/dashboard/ui/button"; +import { Separator } from "@/components/dashboard/ui/separator"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/dashboard/ui/tooltip"; +import { Alert, AlertDescription, AlertTitle } from "@/components/dashboard/ui/alert"; + +const METRIC_IDS = { + PLACED_ORDER: "Y8cqcF", + SHIPPED_ORDER: "VExpdL", + ACCOUNT_CREATED: "TeeypV", + CANCELED_ORDER: "YjVMNg", + NEW_BLOG_POST: "YcxeDr", + PAYMENT_REFUNDED: "R7XUYh", +}; + +const EVENT_ICONS = { + [METRIC_IDS.PLACED_ORDER]: Package, + [METRIC_IDS.SHIPPED_ORDER]: Truck, + [METRIC_IDS.ACCOUNT_CREATED]: UserPlus, + [METRIC_IDS.CANCELED_ORDER]: XCircle, + [METRIC_IDS.PAYMENT_REFUNDED]: DollarSign, + [METRIC_IDS.NEW_BLOG_POST]: FileText, +}; + +const EVENT_TYPES = { + [METRIC_IDS.PLACED_ORDER]: { + label: "Order Placed", + color: "bg-green-500 dark:bg-green-600", + textColor: "text-green-600 dark:text-green-400", + }, + [METRIC_IDS.SHIPPED_ORDER]: { + label: "Order Shipped", + color: "bg-blue-500 dark:bg-blue-600", + textColor: "text-blue-600 dark:text-blue-400", + }, + [METRIC_IDS.ACCOUNT_CREATED]: { + label: "New Account", + color: "bg-purple-500 dark:bg-purple-600", + textColor: "text-purple-600 dark:text-purple-400", + }, + [METRIC_IDS.CANCELED_ORDER]: { + label: "Order Canceled", + color: "bg-red-500 dark:bg-red-600", + textColor: "text-red-600 dark:text-red-400", + }, + [METRIC_IDS.PAYMENT_REFUNDED]: { + label: "Payment Refunded", + color: "bg-orange-500 dark:bg-orange-600", + textColor: "text-orange-600 dark:text-orange-400", + }, + [METRIC_IDS.NEW_BLOG_POST]: { + label: "New Blog Post", + color: "bg-indigo-500 dark:bg-indigo-600", + textColor: "text-indigo-600 dark:text-indigo-400", + }, +}; + +// Helper Functions +const formatCurrency = (amount) => { + // Convert to number if it's a string + const num = typeof amount === "string" ? parseFloat(amount) : amount; + // Handle negative numbers + const absNum = Math.abs(num); + // Format to 2 decimal places and add negative sign if needed + return `${num < 0 ? "-" : ""}$${absNum.toFixed(2)}`; +}; + +const toTitleCase = (str) => { + if (!str) return ""; + return str + .toLowerCase() + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +}; + +const formatShipMethod = (method) => { + if (!method) return "Standard Shipping"; + return method + .replace("usps_", "USPS ") + .replace("ups_", "UPS ") + .replace("fedex_", "FedEx ") + .replace(/_/g, " ") + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +}; + +const formatShipMethodSimple = (method) => { + if (!method) return "Digital"; + if (method.includes("usps")) return "USPS"; + if (method.includes("fedex")) return "FedEx"; + if (method.includes("ups")) return "UPS"; + return "Standard"; +}; + +// Loading State Component +const LoadingState = () => ( +
+ {[...Array(8)].map((_, i) => ( +
+
+ +
+
+
+ +
+
+ +
+
+ + + +
+
+
+ + +
+
+ ))} +
+); + +// Empty State Component +const EmptyState = () => ( +
+
+ +
+

+ No activity yet today +

+

+ Recent activity will appear here as it happens +

+
+); + +// Shared Components +const OrderStatusTags = ({ details }) => ( +
+ {details.HasPreorder && ( + + Includes Pre-order + + )} + {details.LocalPickup && ( + + Local Pickup + + )} + {details.IsOnHold && ( + + On Hold + + )} + {details.HasDigiItem && ( + + Digital Items + + )} + {details.HasNotions && ( + + Includes Notions + + )} + {details.HasDigitalGC && ( + + Gift Card + + )} + {details.StillOwes && ( + + Payment Due + + )} +
+); + +const ProductCard = ({ product }) => ( +
+
+
+
+

+ {product.ProductName || "Unnamed Product"} +

+ {product.ItemStatus === "Pre-Order" && ( + + Pre-order + + )} +
+
+ {product.Brand && ( +
+ + {product.Brand} +
+ )} + {product.SKU && ( +
+ + SKU: {product.SKU} +
+ )} +
+
+
+
+ {formatCurrency(product.ItemPrice)} +
+
+ Qty: {product.Quantity || product.QuantityOrdered || 1} +
+ {product.RowTotal && ( +
+ Total: {formatCurrency(product.RowTotal)} +
+ )} +
+
+
+); + +const PromotionalInfo = ({ details }) => { + if (!details?.PromosUsedReg?.length && !details?.PointsDiscount) return null; + + return ( +
+

+ Savings Applied +

+
+ {Array.isArray(details.PromosUsedReg) && + details.PromosUsedReg.map(([code, amount], index) => ( +
+ + {code} + + + -{formatCurrency(amount)} + +
+ ))} + {details.PointsDiscount > 0 && ( +
+ + Points Discount + + + -{formatCurrency(details.PointsDiscount)} + +
+ )} +
+
+ ); +}; + +const OrderSummary = ({ details }) => ( +
+
+
+

+ Subtotal +

+
+
+ + Items ({details.Items?.length || 0}) + + {formatCurrency(details.Subtotal)} +
+ {details.PointsDiscount > 0 && ( +
+ Points Discount + -{formatCurrency(details.PointsDiscount)} +
+ )} + {details.TotalDiscounts > 0 && ( +
+ Discounts + -{formatCurrency(details.TotalDiscounts)} +
+ )} +
+
+
+

+ Shipping +

+
+
+ Shipping Cost + {formatCurrency(details.ShippingTotal)} +
+ {details.SalesTax > 0 && ( +
+ Sales Tax + {formatCurrency(details.SalesTax)} +
+ )} +
+
+
+ +
+
+
+ Total + {details.TotalSavings > 0 && ( +
+ You saved {formatCurrency(details.TotalSavings)} +
+ )} +
+ + {formatCurrency(details.TotalAmount)} + +
+
+ + +
+); + +const ShippingInfo = ({ details }) => ( +
+
+

+ Shipping Address +

+
+
+ {details.ShippingName || "Customer"} +
+
{details.ShippingStreet1}
+ {details.ShippingStreet2 &&
{details.ShippingStreet2}
} +
+ {details.ShippingCity}, {details.ShippingState} {details.ShippingZip} +
+ {details.ShippingCountry !== "US" && ( +
{details.ShippingCountry}
+ )} +
+
+ {details.TrackingNumber && ( +
+

+ Tracking Information +

+
+
+ {formatShipMethod(details.ShipMethod)} +
+
+ {details.TrackingNumber} +
+
+
+ )} +
+); + +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 ( + + +

{formattedDate}

+ +
+ {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 ( +
+ + {entry.name}: + + {value} +
+ ); + })} +
+ + {previousMetrics.length > 0 && ( + <> +
+
+

Previous Period

+ {previousMetrics.map((entry, index) => { + const value = entry.dataKey.toLowerCase().includes('revenue') || + entry.dataKey.includes('avgOrderValue') + ? formatCurrency(entry.value) + : entry.value.toLocaleString(); + + return ( +
+ + {entry.name.replace('Previous ', '')}: + + {value} +
+ ); + })} +
+ + )} +
+
+ ); + } + return null; +}; + +const EventDialog = ({ event, children }) => { + const eventType = EVENT_TYPES[event.metric_id]; + if (!eventType) return children; + + const details = event.event_properties || {}; + const Icon = EVENT_ICONS[event.metric_id] || Package; + + return ( + + {children} + + +
+ {Icon && } + {eventType.label} +
+
+ + {details.OrderId + ? `Order #${details.OrderId}` + : details.title + ? details.title + : details.EmailAddress + ? details.EmailAddress + : "Event Details"} + + {event.datetime && ( + + )} +
+
+ +
+
+ {event.metric_id === METRIC_IDS.PLACED_ORDER && ( + <> +
+
+ + + Shipping Information + + +

{details.ShippingName}

+ {details.ShippingStreet1 && ( +

{details.ShippingStreet1}

+ )} + {details.ShippingStreet2 && ( +

{details.ShippingStreet2}

+ )} +

+ {details.ShippingCity}, {details.ShippingState} {details.ShippingZip} +

+ {details.ShippingCountry !== "US" && ( +

{details.ShippingCountry}

+ )} +
+
+ + + + Order Properties + + + {details.IsOnHold && ( + + On Hold + + )} + {details.OnHoldReleased && ( + + Hold Released + + )} + {details.StillOwes && ( + + Owes + + )} + {details.LocalPickup && ( + + Local + + )} + {details.HasPreorder && ( + + Pre-order + + )} + {details.HasNotions && ( + + Notions + + )} + {(details.OnlyDigitalGC || details.HasDigitalGC) && ( + + eGift Card + + )} + {(details.HasDigiItem || details.OnlyDigiItem) && ( + + Digital + + )} + + +
+ + + + Order Summary + + +
+
+ Subtotal + {formatCurrency(details.Subtotal)} +
+
+ Shipping + {formatCurrency(details.ShippingTotal)} +
+
+ Tax + {formatCurrency(details.SalesTax)} +
+ {details.PointsDiscount > 0 && ( +
+ Points Discount + -{formatCurrency(details.PointsDiscount)} +
+ )} + {Array.isArray(details.PromosUsedReg) && + details.PromosUsedReg.map(([code, amount], i) => ( +
+ {code} + -{formatCurrency(amount)} +
+ ))} +
+
+
+ Total + {formatCurrency(details.TotalAmount)} +
+
+
+
+
+ + + + Order Items + + +
+ {details.Items?.map((item, i) => ( +
+ {item.ImgThumb && ( + {item.ProductName} + )} +
+
+
+

{item.ProductName}

+

+ {item.Quantity}x @ {formatCurrency(item.ItemPrice)} +

+
+

+ {formatCurrency(item.RowTotal)} +

+
+ {item.ItemStatus && item.ItemStatus !== "Ready" && ( + + {item.ItemStatus} + + )} +
+
+ ))} +
+
+
+ + )} + + {event.metric_id === METRIC_IDS.SHIPPED_ORDER && ( + <> +
+
+ + {toTitleCase(details.ShippingName)} + + + + #{details.OrderId} + +
+
+ {formatShipMethodSimple(details.ShipMethod)} + {event.event_properties?.ShippedBy && ( + <> + + Shipped by {event.event_properties.ShippedBy} + + )} +
+
+ + )} + + {event.metric_id === METRIC_IDS.ACCOUNT_CREATED && ( + + + Customer Information + + +

{details.EmailAddress}

+

+ {details.FirstName} {details.LastName} +

+
+
+ )} + + {event.metric_id === METRIC_IDS.CANCELED_ORDER && ( + <> +
+
+ + + Cancellation Details + + +

Reason: {details.CancelReason}

+ {details.CancelMessage && ( +

{details.CancelMessage}

+ )} +
+
+ + + + Order Summary + + +
+
+ Subtotal + {formatCurrency(details.Subtotal)} +
+
+ Shipping + {formatCurrency(details.ShippingTotal)} +
+
+ Tax + {formatCurrency(details.SalesTax)} +
+
+
+
+ Total Refunded + {formatCurrency(details.TotalAmount)} +
+
+
+
+
+ + + + Canceled Items + + +
+ {details.Items?.map((item, i) => ( +
+ {item.ImgThumb && ( + {item.ProductName} + )} +
+
+
+

{item.ProductName}

+

+ {item.Quantity}x @ {formatCurrency(item.ItemPrice)} +

+
+

+ {formatCurrency(item.RowTotal)} +

+
+
+
+ ))} +
+
+
+
+ + )} + + {event.metric_id === METRIC_IDS.PAYMENT_REFUNDED && ( +
+ + + Refund Details + + +
+ Amount Refunded + + {formatCurrency(details.PaymentAmount)} + +
+
+ Payment Method + {details.PaymentName} +
+ {details.OrderMessage && ( +
+

{details.OrderMessage}

+
+ )} +
+
+ + + + Customer Information + + +

{details.EmailAddress}

+

+ {details.FirstName} {details.LastName} +

+
+
+
+ )} + + {event.metric_id === METRIC_IDS.NEW_BLOG_POST && ( + + + {details.title} + + +

+ {details.description} +

+ {details.url && ( + + Read More + + + )} +
+
+ )} +
+
+
+
+ ); +}; + +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", + }; + + const Icon = EVENT_ICONS[event.metric_id] || Package; + const details = event.event_properties || {}; + + const datetime = event.attributes?.datetime || event.datetime || event.event_properties?.datetime; + const timestamp = datetime ? new Date(datetime) : null; + const isValidDate = timestamp && !isNaN(timestamp.getTime()); + + return ( + + + + ); +}; + +const DEFAULT_METRICS = Object.values(METRIC_IDS); + +const EventFeed = ({ + title = "Event Feed", + selectedMetrics = DEFAULT_METRICS, +}) => { + const metrics = useMemo(() => selectedMetrics, [selectedMetrics]); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdate, setLastUpdate] = useState(null); + const [activeEventTypes, setActiveEventTypes] = useState({ + [METRIC_IDS.PLACED_ORDER]: true, + [METRIC_IDS.SHIPPED_ORDER]: true, + [METRIC_IDS.ACCOUNT_CREATED]: true, + [METRIC_IDS.CANCELED_ORDER]: true, + [METRIC_IDS.PAYMENT_REFUNDED]: true, + [METRIC_IDS.NEW_BLOG_POST]: true, + }); + const [orderFilters, setOrderFilters] = useState({ + hasPreorder: false, + localPickup: false, + isOnHold: false, + onHoldReleased: false, + hasDigiItem: false, + hasNotions: false, + hasGiftCard: false, + stillOwes: false, + }); + + const fetchEvents = useCallback(async () => { + try { + setError(null); + + // Only set loading true if we don't have any events yet + if (events.length === 0) { + setLoading(true); + } + + const response = await axios.get("/api/klaviyo/events/feed", { + params: { + timeRange: "today", + metricIds: JSON.stringify(metrics), + }, + }); + + // Keep the original event structure intact + const processedEvents = (response.data.data || []).map((event) => ({ + ...event, + datetime: event.attributes?.datetime || event.datetime, + // Don't spread event_properties to preserve the nested structure + event_properties: event.attributes?.event_properties || {} + })); + + setEvents(processedEvents); + setLastUpdate(new Date()); + } catch (error) { + console.error("Error fetching events:", error); + setError(error.message); + } finally { + setLoading(false); + } + }, [metrics]); + + // Fetch events on mount and every minute + useEffect(() => { + fetchEvents(); + const interval = setInterval(fetchEvents, 60000); // Refresh every minute + + return () => { + clearInterval(interval); + }; + }, [fetchEvents]); + + const filteredEvents = useMemo(() => { + // Check if any order property filters are active + const hasActiveOrderFilters = Object.values(orderFilters).some(filter => filter); + + return events.filter(event => { + // First check event type filter + if (!activeEventTypes[event.metric_id]) { + return false; + } + + // Then check order property filters if any are active + if (hasActiveOrderFilters) { + if (event.metric_id !== METRIC_IDS.PLACED_ORDER) return false; + + const details = event.event_properties || {}; + if (orderFilters.hasPreorder && !details.HasPreorder) return false; + if (orderFilters.localPickup && !details.LocalPickup) return false; + if (orderFilters.isOnHold && !details.IsOnHold) return false; + if (orderFilters.onHoldReleased && !details.OnHoldReleased) return false; + if (orderFilters.hasDigiItem && !details.HasDigiItem) return false; + if (orderFilters.hasNotions && !details.HasNotions) return false; + if (orderFilters.hasGiftCard && !details.HasDigitalGC) return false; + if (orderFilters.stillOwes && !details.StillOwes) return false; + } + + return true; + }); + }, [events, activeEventTypes, orderFilters]); + + // Calculate counts for event types and order properties + const counts = useMemo(() => { + const eventTypeCounts = { + [METRIC_IDS.PLACED_ORDER]: 0, + [METRIC_IDS.SHIPPED_ORDER]: 0, + [METRIC_IDS.ACCOUNT_CREATED]: 0, + [METRIC_IDS.CANCELED_ORDER]: 0, + [METRIC_IDS.PAYMENT_REFUNDED]: 0, + [METRIC_IDS.NEW_BLOG_POST]: 0, + }; + + const orderPropertyCounts = { + hasPreorder: 0, + localPickup: 0, + isOnHold: 0, + onHoldReleased: 0, + hasDigiItem: 0, + hasNotions: 0, + hasGiftCard: 0, + stillOwes: 0, + }; + + events.forEach(event => { + // Count event types + if (event.metric_id) { + eventTypeCounts[event.metric_id]++; + } + + // Count order properties + if (event.metric_id === METRIC_IDS.PLACED_ORDER) { + const details = event.event_properties || {}; + if (details.HasPreorder) orderPropertyCounts.hasPreorder++; + if (details.LocalPickup) orderPropertyCounts.localPickup++; + if (details.IsOnHold) orderPropertyCounts.isOnHold++; + if (details.OnHoldReleased) orderPropertyCounts.onHoldReleased++; + if (details.HasDigiItem) orderPropertyCounts.hasDigiItem++; + if (details.HasNotions) orderPropertyCounts.hasNotions++; + if (details.HasDigitalGC) orderPropertyCounts.hasGiftCard++; + if (details.StillOwes) orderPropertyCounts.stillOwes++; + } + }); + + return { + eventTypes: eventTypeCounts, + orderProperties: orderPropertyCounts, + }; + }, [events]); + + const handleOrderPropertyClick = (property) => { + setOrderFilters(prev => { + // If clicking the active filter, clear all filters + if (prev[property]) { + return { + hasPreorder: false, + localPickup: false, + isOnHold: false, + onHoldReleased: false, + hasDigiItem: false, + hasNotions: false, + hasGiftCard: false, + stillOwes: false, + }; + } + // Otherwise, set only this filter to true + return { + hasPreorder: property === 'hasPreorder', + localPickup: property === 'localPickup', + isOnHold: property === 'isOnHold', + onHoldReleased: property === 'onHoldReleased', + hasDigiItem: property === 'hasDigiItem', + hasNotions: property === 'hasNotions', + hasGiftCard: property === 'hasGiftCard', + stillOwes: property === 'stillOwes', + }; + }); + }; + + const handleEventTypeClick = (metricId) => { + setActiveEventTypes(prev => { + // If clicking the only active filter, reset to all active + const activeCount = Object.values(prev).filter(Boolean).length; + if (activeCount === 1 && prev[metricId]) { + return { + [METRIC_IDS.PLACED_ORDER]: true, + [METRIC_IDS.SHIPPED_ORDER]: true, + [METRIC_IDS.ACCOUNT_CREATED]: true, + [METRIC_IDS.CANCELED_ORDER]: true, + [METRIC_IDS.PAYMENT_REFUNDED]: true, + [METRIC_IDS.NEW_BLOG_POST]: true, + }; + } + // Otherwise, set only this filter to true + return { + [METRIC_IDS.PLACED_ORDER]: metricId === METRIC_IDS.PLACED_ORDER, + [METRIC_IDS.SHIPPED_ORDER]: metricId === METRIC_IDS.SHIPPED_ORDER, + [METRIC_IDS.ACCOUNT_CREATED]: metricId === METRIC_IDS.ACCOUNT_CREATED, + [METRIC_IDS.CANCELED_ORDER]: metricId === METRIC_IDS.CANCELED_ORDER, + [METRIC_IDS.PAYMENT_REFUNDED]: metricId === METRIC_IDS.PAYMENT_REFUNDED, + [METRIC_IDS.NEW_BLOG_POST]: metricId === METRIC_IDS.NEW_BLOG_POST, + }; + }); + }; + + const EventTypeTooltipContent = () => ( +
+
+ + + Orders + + + {counts.eventTypes[METRIC_IDS.PLACED_ORDER].toLocaleString()} + +
+
+ + + Shipments + + + {counts.eventTypes[METRIC_IDS.SHIPPED_ORDER].toLocaleString()} + +
+
+ + + Accounts + + + {counts.eventTypes[METRIC_IDS.ACCOUNT_CREATED].toLocaleString()} + +
+
+ + + Cancellations + + + {counts.eventTypes[METRIC_IDS.CANCELED_ORDER].toLocaleString()} + +
+
+ + + Refunds + + + {counts.eventTypes[METRIC_IDS.PAYMENT_REFUNDED].toLocaleString()} + +
+
+ + + Blog Posts + + + {counts.eventTypes[METRIC_IDS.NEW_BLOG_POST].toLocaleString()} + +
+
+ ); + + return ( + + +
+
+ {title} + {lastUpdate && ( + + Last updated {format(lastUpdate, "h:mm a")} + + )} +
+ {!error && ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ )} +
+ + {/* Order Property Filters - update styling */} + {!error && ( +
+ {counts.orderProperties.hasPreorder > 0 && ( + handleOrderPropertyClick('hasPreorder')} + className={`${ + orderFilters.hasPreorder + ? 'bg-purple-800 text-purple-200 hover:bg-purple-700' + : 'bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-300 hover:bg-purple-100 dark:hover:bg-purple-900/20' + } cursor-pointer rounded-md`} + > + Pre-order ({counts.orderProperties.hasPreorder}) + + )} + {counts.orderProperties.localPickup > 0 && ( + handleOrderPropertyClick('localPickup')} + className={`${ + orderFilters.localPickup + ? 'bg-green-800 text-green-200 hover:bg-green-700' + : 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/20' + } cursor-pointer rounded-md`} + > + Local ({counts.orderProperties.localPickup}) + + )} + {counts.orderProperties.isOnHold > 0 && ( + handleOrderPropertyClick('isOnHold')} + className={`${ + orderFilters.isOnHold + ? 'bg-blue-800 text-blue-200 hover:bg-blue-700' + : 'bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/20' + } cursor-pointer rounded-md`} + > + On Hold ({counts.orderProperties.isOnHold}) + + )} + {counts.orderProperties.onHoldReleased > 0 && ( + handleOrderPropertyClick('onHoldReleased')} + className={`${ + orderFilters.onHoldReleased + ? 'bg-green-800 text-green-200 hover:bg-green-700' + : 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/20' + } cursor-pointer rounded-md`} + > + Hold Released ({counts.orderProperties.onHoldReleased}) + + )} + {counts.orderProperties.hasDigiItem > 0 && ( + handleOrderPropertyClick('hasDigiItem')} + className={`${ + orderFilters.hasDigiItem + ? 'bg-indigo-800 text-indigo-200 hover:bg-indigo-700' + : 'bg-indigo-100 dark:bg-indigo-900/20 text-indigo-800 dark:text-indigo-300 hover:bg-indigo-100 dark:hover:bg-indigo-900/20' + } cursor-pointer rounded-md`} + > + Digital ({counts.orderProperties.hasDigiItem}) + + )} + {counts.orderProperties.hasNotions > 0 && ( + handleOrderPropertyClick('hasNotions')} + className={`${ + orderFilters.hasNotions + ? 'bg-yellow-800 text-yellow-200 hover:bg-yellow-700' + : 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300 hover:bg-yellow-100 dark:hover:bg-yellow-900/20' + } cursor-pointer rounded-md`} + > + Notions ({counts.orderProperties.hasNotions}) + + )} + {counts.orderProperties.hasGiftCard > 0 && ( + handleOrderPropertyClick('hasGiftCard')} + className={`${ + orderFilters.hasGiftCard + ? 'bg-pink-800 text-pink-200 hover:bg-pink-700' + : 'bg-pink-100 dark:bg-pink-900/20 text-pink-800 dark:text-pink-300 hover:bg-pink-100 dark:hover:bg-pink-900/20' + } cursor-pointer rounded-md`} + > + eGift Card ({counts.orderProperties.hasGiftCard}) + + )} + {counts.orderProperties.stillOwes > 0 && ( + handleOrderPropertyClick('stillOwes')} + className={`${ + orderFilters.stillOwes + ? 'bg-red-800 text-red-200 hover:bg-red-700' + : 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-900/20' + } cursor-pointer rounded-md`} + > + Owes ({counts.orderProperties.stillOwes}) + + )} +
+ )} +
+ + + + {loading && !events.length ? ( + + ) : error ? ( + + + Error + + Failed to load event feed: {error} + + + ) : !filteredEvents || filteredEvents.length === 0 ? ( + + ) : ( +
+ {filteredEvents.map((event) => ( + + ))} +
+ )} +
+
+
+ ); +}; + +export default EventFeed; diff --git a/inventory/src/components/dashboard/GorgiasOverview.jsx b/inventory/src/components/dashboard/GorgiasOverview.jsx new file mode 100644 index 0000000..4fa0f9b --- /dev/null +++ b/inventory/src/components/dashboard/GorgiasOverview.jsx @@ -0,0 +1,580 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { Card, CardContent, CardHeader } from "@/components/dashboard/ui/card"; +import { + Select, + SelectTrigger, + SelectContent, + SelectItem, + SelectValue, +} from "@/components/dashboard/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/dashboard/ui/table"; +import { Skeleton } from "@/components/dashboard/ui/skeleton"; +import { + Clock, + Star, + MessageSquare, + Mail, + Send, + Loader2, + ArrowUp, + ArrowDown, + Zap, + Timer, + BarChart3, + ClipboardCheck, +} from "lucide-react"; +import axios from "axios"; + +const TIME_RANGES = { + "today": "Today", + "7": "Last 7 Days", + "14": "Last 14 Days", + "30": "Last 30 Days", + "90": "Last 90 Days", +}; + +const formatDuration = (seconds) => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; +}; + +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); + + const end = new Date(easternTime); + end.setHours(23, 59, 59, 999); + + return { + start_datetime: start.toISOString(), + end_datetime: end.toISOString() + }; + } + + // For other periods, calculate from end of previous day + const end = new Date(easternTime); + end.setHours(23, 59, 59, 999); + + const start = new Date(easternTime); + start.setDate(start.getDate() - Number(days)); + start.setHours(0, 0, 0, 0); + + return { + start_datetime: start.toISOString(), + end_datetime: end.toISOString() + }; +}; + +const MetricCard = ({ + title, + value, + delta, + suffix = "", + icon: Icon, + colorClass = "blue", + more_is_better = true, + loading = false, +}) => { + const getDeltaColor = (d) => { + if (d === 0) return "text-gray-600 dark:text-gray-400"; + const isPositive = d > 0; + return isPositive === more_is_better + ? "text-green-600 dark:text-green-500" + : "text-red-600 dark:text-red-500"; + }; + + const formatDelta = (d) => { + if (d === undefined || d === null) return null; + if (d === 0) return "0"; + return Math.abs(d) + suffix; + }; + + return ( + + +
+
+ {loading ? ( + <> + +
+ + +
+ + ) : ( + <> +

{title}

+
+

+ {typeof value === "number" + ? value.toLocaleString() + suffix + : value} +

+ {delta !== undefined && delta !== 0 && ( +
+ {delta > 0 ? ( + + ) : ( + + )} + + {formatDelta(delta)} + +
+ )} +
+ + )} +
+ {!loading && Icon && ( + + )} + {loading && ( + + )} +
+
+
+ ); +}; + +const SkeletonMetricCard = () => ( + + +
+
+ +
+ + +
+
+ +
+
+
+); + +const TableSkeleton = () => ( + + + + + + + + + + + {[...Array(5)].map((_, i) => ( + + + + + + + ))} + +
+); + +const GorgiasOverview = () => { + const [timeRange, setTimeRange] = useState("7"); + const [data, setData] = useState({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadStats = useCallback(async () => { + setLoading(true); + const filters = getDateRange(timeRange); + + try { + const [overview, channelStats, agentStats, satisfaction] = + await Promise.all([ + axios.post('/api/gorgias/stats/overview', filters) + .then(res => res.data?.data?.data?.data || []), + axios.post('/api/gorgias/stats/tickets-created-per-channel', filters) + .then(res => res.data?.data?.data?.data?.lines || []), + axios.post('/api/gorgias/stats/tickets-closed-per-agent', filters) + .then(res => res.data?.data?.data?.data?.lines || []), + axios.post('/api/gorgias/stats/satisfaction-surveys', filters) + .then(res => res.data?.data?.data?.data || []), + ]); + + console.log('Raw API responses:', { + overview, + channelStats, + agentStats, + satisfaction, + }); + + setData({ + overview, + channels: channelStats, + agents: agentStats, + satisfaction, + }); + + setError(null); + } catch (err) { + console.error("Error loading stats:", err); + const errorMessage = err.response?.data?.error || err.message; + setError(errorMessage); + if (err.response?.status === 401) { + setError('Authentication failed. Please check your Gorgias API credentials.'); + } + } finally { + setLoading(false); + } + }, [timeRange]); + + useEffect(() => { + loadStats(); + // Set up auto-refresh every 5 minutes + const interval = setInterval(loadStats, 5 * 60 * 1000); + return () => clearInterval(interval); + }, [loadStats]); + + // Convert overview array to stats format + const stats = (data.overview || []).reduce((acc, item) => { + acc[item.name] = { + value: item.value || 0, + delta: item.delta || 0, + type: item.type, + more_is_better: item.more_is_better + }; + return acc; + }, {}); + + console.log('Processed stats:', stats); + + // Process satisfaction data + const satisfactionStats = (data.satisfaction || []).reduce((acc, item) => { + if (item.name !== 'response_distribution') { + acc[item.name] = { + value: item.value || 0, + delta: item.delta || 0, + type: item.type, + more_is_better: item.more_is_better + }; + } + return acc; + }, {}); + + console.log('Processed satisfaction stats:', satisfactionStats); + + // Process channel data + 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 + })) || []; + + console.log('Processed channels:', channels); + + // Process agent data + 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 + })) || []; + + console.log('Processed agents:', agents); + + if (error) { + return ( + + +
+ {error} +
+
+
+ ); + } + + return ( + + +
+

+ Customer Service +

+
+ +
+
+
+ + +
+ {/* Message & Response Metrics */} + {loading ? ( + [...Array(7)].map((_, i) => ( + + )) + ) : ( + <> +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + )} +
+ +
+ {/* Channel Distribution */} + + +

+ Channel Distribution +

+
+ + {loading ? ( + + ) : ( + + + + Channel + Total + % + Change + + + + {channels + .sort((a, b) => b.total - a.total) + .map((channel, index) => ( + + + {channel.name} + + + {channel.total} + + + {channel.percentage}% + + 0 + ? "text-green-600 dark:text-green-500" + : channel.delta < 0 + ? "text-red-600 dark:text-red-500" + : "text-muted-foreground" + }`} + > +
+ {channel.delta !== 0 && ( + <> + {channel.delta > 0 ? ( + + ) : ( + + )} + {Math.abs(channel.delta)}% + + )} +
+
+
+ ))} +
+
+ )} +
+
+ + {/* Agent Performance */} + + +

+ Agent Performance +

+
+ + {loading ? ( + + ) : ( + + + + Agent + Closed + Rating + Change + + + + {agents + .filter((agent) => agent.name !== "Unassigned") + .map((agent, index) => ( + + + {agent.name} + + + {agent.closed} + + + {agent.rating ? `${agent.rating}/5` : "-"} + + 0 + ? "text-green-600 dark:text-green-500" + : agent.delta < 0 + ? "text-red-600 dark:text-red-500" + : "text-muted-foreground" + }`} + > +
+ {agent.delta !== 0 && ( + <> + {agent.delta > 0 ? ( + + ) : ( + + )} + {Math.abs(agent.delta)}% + + )} +
+
+
+ ))} +
+
+ )} +
+
+
+
+
+ ); +}; + +export default GorgiasOverview; \ No newline at end of file diff --git a/inventory/src/components/dashboard/Header.jsx b/inventory/src/components/dashboard/Header.jsx new file mode 100644 index 0000000..a297860 --- /dev/null +++ b/inventory/src/components/dashboard/Header.jsx @@ -0,0 +1,376 @@ +import React, { useState, useEffect } from "react"; +import { Card, CardContent } from "@/components/dashboard/ui/card"; +import { + Calendar, + Clock, + Sun, + Cloud, + CloudRain, + CloudDrizzle, + CloudSnow, + CloudLightning, + CloudFog, + CloudSun, + CircleAlert, + Tornado, + Haze, + Moon, + Monitor, + Wind, + Droplets, + ThermometerSun, + ThermometerSnowflake, + Sunrise, + Sunset, + AlertTriangle, + Umbrella, +} from "lucide-react"; +import { useScroll } from "@/contexts/DashboardScrollContext"; +import { useTheme } from "@/components/dashboard/theme/ThemeProvider"; +import { cn } from "@/lib/utils"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/dashboard/ui/tooltip"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/dashboard/ui/popover"; +import { Alert, AlertDescription } from "@/components/dashboard/ui/alert"; + +const CraftsIcon = () => ( + +); + +const Header = () => { + const [currentTime, setCurrentTime] = useState(new Date()); + const [weather, setWeather] = useState(null); + const [forecast, setForecast] = useState(null); + const { isStuck } = useScroll(); + const { theme, systemTheme, toggleTheme, setTheme } = useTheme(); + + useEffect(() => { + const timer = setInterval(() => { + setCurrentTime(new Date()); + }, 1000); + return () => clearInterval(timer); + }, []); + + useEffect(() => { + const fetchWeatherData = async () => { + try { + const API_KEY = import.meta.env.VITE_OPENWEATHER_API_KEY; + const [weatherResponse, forecastResponse] = await Promise.all([ + fetch( + `https://api.openweathermap.org/data/2.5/weather?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial` + ), + fetch( + `https://api.openweathermap.org/data/2.5/forecast?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial` + ) + ]); + + const weatherData = await weatherResponse.json(); + const forecastData = await forecastResponse.json(); + + setWeather(weatherData); + + // Process forecast data to get daily forecasts + const dailyForecasts = forecastData.list.reduce((acc, item) => { + const date = new Date(item.dt * 1000).toLocaleDateString(); + if (!acc[date]) { + acc[date] = { + ...item, + precipitation: item.rain?.['3h'] || item.snow?.['3h'] || 0, + pop: item.pop * 100 + }; + } + return acc; + }, {}); + + setForecast(Object.values(dailyForecasts).slice(0, 5)); + } catch (error) { + console.error("Error fetching weather:", error); + } + }; + + fetchWeatherData(); + const weatherTimer = setInterval(fetchWeatherData, 300000); + return () => clearInterval(weatherTimer); + }, []); + + const getWeatherIcon = (weatherCode, currentTime, small = false) => { + if (!weatherCode) return ; + + const code = parseInt(weatherCode, 10); + const iconProps = small ? "w-6 h-6" : "w-7 h-7"; + + switch (true) { + case code >= 200 && code < 300: + return ; + case code >= 300 && code < 500: + return ; + case code >= 500 && code < 600: + return ; + case code >= 600 && code < 700: + return ; + case code >= 700 && code < 721: + return ; + case code === 721: + return ; + case code >= 722 && code < 781: + return ; + case code === 781: + return ; + case code === 800: + return currentTime.getHours() >= 6 && currentTime.getHours() < 18 ? ( + + ) : ( + + ); + case code >= 800 && code < 803: + return ; + case code >= 803: + return ; + default: + return ; + } + }; + + const formatTime = (timestamp) => { + if (!timestamp) return '--:--'; + const date = new Date(timestamp * 1000); + return date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + }; + + const WeatherDetails = () => ( +
+
+ +
+ +
+ High + {Math.round(weather.main.temp_max)}°F +
+
+
+ + +
+ +
+ Low + {Math.round(weather.main.temp_min)}°F +
+
+
+ + +
+ +
+ Humidity + {weather.main.humidity}% +
+
+
+ + +
+ +
+ Wind + {Math.round(weather.wind.speed)} mph +
+
+
+ + +
+ +
+ Sunrise + {formatTime(weather.sys?.sunrise)} +
+
+
+ + +
+ +
+ Sunset + {formatTime(weather.sys?.sunset)} +
+
+
+
+ + {forecast && ( +
+
+ {forecast.map((day, index) => ( + +
+ + {new Date(day.dt * 1000).toLocaleDateString('en-US', { weekday: 'short' })} + + {getWeatherIcon(day.weather[0].id, new Date(day.dt * 1000), true)} +
+ + {Math.round(day.main.temp_max)}° + + + {Math.round(day.main.temp_min)}° + +
+
+ {day.rain?.['3h'] > 0 && ( +
+ + {day.rain['3h'].toFixed(2)}" +
+ )} + {day.snow?.['3h'] > 0 && ( +
+ + {day.snow['3h'].toFixed(2)}" +
+ )} + {!day.rain?.['3h'] && !day.snow?.['3h'] && ( +
+ + 0" +
+ )} +
+
+
+ ))} +
+
+ )} +
+ ); + + const formatDate = (date) => + date.toLocaleDateString("en-US", { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + }); + + const formatTimeDisplay = (date) => { + const hours = date.getHours(); + const minutes = String(date.getMinutes()).padStart(2, "0"); + const seconds = String(date.getSeconds()).padStart(2, "0"); + const period = hours >= 12 ? "PM" : "AM"; + const displayHours = hours % 12 || 12; + return `${displayHours}:${minutes}:${seconds} ${period}`; + }; + + return ( + + +
+
+
+
+ +
+
+
+

+ Store Status +

+
+
+
+ {weather?.main && ( + <> +
+ + +
+ {getWeatherIcon(weather.weather[0]?.id, currentTime)} +
+

+ {Math.round(weather.main.temp)}° F +

+
+ {weather.alerts && ( + + )} +
+
+ + {weather.alerts && ( + + + + {weather.alerts[0].event} + + + )} + + +
+
+ + )} +
+
+ +
+

+ {formatDate(currentTime)} +

+
+
+
+
+ +
+

+ {formatTimeDisplay(currentTime)} +

+
+
+
+
+
+
+ ); +}; + +export default Header; diff --git a/inventory/src/components/dashboard/KlaviyoCampaigns.jsx b/inventory/src/components/dashboard/KlaviyoCampaigns.jsx new file mode 100644 index 0000000..6cfff75 --- /dev/null +++ b/inventory/src/components/dashboard/KlaviyoCampaigns.jsx @@ -0,0 +1,477 @@ +import React, { useState, useEffect, useRef } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/dashboard/ui/card"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/dashboard/ui/tooltip"; +import { DateTime } from "luxon"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/dashboard/ui/select"; +import { Button } from "@/components/dashboard/ui/button"; +import { TIME_RANGES } from "@/lib/dashboard/constants"; +import { Mail, MessageSquare, ArrowUpDown, BookOpen } from "lucide-react"; +import { Skeleton } from "@/components/dashboard/ui/skeleton"; + +// Helper functions for formatting +const formatRate = (value, isSMS = false, hideForSMS = false) => { + if (isSMS && hideForSMS) return "N/A"; + if (typeof value !== "number") return "0.0%"; + return `${(value * 100).toFixed(1)}%`; +}; + +const formatCurrency = (value) => { + if (typeof value !== "number") return "$0"; + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(value); +}; + +// Loading skeleton component +const TableSkeleton = () => ( + + + + + + + + + + + + + {[...Array(15)].map((_, i) => ( + + + + + + + + + ))} + +
+ + + + + + + + + + + +
+
+ +
+ + + +
+
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+); + +// Error alert component +const ErrorAlert = ({ description }) => ( +
+ {description} +
+); + +// MetricCell component for displaying campaign metrics +const MetricCell = ({ + value, + count, + isMonetary = false, + showConversionRate = false, + totalRecipients = 0, + isSMS = false, + hideForSMS = false, +}) => { + if (isSMS && hideForSMS) { + return ( + +
N/A
+
-
+ + ); + } + + return ( + +
+ {isMonetary ? formatCurrency(value) : formatRate(value, isSMS, hideForSMS)} +
+
+ {count?.toLocaleString() || 0} {count === 1 ? "recipient" : "recipients"} + {showConversionRate && + totalRecipients > 0 && + ` (${((count / totalRecipients) * 100).toFixed(2)}%)`} +
+ + ); +}; + +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({ + key: "send_time", + direction: "desc", + }); + + const handleSort = (key) => { + setSortConfig((prev) => ({ + key, + direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc", + })); + }; + + const fetchCampaigns = async () => { + try { + setIsLoading(true); + const response = await fetch( + `/api/klaviyo/reporting/campaigns/${selectedTimeRange}` + ); + + if (!response.ok) { + throw new Error(`Failed to fetch campaigns: ${response.status}`); + } + + const data = await response.json(); + setCampaigns(data.data || []); + setError(null); + } catch (err) { + console.error("Error fetching campaigns:", err); + setError(err.message); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchCampaigns(); + const interval = setInterval(fetchCampaigns, 10 * 60 * 1000); // Refresh every 10 minutes + return () => clearInterval(interval); + }, [selectedTimeRange]); // Only refresh when time range changes + + // Sort campaigns + const sortedCampaigns = [...campaigns].sort((a, b) => { + const direction = sortConfig.direction === "desc" ? -1 : 1; + + switch (sortConfig.key) { + case "send_time": + return direction * (DateTime.fromISO(a.send_time) - DateTime.fromISO(b.send_time)); + case "delivery_rate": + return direction * ((a.stats.delivery_rate || 0) - (b.stats.delivery_rate || 0)); + case "open_rate": + return direction * ((a.stats.open_rate || 0) - (b.stats.open_rate || 0)); + case "click_rate": + return direction * ((a.stats.click_rate || 0) - (b.stats.click_rate || 0)); + case "click_to_open_rate": + return direction * ((a.stats.click_to_open_rate || 0) - (b.stats.click_to_open_rate || 0)); + case "conversion_value": + return direction * ((a.stats.conversion_value || 0) - (b.stats.conversion_value || 0)); + default: + return 0; + } + }); + + // Filter campaigns by search term and 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]; + } + ); + + if (isLoading) { + return ( + + +
+ + + +
+
+ + +
+ +
+
+
+ + + +
+ ); + } + + return ( + + {error && } + +
+ + Klaviyo Campaigns + +
+
+ + + +
+ +
+
+
+ + + + + + + + + + + + + + {filteredCampaigns.map((campaign) => ( + + + + + + + +

{campaign.name}

+

{campaign.subject}

+

+ {campaign.send_time + ? DateTime.fromISO(campaign.send_time).toLocaleString(DateTime.DATETIME_MED) + : "No date"} +

+
+ + + + + + + + + ))} + +
+ + + + + + + + + + + +
+
+ {campaign.name?.includes("_Blog") ? ( + + ) : campaign.channel === 'sms' ? ( + + ) : ( + + )} +
+ {campaign.name} +
+
+
+ {campaign.subject} +
+
+ {campaign.send_time + ? DateTime.fromISO(campaign.send_time).toLocaleString(DateTime.DATETIME_MED) + : "No date"} +
+
+
+
+ ); +}; + +export default KlaviyoCampaigns; \ No newline at end of file diff --git a/inventory/src/components/dashboard/LockButton.jsx b/inventory/src/components/dashboard/LockButton.jsx new file mode 100644 index 0000000..b830ae4 --- /dev/null +++ b/inventory/src/components/dashboard/LockButton.jsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { Button } from "@/components/dashboard/ui/button"; +import { Lock } from "lucide-react"; + +const LockButton = () => { + const handleLock = () => { + // Remove PIN verification from session storage + sessionStorage.removeItem('pinVerified'); + // Reset attempt count + localStorage.removeItem('pinAttempts'); + localStorage.removeItem('lastAttemptTime'); + // Force reload to show PIN screen + window.location.reload(); + }; + + return ( + + ); +}; + +export default LockButton; \ No newline at end of file diff --git a/inventory/src/components/dashboard/MetaCampaigns.jsx b/inventory/src/components/dashboard/MetaCampaigns.jsx new file mode 100644 index 0000000..c43bcc3 --- /dev/null +++ b/inventory/src/components/dashboard/MetaCampaigns.jsx @@ -0,0 +1,737 @@ +import React, { useState, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/dashboard/ui/card"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/dashboard/ui/tooltip"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/dashboard/ui/select"; +import { + Instagram, + Loader2, + Users, + DollarSign, + Eye, + Repeat, + MousePointer, + BarChart, + Target, + ShoppingCart, + MessageCircle, + Hash, +} from "lucide-react"; +import { Button } from "@/components/dashboard/ui/button"; +import { Skeleton } from "@/components/dashboard/ui/skeleton"; + +// Helper functions for formatting +const formatCurrency = (value, decimalPlaces = 2) => + new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: decimalPlaces, + maximumFractionDigits: decimalPlaces, + }).format(value || 0); + +const formatNumber = (value, decimalPlaces = 0) => { + return new Intl.NumberFormat("en-US", { + minimumFractionDigits: decimalPlaces, + maximumFractionDigits: decimalPlaces, + }).format(value || 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 ( + + +
+
+

{label}

+

{displayValue}

+
+ {Icon && ( + + )} +
+
+
+ ); +}; + +const MetricCell = ({ value, label, sublabel, isMonetary = false, isPercentage = false, decimalPlaces = 0 }) => { + const formattedValue = isMonetary + ? formatCurrency(value, decimalPlaces) + : isPercentage + ? formatPercent(value, decimalPlaces) + : formatNumber(value, decimalPlaces); + + return ( + +
+ {formattedValue} +
+ {(label || sublabel) && ( +
+ {label || sublabel} +
+ )} + + ); +}; + +const getActionValue = (campaign, actionType) => { + if (actionType === "impressions" || actionType === "reach") { + return campaign.metrics[actionType] || 0; + } + + const actions = campaign.metrics.actions; + if (Array.isArray(actions)) { + const action = actions.find((a) => a.action_type === actionType); + return action ? parseInt(action.value) || 0 : 0; + } + + return 0; +}; + +const CampaignName = ({ name }) => { + if (name.startsWith("Instagram post: ")) { + return ( +
+ + {name.replace("Instagram post: ", "")} +
+ ); + } + return {name}; +}; + +const getObjectiveAction = (campaignObjective) => { + const objectiveMap = { + OUTCOME_AWARENESS: { action_type: "impressions", label: "Impressions" }, + OUTCOME_ENGAGEMENT: { action_type: "post_engagement", label: "Post Engagements" }, + OUTCOME_TRAFFIC: { action_type: "link_click", label: "Link Clicks" }, + OUTCOME_LEADS: { action_type: "lead", label: "Leads" }, + OUTCOME_SALES: { action_type: "purchase", label: "Purchases" }, + MESSAGES: { action_type: "messages", label: "Messages" }, + }; + + return objectiveMap[campaignObjective] || { action_type: "link_click", label: "Link Clicks" }; +}; + +const calculateBudget = (campaign) => { + if (campaign.daily_budget) { + return { value: campaign.daily_budget / 100, type: "day" }; + } + if (campaign.lifetime_budget) { + return { value: campaign.lifetime_budget / 100, type: "lifetime" }; + } + + const adsets = campaign.adsets?.data || []; + const dailyTotal = adsets.reduce((sum, adset) => sum + (adset.daily_budget || 0), 0); + const lifetimeTotal = adsets.reduce((sum, adset) => sum + (adset.lifetime_budget || 0), 0); + + if (dailyTotal > 0) return { value: dailyTotal / 100, type: "day" }; + if (lifetimeTotal > 0) return { value: lifetimeTotal / 100, type: "lifetime" }; + + return { value: 0, type: "day" }; +}; + +const processMetrics = (campaign) => { + const insights = campaign.insights?.data?.[0] || {}; + const spend = parseFloat(insights.spend || 0); + const impressions = parseInt(insights.impressions || 0); + const clicks = parseInt(insights.clicks || 0); + const reach = parseInt(insights.reach || 0); + const cpc = parseFloat(insights.cpc || 0); + const ctr = parseFloat(insights.ctr || 0); + 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); + + const totalPurchases = (insights.actions || []) + .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; + actionMap.set(action_type, currentValue + parseInt(value || 0)); + }); + + const actions = Array.from(actionMap.entries()).map(([action_type, value]) => ({ + action_type, + 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 { + spend, + impressions, + clicks, + reach, + frequency, + ctr, + cpm, + cpc, + actions, + costPerActionMap, + purchaseValue, + totalPurchases, + totalPostEngagements, + }; +}; + +const processCampaignData = (campaign) => { + const metrics = processMetrics(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 { + id: campaign.id, + name: campaign.name, + status: campaign.status, + objective: label, + objectiveActionType: action_type, + budget: budget.value, + budgetType: budget.type, + metrics: { + ...metrics, + costPerResult, + }, + }; +}; + +const SkeletonMetricCard = () => ( + + +
+
+ +
+ +
+
+ +
+
+
+); + +const SkeletonTable = () => ( +
+ + + + + {[...Array(8)].map((_, i) => ( + + ))} + + + + {[...Array(5)].map((_, rowIndex) => ( + + + {[...Array(8)].map((_, colIndex) => ( + + ))} + + ))} + +
+ + + +
+
+ +
+ + + +
+
+
+
+ + +
+
+
+); + +const MetaCampaigns = () => { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [campaigns, setCampaigns] = useState([]); + const [timeframe, setTimeframe] = useState("7"); + const [summaryMetrics, setSummaryMetrics] = useState(null); + const [sortConfig, setSortConfig] = useState({ + key: "spend", + direction: "desc", + }); + + const handleSort = (key) => { + setSortConfig((prev) => ({ + key, + direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc", + })); + }; + + 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 + + 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 + + sinceDate = new Date(untilDate); + sinceDate.setDate(sinceDate.getDate() - parseInt(timeframe) + 1); + } + + return { + since: sinceDate.toISOString().split("T")[0], + until: untilDate.toISOString().split("T")[0], + }; + }; + + useEffect(() => { + const fetchMetaAdsData = async () => { + try { + setLoading(true); + setError(null); + + const { since, until } = computeDateRange(timeframe); + + const [campaignData, accountInsights] = await Promise.all([ + fetch(`/api/meta/campaigns?since=${since}&until=${until}`), + fetch(`/api/meta/account-insights?since=${since}&until=${until}`) + ]); + + const [campaignsJson, accountJson] = await Promise.all([ + campaignData.json(), + 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); + + if (activeCampaigns.length > 0) { + const totalSpend = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.spend, 0); + const totalImpressions = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.impressions, 0); + const totalReach = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.reach, 0); + const totalPurchases = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.totalPurchases, 0); + const totalPurchaseValue = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.purchaseValue, 0); + const totalLinkClicks = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.clicks, 0); + const totalPostEngagements = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.totalPostEngagements, 0); + + const numCampaigns = activeCampaigns.length; + const avgFrequency = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.frequency, 0) / numCampaigns; + const avgCpm = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.cpm, 0) / numCampaigns; + const avgCtr = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.ctr, 0) / numCampaigns; + const avgCpc = activeCampaigns.reduce((sum, camp) => sum + camp.metrics.cpc, 0) / numCampaigns; + + setSummaryMetrics({ + totalSpend, + totalPurchaseValue, + totalLinkClicks, + totalImpressions, + totalReach, + totalPurchases, + avgFrequency, + avgCpm, + avgCtr, + avgCpc, + totalPostEngagements, + totalCampaigns: numCampaigns, + }); + } + } catch (err) { + console.error("Meta Ads fetch error:", err); + setError(`Failed to fetch Meta Ads data: ${err.message}`); + } finally { + setLoading(false); + } + }; + + fetchMetaAdsData(); + }, [timeframe]); + + // Sort campaigns + const sortedCampaigns = [...campaigns].sort((a, b) => { + const direction = sortConfig.direction === "desc" ? -1 : 1; + + 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)); + case "reach": + return direction * ((a.metrics.reach || 0) - (b.metrics.reach || 0)); + case "impressions": + return direction * ((a.metrics.impressions || 0) - (b.metrics.impressions || 0)); + case "cpm": + return direction * ((a.metrics.cpm || 0) - (b.metrics.cpm || 0)); + case "ctr": + return direction * ((a.metrics.ctr || 0) - (b.metrics.ctr || 0)); + case "results": + return direction * ((getActionValue(a, a.objectiveActionType) || 0) - (getActionValue(b, b.objectiveActionType) || 0)); + case "value": + return direction * ((a.metrics.purchaseValue || 0) - (b.metrics.purchaseValue || 0)); + case "engagements": + return direction * ((a.metrics.totalPostEngagements || 0) - (b.metrics.totalPostEngagements || 0)); + default: + return 0; + } + }); + + if (loading) { + return ( + + +
+ + Meta Ads Performance + + +
+
+ {[...Array(12)].map((_, i) => ( + + ))} +
+
+ + + +
+ ); + } + + if (error) { + return ( + + +
+ {error} +
+
+
+ ); + } + + return ( + + +
+ + Meta Ads Performance + + +
+
+ {[ + { + 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) => ( +
+ {summaryCard(card.label, card.value, card.options)} +
+ ))} +
+
+ + + + + + + + + + + + + + + + + + {sortedCampaigns.map((campaign) => ( + + + + + + + + + + + + + + + + + + + + ))} + +
+ + + + + + + + + + + + + + + + + +
+
+
+ +
+
+ {campaign.objective} +
+
+
+
+
+ ); +}; + +export default MetaCampaigns; \ No newline at end of file diff --git a/inventory/src/components/dashboard/MiniEventFeed.jsx b/inventory/src/components/dashboard/MiniEventFeed.jsx new file mode 100644 index 0000000..e6bc6a9 --- /dev/null +++ b/inventory/src/components/dashboard/MiniEventFeed.jsx @@ -0,0 +1,487 @@ +import React, { useState, useEffect, useCallback, useRef } from "react"; +import axios from "axios"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/dashboard/ui/card"; +import { Badge } from "@/components/dashboard/ui/badge"; +import { ScrollArea } from "@/components/dashboard/ui/scroll-area"; +import { + Package, + Truck, + UserPlus, + XCircle, + DollarSign, + Activity, + AlertCircle, + FileText, + ChevronLeft, + ChevronRight, +} from "lucide-react"; +import { format } from "date-fns"; +import { Alert, AlertDescription, AlertTitle } from "@/components/dashboard/ui/alert"; +import { Skeleton } from "@/components/dashboard/ui/skeleton"; +import { EventDialog } from "./EventFeed.jsx"; +import { Button } from "@/components/dashboard/ui/button"; + +const METRIC_IDS = { + PLACED_ORDER: "Y8cqcF", + SHIPPED_ORDER: "VExpdL", + ACCOUNT_CREATED: "TeeypV", + CANCELED_ORDER: "YjVMNg", + NEW_BLOG_POST: "YcxeDr", + PAYMENT_REFUNDED: "R7XUYh", +}; + +const EVENT_TYPES = { + [METRIC_IDS.PLACED_ORDER]: { + label: "Order Placed", + color: "bg-green-200", + textColor: "text-green-50", + iconColor: "text-green-800", + gradient: "from-green-800 to-green-700", + }, + [METRIC_IDS.SHIPPED_ORDER]: { + label: "Order Shipped", + color: "bg-blue-200", + textColor: "text-blue-50", + iconColor: "text-blue-800", + gradient: "from-blue-800 to-blue-700", + }, + [METRIC_IDS.ACCOUNT_CREATED]: { + label: "New Account", + color: "bg-purple-200", + textColor: "text-purple-50", + iconColor: "text-purple-800", + gradient: "from-purple-800 to-purple-700", + }, + [METRIC_IDS.CANCELED_ORDER]: { + label: "Order Canceled", + color: "bg-red-200", + textColor: "text-red-50", + iconColor: "text-red-800", + gradient: "from-red-800 to-red-700", + }, + [METRIC_IDS.PAYMENT_REFUNDED]: { + label: "Payment Refunded", + color: "bg-orange-200", + textColor: "text-orange-50", + iconColor: "text-orange-800", + gradient: "from-orange-800 to-orange-700", + }, + [METRIC_IDS.NEW_BLOG_POST]: { + label: "New Blog Post", + color: "bg-indigo-200", + textColor: "text-indigo-50", + iconColor: "text-indigo-800", + gradient: "from-indigo-800 to-indigo-700", + }, +}; + +const EVENT_ICONS = { + [METRIC_IDS.PLACED_ORDER]: Package, + [METRIC_IDS.SHIPPED_ORDER]: Truck, + [METRIC_IDS.ACCOUNT_CREATED]: UserPlus, + [METRIC_IDS.CANCELED_ORDER]: XCircle, + [METRIC_IDS.PAYMENT_REFUNDED]: DollarSign, + [METRIC_IDS.NEW_BLOG_POST]: FileText, +}; + +// Loading State Component +const LoadingState = () => ( +
+ {[...Array(6)].map((_, i) => ( + + +
+ + +
+
+
+ +
+ + +
+ +
+ +
+
+
+ + ))} +
+); + +// Empty State Component +const EmptyState = () => ( + + +
+ +
+

+ No recent activity +

+
+
+); + +const EventCard = ({ event }) => { + const eventType = EVENT_TYPES[event.metric_id]; + if (!eventType) return null; + + const Icon = EVENT_ICONS[event.metric_id] || Package; + const details = event.event_properties || {}; + + return ( + + + +
+ + {eventType.label} + + {event.datetime && ( + + {format(new Date(event.datetime), "h:mm a")} + + )} +
+
+
+ +
+ + + {event.metric_id === METRIC_IDS.PLACED_ORDER && ( + <> +
+ {details.ShippingName} +
+
+
+ #{details.OrderId} • {formatCurrency(details.TotalAmount)} +
+
+ {(details.IsOnHold || details.OnHoldReleased || details.StillOwes || details.LocalPickup || details.HasPreorder || details.HasNotions || details.OnlyDigitalGC || details.HasDigitalGC || details.HasDigiItem || details.OnlyDigiItem) && ( +
+ {details.IsOnHold && ( + + On Hold + + )} + {details.OnHoldReleased && ( + + Hold Released + + )} + {details.StillOwes && ( + + Owes + + )} + {details.LocalPickup && ( + + Local + + )} + {details.HasPreorder && ( + + Pre-order + + )} + {details.HasNotions && ( + + Notions + + )} + {(details.OnlyDigitalGC || details.HasDigitalGC) && ( + + eGift Card + + )} + {(details.HasDigiItem || details.OnlyDigiItem) && ( + + Digital + + )} +
+ )} + + )} + + {event.metric_id === METRIC_IDS.SHIPPED_ORDER && ( + <> +
+ {details.ShippingName} +
+
+
+ #{details.OrderId} • {formatShipMethodSimple(details.ShipMethod)} +
+
+ {event.event_properties?.ShippedBy && ( +
+ Shipped by {event.event_properties.ShippedBy} +
+ )} + + )} + + {event.metric_id === METRIC_IDS.ACCOUNT_CREATED && ( + <> +
+ {details.FirstName} {details.LastName} +
+
+ {details.EmailAddress} +
+ + )} + + {event.metric_id === METRIC_IDS.CANCELED_ORDER && ( + <> +
+ {details.ShippingName} +
+
+
+ #{details.OrderId} • {formatCurrency(details.TotalAmount)} +
+
+
+ {details.CancelReason} +
+ + )} + + {event.metric_id === METRIC_IDS.PAYMENT_REFUNDED && ( + <> +
+ {details.ShippingName} +
+
+
+ #{details.FromOrder} • {formatCurrency(details.PaymentAmount)} +
+
+
+ via {details.PaymentName} +
+ + )} + + {event.metric_id === METRIC_IDS.NEW_BLOG_POST && ( + <> +
+ {details.title} +
+
+ {details.description} +
+ + )} +
+ + + ); +}; + +const DEFAULT_METRICS = Object.values(METRIC_IDS); + +const MiniEventFeed = ({ + selectedMetrics = DEFAULT_METRICS, +}) => { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdate, setLastUpdate] = useState(null); + const scrollRef = useRef(null); + const [showLeftArrow, setShowLeftArrow] = useState(false); + const [showRightArrow, setShowRightArrow] = useState(false); + + const handleScroll = () => { + if (scrollRef.current) { + const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current; + setShowLeftArrow(scrollLeft > 0); + setShowRightArrow(scrollLeft < scrollWidth - clientWidth - 1); + } + }; + + const scrollToEnd = () => { + if (scrollRef.current) { + scrollRef.current.scrollTo({ + left: scrollRef.current.scrollWidth, + behavior: 'smooth' + }); + } + }; + + const scrollToStart = () => { + if (scrollRef.current) { + scrollRef.current.scrollTo({ + left: 0, + behavior: 'smooth' + }); + } + }; + + const fetchEvents = useCallback(async () => { + try { + setError(null); + + if (events.length === 0) { + setLoading(true); + } + + const response = await axios.get("/api/klaviyo/events/feed", { + params: { + timeRange: "today", + metricIds: JSON.stringify(selectedMetrics), + }, + }); + + const processedEvents = (response.data.data || []).map((event) => ({ + ...event, + datetime: event.attributes?.datetime || event.datetime, + event_properties: event.attributes?.event_properties || {} + })); + + setEvents(processedEvents); + setLastUpdate(new Date()); + + // Scroll to the right after events are loaded + if (scrollRef.current) { + setTimeout(() => { + scrollRef.current.scrollTo({ + left: scrollRef.current.scrollWidth, + behavior: 'instant' + }); + handleScroll(); + }, 0); + } + } catch (error) { + console.error("Error fetching events:", error); + setError(error.message); + } finally { + setLoading(false); + } + }, [selectedMetrics]); + + useEffect(() => { + fetchEvents(); + const interval = setInterval(fetchEvents, 30000); + return () => clearInterval(interval); + }, [fetchEvents]); + + useEffect(() => { + handleScroll(); + }, [events]); + + return ( +
+ +
+ {showLeftArrow && ( + + )} + {showRightArrow && ( + + )} +
+
+ {loading && !events.length ? ( + + ) : error ? ( + + + Error + + Failed to load event feed: {error} + + + ) : !events || events.length === 0 ? ( +
+ +
+ ) : ( + [...events].reverse().map((event) => ( + + )) + )} +
+
+
+
+
+ ); +}; + +export default MiniEventFeed; + +// Helper Functions +const formatCurrency = (amount) => { + // Convert to number if it's a string + const num = typeof amount === "string" ? parseFloat(amount) : amount; + // Handle negative numbers + const absNum = Math.abs(num); + // Format to 2 decimal places and add negative sign if needed + return `${num < 0 ? "-" : ""}$${absNum.toFixed(2)}`; +}; + +const formatShipMethodSimple = (method) => { + if (!method) return "Digital"; + if (method.includes("usps")) return "USPS"; + if (method.includes("fedex")) return "FedEx"; + if (method.includes("ups")) return "UPS"; + return "Standard"; +}; \ No newline at end of file diff --git a/inventory/src/components/dashboard/MiniRealtimeAnalytics.jsx b/inventory/src/components/dashboard/MiniRealtimeAnalytics.jsx new file mode 100644 index 0000000..64ed37f --- /dev/null +++ b/inventory/src/components/dashboard/MiniRealtimeAnalytics.jsx @@ -0,0 +1,256 @@ +import React, { useState, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/dashboard/ui/card"; +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import { AlertTriangle, Users, Activity } from "lucide-react"; +import { Alert, AlertDescription } from "@/components/dashboard/ui/alert"; +import { format } from "date-fns"; +import { + summaryCard, + SkeletonSummaryCard, + SkeletonBarChart, + processBasicData, +} from "./RealtimeAnalytics"; +import { Skeleton } from "@/components/dashboard/ui/skeleton"; + +const SkeletonCard = ({ colorScheme = "sky" }) => ( + + + +
+ +
+
+
+
+
+
+ + +
+
+ +
+
+ +
+
+
+
+
+ +); + +const MiniRealtimeAnalytics = () => { + const [basicData, setBasicData] = useState({ + last30MinUsers: 0, + last5MinUsers: 0, + byMinute: [], + tokenQuota: null, + lastUpdated: null, + }); + + const [loading, setLoading] = useState(true); + const [isPaused, setIsPaused] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let basicInterval; + + const fetchBasicData = async () => { + if (isPaused) return; + try { + const response = await fetch("/api/dashboard-analytics/realtime/basic", { + credentials: "include", + }); + + if (!response.ok) { + throw new Error("Failed to fetch basic realtime data"); + } + + const result = await response.json(); + const processed = processBasicData(result.data); + setBasicData(processed); + setError(null); + setLoading(false); + } catch (error) { + console.error("Error details:", { + message: error.message, + stack: error.stack, + response: error.response, + }); + if (error.message === "QUOTA_EXCEEDED") { + setError("Quota exceeded. Analytics paused until manually resumed."); + setIsPaused(true); + } else { + setError("Failed to fetch analytics data"); + } + } + }; + + // Initial fetch + fetchBasicData(); + + // Set up interval + basicInterval = setInterval(fetchBasicData, 30000); // 30 seconds + + return () => { + clearInterval(basicInterval); + }; + }, [isPaused]); + + const renderContent = () => { + if (error) { + return ( + + + {error} + + ); + } + + if (loading) { + return ( +
+
+ + +
+ + + +
+
+ {/* Grid lines */} + {[...Array(5)].map((_, i) => ( +
+ ))} + {/* Y-axis labels */} +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ {/* X-axis labels */} +
+ {[...Array(6)].map((_, i) => ( + + ))} +
+ {/* Bars */} +
+ {[...Array(24)].map((_, i) => ( +
+ ))} +
+
+
+ + +
+ ); + } + + return ( +
+
+ {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" + } + )} +
+ + + +
+ + + value + "m"} + className="text-xs" + tick={{ fill: "#e0f2fe" }} + /> + + { + if (active && payload && payload.length) { + return ( + + +

+ {payload[0].payload.timestamp} +

+
+ + Active Users: + + + {payload[0].value} + +
+
+
+ ); + } + return null; + }} + /> + +
+
+
+
+
+
+ ); + }; + + return renderContent(); +}; + +export default MiniRealtimeAnalytics; \ No newline at end of file diff --git a/inventory/src/components/dashboard/MiniSalesChart.jsx b/inventory/src/components/dashboard/MiniSalesChart.jsx new file mode 100644 index 0000000..7c9f27c --- /dev/null +++ b/inventory/src/components/dashboard/MiniSalesChart.jsx @@ -0,0 +1,487 @@ +import React, { useState, useEffect, useCallback, memo } from "react"; +import axios from "axios"; +import { acotService } from "@/services/dashboard/acotService"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/dashboard/ui/card"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import { DateTime } from "luxon"; +import { Skeleton } from "@/components/dashboard/ui/skeleton"; +import { Alert, AlertDescription, AlertTitle } from "@/components/dashboard/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 = () => ( +
+
+ {/* Grid lines */} + {[...Array(5)].map((_, i) => ( +
+ ))} + {/* Y-axis labels */} +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ {/* X-axis labels */} +
+ {[...Array(6)].map((_, i) => ( + + ))} +
+ {/* Chart lines */} +
+
+
+
+
+
+
+); + +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" +}) => ( + + + + {title} + + {Icon && ( +
+
+ +
+ )} + + +
+
+
+ {value} +
+
+ Prev: {previousValue} + {trend && ( + + {trend === "up" ? ( + + ) : ( + + )} + {trendValue} + + )} +
+
+
+
+ +)); + +MiniStatCard.displayName = "MiniStatCard"; + +const SkeletonCard = ({ colorScheme = "emerald" }) => ( + + + +
+ +
+
+
+
+ +
+ + +
+ +
+ + +
+
+
+ +); + +const MiniSalesChart = ({ className = "" }) => { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [visibleMetrics, setVisibleMetrics] = useState({ + revenue: true, + orders: true + }); + const [summaryStats, setSummaryStats] = useState({ + totalRevenue: 0, + totalOrders: 0, + prevRevenue: 0, + prevOrders: 0, + periodProgress: 100 + }); + const [projection, setProjection] = useState(null); + const [projectionLoading, setProjectionLoading] = useState(false); + + const fetchProjection = useCallback(async () => { + if (summaryStats.periodProgress >= 100) return; + + try { + setProjectionLoading(true); + const response = await acotService.getProjection({ timeRange: "last30days" }); + setProjection(response); + } catch (error) { + console.error("Error loading projection:", error); + } finally { + setProjectionLoading(false); + } + }, [summaryStats.periodProgress]); + + const fetchData = useCallback(async () => { + try { + setLoading(true); + setError(null); + + const response = await acotService.getStatsDetails({ + timeRange: "last30days", + metric: "revenue", + daily: true, + }); + + if (!response.stats) { + throw new Error("Invalid response format"); + } + + const stats = Array.isArray(response.stats) + ? response.stats + : []; + + const processedData = processData(stats); + + // Calculate totals and growth + const totals = stats.reduce((acc, day) => ({ + totalRevenue: acc.totalRevenue + (Number(day.revenue) || 0), + totalOrders: acc.totalOrders + (Number(day.orders) || 0), + prevRevenue: acc.prevRevenue + (Number(day.prevRevenue) || 0), + prevOrders: acc.prevOrders + (Number(day.prevOrders) || 0), + periodProgress: day.periodProgress || 100, + }), { + totalRevenue: 0, + totalOrders: 0, + prevRevenue: 0, + prevOrders: 0, + periodProgress: 100 + }); + + setData(processedData); + setSummaryStats(totals); + setError(null); + + // Fetch projection if needed + if (totals.periodProgress < 100) { + fetchProjection(); + } + } catch (error) { + console.error("Error fetching data:", error); + setError(error.message); + } finally { + setLoading(false); + } + }, [fetchProjection]); + + useEffect(() => { + fetchData(); + const intervalId = setInterval(fetchData, 300000); + return () => clearInterval(intervalId); + }, [fetchData]); + + const formatXAxis = (value) => { + if (!value) return ""; + const date = new Date(value); + return date.toLocaleDateString([], { + month: "short", + day: "numeric" + }); + }; + + const toggleMetric = (metric) => { + setVisibleMetrics(prev => ({ + ...prev, + [metric]: !prev[metric] + })); + }; + + if (error) { + return ( + + + Error + Failed to load sales data: {error} + + ); + } + + if (loading && !data) { + return ( +
+ {/* Stat Cards */} +
+ + +
+ + {/* Chart Card */} + + + + + +
+ ); + } + + return ( +
+ {/* Stat Cards */} +
+ {loading ? ( + <> + + + + ) : ( + <> + = 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" + icon={PiggyBank} + iconColor="text-emerald-900" + iconBackground="bg-emerald-300" + onClick={() => toggleMetric('revenue')} + active={visibleMetrics.revenue} + /> + = 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" + icon={Truck} + iconColor="text-blue-900" + iconBackground="bg-blue-300" + onClick={() => toggleMetric('orders')} + active={visibleMetrics.orders} + /> + + )} +
+ + {/* Chart Card */} + + +
+ {loading ? ( +
+ {/* Grid lines */} + {[...Array(5)].map((_, i) => ( +
+ ))} + {/* Y-axis labels */} +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ {/* X-axis labels */} +
+ {[...Array(6)].map((_, i) => ( + + ))} +
+ {/* Chart lines */} +
+
+
+
+
+
+ ) : ( + + + + + {visibleMetrics.revenue && ( + formatCurrency(value, false)} + className="text-xs" + tick={{ fill: "#f5f5f4" }} + /> + )} + {visibleMetrics.orders && ( + + )} + { + if (active && payload && payload.length) { + const timestamp = new Date(payload[0].payload.timestamp); + return ( + + +

+ {timestamp.toLocaleDateString([], { + weekday: "short", + month: "short", + day: "numeric" + })} +

+ {payload + .filter(entry => visibleMetrics[entry.dataKey]) + .map((entry, index) => ( +
+ + {entry.name}: + + + {entry.dataKey === 'revenue' + ? formatCurrency(entry.value) + : entry.value.toLocaleString()} + +
+ ))} +
+
+ ); + } + return null; + }} + /> + {visibleMetrics.revenue && ( + + )} + {visibleMetrics.orders && ( + + )} +
+
+ )} +
+ + +
+ ); +}; + +export default MiniSalesChart; \ No newline at end of file diff --git a/inventory/src/components/dashboard/MiniStatCards.jsx b/inventory/src/components/dashboard/MiniStatCards.jsx new file mode 100644 index 0000000..e22ea66 --- /dev/null +++ b/inventory/src/components/dashboard/MiniStatCards.jsx @@ -0,0 +1,747 @@ +import React, { useState, useEffect, useCallback, memo } from "react"; +import axios from "axios"; +import { acotService } from "@/services/dashboard/acotService"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/dashboard/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/dashboard/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/dashboard/ui/dialog"; +import { DateTime } from "luxon"; +import { TIME_RANGES } from "@/lib/dashboard/constants"; +import { Alert, AlertDescription, AlertTitle } from "@/components/dashboard/ui/alert"; +import { + DollarSign, + ShoppingCart, + Package, + AlertCircle, + CircleDollarSign, + Loader2, +} from "lucide-react"; +import { Skeleton } from "@/components/dashboard/ui/skeleton"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, + TooltipProvider, +} from "@/components/dashboard/ui/tooltip"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/dashboard/ui/table"; + +// Import the detail view components and utilities from StatCards +import { + RevenueDetails, + OrdersDetails, + AverageOrderDetails, + ShippingDetails, + StatCard, + DetailDialog, + formatCurrency, + formatPercentage, + SkeletonCard, +} from "./StatCards"; + +// Mini skeleton components +const MiniSkeletonChart = ({ type = "line" }) => ( +
+
+ {/* Grid lines */} + {[...Array(5)].map((_, i) => ( +
+ ))} + {/* Y-axis labels */} +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ {/* X-axis labels */} +
+ {[...Array(6)].map((_, i) => ( + + ))} +
+ {type === "bar" ? ( +
+ {[...Array(24)].map((_, i) => ( +
+ ))} +
+ ) : ( +
+
+
+
+
+ )} +
+
+); + +const MiniSkeletonTable = ({ rows = 8, colorScheme = "orange" }) => ( +
+ + + + + + + + + + + + + + + + {[...Array(rows)].map((_, i) => ( + + + + + + + + + + + + ))} + +
+
+); + +const MiniStatCards = ({ + timeRange: initialTimeRange = "today", + startDate, + endDate, + title = "Quick Stats", + description = "", + compact = false, +}) => { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdate, setLastUpdate] = useState(null); + const [timeRange, setTimeRange] = useState(initialTimeRange); + const [selectedMetric, setSelectedMetric] = useState(null); + const [detailDataLoading, setDetailDataLoading] = useState({}); + const [detailData, setDetailData] = useState({}); + const [projection, setProjection] = useState(null); + const [projectionLoading, setProjectionLoading] = useState(false); + + // Reuse the trend calculation functions + const calculateTrend = useCallback((current, previous) => { + if (!current || !previous) return null; + const trend = current >= previous ? "up" : "down"; + const diff = Math.abs(current - previous); + const percentage = (diff / previous) * 100; + + return { + trend, + value: percentage, + current, + previous, + }; + }, []); + + const calculateRevenueTrend = useCallback(() => { + if (!stats?.prevPeriodRevenue && stats?.prevPeriodRevenue !== 0) return null; + + // If period is complete, use actual revenue + // If period is incomplete, use smart projection when available, fallback to simple projection + const currentRevenue = stats.periodProgress < 100 + ? (projection?.projectedRevenue || stats.projectedRevenue) + : stats.revenue; + const prevRevenue = stats.prevPeriodRevenue; // Previous period's total revenue + + console.log('[MiniStatCards RevenueTrend Debug]', { + periodProgress: stats.periodProgress, + currentRevenue, + smartProjection: projection?.projectedRevenue, + simpleProjection: stats.projectedRevenue, + actualRevenue: stats.revenue, + prevRevenue, + isProjected: stats.periodProgress < 100 + }); + + if (!currentRevenue || !prevRevenue) return null; + + // Calculate absolute difference percentage + const trend = currentRevenue >= prevRevenue ? "up" : "down"; + const diff = Math.abs(currentRevenue - prevRevenue); + const percentage = (diff / prevRevenue) * 100; + + console.log('[MiniStatCards RevenueTrend Result]', { + trend, + percentage, + calculation: `(|${currentRevenue} - ${prevRevenue}| / ${prevRevenue}) * 100 = ${percentage}%` + }); + + return { + trend, + value: percentage, + current: currentRevenue, + previous: prevRevenue, + }; + }, [stats, projection]); + + const calculateOrderTrend = useCallback(() => { + if (!stats?.prevPeriodOrders) return null; + return calculateTrend(stats.orderCount, stats.prevPeriodOrders); + }, [stats, calculateTrend]); + + const calculateAOVTrend = useCallback(() => { + if (!stats?.prevPeriodAOV) return null; + return calculateTrend(stats.averageOrderValue, stats.prevPeriodAOV); + }, [stats, calculateTrend]); + + // Initial load effect + useEffect(() => { + let isMounted = true; + + const loadData = async () => { + try { + setLoading(true); + setStats(null); + + const params = + timeRange === "custom" ? { startDate, endDate } : { timeRange }; + const response = await acotService.getStats(params); + + if (!isMounted) return; + + setStats(response.stats); + setLastUpdate(DateTime.now().setZone("America/New_York")); + setError(null); + } catch (error) { + console.error("Error loading data:", error); + if (isMounted) { + setError(error.message); + } + } finally { + if (isMounted) { + setLoading(false); + } + } + }; + + loadData(); + return () => { + isMounted = false; + }; + }, [timeRange, startDate, endDate]); + + // Load smart projection separately + useEffect(() => { + let isMounted = true; + + const loadProjection = async () => { + if (!stats?.periodProgress || stats.periodProgress >= 100) return; + + try { + setProjectionLoading(true); + const params = + timeRange === "custom" ? { startDate, endDate } : { timeRange }; + const response = await acotService.getProjection(params); + + if (!isMounted) return; + setProjection(response); + } catch (error) { + console.error("Error loading projection:", error); + } finally { + if (isMounted) { + setProjectionLoading(false); + } + } + }; + + loadProjection(); + return () => { + isMounted = false; + }; + }, [timeRange, startDate, endDate, stats?.periodProgress]); + + // Auto-refresh for 'today' view + useEffect(() => { + if (timeRange !== "today") return; + + const interval = setInterval(async () => { + try { + const [statsResponse, projectionResponse] = await Promise.all([ + acotService.getStats({ timeRange: "today" }), + acotService.getProjection({ timeRange: "today" }), + ]); + + setStats(statsResponse.stats); + setProjection(projectionResponse); + setLastUpdate(DateTime.now().setZone("America/New_York")); + } catch (error) { + console.error("Error auto-refreshing stats:", error); + } + }, 60000); + + return () => clearInterval(interval); + }, [timeRange]); + + // Add function to fetch detail data + const fetchDetailData = useCallback( + async (metric) => { + if (detailData[metric]) return; + + setDetailDataLoading((prev) => ({ ...prev, [metric]: true })); + try { + const response = await acotService.getStatsDetails({ + timeRange: "last30days", + metric, + daily: true, + }); + + setDetailData((prev) => ({ ...prev, [metric]: response.stats })); + } catch (error) { + console.error(`Error fetching detail data for ${metric}:`, error); + } finally { + setDetailDataLoading((prev) => ({ ...prev, [metric]: false })); + } + }, + [detailData] + ); + + // Add effect to load detail data when metric is selected + useEffect(() => { + if (selectedMetric) { + fetchDetailData(selectedMetric); + } + }, [selectedMetric, fetchDetailData]); + + // Add preload effect with throttling + useEffect(() => { + // Preload detail data with throttling to avoid overwhelming the server + const preloadData = async () => { + const metrics = ["revenue", "orders", "average_order", "shipping"]; + for (const metric of metrics) { + try { + await fetchDetailData(metric); + // Small delay between requests + await new Promise(resolve => setTimeout(resolve, 25)); + } catch (error) { + console.error(`Error preloading ${metric}:`, error); + } + } + }; + + preloadData(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + if (loading && !stats) { + return ( +
+ + + + + +
+
+ +
+ + +
+ +
+ + +
+
+
+ + + + + + + +
+
+ +
+ + +
+ +
+ + +
+
+
+ + + + + + + +
+
+ +
+ + +
+ +
+ + +
+
+
+ + + + + + + +
+
+ +
+ + +
+ +
+ + +
+
+
+ +
+ ); + } + + if (error) { + return ( + + + Error + Failed to load stats: {error} + + ); + } + + if (!stats) return null; + + const revenueTrend = calculateRevenueTrend(); + const orderTrend = calculateOrderTrend(); + const aovTrend = calculateAOVTrend(); + + return ( + <> +
+ + Proj: + {projectionLoading ? ( +
+ +
+ ) : ( + formatCurrency( + projection?.projectedRevenue || stats.projectedRevenue + ) + )} +
+ ) : null + } + progress={stats?.periodProgress < 100 ? stats.periodProgress : undefined} + trend={projectionLoading && stats?.periodProgress < 100 ? undefined : revenueTrend?.trend} + trendValue={ + projectionLoading && stats?.periodProgress < 100 ? ( +
+ + +
+ ) : revenueTrend?.value ? ( + formatPercentage(revenueTrend.value) + ) : null + } + colorClass="text-emerald-200" + titleClass="text-emerald-100 font-bold text-md" + descriptionClass="text-emerald-200 text-md font-semibold" + icon={DollarSign} + 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" + /> + + setSelectedMetric("orders")} + isLoading={loading || !stats} + variant="mini" + background="h-[150px] bg-gradient-to-br from-blue-900 to-blue-800" + /> + + setSelectedMetric("average_order")} + isLoading={loading || !stats} + variant="mini" + background="h-[150px] bg-gradient-to-br from-violet-900 to-violet-800" + /> + + setSelectedMetric("shipping")} + isLoading={loading || !stats} + variant="mini" + background="h-[150px] bg-gradient-to-br from-orange-900 to-orange-800" + /> +
+ + setSelectedMetric(null)} + > + +
+
+ + + {selectedMetric + ? `${selectedMetric + .split("_") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" ")} Details` + : ""} + + +
+ {detailDataLoading[selectedMetric] ? ( +
+ {selectedMetric === "shipping" ? ( + + ) : ( + <> + + {selectedMetric === "orders" && ( +
+

+ Hourly Distribution +

+ +
+ )} + + )} +
+ ) : ( +
+ {selectedMetric === "revenue" && ( + + )} + {selectedMetric === "orders" && ( + + )} + {selectedMetric === "average_order" && ( + + )} + {selectedMetric === "shipping" && ( + + )} +
+ )} +
+
+
+
+
+ + ); +}; + +export default MiniStatCards; diff --git a/inventory/src/components/dashboard/Navigation.jsx b/inventory/src/components/dashboard/Navigation.jsx new file mode 100644 index 0000000..199aaa0 --- /dev/null +++ b/inventory/src/components/dashboard/Navigation.jsx @@ -0,0 +1,268 @@ +import React, { useState, useEffect, useRef } from "react"; +import { Button } from "@/components/dashboard/ui/button"; +import { Card, CardContent } from "@/components/dashboard/ui/card"; +import { cn } from "@/lib/utils"; +import { useScroll } from "@/contexts/DashboardScrollContext"; +import { ArrowUpToLine } from "lucide-react"; + +const Navigation = () => { + const [activeSections, setActiveSections] = useState([]); + const { isStuck, scrollContainerRef, scrollToSection } = useScroll(); + const buttonRefs = useRef({}); + const scrollContainerRef2 = useRef(null); + const [shouldAutoScroll, setShouldAutoScroll] = useState(true); + const lastScrollLeft = useRef(0); + const lastScrollTop = useRef(0); + + // Define base sections that are always visible + const baseSections = [ + { id: "stats", label: "Statistics" }, + { id: "realtime", label: "Realtime" }, + { id: "feed", label: "Event Feed" }, + { id: "sales", label: "Sales Chart" }, + { id: "products", label: "Top Products" }, + { id: "campaigns", label: "Campaigns" }, + { id: "analytics", label: "Analytics" }, + { id: "user-behavior", label: "User Behavior" }, + { id: "meta-campaigns", label: "Meta Ads" }, + { id: "typeform", label: "Customer Surveys" }, + { id: "gorgias-overview", label: "Customer Service" }, + { id: "calls", label: "Calls" }, + ]; + + const sortSections = (sections) => { + const isMediumScreen = window.matchMedia( + "(min-width: 768px) and (max-width: 1023px)" + ).matches; + + return [...sections].sort((a, b) => { + const aOrder = a.order + ? isMediumScreen + ? a.order.md + : a.order.default + : 0; + const bOrder = b.order + ? isMediumScreen + ? b.order.md + : b.order.default + : 0; + + if (aOrder && bOrder) { + return aOrder - bOrder; + } + return 0; + }); + }; + + const sections = sortSections(baseSections); + + const scrollToTop = () => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTo({ + top: 0, + behavior: "smooth", + }); + } else { + window.scrollTo({ + top: 0, + behavior: "smooth", + }); + } + }; + + const handleSectionClick = (sectionId, responsiveIds) => { + scrollToSection(sectionId); + }; + + // Track horizontal scroll position changes + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + const handleButtonBarScroll = () => { + if (Math.abs(container.scrollLeft - lastScrollLeft.current) > 5) { + setShouldAutoScroll(false); + } + lastScrollLeft.current = container.scrollLeft; + }; + + container.addEventListener("scroll", handleButtonBarScroll); + return () => container.removeEventListener("scroll", handleButtonBarScroll); + }, []); + + // Handle page scroll and active sections + useEffect(() => { + const handlePageScroll = (e) => { + const scrollTop = e?.target?.scrollTop || window.pageYOffset || document.documentElement.scrollTop; + + if (Math.abs(scrollTop - lastScrollTop.current) > 5) { + setShouldAutoScroll(true); + lastScrollTop.current = scrollTop; + } else { + return; + } + + const activeIds = []; + const viewportHeight = window.innerHeight; + const threshold = viewportHeight * 0.5; + const container = scrollContainerRef.current; + + sections.forEach((section) => { + if (section.responsiveIds) { + const visibleId = section.responsiveIds.find((id) => { + const element = document.getElementById(id); + if (!element) return false; + const style = window.getComputedStyle(element); + if (style.display === "none") return false; + + if (container) { + // For container-based scrolling + const rect = element.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + const relativeTop = rect.top - containerRect.top; + const relativeBottom = rect.bottom - containerRect.top; + return ( + relativeTop < containerRect.height - threshold && + relativeBottom > threshold + ); + } else { + // For window-based scrolling + const rect = element.getBoundingClientRect(); + return ( + rect.top < viewportHeight - threshold && rect.bottom > threshold + ); + } + }); + + if (visibleId) { + activeIds.push(section.id); + } + } else { + const element = document.getElementById(section.id); + if (element) { + if (container) { + // For container-based scrolling + const rect = element.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + const relativeTop = rect.top - containerRect.top; + const relativeBottom = rect.bottom - containerRect.top; + if ( + relativeTop < containerRect.height - threshold && + relativeBottom > threshold + ) { + activeIds.push(section.id); + } + } else { + // For window-based scrolling + const rect = element.getBoundingClientRect(); + if ( + rect.top < viewportHeight - threshold && + rect.bottom > threshold + ) { + activeIds.push(section.id); + } + } + } + } + }); + + setActiveSections(activeIds); + + if (shouldAutoScroll && activeIds.length > 0) { + const firstActiveButton = buttonRefs.current[activeIds[0]]; + if (firstActiveButton && scrollContainerRef2.current) { + scrollContainerRef2.current.scrollTo({ + left: + firstActiveButton.offsetLeft - + scrollContainerRef2.current.offsetWidth / 2 + + firstActiveButton.offsetWidth / 2, + behavior: "auto", + }); + } + } + }; + + // Attach to container or window + const container = scrollContainerRef.current; + if (container) { + container.addEventListener("scroll", handlePageScroll); + handlePageScroll({ target: container }); + } else { + window.addEventListener("scroll", handlePageScroll); + handlePageScroll(); + } + + return () => { + if (container) { + container.removeEventListener("scroll", handlePageScroll); + } else { + window.removeEventListener("scroll", handlePageScroll); + } + }; + }, [sections, shouldAutoScroll, scrollContainerRef]); + + return ( +
+ + +
+
+
+ {sections.map(({ id, label, responsiveIds }) => ( + + ))} +
+
+
+ +
+
+
+
+
+ ); +}; + +export default Navigation; diff --git a/inventory/src/components/dashboard/PinProtection.jsx b/inventory/src/components/dashboard/PinProtection.jsx new file mode 100644 index 0000000..f911717 --- /dev/null +++ b/inventory/src/components/dashboard/PinProtection.jsx @@ -0,0 +1,203 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "@/components/dashboard/ui/input-otp" +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/dashboard/ui/card"; +import { Button } from "@/components/dashboard/ui/button"; +import { Lock, Delete } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; + +const MAX_ATTEMPTS = 3; +const LOCKOUT_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds + +const PinProtection = ({ onSuccess }) => { + const [pin, setPin] = useState(""); + const [attempts, setAttempts] = useState(() => { + return parseInt(localStorage.getItem('pinAttempts') || '0'); + }); + const [lockoutTime, setLockoutTime] = useState(() => { + const lastAttempt = localStorage.getItem('lastAttemptTime'); + if (!lastAttempt) return 0; + + const timeSinceLastAttempt = Date.now() - parseInt(lastAttempt); + if (timeSinceLastAttempt < LOCKOUT_DURATION) { + return LOCKOUT_DURATION - timeSinceLastAttempt; + } + return 0; + }); + const { toast } = useToast(); + + useEffect(() => { + let timer; + if (lockoutTime > 0) { + timer = setInterval(() => { + setLockoutTime(prev => { + const newTime = prev - 1000; + if (newTime <= 0) { + localStorage.removeItem('pinAttempts'); + localStorage.removeItem('lastAttemptTime'); + return 0; + } + return newTime; + }); + }, 1000); + } + return () => clearInterval(timer); + }, [lockoutTime]); + + const handleComplete = useCallback((value) => { + if (lockoutTime > 0) { + return; + } + + const newAttempts = attempts + 1; + setAttempts(newAttempts); + localStorage.setItem('pinAttempts', newAttempts.toString()); + localStorage.setItem('lastAttemptTime', Date.now().toString()); + + if (newAttempts >= MAX_ATTEMPTS) { + setLockoutTime(LOCKOUT_DURATION); + toast({ + title: "Too many attempts", + description: `Please try again in ${Math.ceil(LOCKOUT_DURATION / 60000)} minutes`, + variant: "destructive", + }); + setPin(""); + return; + } + + if (value === "123456") { + toast({ + title: "Success", + description: "PIN accepted", + }); + // Reset attempts on success + setAttempts(0); + localStorage.removeItem('pinAttempts'); + localStorage.removeItem('lastAttemptTime'); + onSuccess(); + } else { + toast({ + title: "Error", + description: `Incorrect PIN. ${MAX_ATTEMPTS - newAttempts} attempts remaining`, + variant: "destructive", + }); + setPin(""); + } + }, [attempts, lockoutTime, onSuccess, toast]); + + const handleKeyPress = (value) => { + if (pin.length < 6) { + const newPin = pin + value; + setPin(newPin); + if (newPin.length === 6) { + handleComplete(newPin); + } + } + }; + + const handleDelete = () => { + setPin(prev => prev.slice(0, -1)); + }; + + const renderKeypad = () => { + const keys = [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ['clear', 0, 'delete'] + ]; + + return keys.map((row, rowIndex) => ( +
+ {row.map((key, index) => { + if (key === 'delete') { + return ( + + ); + } + if (key === 'clear') { + return ( + + ); + } + return ( + + ); + })} +
+ )); + }; + + // Create masked version of PIN + const maskedPin = pin.replace(/./g, '•'); + + return ( +
+ + +
+ +
+ Enter PIN + + {lockoutTime > 0 ? ( + `Too many attempts. Try again in ${Math.ceil(lockoutTime / 60000)} minutes` + ) : ( + "Enter your PIN to access the display" + )} + +
+ +
+ + + {[0,1,2,3,4,5].map((index) => ( + + ))} + + +
+ +
+ {renderKeypad()} +
+
+
+
+ ); +}; + +export default PinProtection; \ No newline at end of file diff --git a/inventory/src/components/dashboard/ProductGrid.jsx b/inventory/src/components/dashboard/ProductGrid.jsx new file mode 100644 index 0000000..0ebdc2d --- /dev/null +++ b/inventory/src/components/dashboard/ProductGrid.jsx @@ -0,0 +1,401 @@ +import React, { useState, useEffect } from "react"; +import axios from "axios"; +import { acotService } from "@/services/dashboard/acotService"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/dashboard/ui/card"; +import { ScrollArea } from "@/components/dashboard/ui/scroll-area"; +import { Loader2, ArrowUpDown, AlertCircle, Package, Settings2, Search, X } from "lucide-react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/dashboard/ui/table"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/dashboard/ui/select"; +import { Input } from "@/components/dashboard/ui/input"; +import { Button } from "@/components/dashboard/ui/button"; +import { TIME_RANGES } from "@/lib/dashboard/constants"; +import { cn } from "@/lib/utils"; +import { Skeleton } from "@/components/dashboard/ui/skeleton"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/dashboard/ui/tooltip"; +import { Alert, AlertDescription, AlertTitle } from "@/components/dashboard/ui/alert"; + +const ProductGrid = ({ + timeRange = "today", + onTimeRangeChange, + title = "Top Products", + description +}) => { + const [products, setProducts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedTimeRange, setSelectedTimeRange] = useState(timeRange); + const [sorting, setSorting] = useState({ + column: "totalQuantity", + direction: "desc", + }); + const [searchQuery, setSearchQuery] = useState(""); + const [isSearchVisible, setIsSearchVisible] = useState(false); + + useEffect(() => { + fetchProducts(); + }, [selectedTimeRange]); + + const fetchProducts = async () => { + try { + setLoading(true); + setError(null); + + const response = await acotService.getProducts({ timeRange: selectedTimeRange }); + setProducts(response.stats.products.list || []); + } catch (error) { + console.error("Error fetching products:", error); + setError(error.message); + } finally { + setLoading(false); + } + }; + + const handleTimeRangeChange = (value) => { + setSelectedTimeRange(value); + if (onTimeRangeChange) { + onTimeRangeChange(value); + } + }; + + const handleSort = (column) => { + setSorting((prev) => ({ + column, + direction: + prev.column === column && prev.direction === "desc" ? "asc" : "desc", + })); + }; + + const sortedProducts = [...products].sort((a, b) => { + const direction = sorting.direction === "desc" ? -1 : 1; + const aValue = a[sorting.column]; + const bValue = b[sorting.column]; + + if (typeof aValue === "number") { + return (aValue - bValue) * direction; + } + return String(aValue).localeCompare(String(bValue)) * direction; + }); + + const filteredProducts = sortedProducts.filter(product => + product.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const SkeletonProduct = () => ( + + + + + +
+ + +
+ + + + + + + + + + + + ); + + const LoadingState = () => ( +
+
+ + + + + + + + + + + {[...Array(20)].map((_, i) => ( + + ))} + +
+ + + + + + + + +
+
+
+ ); + + if (loading) { + return ( + + +
+
+
+ + + + {description && ( + + + + )} +
+
+ + +
+
+
+
+ + +
+ +
+
+
+ ); + } + + return ( + + +
+
+
+ {title} + {description && ( + {description} + )} +
+
+ {!error && ( + + )} + +
+
+ {isSearchVisible && !error && ( +
+ + setSearchQuery(e.target.value)} + className="pl-9 pr-9 h-9 w-full" + autoFocus + /> + {searchQuery && ( + + )} +
+ )} +
+
+ + +
+ {error ? ( + + + Error + + Failed to load products: {error} + + + ) : !products?.length ? ( +
+ +

No product data available

+

Try selecting a different time range

+
+ ) : ( +
+
+ + + + + + + + + + + {filteredProducts.map((product) => ( + + + + + + + + ))} + +
+ + + + + + + + +
+ {product.ImgThumb && ( + (e.target.style.display = "none")} + /> + )} + +
+ + + + + {product.name} + + + +

{product.name}

+
+
+
+
+
+ {product.totalQuantity} + + ${product.totalRevenue.toFixed(2)} + + {product.orderCount} +
+
+
+ )} +
+
+
+ ); +}; + +export default ProductGrid; diff --git a/inventory/src/components/dashboard/RealtimeAnalytics.jsx b/inventory/src/components/dashboard/RealtimeAnalytics.jsx new file mode 100644 index 0000000..1c85c5a --- /dev/null +++ b/inventory/src/components/dashboard/RealtimeAnalytics.jsx @@ -0,0 +1,633 @@ +import React, { useState, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/dashboard/ui/card"; +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + PieChart, + Pie, + Cell, +} from "recharts"; +import { Loader2, AlertTriangle } from "lucide-react"; +import { + Tooltip as UITooltip, + TooltipContent, + TooltipTrigger, + TooltipProvider, +} from "@/components/dashboard/ui/tooltip"; +import { Alert, AlertDescription } from "@/components/dashboard/ui/alert"; +import { Button } from "@/components/dashboard/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/dashboard/ui/tabs"; +import { + Table, + TableHeader, + TableHead, + TableBody, + TableRow, + TableCell, +} from "@/components/dashboard/ui/table"; +import { format } from "date-fns"; +import { Skeleton } from "@/components/dashboard/ui/skeleton"; + +export const METRIC_COLORS = { + activeUsers: { + color: "#8b5cf6", + className: "text-purple-600 dark:text-purple-400", + }, + pages: { + color: "#10b981", + className: "text-emerald-600 dark:text-emerald-400", + }, + sources: { + color: "#f59e0b", + className: "text-amber-600 dark:text-amber-400", + }, +}; + +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 ( + + + + {label} + + {Icon && ( +
+
+ +
+ )} + + +
+
+
+ {value.toLocaleString()} +
+
+ {sublabel} +
+
+
+
+ + ); +}; + +export const SkeletonSummaryCard = () => ( + + + + + + + + + +); + +export const SkeletonBarChart = () => ( +
+
+ {/* Grid lines */} + {[...Array(5)].map((_, i) => ( +
+ ))} + {/* Y-axis labels */} +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ {/* X-axis labels */} +
+ {[...Array(6)].map((_, i) => ( + + ))} +
+ {/* Bars */} +
+ {[...Array(30)].map((_, i) => ( +
+ ))} +
+
+
+); + +export const SkeletonTable = () => ( +
+ + + + + + + + + + + + + {[...Array(8)].map((_, i) => ( + + + + + + + + + ))} + +
+
+); + +export const processBasicData = (data) => { + const last30MinUsers = parseInt( + data.userResponse?.rows?.[0]?.metricValues?.[0]?.value || 0 + ); + const last5MinUsers = parseInt( + data.fiveMinResponse?.rows?.[0]?.metricValues?.[0]?.value || 0 + ); + + const byMinute = Array.from({ length: 30 }, (_, i) => { + const matchingRow = data.timeSeriesResponse?.rows?.find( + (row) => parseInt(row.dimensionValues[0].value) === i + ); + const users = matchingRow + ? parseInt(matchingRow.metricValues[0].value) + : 0; + const timestamp = new Date(Date.now() - i * 60000); + return { + minute: -i, + users, + timestamp: timestamp.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }), + }; + }).reverse(); + + const tokenQuota = data.quotaInfo + ? { + projectHourly: data.quotaInfo.projectHourly || {}, + daily: data.quotaInfo.daily || {}, + serverErrors: data.quotaInfo.serverErrors || {}, + thresholdedRequests: data.quotaInfo.thresholdedRequests || {}, + } + : null; + + return { + last30MinUsers, + last5MinUsers, + byMinute, + tokenQuota, + lastUpdated: new Date().toISOString(), + }; +}; + +export const QuotaInfo = ({ tokenQuota }) => { + if (!tokenQuota || typeof tokenQuota !== "object") return null; + + const { + projectHourly = {}, + daily = {}, + serverErrors = {}, + thresholdedRequests = {}, + } = tokenQuota; + + const { + remaining: projectHourlyRemaining = 0, + consumed: projectHourlyConsumed = 0, + } = projectHourly; + + const { remaining: dailyRemaining = 0, consumed: dailyConsumed = 0 } = daily; + + const { remaining: errorsRemaining = 10, consumed: errorsConsumed = 0 } = + serverErrors; + + const { + remaining: thresholdRemaining = 120, + consumed: thresholdConsumed = 0, + } = thresholdedRequests; + + const hourlyPercentage = ((projectHourlyRemaining / 14000) * 100).toFixed(1); + const dailyPercentage = ((dailyRemaining / 200000) * 100).toFixed(1); + const errorPercentage = ((errorsRemaining / 10) * 100).toFixed(1); + const thresholdPercentage = ((thresholdRemaining / 120) * 100).toFixed(1); + + 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 ( + <> +
+ Quota: + + {hourlyPercentage}% + +
+ +
+
+
+
+ Project Hourly +
+
+ {projectHourlyRemaining.toLocaleString()} / 14,000 remaining +
+
+
+
+ Daily +
+
+ {dailyRemaining.toLocaleString()} / 200,000 remaining +
+
+
+
+ Server Errors +
+
+ {errorsConsumed} / 10 used this hour +
+
+
+
+ Thresholded Requests +
+
+ {thresholdConsumed} / 120 used this hour +
+
+
+
+ + ); +}; + +export const RealtimeAnalytics = () => { + const [basicData, setBasicData] = useState({ + last30MinUsers: 0, + last5MinUsers: 0, + byMinute: [], + tokenQuota: null, + lastUpdated: null, + }); + + const [detailedData, setDetailedData] = useState({ + currentPages: [], + sources: [], + recentEvents: [], + lastUpdated: null, + }); + const [loading, setLoading] = useState(true); + const [isPaused, setIsPaused] = useState(false); + const [error, setError] = useState(null); + + const processDetailedData = (data) => { + return { + currentPages: + data.pageResponse?.rows?.map((row) => ({ + path: row.dimensionValues[0].value, + activeUsers: parseInt(row.metricValues[0].value), + })) || [], + + sources: + data.sourceResponse?.rows?.map((row) => ({ + source: row.dimensionValues[0].value, + activeUsers: parseInt(row.metricValues[0].value), + })) || [], + + recentEvents: + data.eventResponse?.rows + ?.filter( + (row) => + !["session_start", "(other)"].includes( + row.dimensionValues[0].value + ) + ) + .map((row) => ({ + event: row.dimensionValues[0].value, + count: parseInt(row.metricValues[0].value), + })) || [], + lastUpdated: new Date().toISOString(), + }; + }; + + useEffect(() => { + let basicInterval; + let detailedInterval; + + const fetchBasicData = async () => { + if (isPaused) return; + try { + const response = await fetch("/api/dashboard-analytics/realtime/basic", { + credentials: "include", + }); + + if (!response.ok) { + throw new Error("Failed to fetch basic realtime data"); + } + + const result = await response.json(); + const processed = processBasicData(result.data); + setBasicData(processed); + setError(null); + } catch (error) { + console.error("Error details:", { + message: error.message, + stack: error.stack, + response: error.response, + }); + if (error.message === "QUOTA_EXCEEDED") { + setError("Quota exceeded. Analytics paused until manually resumed."); + setIsPaused(true); + } else { + setError("Failed to fetch analytics data"); + } + } + }; + + const fetchDetailedData = async () => { + if (isPaused) return; + try { + const response = await fetch("/api/dashboard-analytics/realtime/detailed", { + credentials: "include", + }); + + if (!response.ok) { + throw new Error("Failed to fetch detailed realtime data"); + } + + const result = await response.json(); + const processed = processDetailedData(result.data); + setDetailedData(processed); + } catch (error) { + console.error("Failed to fetch detailed realtime data:", error); + if (error.message === "QUOTA_EXCEEDED") { + setError("Quota exceeded. Analytics paused until manually resumed."); + setIsPaused(true); + } else { + setError("Failed to fetch analytics data"); + } + } finally { + setLoading(false); + } + }; + + // Initial fetches + fetchBasicData(); + fetchDetailedData(); + + // Set up intervals + basicInterval = setInterval(fetchBasicData, 30000); // 30 seconds + detailedInterval = setInterval(fetchDetailedData, 300000); // 5 minutes + + return () => { + clearInterval(basicInterval); + clearInterval(detailedInterval); + }; + }, [isPaused]); + + const togglePause = () => { + setIsPaused(!isPaused); + }; + + if (loading && !basicData && !detailedData) { + return ( + + +
+ + Real-Time Analytics + + +
+
+ + +
+ + +
+ +
+
+ {[...Array(3)].map((_, i) => ( + + ))} +
+ +
+
+
+ ); + } + + return ( + + +
+ + Real-Time Analytics + +
+ + + +
+ Last updated:{" "} + {format(new Date(basicData.lastUpdated), "h:mm a")} +
+
+ + + +
+
+
+
+
+ + + {error && ( + + + {error} + + )} + +
+ {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 } + )} +
+ + + + Activity + Current Pages + Traffic Sources + + + +
+ + + value + "m"} + className="text-xs" + tick={{ fill: "currentColor" }} + /> + + { + if (active && payload && payload.length) { + const timestamp = new Date( + Date.now() + payload[0].payload.minute * 60000 + ); + return ( + + +

+ {format(timestamp, "h:mm a")} +

+
+ + Active Users: + + + {payload[0].value.toLocaleString()} + +
+
+
+ ); + } + return null; + }} + /> + +
+
+
+
+ + +
+ + + + + Page + + + Active Users + + + + + {detailedData.currentPages.map((page, index) => ( + + + {page.path} + + + {page.activeUsers} + + + ))} + +
+
+
+ + +
+ + + + + Source + + + Active Users + + + + + {detailedData.sources.map((source, index) => ( + + + {source.source} + + + {source.activeUsers} + + + ))} + +
+
+
+
+
+
+ ); +}; + +export default RealtimeAnalytics; diff --git a/inventory/src/components/dashboard/SalesChart.jsx b/inventory/src/components/dashboard/SalesChart.jsx new file mode 100644 index 0000000..de1672b --- /dev/null +++ b/inventory/src/components/dashboard/SalesChart.jsx @@ -0,0 +1,1127 @@ +import React, { useState, useEffect, useMemo, useCallback, memo } from "react"; +import axios from "axios"; +import { acotService } from "@/services/dashboard/acotService"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/dashboard/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/dashboard/ui/select"; +import { Input } from "@/components/dashboard/ui/input"; +import { Label } from "@/components/dashboard/ui/label"; +import { Button } from "@/components/dashboard/ui/button"; +import { + Loader2, + TrendingUp, + TrendingDown, + Info, + AlertCircle, + ArrowUp, + ArrowDown, +} from "lucide-react"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, + ReferenceLine, +} from "recharts"; +import { + TIME_RANGES, + GROUP_BY_OPTIONS, + formatDateForInput, + parseDateFromInput, +} from "@/lib/dashboard/constants"; +import { Checkbox } from "@/components/dashboard/ui/checkbox"; +import { + Table, + TableHeader, + TableHead, + TableRow, + TableBody, + TableCell, +} from "@/components/dashboard/ui/table"; +import { debounce } from "lodash"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/dashboard/ui/dialog"; +import { Skeleton } from "@/components/dashboard/ui/skeleton"; +import { Separator } from "@/components/dashboard/ui/separator"; +import { ToggleGroup, ToggleGroupItem } from "@/components/dashboard/ui/toggle-group"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/dashboard/ui/collapsible"; +import { Alert, AlertDescription, AlertTitle } from "@/components/dashboard/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; +}; + +// Move formatCurrency to top and export it +export const formatCurrency = (value, minimumFractionDigits = 0) => { + if (!value || isNaN(value)) return "$0"; + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits, + maximumFractionDigits: minimumFractionDigits, + }).format(value); +}; + +// Add a helper function for percentage formatting +const formatPercentage = (value) => { + if (typeof value !== "number") return "0%"; + return `${Math.abs(Math.round(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", + }) => ( + + + {title} + {trend && ( + + {trend === "up" ? ( + + ) : ( + + )} + {trendPrefix} + {trendValue} + {trendSuffix} + + )} + + +
+ {valuePrefix} + {value} + {valueSuffix} +
+ {description && ( +
{description}
+ )} +
+
+ ) +); + +StatCard.displayName = "StatCard"; + +// Export CustomTooltip +export 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", + }); + + return ( + + +

{formattedDate}

+ {payload.map((entry, index) => { + const value = entry.dataKey.toLowerCase().includes('revenue') || entry.dataKey === 'avgOrderValue' + ? formatCurrency(entry.value) + : entry.value.toLocaleString(); + + return ( +
+ {entry.name}: + {value} +
+ ); + })} +
+
+ ); + } + return null; +}; + +const calculate7DayAverage = (data) => { + if (!Array.isArray(data) || data.length === 0) return []; + + return data.map((day, index, array) => { + // Get up to 7 days of data, including current day + const startIndex = Math.max(0, index - 6); + const window = array.slice(startIndex, index + 1); + + // Calculate averages for all metrics + const validPoints = window.filter( + (point) => + point && + typeof point.revenue === "number" && + typeof point.orders === "number" && + !isNaN(point.revenue) && + !isNaN(point.orders) + ); + + if (validPoints.length === 0) { + return { + ...day, + movingAverage: 0, + orderMovingAverage: 0, + aovMovingAverage: 0, + }; + } + + const revenueSum = validPoints.reduce((acc, curr) => acc + curr.revenue, 0); + const orderSum = validPoints.reduce((acc, curr) => acc + curr.orders, 0); + + const revenueAvg = revenueSum / validPoints.length; + const orderAvg = orderSum / validPoints.length; + const aovAvg = orderAvg > 0 ? revenueAvg / orderAvg : 0; + + return { + ...day, + movingAverage: Number(revenueAvg.toFixed(2)), + orderMovingAverage: Number(orderAvg.toFixed(2)), + aovMovingAverage: Number(aovAvg.toFixed(2)), + }; + }); +}; + +// Export processData +export const processData = (stats = []) => { + if (!Array.isArray(stats)) return []; + + // First, convert the stats array into the base format + const baseData = stats.map((day) => ({ + timestamp: day.date || day.timestamp, + revenue: Number(day.revenue || 0), + orders: Number(day.orders || 0), + avgOrderValue: Number( + day.averageOrderValue || (day.orders > 0 ? day.revenue / day.orders : 0) + ), + prevRevenue: Number(day.prevRevenue || 0), + prevOrders: Number(day.prevOrders || 0), + prevAvgOrderValue: Number( + day.prevAvgOrderValue || + (day.prevOrders > 0 ? day.prevRevenue / day.prevOrders : 0) + ), + growth: { + revenue: 0, + orders: 0, + avgOrderValue: 0, + }, + })); + + // Calculate growth rates + baseData.forEach((day) => { + // Calculate growth + day.growth = { + revenue: + day.prevRevenue > 0 + ? ((day.revenue - day.prevRevenue) / day.prevRevenue) * 100 + : 0, + orders: + day.prevOrders > 0 + ? ((day.orders - day.prevOrders) / day.prevOrders) * 100 + : 0, + avgOrderValue: + day.prevAvgOrderValue > 0 + ? ((day.avgOrderValue - day.prevAvgOrderValue) / + day.prevAvgOrderValue) * + 100 + : 0, + }; + }); + + // Calculate 7-day moving averages + return calculate7DayAverage(baseData); +}; + +const calculateSummaryStats = (data = []) => { + if (!Array.isArray(data) || data.length === 0) return {}; + + // Calculate current period totals + const totalRevenue = data.reduce((sum, item) => sum + item.revenue, 0); + const totalOrders = data.reduce((sum, item) => sum + item.orders, 0); + const avgOrderValue = totalOrders ? totalRevenue / totalOrders : 0; + + // Calculate previous period totals + const prevRevenue = data.reduce((sum, item) => sum + item.prevRevenue, 0); + const prevOrders = data.reduce((sum, item) => sum + item.prevOrders, 0); + const prevAvgOrderValue = prevOrders ? prevRevenue / prevOrders : 0; + + // Find best day + const bestDay = data.reduce((best, current) => { + if (current.revenue > (best?.revenue || 0)) { + return { + revenue: current.revenue, + timestamp: current.timestamp, + orders: current.orders, + avgOrderValue: current.avgOrderValue, + }; + } + return best; + }, null); + + // Get period progress from the last day + const periodProgress = data[data.length - 1]?.periodProgress || 100; + + return { + totalRevenue, + totalOrders, + avgOrderValue, + bestDay, + prevRevenue, + prevOrders, + prevAvgOrderValue, + periodProgress, + movingAverages: { + revenue: data[data.length - 1]?.movingAverage || 0, + orders: data[data.length - 1]?.orderMovingAverage || 0, + avgOrderValue: data[data.length - 1]?.aovMovingAverage || 0, + }, + }; +}; + +// Add memoized SummaryStats component +const SummaryStats = memo(({ stats = {}, projection = null, projectionLoading = false }) => { + const { + totalRevenue = 0, + totalOrders = 0, + avgOrderValue = 0, + bestDay = null, + prevRevenue = 0, + prevOrders = 0, + prevAvgOrderValue = 0, + periodProgress = 100 + } = stats; + + // Calculate projected values when period is incomplete + const currentRevenue = periodProgress < 100 ? (projection?.projectedRevenue || totalRevenue) : totalRevenue; + const revenueTrend = currentRevenue >= prevRevenue ? "up" : "down"; + const revenueDiff = Math.abs(currentRevenue - prevRevenue); + const revenuePercentage = (revenueDiff / prevRevenue) * 100; + + // Calculate order trends + const currentOrders = periodProgress < 100 ? (projection?.projectedOrders || totalOrders) : totalOrders; + const ordersTrend = currentOrders >= prevOrders ? "up" : "down"; + const ordersDiff = Math.abs(currentOrders - prevOrders); + const ordersPercentage = (ordersDiff / prevOrders) * 100; + + // Calculate AOV trends + const currentAOV = currentOrders ? currentRevenue / currentOrders : avgOrderValue; + const aovTrend = currentAOV >= prevAvgOrderValue ? "up" : "down"; + const aovDiff = Math.abs(currentAOV - prevAvgOrderValue); + const aovPercentage = (aovDiff / prevAvgOrderValue) * 100; + + return ( +
+ + + + + + + +
+ ); +}); + +SummaryStats.displayName = "SummaryStats"; + +// Add these skeleton components near the top of the file +const SkeletonChart = () => ( +
+
+
+ {/* Grid lines */} + {[...Array(6)].map((_, i) => ( +
+ ))} + {/* Y-axis labels */} +
+ {[...Array(6)].map((_, i) => ( + + ))} +
+ {/* X-axis labels */} +
+ {[...Array(7)].map((_, i) => ( + + ))} +
+ {/* Chart line */} +
+
+
+
+
+
+); + +const SkeletonStats = () => ( +
+ {[...Array(4)].map((_, i) => ( + + + + + + + + + + + ))} +
+); + +const SkeletonTable = () => ( +
+
+ {[...Array(7)].map((_, i) => ( +
+ + +
+ ))} +
+
+); + +const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedTimeRange, setSelectedTimeRange] = useState(timeRange); + const [showDailyTable, setShowDailyTable] = useState(false); + const [metrics, setMetrics] = useState({ + revenue: true, + orders: true, + avgOrderValue: false, + movingAverage: true, + showPrevious: false, + }); + const [summaryStats, setSummaryStats] = useState({}); + const [projection, setProjection] = useState(null); + const [projectionLoading, setProjectionLoading] = useState(false); + + // Add function to fetch projection + const fetchProjection = useCallback(async (params) => { + if (!params) return; + + try { + setProjectionLoading(true); + const response = await acotService.getProjection(params); + setProjection(response); + } catch (error) { + console.error("Error loading projection:", error); + } finally { + setProjectionLoading(false); + } + }, []); + + // Fetch data function + const fetchData = useCallback(async (params) => { + try { + setLoading(true); + setError(null); + + // Fetch data + const response = await acotService.getStatsDetails({ + ...params, + metric: "revenue", + daily: true, + }); + + if (!response.stats) { + throw new Error("Invalid response format"); + } + + // Process the data + const currentStats = Array.isArray(response.stats) + ? response.stats + : []; + + // Process the data directly without remapping + const processedData = processData(currentStats); + const stats = calculateSummaryStats(processedData); + + setData(processedData); + setSummaryStats(stats); + setError(null); + + // Fetch projection if needed + if (stats.periodProgress < 100) { + fetchProjection(params); + } + } catch (error) { + console.error("Error fetching data:", error); + setError(error.message); + } finally { + setLoading(false); + } + }, [fetchProjection]); + + // Handle time range change + const handleTimeRangeChange = useCallback( + (value) => { + setSelectedTimeRange(value); + fetchData({ timeRange: value }); + }, + [fetchData] + ); + + // Initial load and auto-refresh effect + useEffect(() => { + let intervalId = null; + + // Initial fetch + fetchData({ timeRange: selectedTimeRange }); + + // Set up auto-refresh only for 'today' view + if (selectedTimeRange === "today") { + intervalId = setInterval(() => { + fetchData({ timeRange: "today" }); + }, 60000); + } + + return () => { + if (intervalId) { + clearInterval(intervalId); + } + }; + }, [selectedTimeRange, fetchData]); + + const formatXAxis = (value) => { + if (!value) return ""; + const date = new Date(value); + return date.toLocaleDateString([], { month: "short", day: "numeric" }); + }; + + const averageRevenue = + data.length > 0 + ? data.reduce((sum, day) => sum + day.revenue, 0) / data.length + : 0; + + return ( + + +
+
+
+ + {title} + +
+
+ {!error && ( + + + + + + + + Daily Details + +
+
+ + + + +
+ + + + +
+
+
+
+ + + + + Date + + + {metrics.revenue && ( + <> + + Revenue + + {metrics.showPrevious && ( + + Prev Revenue + + )} + + )} + {metrics.orders && ( + <> + + Orders + + {metrics.showPrevious && ( + + Prev Orders + + )} + + )} + {metrics.avgOrderValue && ( + <> + + AOV + + {metrics.showPrevious && ( + + Prev AOV + + )} + + )} + {metrics.movingAverage && ( + + 7-Day Avg + + )} + + + + {data.map((day) => ( + + + {formatXAxis(day.timestamp)} + + + {metrics.revenue && ( + <> + + {formatCurrency(day.revenue)} + + {metrics.showPrevious && ( + + {formatCurrency(day.prevRevenue)} + + )} + + )} + {metrics.orders && ( + <> + + {day.orders.toLocaleString()} + + {metrics.showPrevious && ( + + {day.prevOrders.toLocaleString()} + + )} + + )} + {metrics.avgOrderValue && ( + <> + + {formatCurrency(day.avgOrderValue)} + + {metrics.showPrevious && ( + + {formatCurrency(day.prevAvgOrderValue)} + + )} + + )} + {metrics.movingAverage && ( + + {formatCurrency(day.movingAverage)} + + )} + + ))} + +
+
+
+
+
+ )} + +
+
+ + {/* Show stats only if not in error state */} + {!error && + (loading ? ( + + ) : ( + + ))} + + {/* Show metric toggles only if not in error state */} + {!error && ( +
+
+ + + + +
+ + + + + +
+ )} +
+
+ + + {loading ? ( +
+ +
+ +
+ {showDailyTable && } +
+ ) : error ? ( + + + Error + + Failed to load sales data: {error} + + + ) : !data.length ? ( +
+
+ +
+ No sales data available +
+
+ Try selecting a different time range +
+
+
+ ) : ( + <> +
+ + + + + formatCurrency(value, false)} + className="text-xs text-muted-foreground" + tick={{ fill: "currentColor" }} + /> + value.toLocaleString()} + className="text-xs text-muted-foreground" + tick={{ fill: "currentColor" }} + /> + } /> + + + {metrics.revenue && ( + + )} + {metrics.revenue && metrics.showPrevious && ( + + )} + {metrics.orders && ( + + )} + {metrics.orders && metrics.showPrevious && ( + + )} + {metrics.avgOrderValue && ( + + )} + {metrics.avgOrderValue && metrics.showPrevious && ( + + )} + {metrics.movingAverage && ( + + )} + + +
+ + )} +
+
+ ); +}; + +export default SalesChart; diff --git a/inventory/src/components/dashboard/StatCards.jsx b/inventory/src/components/dashboard/StatCards.jsx new file mode 100644 index 0000000..6226015 --- /dev/null +++ b/inventory/src/components/dashboard/StatCards.jsx @@ -0,0 +1,2320 @@ +import React, { useState, useEffect, useCallback, Suspense, memo } from "react"; +import axios from "axios"; +import { acotService } from "@/services/dashboard/acotService"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/dashboard/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/dashboard/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/dashboard/ui/dialog"; +import { + LineChart, + Line, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + ResponsiveContainer, + Legend, + PieChart, + Pie, + Cell, +} from "recharts"; +// Import Tooltip from recharts with alias to avoid naming conflict +import { Tooltip as RechartsTooltip } from "recharts"; +import { + DollarSign, + ShoppingCart, + Package, + Clock, + Map, + Tags, + Star, + XCircle, + TrendingUp, + TrendingDown, + ArrowDown, + ArrowUp, + AlertCircle, + Box, + RefreshCcw, + CircleDollarSign, + MapPin, + Info, + Loader2, +} from "lucide-react"; +import { DateTime } from "luxon"; +import { TIME_RANGES } from "@/lib/dashboard/constants"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/dashboard/ui/table"; +import { Skeleton } from "@/components/dashboard/ui/skeleton"; +import { Alert, AlertDescription, AlertTitle } from "@/components/dashboard/ui/alert"; +import { Button } from "@/components/dashboard/ui/button"; +import { Progress } from "@/components/dashboard/ui/progress"; +import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from "@/components/dashboard/ui/tooltip"; + +const formatCurrency = (value, minimumFractionDigits = 0) => { + if (!value || isNaN(value)) return "$0"; + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits, + maximumFractionDigits: minimumFractionDigits, + }).format(value); +}; + +const formatPercentage = (value) => { + if (typeof value !== "number") return "0%"; + return `${Math.round(value)}%`; +}; + +const formatHour = (hour) => { + const date = new Date(); + date.setHours(hour, 0, 0); + return date.toLocaleString("en-US", { hour: "numeric", hour12: true }); +}; + +// Reusable chart components +const TimeSeriesChart = ({ + data, + dataKey, + name, + color = "hsl(var(--primary))", + type = "line", + valueFormatter = (value) => value, + height = 400, +}) => { + const ChartComponent = type === "line" ? LineChart : BarChart; + const DataComponent = type === "line" ? Line : Bar; + + // Handle multiple series + const keys = Array.isArray(dataKey) ? dataKey : [dataKey]; + const names = Array.isArray(name) ? name : [name]; + const colors = Array.isArray(color) ? color : [color]; + + return ( +
+ + + + DateTime.fromISO(value).toFormat("LLL d")} + className="text-xs" + /> + + { + if (!active || !payload?.length) return null; + return ( +
+

+ {DateTime.fromISO(label).toFormat("LLL d")} +

+ {payload.map((entry, i) => ( +

+ {entry.name}: {valueFormatter(entry.value)} +

+ ))} +
+ ); + }} + /> + + {keys.map((key, index) => ( + + ))} +
+
+
+ ); +}; + +const DetailDialog = ({ open, onOpenChange, title, children }) => ( + + + + {title} + + {children} + + +); + +// Detail view components +const RevenueDetails = ({ data }) => { + if (!data?.length) + return ( +
+ No data available for the selected time range. +
+ ); + + // Ensure we have daily data points and they're properly formatted + const chartData = data + .map((day) => ({ + timestamp: DateTime.fromISO(day.timestamp).toFormat("yyyy-MM-dd"), + revenue: parseFloat(day.revenue || 0), + orders: parseInt(day.orders || 0), + date: DateTime.fromISO(day.timestamp).toFormat("LLL d"), + })) + .sort( + (a, b) => DateTime.fromISO(a.timestamp) - DateTime.fromISO(b.timestamp) + ); + + return ( + formatCurrency(value)} + height={400} + /> + ); +}; + +const OrdersDetails = ({ data }) => { + if (!data?.length) + return ( +
+ No data available for the selected time range. +
+ ); + + return ( + <> + + {data[0]?.hourlyOrders && ( +
+

Hourly Distribution

+ ({ + hour: formatHour(hour), + orders: count, + }))} + dataKey="orders" + name="Orders" + type="bar" + color=" + " + /> +
+ )} + + ); +}; + +// Add additional chart components +const BarList = ({ data, valueFormatter = (v) => v }) => ( +
+ {data.map((item, i) => ( +
+
+ {item.name} + {valueFormatter(item.value)} +
+
+
+
+
+ ))} +
+); + +const StatGrid = ({ stats }) => ( +
+ {stats.map((stat, i) => ( + +
{stat.label}
+
{stat.value}
+ {stat.description && ( +
+ {stat.description} +
+ )} +
+ ))} +
+); + +// Add detail view components +const AverageOrderDetails = ({ data, orderCount }) => { + if (!data?.length) + return ( +
+ No data available for the selected time range. +
+ ); + + return ( + formatCurrency(value)} + /> + ); +}; + +const CancellationsDetails = ({ data }) => { + if (!data?.length) + return ( +
+ No data available for the selected time range. +
+ ); + + const cancelData = data[0]?.canceledOrders || { + total: 0, + count: 0, + reasons: {}, + items: [], + }; + const timeSeriesData = data.map((day) => ({ + timestamp: day.timestamp, + total: day.canceledOrders?.total || 0, + count: day.canceledOrders?.count || 0, + })); + + const reasonData = Object.entries(cancelData.reasons || {}) + .map(([reason, count]) => ({ + reason, + count, + })) + .sort((a, b) => b.count - a.count); + + return ( +
+
+

Daily Cancellation Amount

+ formatCurrency(value)} + /> +
+ +
+

Daily Cancellation Count

+ +
+ + {reasonData.length > 0 && ( +
+

Cancellation Reasons

+
+ + + + Reason + Count + + + + {reasonData.map((item, index) => ( + + {item.reason} + + {item.count.toLocaleString()} + + + ))} + +
+
+
+ )} +
+ ); +}; + +const BrandsCategoriesDetails = ({ data }) => { + if (!data?.length) + return ( +
+ No data available for the selected time range. +
+ ); + + const stats = data[0]; + const brandsList = stats?.brands?.list || []; + const categoriesList = stats?.categories?.list || []; + + return ( +
+
+

+ + Brands ({stats?.brands?.total || 0}) +

+
+ + + + Brand + Items + Revenue + + + + {brandsList.map((brand) => ( + + {brand.name} + + {brand.count?.toLocaleString()} + + + ${brand.revenue?.toFixed(2)} + + + ))} + +
+
+
+ +
+

+ + Categories ({stats?.categories?.total || 0}) +

+
+ + + + Category + Items + Revenue + + + + {categoriesList.map((category) => ( + + {category.name} + + {category.count?.toLocaleString()} + + + ${category.revenue?.toFixed(2)} + + + ))} + +
+
+
+
+ ); +}; + +const ShippingDetails = ({ data }) => { + if (!data?.length) + return ( +
+ No data available for the selected time range. +
+ ); + + const shippedCount = data[0]?.shipping?.shippedCount || 0; + const locations = data[0]?.shipping?.locations || {}; + const methodStats = data[0]?.shipping?.methodStats || []; + + // Shipping method name mappings + const shippingMethodNames = { + usps_ground_advantage: "USPS Ground Advantage", + usps_priority: "USPS Priority", + Unknown: "Digital", + fedex_ieconomy: "FedEx Intl Economy", + fedex_homedelivery: "FedEx Ground", + fedex_ground: "FedEx Ground", + fedex_iground: "FedEx Intl Ground", + fedex_2day: "FedEx 2 Day", + }; + + return ( +
+ {/* Shipping Methods */} +
+

+ + Shipping Methods +

+
+ + + + Method + Orders + % of Total + + + + {methodStats.map((method) => ( + + + {shippingMethodNames[method.name] || method.name} + + + {method.value.toLocaleString()} + + + {((method.value / shippedCount) * 100).toFixed(1)}% + + + ))} + +
+
+
+ + {/* Countries */} +
+

+ + Countries ({locations.byCountry?.length || 0}) +

+
+ + + + Country + Orders + % of Total + + + + {locations.byCountry?.map((country) => ( + + + {country.country} + + + {country.count.toLocaleString()} + + + {country.percentage.toFixed(1)}% + + + ))} + +
+
+
+ + {/* States */} +
+

+ + States/Regions ({locations.byState?.length || 0}) +

+
+ + + + State + Orders + % of Total + + + + {locations.byState?.map((state) => ( + + {state.state} + + {state.count.toLocaleString()} + + + {state.percentage.toFixed(1)}% + + + ))} + +
+
+
+
+ ); +}; + +const OrderTypeDetails = ({ data, type }) => { + if (!data?.length) + return ( +
+ No data available for the selected time range. +
+ ); + + const timeSeriesData = data.map((day) => ({ + timestamp: day.timestamp, + count: day.count, + value: day.value, + percentage: day.percentage, + })); + + const typeColors = { + pre_orders: "hsl(47.9 95.8% 53.1%)", // Yellow for pre-orders + local_pickup: "hsl(192.2 70.1% 51.4%)", // Cyan for local pickup + on_hold: "hsl(346.8 77.2% 49.8%)", // Red for on hold + }; + + const color = typeColors[type]; + + return ( +
+
+

Daily Order Count

+ +
+ +
+

Daily Value

+ formatCurrency(value)} + /> +
+ +
+

Percentage of Orders

+ `${value.toFixed(1)}%`} + /> +
+
+ ); +}; + +const PeakHourDetails = ({ data }) => { + if (!data?.length) + return ( +
+ No data available for the selected time range. +
+ ); + + const hourlyData = + data[0]?.hourlyOrders?.map((count, hour) => ({ + timestamp: hour, // Use raw hour number for x-axis + orders: count, + })) || []; + + return ( +
+ + + + { + const date = new Date(); + date.setHours(hour, 0, 0); + return date.toLocaleString("en-US", { + hour: "numeric", + hour12: true, + }); + }} + className="text-xs" + /> + + { + if (!active || !payload?.length) return null; + const date = new Date(); + date.setHours(label, 0, 0); + const time = date.toLocaleString("en-US", { + hour: "numeric", + hour12: true, + }); + return ( +
+

{time}

+

Orders: {payload[0].value}

+
+ ); + }} + /> + +
+
+
+ ); +}; + +const RefundDetails = ({ data }) => { + if (!data?.length) + return ( +
+ No data available for the selected time range. +
+ ); + + const refundData = data[0]?.refunds || { total: 0, count: 0, reasons: {} }; + const timeSeriesData = data.map((day) => ({ + timestamp: day.timestamp, + total: day.refunds?.total || 0, + count: day.refunds?.count || 0, + })); + + const reasonData = Object.entries(refundData.reasons || {}) + .map(([reason, count]) => ({ + reason, + count, + })) + .sort((a, b) => b.count - a.count); + + return ( +
+
+

Daily Refund Amount

+ formatCurrency(value)} + /> +
+ +
+

Daily Refund Count

+ +
+ + {reasonData.length > 0 && ( +
+

Refund Reasons

+
+ + + + Reason + Count + + + + {reasonData.map((item, index) => ( + + {item.reason} + + {item.count.toLocaleString()} + + + ))} + +
+
+
+ )} +
+ ); +}; + +const OrderRangeDetails = ({ data }) => { + if (!data?.length) + return ( +
+ No data available for the selected time range. +
+ ); + + // Get the data from the entire period + const allData = data.reduce((acc, day) => { + // Initialize distribution data structure if not exists + if (!acc.orderValueDistribution) { + acc.orderValueDistribution = + day.orderValueDistribution?.map((range) => ({ + ...range, + count: 0, + total: 0, + })) || []; + } + + // Aggregate distribution data + day.orderValueDistribution?.forEach((range, index) => { + if (acc.orderValueDistribution[index]) { + acc.orderValueDistribution[index].count += range.count; + acc.orderValueDistribution[index].total += range.total; + } + }); + + // Track total orders for percentage calculation + acc.totalOrders = (acc.totalOrders || 0) + (day.orders || 0); + + return acc; + }, {}); + + const timeSeriesData = data.map((day) => ({ + timestamp: day.timestamp, + largest: day.orderValueRange?.largest || 0, + smallest: day.orderValueRange?.smallest || 0, + average: day.averageOrderValue || 0, + })); + + // Transform distribution data using aggregated values + const formattedDistributionData = + allData.orderValueDistribution?.map((range) => { + const totalRevenue = allData.orderValueDistribution.reduce( + (sum, r) => sum + (r.total || 0), + 0 + ); + return { + range: + range.max === "Infinity" + ? `$${range.min}+` + : `$${range.min}-${range.max}`, + count: range.count, + total: range.total, + percentage: ((range.count / (allData.totalOrders || 1)) * 100).toFixed( + 1 + ), + revenuePercentage: ((range.total / (totalRevenue || 1)) * 100).toFixed( + 1 + ), + }; + }) || []; + + return ( +
+
+

Order Value Range

+ formatCurrency(value)} + /> +
+ +
+

Average Order Value

+ formatCurrency(value)} + /> +
+ + {formattedDistributionData.length > 0 && ( +
+

Order Value Distribution

+
+ + + + Range + Orders + Total Revenue + % of Orders + % of Revenue + + + + {formattedDistributionData.map((range, index) => ( + + + {range.range} + + + {range.count.toLocaleString()} + + + {formatCurrency(range.total)} + + + {range.percentage}% + + + {range.revenuePercentage}% + + + ))} + +
+
+
+ + + + + `${value}%`} + /> + { + if (!active || !payload?.length) return null; + const data = payload[0].payload; + return ( +
+

{data.range}

+

+ Orders: {data.count.toLocaleString()} +

+

+ Revenue: {formatCurrency(data.total)} +

+

+ % of Orders: {data.percentage}% +

+

+ % of Revenue: {data.revenuePercentage}% +

+
+ ); + }} + /> + +
+
+
+
+ )} +
+ ); +}; + +const StatCard = ({ + title, + value, + description, + trend, + trendValue, + valuePrefix = "", + valueSuffix = "", + trendPrefix = "", + trendSuffix = "", + className = "", + colorClass = "text-gray-900 dark:text-gray-100", + titleClass, + descriptionClass, + icon: Icon, + iconColor = "text-gray-500", + iconBackground, + onClick, + info, + onDetailsClick, + isLoading = false, + progress, + variant = "default", + background +}) => { + const variants = { + default: "bg-white dark:bg-gray-900/60", + mini: background || "bg-gradient-to-br from-gray-800 to-gray-900 backdrop-blur-md" + }; + + const titleVariants = { + default: "text-sm font-medium text-gray-600 dark:text-gray-300", + mini: titleClass || "text-sm font-bold text-gray-100" + }; + + const valueVariants = { + default: "text-2xl font-bold", + mini: "text-3xl font-extrabold" + }; + + const descriptionVariants = { + default: "text-sm text-muted-foreground", + mini: descriptionClass || "text-sm font-semibold text-gray-200" + }; + + const trendColorVariants = { + default: { + up: "text-emerald-600 dark:text-emerald-400", + down: "text-rose-600 dark:text-rose-400" + }, + mini: { + up: "text-emerald-900", + down: "text-rose-900" + } + }; + + const iconVariants = { + default: iconColor, + mini: iconColor || colorClass + }; + + return ( + + +
+ + {title} + + {info && ( + + )} +
+ {Icon && ( +
+ {variant === 'mini' && iconBackground && ( +
+ )} + +
+ )} + + + {isLoading ? ( + <> + + + + ) : ( +
+
+
+ {valuePrefix} + {value} + {valueSuffix} +
+ {description && ( +
+ {description} + {trend && ( + + {trend === "up" ? ( + + ) : ( + + )} + {trendPrefix} + {trendValue} + {trendSuffix} + + )} +
+ )} +
+
+ )} +
+ + ); +}; + +// Add this before the StatCards component +const useDataCache = () => { + const [cache, setCache] = useState({}); + + const getCacheKey = (timeRange, metric) => `${timeRange}_${metric}`; + + const setCacheData = (timeRange, metric, data) => { + setCache((prev) => ({ + ...prev, + [getCacheKey(timeRange, metric)]: { + data, + timestamp: Date.now(), + }, + })); + }; + + const getCacheData = (timeRange, metric) => { + const key = getCacheKey(timeRange, metric); + return cache[key]?.data; + }; + + const clearCache = () => setCache({}); + + return { setCacheData, getCacheData, clearCache }; +}; + +// Add memoized detail components at the top level +const MemoizedRevenueDetails = memo(RevenueDetails); +const MemoizedOrdersDetails = memo(OrdersDetails); +const MemoizedAverageOrderDetails = memo(AverageOrderDetails); +const MemoizedRefundDetails = memo(RefundDetails); +const MemoizedOrderRangeDetails = memo(OrderRangeDetails); +const MemoizedOrderTypeDetails = memo(OrderTypeDetails); +const MemoizedBrandsCategoriesDetails = memo(BrandsCategoriesDetails); +const MemoizedShippingDetails = memo(ShippingDetails); +const MemoizedPeakHourDetails = memo(PeakHourDetails); +const MemoizedCancellationsDetails = memo(CancellationsDetails); + +// Add this before the StatCards component +const useDebouncedEffect = (effect, deps, delay) => { + useEffect(() => { + const handler = setTimeout(() => effect(), delay); + return () => clearTimeout(handler); + }, [...deps, delay]); +}; + +// Add these skeleton components near the top of the file +const SkeletonCard = () => ( + + +
+ + +
+ +
+ +
+ +
+ + +
+
+
+
+); + +const SkeletonChart = ({ type = "line" }) => ( +
+
+ {/* Grid lines */} + {[...Array(5)].map((_, i) => ( +
+ ))} + {/* Y-axis labels */} +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ {/* X-axis labels */} +
+ {[...Array(6)].map((_, i) => ( + + ))} +
+ {type === "bar" ? ( +
+ {[...Array(24)].map((_, i) => ( +
+ ))} +
+ ) : ( +
+
+
+
+
+ )} +
+
+); + +const SkeletonTable = ({ rows = 8 }) => ( +
+ + + + + + + + + + + + + + + + {[...Array(rows)].map((_, i) => ( + + + + + + + + + + + + ))} + +
+
+); + +const SummaryStats = memo(({ stats = {}, projection = null, projectionLoading = false }) => { + const { + totalRevenue = 0, + totalOrders = 0, + avgOrderValue = 0, + bestDay = null, + prevRevenue = 0, + prevOrders = 0, + prevAvgOrderValue = 0, + periodProgress = 100 + } = stats; + + // Calculate projected values when period is incomplete + const currentRevenue = periodProgress < 100 ? (projection?.projectedRevenue || totalRevenue) : totalRevenue; + const revenueTrend = currentRevenue >= prevRevenue ? "up" : "down"; + const revenueDiff = Math.abs(currentRevenue - prevRevenue); + const revenuePercentage = (revenueDiff / prevRevenue) * 100; + + // Calculate order trends + const currentOrders = periodProgress < 100 ? Math.round(totalOrders * (100 / periodProgress)) : totalOrders; + const ordersTrend = currentOrders >= prevOrders ? "up" : "down"; + const ordersDiff = Math.abs(currentOrders - prevOrders); + const ordersPercentage = (ordersDiff / prevOrders) * 100; + + // Calculate AOV trends + const currentAOV = currentOrders ? currentRevenue / currentOrders : avgOrderValue; + const aovTrend = currentAOV >= prevAvgOrderValue ? "up" : "down"; + const aovDiff = Math.abs(currentAOV - prevAvgOrderValue); + const aovPercentage = (aovDiff / prevAvgOrderValue) * 100; + + return ( +
+ + + + + + + +
+ ); +}); + +const StatCards = ({ + timeRange: initialTimeRange = "today", + startDate, + endDate, + title = "Sales Dashboard", + description = "", +}) => { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdate, setLastUpdate] = useState(null); + const [timeRange, setTimeRange] = useState(initialTimeRange); + const [selectedMetric, setSelectedMetric] = useState(null); + const [dateRange, setDateRange] = useState(null); + const [detailDataLoading, setDetailDataLoading] = useState({}); + const [detailData, setDetailData] = useState({}); + const [isInitialLoad, setIsInitialLoad] = useState(true); + const [projection, setProjection] = useState(null); + const [projectionLoading, setProjectionLoading] = useState(false); + const { setCacheData, getCacheData, clearCache } = useDataCache(); + + // Function to determine if we should use last30days for trend charts + const shouldUseLast30Days = useCallback( + (metric) => { + if (["brands_categories", "shipping"].includes(metric)) { + return false; + } + const shortPeriods = [ + "today", + "yesterday", + "last7days", + "thisWeek", + "lastWeek", + ]; + return shortPeriods.includes(timeRange); + }, + [timeRange] + ); + + // Function to fetch detail data for a specific metric + const fetchDetailData = useCallback( + async (metric, orderType) => { + const detailTimeRange = shouldUseLast30Days(metric) + ? "last30days" + : timeRange; + const cachedData = getCacheData(detailTimeRange, metric); + + if (cachedData) { + console.log(`Using cached data for ${metric}`); + setDetailData((prev) => ({ ...prev, [metric]: cachedData })); + return cachedData; + } + + console.log(`Fetching detail data for ${metric}`); + setDetailDataLoading((prev) => ({ ...prev, [metric]: true })); + + try { + const params = { + ...(timeRange === "custom" + ? { startDate, endDate } + : { timeRange: detailTimeRange }), + metric, + daily: true, + }; + + // For metrics that need the full stats + if (["shipping", "brands_categories"].includes(metric)) { + const response = await acotService.getStats(params); + const data = [response.stats]; + setCacheData(detailTimeRange, metric, data); + setDetailData((prev) => ({ ...prev, [metric]: data })); + setError(null); + return data; + } + + // For order types (pre_orders, local_pickup, on_hold) + if (["pre_orders", "local_pickup", "on_hold"].includes(metric)) { + const response = await acotService.getStatsDetails({ + ...params, + orderType: orderType, + }); + const data = response.stats; + setCacheData(detailTimeRange, metric, data); + setDetailData((prev) => ({ ...prev, [metric]: data })); + setError(null); + return data; + } + + // For refunds and cancellations + if (["refunds", "cancellations"].includes(metric)) { + const response = await acotService.getStatsDetails({ + ...params, + eventType: + metric === "refunds" ? "PAYMENT_REFUNDED" : "CANCELED_ORDER", + }); + const data = response.stats; + + // Transform the data to match the expected format + const transformedData = data.map((day) => ({ + ...day, + timestamp: day.timestamp, + refunds: + metric === "refunds" + ? { + total: day.refunds?.total || 0, + count: day.refunds?.count || 0, + reasons: day.refunds?.reasons || {}, + } + : undefined, + canceledOrders: + metric === "cancellations" + ? { + total: day.canceledOrders?.total || 0, + count: day.canceledOrders?.count || 0, + reasons: day.canceledOrders?.reasons || {}, + } + : undefined, + })); + + setCacheData(detailTimeRange, metric, transformedData); + setDetailData((prev) => ({ ...prev, [metric]: transformedData })); + setError(null); + return transformedData; + } + + // For order range + if (metric === "order_range") { + const response = await acotService.getStatsDetails({ + ...params, + eventType: "PLACED_ORDER", + }); + const data = response.stats; + console.log("Fetched order range data:", data); + setCacheData(detailTimeRange, metric, data); + setDetailData((prev) => ({ ...prev, [metric]: data })); + setError(null); + return data; + } + + // For all other metrics + const response = await acotService.getStatsDetails(params); + const data = response.stats; + setCacheData(detailTimeRange, metric, data); + setDetailData((prev) => ({ ...prev, [metric]: data })); + setError(null); + return data; + } catch (error) { + console.error(`Error fetching detail data for ${metric}:`, error); + setError(error.response?.data?.error || error.message); + return null; + } finally { + setDetailDataLoading((prev) => ({ ...prev, [metric]: false })); + } + }, + [ + timeRange, + startDate, + endDate, + shouldUseLast30Days, + setCacheData, + getCacheData, + ] + ); + + // Throttled preloadDetailData function to avoid overwhelming the server + const preloadDetailData = useCallback(async () => { + const metrics = [ + "revenue", + "orders", + "average_order", + "refunds", + "cancellations", + "order_range", + "pre_orders", + "local_pickup", + "on_hold", + ]; + + // Process metrics in batches of 3 to avoid overwhelming the connection pool + const batchSize = 3; + for (let i = 0; i < metrics.length; i += batchSize) { + const batch = metrics.slice(i, i + batchSize); + try { + await Promise.all( + batch.map((metric) => fetchDetailData(metric, metric)) + ); + // Small delay between batches to prevent overwhelming the server + if (i + batchSize < metrics.length) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + } catch (error) { + console.error(`Error during detail data preload batch ${i / batchSize + 1}:`, error); + } + } + }, [fetchDetailData]); + + // Move trend calculation functions inside the component + const calculateTrend = useCallback((current, previous) => { + if (!current || !previous) return null; + const trend = current >= previous ? "up" : "down"; + const diff = Math.abs(current - previous); + const percentage = (diff / previous) * 100; + + return { + trend, + value: percentage, + current, + previous, + }; + }, []); + + const calculateRevenueTrend = useCallback(() => { + if (!stats?.prevPeriodRevenue && stats?.prevPeriodRevenue !== 0) return null; + + // If period is complete, use actual revenue + // If period is incomplete, use smart projection when available, fallback to simple projection + const currentRevenue = stats.periodProgress < 100 + ? (projection?.projectedRevenue || stats.projectedRevenue) + : stats.revenue; + const prevRevenue = stats.prevPeriodRevenue; // Previous period's total revenue + + console.log('[RevenueTrend Debug]', { + periodProgress: stats.periodProgress, + currentRevenue, + smartProjection: projection?.projectedRevenue, + simpleProjection: stats.projectedRevenue, + actualRevenue: stats.revenue, + prevRevenue, + isProjected: stats.periodProgress < 100 + }); + + if (!currentRevenue || !prevRevenue) return null; + + // Calculate absolute difference percentage + const trend = currentRevenue >= prevRevenue ? "up" : "down"; + const diff = Math.abs(currentRevenue - prevRevenue); + const percentage = (diff / prevRevenue) * 100; + + console.log('[RevenueTrend Result]', { + trend, + percentage, + calculation: `(|${currentRevenue} - ${prevRevenue}| / ${prevRevenue}) * 100 = ${percentage}%` + }); + + return { + trend, + value: percentage, + current: currentRevenue, + previous: prevRevenue, + }; + }, [stats, projection]); + + const calculateOrderTrend = useCallback(() => { + if (!stats?.prevPeriodOrders) return null; + return calculateTrend(stats.orderCount, stats.prevPeriodOrders); + }, [stats, calculateTrend]); + + const calculateAOVTrend = useCallback(() => { + if (!stats?.prevPeriodAOV) return null; + return calculateTrend(stats.averageOrderValue, stats.prevPeriodAOV); + }, [stats, calculateTrend]); + + // Initial load effect + useEffect(() => { + let isMounted = true; + + const loadData = async () => { + try { + setLoading(true); + setStats(null); + + const params = + timeRange === "custom" ? { startDate, endDate } : { timeRange }; + + const response = await acotService.getStats(params); + + if (!isMounted) return; + + setDateRange(response.timeRange); + setStats(response.stats); + setLastUpdate(DateTime.now().setZone("America/New_York")); + setError(null); + + // Start preloading detail data + preloadDetailData(); + } catch (error) { + console.error("Error loading data:", error); + if (isMounted) { + setError(error.message); + } + } finally { + if (isMounted) { + setLoading(false); + setIsInitialLoad(false); + } + } + }; + + loadData(); + return () => { + isMounted = false; + }; + }, [timeRange, startDate, endDate]); + + // Load smart projection separately + useEffect(() => { + let isMounted = true; + + const loadProjection = async () => { + if (!stats?.periodProgress || stats.periodProgress >= 100) return; + + try { + setProjectionLoading(true); + const params = + timeRange === "custom" ? { startDate, endDate } : { timeRange }; + + const response = await acotService.getProjection(params); + + if (!isMounted) return; + setProjection(response); + } catch (error) { + console.error("Error loading projection:", error); + } finally { + if (isMounted) { + setProjectionLoading(false); + } + } + }; + + loadProjection(); + return () => { + isMounted = false; + }; + }, [timeRange, startDate, endDate, stats?.periodProgress]); + + // Auto-refresh for 'today' view + useEffect(() => { + if (timeRange !== "today") return; + + const interval = setInterval(async () => { + try { + const [statsResponse, projectionResponse] = await Promise.all([ + acotService.getStats({ timeRange: "today" }), + acotService.getProjection({ timeRange: "today" }), + ]); + + setStats(statsResponse.stats); + setProjection(projectionResponse); + setLastUpdate(DateTime.now().setZone("America/New_York")); + } catch (error) { + console.error("Error auto-refreshing stats:", error); + } + }, 60000); + + return () => clearInterval(interval); + }, [timeRange]); + + // Modified AsyncDetailView component + const AsyncDetailView = memo(({ metric, type, orderCount }) => { + const detailTimeRange = shouldUseLast30Days(metric) + ? "last30days" + : timeRange; + const cachedData = + detailData[metric] || getCacheData(detailTimeRange, metric); + const isLoading = detailDataLoading[metric]; + const isOrderTypeMetric = [ + "pre_orders", + "local_pickup", + "on_hold", + ].includes(metric); + + useEffect(() => { + let isMounted = true; + + const loadData = async () => { + if (!cachedData && !isLoading) { + // Pass type only for order type metrics + const data = await fetchDetailData( + metric, + isOrderTypeMetric ? metric : undefined + ); + if (!isMounted) return; + // The state updates are handled in fetchDetailData + } + }; + + loadData(); + return () => { + isMounted = false; + }; + }, [metric, timeRange, isOrderTypeMetric]); // Depend on isOrderTypeMetric + + if (isLoading || (!cachedData && !error)) { + switch (metric) { + case "revenue": + case "orders": + case "average_order": + return ; + case "refunds": + case "cancellations": + case "order_range": + case "pre_orders": + case "local_pickup": + case "on_hold": + return ; + case "brands_categories": + case "shipping": + return ; + case "peak_hour": + return ; + default: + return
Loading...
; + } + } + + if (!cachedData && error) { + return ( + + + Error + Failed to load stats: {error} + + ); + } + + if (!cachedData) + return ( +
+ No data available for the selected time range. +
+ ); + + switch (metric) { + case "revenue": + return ; + case "orders": + return ; + case "average_order": + return ( + + ); + case "refunds": + return ; + case "cancellations": + return ; + case "order_range": + return ; + case "pre_orders": + case "local_pickup": + case "on_hold": + return ; + default: + return ( +
Invalid metric selected.
+ ); + } + }); + + AsyncDetailView.displayName = "AsyncDetailView"; + + // Modified getDetailComponent to use memoized components + const getDetailComponent = useCallback(() => { + if (!selectedMetric || !stats) { + return ( +
+ No data available for the selected time range. +
+ ); + } + + const data = detailData[selectedMetric]; + const isLoading = detailDataLoading[selectedMetric]; + const isOrderTypeMetric = [ + "pre_orders", + "local_pickup", + "on_hold", + ].includes(selectedMetric); + + if (isLoading) { + return ; + } + + switch (selectedMetric) { + case "revenue": + case "best_revenue_day": + return ; + case "orders": + return ; + case "average_order": + return ( + + ); + case "refunds": + return ; + case "cancellations": + return ; + case "order_range": + return ; + case "pre_orders": + case "local_pickup": + case "on_hold": + return ( + + ); + case "brands_categories": + return ; + case "shipping": + return ; + case "peak_hour": + if (!["today", "yesterday"].includes(timeRange)) { + return ( +
+ Peak hour details are only available for single-day periods. +
+ ); + } + return ; + default: + return ( +
Invalid metric selected.
+ ); + } + }, [selectedMetric, stats, timeRange, detailData, detailDataLoading]); + + if (loading && !stats) { + return ( + + +
+
+
+ + {title} + + {description && ( + + {description} + + )} +
+
+ + +
+
+
+
+ +
+ {[...Array(12)].map((_, i) => ( + + ))} +
+
+
+ ); + } + + if (error) { + return ( + + +
+
+
+ + {title} + + {description && ( + + {description} + + )} +
+
+ {lastUpdate && !loading && ( + + Last updated: {lastUpdate.toFormat("hh:mm a")} + + )} + +
+
+
+
+ + + + Error + Failed to load stats: {error} + + +
+ ); + } + + if (!stats) return null; + + const revenueTrend = calculateRevenueTrend(); + const orderTrend = calculateOrderTrend(); + const aovTrend = calculateAOVTrend(); + const isSingleDay = ["today", "yesterday"].includes(timeRange); + + return ( + + +
+
+
+ + {title} + + {lastUpdate && !loading && ( + + Last updated {lastUpdate.toFormat("h:mm a")} + {projection?.confidence > 0 && !projectionLoading && ( + + + + + ( + + {Math.round(projection.confidence * 100)}% + + ) + + + +

Confidence level of revenue projection based on historical data patterns

+
+
+
+ )} +
+ )} +
+ +
+ +
+
+
+
+ +
+ +
+ {stats?.periodProgress < 100 ? ( +
+ Proj: + Projected: + {projectionLoading ? ( +
+ +
+ ) : ( + formatCurrency( + projection?.projectedRevenue || stats.projectedRevenue + ) + )} +
+ ) : ( +
+ Prev: + Previous: + {formatCurrency(stats.prevPeriodRevenue || 0)} +
+ )} +
+
+ } + progress={ + stats?.periodProgress < 100 ? stats.periodProgress : undefined + } + trend={projectionLoading && stats?.periodProgress < 100 ? undefined : revenueTrend?.trend} + trendValue={ + projectionLoading && stats?.periodProgress < 100 ? ( +
+ + +
+ ) : revenueTrend?.value ? ( + + + + {formatPercentage(revenueTrend.value)} + + +

Previous Period: {formatCurrency(stats.prevPeriodRevenue || 0)}

+
+
+
+ ) : null + } + colorClass="text-green-600 dark:text-green-400" + icon={DollarSign} + iconColor="text-green-500" + iconBackground="bg-green-500" + onDetailsClick={() => setSelectedMetric("revenue")} + isLoading={loading || !stats} + /> + + setSelectedMetric("orders")} + isLoading={loading || !stats} + /> + + setSelectedMetric("average_order")} + isLoading={loading || !stats} + /> + + setSelectedMetric("brands_categories")} + isLoading={loading || !stats} + /> + + setSelectedMetric("shipping")} + isLoading={loading || !stats} + /> + + setSelectedMetric("pre_orders")} + isLoading={loading || !stats} + /> + + setSelectedMetric("local_pickup")} + isLoading={loading || !stats} + /> + + setSelectedMetric("on_hold")} + isLoading={loading || !stats} + /> + + {isSingleDay ? ( + setSelectedMetric("peak_hour")} + isLoading={loading || !stats} + /> + ) : ( + + )} + + setSelectedMetric("refunds")} + isLoading={loading || !stats} + /> + + setSelectedMetric("cancellations")} + isLoading={loading || !stats} + /> + + setSelectedMetric("order_range")} + isLoading={loading || !stats} + /> +
+ + setSelectedMetric(null)} + title={ + selectedMetric + ? `${selectedMetric + .split("_") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" ")} Details` + : "" + } + > + {getDetailComponent()} + + + + ); +}; + +// Export components and utilities for MiniStatCards +export { + RevenueDetails, + OrdersDetails, + AverageOrderDetails, + ShippingDetails, + StatCard, + DetailDialog, + formatCurrency, + formatPercentage, + SkeletonCard, + SkeletonChart, + SkeletonTable, +}; + +export default StatCards; diff --git a/inventory/src/components/dashboard/TypeformDashboard.jsx b/inventory/src/components/dashboard/TypeformDashboard.jsx new file mode 100644 index 0000000..79198d7 --- /dev/null +++ b/inventory/src/components/dashboard/TypeformDashboard.jsx @@ -0,0 +1,700 @@ +import React, { useState, useEffect } from "react"; +import axios from "axios"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/dashboard/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/dashboard/ui/table"; +import { Badge } from "@/components/dashboard/ui/badge"; +import { ScrollArea } from "@/components/dashboard/ui/scroll-area"; +import { Alert, AlertDescription, AlertTitle } from "@/components/dashboard/ui/alert"; +import { Skeleton } from "@/components/dashboard/ui/skeleton"; +import { AlertCircle } from "lucide-react"; +import { format } from "date-fns"; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Cell, + ReferenceLine, +} from "recharts"; + +// Get form IDs from environment variables +const FORM_IDS = { + FORM_1: import.meta.env.VITE_TYPEFORM_FORM_ID_1, + FORM_2: import.meta.env.VITE_TYPEFORM_FORM_ID_2, +}; + +const FORM_NAMES = { + [FORM_IDS.FORM_1]: "Product Relevance", + [FORM_IDS.FORM_2]: "Winback Survey", +}; + +// Loading skeleton components +const SkeletonChart = () => ( +
+
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+
+ {[...Array(3)].map((_, i) => ( + + ))} +
+
+
+); + +const SkeletonTable = () => ( +
+ + + + + + + + + + + + + + + + {[...Array(5)].map((_, i) => ( + + + + + + + + + + + + ))} + +
+
+); + +const ResponseFeed = ({ responses, title, renderSummary }) => ( + + + {title} + + + +
+ {responses.items.map((response) => ( +
+ {renderSummary(response)} +
+ ))} +
+
+
+
+); + +const ProductRelevanceFeed = ({ responses }) => ( + { + const answer = response.answers?.find((a) => a.type === "boolean"); + const textAnswer = response.answers?.find((a) => a.type === "text")?.text; + + return ( +
+
+
+ {response.hidden?.email ? ( + + {response.hidden?.name || "Anonymous"} + + ) : ( + + {response.hidden?.name || "Anonymous"} + + )} + + {answer?.boolean ? "Yes" : "No"} + +
+ +
+ {textAnswer && ( +
"{textAnswer}"
+ )} +
+ ); + }} + /> +); + +const WinbackFeed = ({ responses }) => ( + { + const likelihoodAnswer = response.answers?.find( + (a) => a.type === "number" + ); + const reasonsAnswer = response.answers?.find((a) => a.type === "choices"); + const feedbackAnswer = response.answers?.find( + (a) => a.type === "text" && a.field.type === "long_text" + ); + + return ( +
+
+
+ {response.hidden?.email ? ( + + {response.hidden?.name || "Anonymous"} + + ) : ( + + {response.hidden?.name || "Anonymous"} + + )} + + {likelihoodAnswer?.number}/5 + +
+ +
+
+ {(reasonsAnswer?.choices?.labels || []).map((label, idx) => ( + + {label} + + ))} + {reasonsAnswer?.choices?.other && ( + + {reasonsAnswer.choices.other} + + )} +
+ {feedbackAnswer?.text && ( +
+ {feedbackAnswer.text} +
+ )} +
+ ); + }} + /> +); + +const TypeformDashboard = () => { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [formData, setFormData] = useState({ + form1: { responses: null, hasMore: false, lastToken: null }, + form2: { responses: null, hasMore: false, lastToken: null }, + }); + + const fetchResponses = async (formId, before = null) => { + const params = { page_size: 1000 }; + if (before) params.before = before; + + const response = await axios.get( + `/api/typeform/forms/${formId}/responses`, + { params } + ); + return response.data; + }; + + useEffect(() => { + const fetchFormData = async () => { + try { + setLoading(true); + setError(null); + + const forms = [FORM_IDS.FORM_1, FORM_IDS.FORM_2]; + const results = await Promise.all( + forms.map(async (formId) => { + const responses = await fetchResponses(formId); + const hasMore = responses.items.length === 1000; + const lastToken = hasMore + ? responses.items[responses.items.length - 1].token + : null; + + return { + responses, + hasMore, + lastToken, + }; + }) + ); + + setFormData({ + form1: results[0], + form2: results[1], + }); + } catch (err) { + console.error("Error fetching Typeform data:", err); + setError("Failed to load form data. Please try again later."); + } finally { + setLoading(false); + } + }; + + fetchFormData(); + }, []); + + const calculateMetrics = () => { + if (!formData.form1.responses || !formData.form2.responses) return null; + + const form1Responses = formData.form1.responses.items; + const form2Responses = formData.form2.responses.items; + + // Product Relevance metrics + const yesResponses = form1Responses.filter((r) => + r.answers?.some((a) => a.type === "boolean" && a.boolean === true) + ).length; + const totalForm1 = form1Responses.length; + const yesPercentage = Math.round((yesResponses / totalForm1) * 100) || 0; + + // Winback Survey metrics + const likelihoodAnswers = form2Responses + .map((r) => r.answers?.find((a) => a.type === "number")) + .filter(Boolean) + .map((a) => a.number); + const averageLikelihood = likelihoodAnswers.length + ? Math.round( + (likelihoodAnswers.reduce((a, b) => a + b, 0) / + likelihoodAnswers.length) * + 10 + ) / 10 + : 0; + + // Get reasons for not ordering (only predefined choices) + const reasonsMap = new Map(); + form2Responses.forEach((response) => { + const reasonsAnswer = response.answers?.find((a) => a.type === "choices"); + if (reasonsAnswer?.choices?.labels) { + reasonsAnswer.choices.labels.forEach((label) => { + reasonsMap.set(label, (reasonsMap.get(label) || 0) + 1); + }); + } + }); + + const sortedReasons = Array.from(reasonsMap.entries()) + .sort(([, a], [, b]) => b - a) + .map(([label, count]) => ({ + reason: label, + count, + percentage: Math.round((count / form2Responses.length) * 100), + })); + + return { + productRelevance: { + yesPercentage, + yesCount: yesResponses, + noCount: totalForm1 - yesResponses, + }, + winback: { + averageRating: averageLikelihood, + reasons: sortedReasons, + }, + }; + }; + + const metrics = loading ? null : calculateMetrics(); + + // Find the newest response across both forms + const getNewestResponse = () => { + if ( + !formData.form1.responses?.items?.length && + !formData.form2.responses?.items?.length + ) + return null; + + const form1Latest = formData.form1.responses?.items[0]?.submitted_at; + const form2Latest = formData.form2.responses?.items[0]?.submitted_at; + + if (!form1Latest) return form2Latest; + if (!form2Latest) return form1Latest; + + return new Date(form1Latest) > new Date(form2Latest) + ? form1Latest + : form2Latest; + }; + + const newestResponse = getNewestResponse(); + + if (error) { + return ( + + +
+ {error} +
+
+
+ ); + } + + // Calculate likelihood counts for the chart + const likelihoodCounts = + !loading && formData.form2.responses + ? [1, 2, 3, 4, 5].map((rating) => ({ + rating: rating.toString(), + count: formData.form2.responses.items.filter( + (r) => + r.answers?.find((a) => a.type === "number")?.number === rating + ).length, + })) + : []; + + return ( + + +
+ + Customer Surveys + + {newestResponse && ( +

+ Newest response:{" "} + {format(new Date(newestResponse), "MMM d, h:mm a")} +

+ )} +
+
+ + {loading ? ( +
+ + +
+ ) : ( + <> +
+ + +
+ + How likely are you to place another order with us? + + + {metrics.winback.averageRating} + + /5 avg + + +
+
+ +
+ + + + { + return value === "1" + ? "Not at all" + : value === "5" + ? "Extremely" + : ""; + }} + textAnchor="middle" + interval={0} + height={50} + className="text-muted-foreground text-xs md:text-sm" + /> + + { + if (payload && payload.length) { + const { rating, count } = payload[0].payload; + return ( + + +
+ {rating} Rating: {count} responses +
+
+
+ ); + } + return null; + }} + /> + + {likelihoodCounts.map((_, index) => ( + + ))} + +
+
+
+
+
+ + +
+ + Were the suggested products in this email relevant to you? + +
+ + {metrics.productRelevance.yesPercentage}% Relevant + +
+
+
+ +
+ + + + + { + if (payload && payload.length) { + 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 + ); + return ( + + +
+
+ + Yes: + + + {yesCount} ({yesPercent}%) + +
+
+ + No: + + + {noCount} ({noPercent}%) + +
+
+
+
+ ); + } + return null; + }} + /> + + + {metrics.productRelevance.yesPercentage}% + + + +
+
+
+
+
Yes: {metrics.productRelevance.yesCount}
+
No: {metrics.productRelevance.noCount}
+
+
+
+
+ +
+
+ + + + Reasons for Not Ordering + + + +
+ + + + + Reason + + + Count + + + % + + + + + {metrics.winback.reasons.map((reason, index) => ( + + + {reason.reason} + + + {reason.count} + + + {reason.percentage}% + + + ))} + +
+
+
+
+
+ +
+ +
+
+ +
+
+ + )} +
+
+ ); +}; + +export default TypeformDashboard; diff --git a/inventory/src/components/dashboard/UserBehaviorDashboard.jsx b/inventory/src/components/dashboard/UserBehaviorDashboard.jsx new file mode 100644 index 0000000..429e970 --- /dev/null +++ b/inventory/src/components/dashboard/UserBehaviorDashboard.jsx @@ -0,0 +1,412 @@ +import React, { useState, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/dashboard/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/dashboard/ui/tabs"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/dashboard/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/dashboard/ui/table"; +import { + PieChart, + Pie, + Cell, + ResponsiveContainer, + Tooltip, + Legend, +} from "recharts"; +import { Loader2 } from "lucide-react"; +import { Skeleton } from "@/components/dashboard/ui/skeleton"; + +// Add skeleton components +const SkeletonTable = ({ rows = 12 }) => ( +
+ + + + + + + + + + + {[...Array(rows)].map((_, i) => ( + + + + + + + ))} + +
+
+); + +const SkeletonPieChart = () => ( +
+
+
+
+
+ {[...Array(3)].map((_, i) => ( +
+ + +
+ ))} +
+
+); + +const SkeletonTabs = () => ( +
+
+ {[...Array(3)].map((_, i) => ( + + ))} +
+
+); + +export const UserBehaviorDashboard = () => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [timeRange, setTimeRange] = useState("30"); + + const processPageData = (data) => { + if (!data?.rows) { + console.log("No rows in page data"); + return []; + } + + return data.rows.map((row) => ({ + path: row.dimensionValues[0].value || "Unknown", + pageViews: parseInt(row.metricValues[0].value || 0), + avgSessionDuration: parseFloat(row.metricValues[1].value || 0), + bounceRate: parseFloat(row.metricValues[2].value || 0) * 100, + engagedSessions: parseInt(row.metricValues[3].value || 0), + })); + }; + + const processDeviceData = (data) => { + if (!data?.rows) { + console.log("No rows in device data"); + return []; + } + + return data.rows + .filter((row) => { + const device = (row.dimensionValues[0].value || "").toLowerCase(); + return ["desktop", "mobile", "tablet"].includes(device); + }) + .map((row) => { + const device = row.dimensionValues[0].value || "Unknown"; + return { + device: device.charAt(0).toUpperCase() + device.slice(1).toLowerCase(), + pageViews: parseInt(row.metricValues[0].value || 0), + sessions: parseInt(row.metricValues[1].value || 0), + }; + }) + .sort((a, b) => b.pageViews - a.pageViews); + }; + + const processSourceData = (data) => { + if (!data?.rows) { + console.log("No rows in source data"); + return []; + } + + return data.rows.map((row) => ({ + source: row.dimensionValues[0].value || "Unknown", + sessions: parseInt(row.metricValues[0].value || 0), + conversions: parseInt(row.metricValues[1].value || 0), + })); + }; + + useEffect(() => { + const fetchData = async () => { + setLoading(true); + try { + const response = await fetch( + `/api/dashboard-analytics/user-behavior?timeRange=${timeRange}`, + { + credentials: "include", + } + ); + + if (!response.ok) { + throw new Error("Failed to fetch user behavior"); + } + + const result = await response.json(); + console.log("Raw user behavior response:", result); + + if (!result?.success) { + 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]; + + console.log("Extracted responses:", { + pageResponse, + deviceResponse, + sourceResponse, + }); + + const processed = { + success: true, + data: { + pageData: { + pageData: processPageData(pageResponse), + deviceData: processDeviceData(deviceResponse), + }, + sourceData: processSourceData(sourceResponse), + }, + }; + + console.log("Final processed data:", processed); + setData(processed); + } catch (error) { + console.error("Failed to fetch behavior data:", error); + } finally { + setLoading(false); + } + }; + fetchData(); + }, [timeRange]); + + if (loading) { + return ( + + +
+ + User Behavior Analysis + + +
+
+ + + + Top Pages + Traffic Sources + Device Usage + + + + + + + + + + + + + + + +
+ ); + } + + const COLORS = { + desktop: "#8b5cf6", // Purple + mobile: "#10b981", // Green + tablet: "#f59e0b", // Yellow + }; + + const deviceData = data?.data?.pageData?.deviceData || []; + const totalViews = deviceData.reduce((sum, item) => sum + item.pageViews, 0); + const totalSessions = deviceData.reduce( + (sum, item) => sum + item.sessions, + 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); + return ( + + +

+ {data.device} +

+

+ {data.pageViews.toLocaleString()} views ({percentage}%) +

+

+ {data.sessions.toLocaleString()} sessions ({sessionPercentage}%) +

+
+
+ ); + } + return null; + }; + + const formatDuration = (seconds) => { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = Math.floor(seconds % 60); + return `${minutes}m ${remainingSeconds}s`; + }; + + return ( + + +
+ + User Behavior Analysis + + +
+
+ + + + Top Pages + Traffic Sources + Device Usage + + + + + + + Page Path + Views + Bounce Rate + Avg. Duration + + + + {data?.data?.pageData?.pageData.map((page, index) => ( + + + {page.path} + + + {page.pageViews.toLocaleString()} + + + {page.bounceRate.toFixed(1)}% + + + {formatDuration(page.avgSessionDuration)} + + + ))} + +
+
+ + + + + + Source + Sessions + Conv. + Conv. Rate + + + + {data?.data?.sourceData?.map((source, index) => ( + + + {source.source} + + + {source.sessions.toLocaleString()} + + + {source.conversions.toLocaleString()} + + + {((source.conversions / source.sessions) * 100).toFixed(1)}% + + + ))} + +
+
+ + +
+ + + + `${name} ${(percent * 100).toFixed(1)}%` + } + > + {deviceData.map((entry, index) => ( + + ))} + + } /> + + +
+
+
+
+
+ ); +}; + +export default UserBehaviorDashboard; \ No newline at end of file diff --git a/inventory/src/components/dashboard/theme/ModeToggle.jsx b/inventory/src/components/dashboard/theme/ModeToggle.jsx new file mode 100644 index 0000000..d180cec --- /dev/null +++ b/inventory/src/components/dashboard/theme/ModeToggle.jsx @@ -0,0 +1,26 @@ +import { Moon, Sun } from "lucide-react" +import { useTheme } from "@/components/dashboard/theme/ThemeProvider" +import { Button } from "@/components/dashboard/ui/button" + +export function ModeToggle() { + const { theme, setTheme } = useTheme() + + return ( + + ) +} \ No newline at end of file diff --git a/inventory/src/components/dashboard/theme/ThemeProvider.jsx b/inventory/src/components/dashboard/theme/ThemeProvider.jsx new file mode 100644 index 0000000..f4f0917 --- /dev/null +++ b/inventory/src/components/dashboard/theme/ThemeProvider.jsx @@ -0,0 +1,44 @@ +import { createContext, useContext, useEffect, useState } from "react" +import { useTheme as useNextTheme } from "next-themes" + +const ThemeProviderContext = createContext({ + theme: "system", + setTheme: () => null, + toggleTheme: () => null, +}) + +// Wrapper to make dashboard components compatible with next-themes +export function ThemeProvider({ children }) { + const { theme: nextTheme, setTheme: setNextTheme, systemTheme: nextSystemTheme } = useNextTheme() + + const toggleTheme = () => { + if (nextTheme === 'system') { + const newTheme = nextSystemTheme === 'dark' ? 'light' : 'dark' + setNextTheme(newTheme) + } else { + const newTheme = nextTheme === 'light' ? 'dark' : 'light' + setNextTheme(newTheme) + } + } + + const value = { + theme: nextTheme || 'system', + systemTheme: nextSystemTheme || 'light', + setTheme: setNextTheme, + toggleTheme, + } + + return ( + + {children} + + ) +} + +export const useTheme = () => { + const context = useContext(ThemeProviderContext) + if (context === undefined) { + throw new Error("useTheme must be used within a ThemeProvider") + } + return context +} \ No newline at end of file diff --git a/inventory/src/components/dashboard/ui/alert.jsx b/inventory/src/components/dashboard/ui/alert.jsx new file mode 100644 index 0000000..28597e8 --- /dev/null +++ b/inventory/src/components/dashboard/ui/alert.jsx @@ -0,0 +1,47 @@ +import * as React from "react" +import { cva } from "class-variance-authority"; + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/inventory/src/components/dashboard/ui/badge.jsx b/inventory/src/components/dashboard/ui/badge.jsx new file mode 100644 index 0000000..a687eba --- /dev/null +++ b/inventory/src/components/dashboard/ui/badge.jsx @@ -0,0 +1,34 @@ +import * as React from "react" +import { cva } from "class-variance-authority"; + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + ...props +}) { + return (
); +} + +export { Badge, badgeVariants } diff --git a/inventory/src/components/dashboard/ui/button.jsx b/inventory/src/components/dashboard/ui/button.jsx new file mode 100644 index 0000000..bf3d2ef --- /dev/null +++ b/inventory/src/components/dashboard/ui/button.jsx @@ -0,0 +1,48 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva } from "class-variance-authority"; + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + () + ); +}) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/inventory/src/components/dashboard/ui/calendar.jsx b/inventory/src/components/dashboard/ui/calendar.jsx new file mode 100644 index 0000000..43a2a7e --- /dev/null +++ b/inventory/src/components/dashboard/ui/calendar.jsx @@ -0,0 +1,67 @@ +import * as React from "react" +import { ChevronLeft, ChevronRight } from "lucide-react" +import { DayPicker } from "react-day-picker" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/dashboard/ui/button" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}) { + return ( + (.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" + : "[&:has([aria-selected])]:rounded-md" + ), + day: cn( + buttonVariants({ variant: "ghost" }), + "h-8 w-8 p-0 font-normal aria-selected:opacity-100" + ), + day_range_start: "day-range-start", + day_range_end: "day-range-end", + day_selected: + "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", + day_today: "bg-accent text-accent-foreground", + day_outside: + "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground", + day_disabled: "text-muted-foreground opacity-50", + day_range_middle: + "aria-selected:bg-accent aria-selected:text-accent-foreground", + day_hidden: "invisible", + ...classNames, + }} + components={{ + IconLeft: ({ ...props }) => , + IconRight: ({ ...props }) => , + }} + {...props} />) + ); +} +Calendar.displayName = "Calendar" + +export { Calendar } diff --git a/inventory/src/components/dashboard/ui/calendaredit.jsx b/inventory/src/components/dashboard/ui/calendaredit.jsx new file mode 100644 index 0000000..39645b8 --- /dev/null +++ b/inventory/src/components/dashboard/ui/calendaredit.jsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { ChevronLeft, ChevronRight } from "lucide-react" +import { DayPicker } from "react-day-picker" +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/dashboard/ui/button" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}) { + return ( + .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" + : "[&:has([aria-selected])]:rounded-md" + ), + day: cn( + buttonVariants({ variant: "ghost" }), + "h-6 w-6 p-0 font-normal text-xs aria-selected:opacity-100" // Reduced from h-12 w-12 and text-lg + ), + day_range_start: "day-range-start", + day_range_end: "day-range-end", + day_selected: + "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", + day_today: "bg-accent text-accent-foreground", + day_outside: + "day-outside text-muted-foreground/50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground", + day_disabled: "text-muted-foreground opacity-50", + day_range_middle: + "aria-selected:bg-accent aria-selected:text-accent-foreground", + day_hidden: "invisible", + ...classNames, + }} + components={{ + IconLeft: ({ ...props }) => , + IconRight: ({ ...props }) => , + }} + {...props} + /> + ); +} + +Calendar.displayName = "Calendar" +export { Calendar } \ No newline at end of file diff --git a/inventory/src/components/dashboard/ui/card.jsx b/inventory/src/components/dashboard/ui/card.jsx new file mode 100644 index 0000000..2985cca --- /dev/null +++ b/inventory/src/components/dashboard/ui/card.jsx @@ -0,0 +1,50 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef(({ className, ...props }, ref) => ( +
+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef(({ className, ...props }, ref) => ( +
+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef(({ className, ...props }, ref) => ( +
+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/inventory/src/components/dashboard/ui/chart.jsx b/inventory/src/components/dashboard/ui/chart.jsx new file mode 100644 index 0000000..7a16db9 --- /dev/null +++ b/inventory/src/components/dashboard/ui/chart.jsx @@ -0,0 +1,308 @@ +import * as React from "react" +import * as RechartsPrimitive from "recharts" + +import { cn } from "@/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { + light: "", + dark: ".dark" +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +const ChartContainer = React.forwardRef(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` + + return ( + ( +
+ + + {children} + +
+
) + ); +}) +ChartContainer.displayName = "Chart" + +const ChartStyle = ({ + id, + config +}) => { + const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color) + + if (!colorConfig.length) { + return null + } + + return ( + (