Move more of dashboard to shared components
This commit is contained in:
@@ -8,14 +8,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import {
|
import {
|
||||||
BarChart,
|
BarChart,
|
||||||
Bar,
|
Bar,
|
||||||
@@ -34,10 +26,9 @@ import {
|
|||||||
DashboardStatCardSkeleton,
|
DashboardStatCardSkeleton,
|
||||||
DashboardSectionHeader,
|
DashboardSectionHeader,
|
||||||
DashboardErrorState,
|
DashboardErrorState,
|
||||||
|
DashboardTable,
|
||||||
ChartSkeleton,
|
ChartSkeleton,
|
||||||
TableSkeleton,
|
|
||||||
CARD_STYLES,
|
CARD_STYLES,
|
||||||
SCROLL_STYLES,
|
|
||||||
METRIC_COLORS,
|
METRIC_COLORS,
|
||||||
} from "@/components/dashboard/shared";
|
} from "@/components/dashboard/shared";
|
||||||
import { Phone, Clock, Zap, Timer } from "lucide-react";
|
import { Phone, Clock, Zap, Timer } from "lucide-react";
|
||||||
@@ -73,47 +64,6 @@ const formatDuration = (seconds) => {
|
|||||||
return `${minutes}m ${remainingSeconds}s`;
|
return `${minutes}m ${remainingSeconds}s`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AgentPerformanceTable = ({ agents, onSort }) => {
|
|
||||||
const [sortConfig, setSortConfig] = useState({
|
|
||||||
key: "total",
|
|
||||||
direction: "desc",
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSort = (key) => {
|
|
||||||
const direction =
|
|
||||||
sortConfig.key === key && sortConfig.direction === "desc"
|
|
||||||
? "asc"
|
|
||||||
: "desc";
|
|
||||||
setSortConfig({ key, direction });
|
|
||||||
onSort(key, direction);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="hover:bg-transparent">
|
|
||||||
<TableHead>Agent</TableHead>
|
|
||||||
<TableHead onClick={() => handleSort("total")} className="cursor-pointer">Total Calls</TableHead>
|
|
||||||
<TableHead onClick={() => handleSort("answered")} className="cursor-pointer">Answered</TableHead>
|
|
||||||
<TableHead onClick={() => handleSort("missed")} className="cursor-pointer">Missed</TableHead>
|
|
||||||
<TableHead onClick={() => handleSort("average_duration")} className="cursor-pointer">Average Duration</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{agents.map((agent) => (
|
|
||||||
<TableRow key={agent.id} className="hover:bg-muted/50 transition-colors">
|
|
||||||
<TableCell className="font-medium text-foreground">{agent.name}</TableCell>
|
|
||||||
<TableCell>{agent.total}</TableCell>
|
|
||||||
<TableCell className="text-trend-positive">{agent.answered}</TableCell>
|
|
||||||
<TableCell className="text-trend-negative">{agent.missed}</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">{formatDuration(agent.average_duration)}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AircallDashboard = () => {
|
const AircallDashboard = () => {
|
||||||
const [timeRange, setTimeRange] = useState("last7days");
|
const [timeRange, setTimeRange] = useState("last7days");
|
||||||
const [metrics, setMetrics] = useState(null);
|
const [metrics, setMetrics] = useState(null);
|
||||||
@@ -163,6 +113,58 @@ const AircallDashboard = () => {
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Column definitions for Agent Performance table
|
||||||
|
const agentColumns = [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "Agent",
|
||||||
|
render: (value) => <span className="font-medium text-foreground">{value}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "total",
|
||||||
|
header: "Total Calls",
|
||||||
|
align: "right",
|
||||||
|
sortable: true,
|
||||||
|
render: (value) => <span className="text-muted-foreground">{value}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "answered",
|
||||||
|
header: "Answered",
|
||||||
|
align: "right",
|
||||||
|
sortable: true,
|
||||||
|
render: (value) => <span className="text-trend-positive">{value}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "missed",
|
||||||
|
header: "Missed",
|
||||||
|
align: "right",
|
||||||
|
sortable: true,
|
||||||
|
render: (value) => <span className="text-trend-negative">{value}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "average_duration",
|
||||||
|
header: "Avg Duration",
|
||||||
|
align: "right",
|
||||||
|
sortable: true,
|
||||||
|
render: (value) => <span className="text-muted-foreground">{formatDuration(value)}</span>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Column definitions for Missed Reasons table
|
||||||
|
const missedReasonsColumns = [
|
||||||
|
{
|
||||||
|
key: "reason",
|
||||||
|
header: "Reason",
|
||||||
|
render: (value) => <span className="font-medium text-foreground">{value}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "count",
|
||||||
|
header: "Count",
|
||||||
|
align: "right",
|
||||||
|
render: (value) => <span className="text-trend-negative">{value}</span>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -363,16 +365,17 @@ const AircallDashboard = () => {
|
|||||||
<Card className={CARD_STYLES.base}>
|
<Card className={CARD_STYLES.base}>
|
||||||
<DashboardSectionHeader title="Agent Performance" compact />
|
<DashboardSectionHeader title="Agent Performance" compact />
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{isLoading ? (
|
<DashboardTable
|
||||||
<TableSkeleton rows={5} columns={5} />
|
columns={agentColumns}
|
||||||
) : (
|
data={sortedAgents}
|
||||||
<div className={SCROLL_STYLES.md}>
|
loading={isLoading}
|
||||||
<AgentPerformanceTable
|
skeletonRows={5}
|
||||||
agents={sortedAgents}
|
getRowKey={(agent) => agent.id}
|
||||||
|
sortConfig={agentSort}
|
||||||
onSort={(key, direction) => setAgentSort({ key, direction })}
|
onSort={(key, direction) => setAgentSort({ key, direction })}
|
||||||
|
maxHeight="md"
|
||||||
|
compact
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -380,32 +383,15 @@ const AircallDashboard = () => {
|
|||||||
<Card className={CARD_STYLES.base}>
|
<Card className={CARD_STYLES.base}>
|
||||||
<DashboardSectionHeader title="Missed Call Reasons" compact />
|
<DashboardSectionHeader title="Missed Call Reasons" compact />
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{isLoading ? (
|
<DashboardTable
|
||||||
<TableSkeleton rows={5} columns={2} />
|
columns={missedReasonsColumns}
|
||||||
) : (
|
data={chartData.missedReasons}
|
||||||
<div className={SCROLL_STYLES.md}>
|
loading={isLoading}
|
||||||
<Table>
|
skeletonRows={5}
|
||||||
<TableHeader>
|
getRowKey={(reason, index) => `${reason.reason}-${index}`}
|
||||||
<TableRow className="hover:bg-transparent">
|
maxHeight="md"
|
||||||
<TableHead className="font-medium">Reason</TableHead>
|
compact
|
||||||
<TableHead className="text-right font-medium">Count</TableHead>
|
/>
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{chartData.missedReasons.map((reason, index) => (
|
|
||||||
<TableRow key={index} className="hover:bg-muted/50 transition-colors">
|
|
||||||
<TableCell className="font-medium text-foreground">
|
|
||||||
{reason.reason}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right text-trend-negative">
|
|
||||||
{reason.count}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -22,12 +22,14 @@ import { TrendingUp } from "lucide-react";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { CARD_STYLES, TYPOGRAPHY } from "@/lib/dashboard/designTokens";
|
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
||||||
import {
|
import {
|
||||||
|
DashboardSectionHeader,
|
||||||
DashboardStatCard,
|
DashboardStatCard,
|
||||||
|
DashboardStatCardSkeleton,
|
||||||
|
DashboardChartTooltip,
|
||||||
ChartSkeleton,
|
ChartSkeleton,
|
||||||
DashboardEmptyState,
|
DashboardEmptyState,
|
||||||
TOOLTIP_STYLES,
|
|
||||||
} from "@/components/dashboard/shared";
|
} from "@/components/dashboard/shared";
|
||||||
|
|
||||||
// Note: Using ChartSkeleton from @/components/dashboard/shared
|
// Note: Using ChartSkeleton from @/components/dashboard/shared
|
||||||
@@ -35,15 +37,7 @@ import {
|
|||||||
const SkeletonStats = () => (
|
const SkeletonStats = () => (
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4">
|
||||||
{[...Array(4)].map((_, i) => (
|
{[...Array(4)].map((_, i) => (
|
||||||
<Card key={i} className={CARD_STYLES.base}>
|
<DashboardStatCardSkeleton key={i} size="compact" hasIcon={false} hasSubtitle />
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
|
||||||
<Skeleton className="h-4 w-24 bg-muted rounded-sm" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-4 pt-0">
|
|
||||||
<Skeleton className="h-8 w-32 bg-muted rounded-sm mb-2" />
|
|
||||||
<Skeleton className="h-4 w-24 bg-muted rounded-sm" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -165,21 +159,23 @@ export const AnalyticsDashboard = () => {
|
|||||||
|
|
||||||
const summaryStats = calculateSummaryStats();
|
const summaryStats = calculateSummaryStats();
|
||||||
|
|
||||||
return (
|
// Time selector for DashboardSectionHeader
|
||||||
<Card className={`w-full ${CARD_STYLES.base}`}>
|
const timeSelector = (
|
||||||
<CardHeader className="p-6 pb-4">
|
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||||
<div className="flex flex-col space-y-2">
|
<SelectTrigger className="w-[130px] h-9">
|
||||||
<div className="flex justify-between items-start">
|
<SelectValue placeholder="Select range" />
|
||||||
<div>
|
</SelectTrigger>
|
||||||
<CardTitle className={TYPOGRAPHY.sectionTitle}>
|
<SelectContent>
|
||||||
Analytics Overview
|
<SelectItem value="7">Last 7 days</SelectItem>
|
||||||
</CardTitle>
|
<SelectItem value="14">Last 14 days</SelectItem>
|
||||||
</div>
|
<SelectItem value="30">Last 30 days</SelectItem>
|
||||||
<div className="flex items-center gap-2">
|
<SelectItem value="90">Last 90 days</SelectItem>
|
||||||
{loading ? (
|
</SelectContent>
|
||||||
<Skeleton className="h-9 w-[130px] bg-muted rounded-sm" />
|
</Select>
|
||||||
) : (
|
);
|
||||||
<>
|
|
||||||
|
// Header actions: Details dialog
|
||||||
|
const headerActions = !loading ? (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" className="h-9">
|
<Button variant="outline" className="h-9">
|
||||||
@@ -264,26 +260,29 @@ export const AnalyticsDashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
) : <Skeleton className="h-9 w-20 bg-muted rounded-sm" />;
|
||||||
<SelectTrigger className="w-[130px] h-9">
|
|
||||||
<SelectValue placeholder="Select range" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="7">Last 7 days</SelectItem>
|
|
||||||
<SelectItem value="14">Last 14 days</SelectItem>
|
|
||||||
<SelectItem value="30">Last 30 days</SelectItem>
|
|
||||||
<SelectItem value="90">Last 90 days</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
// Label formatter for chart tooltip
|
||||||
|
const analyticsLabelFormatter = (label) => {
|
||||||
|
const date = label instanceof Date ? label : new Date(label);
|
||||||
|
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={`w-full ${CARD_STYLES.base}`}>
|
||||||
|
<DashboardSectionHeader
|
||||||
|
title="Analytics Overview"
|
||||||
|
loading={loading}
|
||||||
|
timeSelector={timeSelector}
|
||||||
|
actions={headerActions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CardContent className="p-6 pt-0 space-y-4">
|
||||||
|
{/* Stats cards */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<SkeletonStats />
|
<SkeletonStats />
|
||||||
) : summaryStats ? (
|
) : summaryStats ? (
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<DashboardStatCard
|
<DashboardStatCard
|
||||||
title="Active Users"
|
title="Active Users"
|
||||||
value={summaryStats.totals.activeUsers.toLocaleString()}
|
value={summaryStats.totals.activeUsers.toLocaleString()}
|
||||||
@@ -319,7 +318,8 @@ export const AnalyticsDashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4 pt-2">
|
{/* Metric toggles */}
|
||||||
|
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4">
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant={metrics.activeUsers ? "default" : "outline"}
|
variant={metrics.activeUsers ? "default" : "outline"}
|
||||||
@@ -379,10 +379,6 @@ export const AnalyticsDashboard = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="p-6 pt-0">
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<ChartSkeleton height="default" withCard={false} />
|
<ChartSkeleton height="default" withCard={false} />
|
||||||
) : !data.length ? (
|
) : !data.length ? (
|
||||||
@@ -420,34 +416,12 @@ export const AnalyticsDashboard = () => {
|
|||||||
tick={{ fill: "currentColor" }}
|
tick={{ fill: "currentColor" }}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={({ active, payload }) => {
|
content={
|
||||||
if (!active || !payload?.length) return null;
|
<DashboardChartTooltip
|
||||||
const date = payload[0]?.payload?.date;
|
labelFormatter={analyticsLabelFormatter}
|
||||||
const formattedDate = date instanceof Date
|
valueFormatter={(value) => value.toLocaleString()}
|
||||||
? date.toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
|
||||||
: String(date);
|
|
||||||
return (
|
|
||||||
<div className={TOOLTIP_STYLES.container}>
|
|
||||||
<p className={TOOLTIP_STYLES.header}>{formattedDate}</p>
|
|
||||||
<div className={TOOLTIP_STYLES.content}>
|
|
||||||
{payload.map((entry, i) => (
|
|
||||||
<div key={i} className={TOOLTIP_STYLES.row}>
|
|
||||||
<div className={TOOLTIP_STYLES.rowLabel}>
|
|
||||||
<span
|
|
||||||
className={TOOLTIP_STYLES.dot}
|
|
||||||
style={{ backgroundColor: entry.stroke || "#888" }}
|
|
||||||
/>
|
/>
|
||||||
<span className={TOOLTIP_STYLES.name}>{entry.name}</span>
|
}
|
||||||
</div>
|
|
||||||
<span className={TOOLTIP_STYLES.value}>
|
|
||||||
{entry.value.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<Legend />
|
<Legend />
|
||||||
{metrics.activeUsers && (
|
{metrics.activeUsers && (
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { acotService } from "@/services/dashboard/acotService";
|
import { acotService } from "@/services/dashboard/acotService";
|
||||||
import {
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -42,7 +37,6 @@ import {
|
|||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import type { TooltipProps } from "recharts";
|
import type { TooltipProps } from "recharts";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import { TrendingUp, DollarSign, Package, PiggyBank, Percent } from "lucide-react";
|
import { TrendingUp, DollarSign, Package, PiggyBank, Percent } from "lucide-react";
|
||||||
import PeriodSelectionPopover, {
|
import PeriodSelectionPopover, {
|
||||||
type QuickPreset,
|
type QuickPreset,
|
||||||
@@ -50,8 +44,10 @@ import PeriodSelectionPopover, {
|
|||||||
import type { CustomPeriod, NaturalLanguagePeriodResult } from "@/utils/naturalLanguagePeriod";
|
import type { CustomPeriod, NaturalLanguagePeriodResult } from "@/utils/naturalLanguagePeriod";
|
||||||
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
||||||
import {
|
import {
|
||||||
|
DashboardSectionHeader,
|
||||||
DashboardStatCard,
|
DashboardStatCard,
|
||||||
DashboardStatCardSkeleton,
|
DashboardStatCardSkeleton,
|
||||||
|
ChartSkeleton,
|
||||||
DashboardEmptyState,
|
DashboardEmptyState,
|
||||||
DashboardErrorState,
|
DashboardErrorState,
|
||||||
TOOLTIP_STYLES,
|
TOOLTIP_STYLES,
|
||||||
@@ -1102,20 +1098,9 @@ const FinancialOverview = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
// Header actions: Details dialog and Period selector
|
||||||
<Card className={`w-full ${CARD_STYLES.base}`}>
|
const headerActions = !error ? (
|
||||||
<CardHeader className="p-6 pb-4">
|
|
||||||
<div className="flex flex-col space-y-2">
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-xl font-semibold text-foreground">
|
|
||||||
Profit & Loss Overview
|
|
||||||
</CardTitle>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{!error && (
|
|
||||||
<>
|
<>
|
||||||
|
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" className="h-9" disabled={loading || !detailRows.length}>
|
<Button variant="outline" className="h-9" disabled={loading || !detailRows.length}>
|
||||||
@@ -1239,24 +1224,30 @@ const FinancialOverview = () => {
|
|||||||
onQuickSelect={handleQuickPeriod}
|
onQuickSelect={handleQuickPeriod}
|
||||||
onApplyResult={handleNaturalLanguageResult}
|
onApplyResult={handleNaturalLanguageResult}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
) : null;
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={`w-full ${CARD_STYLES.base}`}>
|
||||||
|
<DashboardSectionHeader
|
||||||
|
title="Profit & Loss Overview"
|
||||||
|
size="large"
|
||||||
|
actions={headerActions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CardContent className="p-6 pt-0 space-y-4">
|
||||||
{/* Show stats only if not in error state */}
|
{/* Show stats only if not in error state */}
|
||||||
{!error &&
|
{!error && (
|
||||||
(loading ? (
|
loading ? (
|
||||||
<SkeletonStats />
|
<SkeletonStats />
|
||||||
) : (
|
) : (
|
||||||
cards.length > 0 && <FinancialStatGrid cards={cards} />
|
cards.length > 0 && <FinancialStatGrid cards={cards} />
|
||||||
))}
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Show metric toggles only if not in error state */}
|
{/* Show metric toggles only if not in error state */}
|
||||||
{!error && (
|
{!error && (
|
||||||
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4 pt-2">
|
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4">
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{SERIES_DEFINITIONS.map((series) => (
|
{SERIES_DEFINITIONS.map((series) => (
|
||||||
<Button
|
<Button
|
||||||
@@ -1297,12 +1288,9 @@ const FinancialOverview = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-6 pt-0">
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<SkeletonChart />
|
<SkeletonChartSection />
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<DashboardErrorState error={`Failed to load financial data: ${error}`} className="mx-0 my-0" />
|
<DashboardErrorState error={`Failed to load financial data: ${error}`} className="mx-0 my-0" />
|
||||||
@@ -1496,54 +1484,9 @@ function SkeletonStats() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SkeletonChart() {
|
function SkeletonChartSection() {
|
||||||
return (
|
return (
|
||||||
<div className={`h-[400px] mt-4 ${CARD_STYLES.base} rounded-lg p-0 relative`}>
|
<ChartSkeleton type="area" height="default" withCard={false} />
|
||||||
<div className="h-full flex flex-col">
|
|
||||||
<div className="flex-1 relative">
|
|
||||||
{/* Grid lines */}
|
|
||||||
{[...Array(6)].map((_, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="absolute w-full h-px bg-muted/30"
|
|
||||||
style={{ top: `${(i + 1) * 16}%` }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{/* Y-axis labels */}
|
|
||||||
<div className="absolute left-0 top-0 bottom-0 w-16 flex flex-col justify-between py-4">
|
|
||||||
{[...Array(6)].map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-3 w-12 bg-muted rounded-sm" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{/* X-axis labels */}
|
|
||||||
<div className="absolute left-16 right-0 bottom-0 flex justify-between px-4">
|
|
||||||
{[...Array(7)].map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-3 w-12 bg-muted rounded-sm" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{/* Chart area */}
|
|
||||||
<div className="absolute inset-0 mt-8 mb-8 ml-20 mr-4">
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 bg-muted/20"
|
|
||||||
style={{
|
|
||||||
clipPath:
|
|
||||||
"polygon(0 60%, 15% 45%, 30% 55%, 45% 35%, 60% 50%, 75% 30%, 90% 45%, 100% 40%, 100% 100%, 0 100%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Simulated line chart */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 bg-blue-500/30"
|
|
||||||
style={{
|
|
||||||
clipPath:
|
|
||||||
"polygon(0 60%, 15% 45%, 30% 55%, 45% 35%, 60% 50%, 75% 30%, 90% 45%, 100% 40%, 100% 40%, 90% 45%, 75% 30%, 60% 50%, 45% 35%, 30% 55%, 15% 45%, 0 60%)",
|
|
||||||
height: "2px",
|
|
||||||
top: "50%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,15 +7,6 @@ import {
|
|||||||
SelectItem,
|
SelectItem,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import {
|
import {
|
||||||
Mail,
|
Mail,
|
||||||
Send,
|
Send,
|
||||||
@@ -34,6 +25,7 @@ import {
|
|||||||
DashboardStatCardSkeleton,
|
DashboardStatCardSkeleton,
|
||||||
DashboardSectionHeader,
|
DashboardSectionHeader,
|
||||||
DashboardErrorState,
|
DashboardErrorState,
|
||||||
|
DashboardTable,
|
||||||
} from "@/components/dashboard/shared";
|
} from "@/components/dashboard/shared";
|
||||||
|
|
||||||
const TIME_RANGES = {
|
const TIME_RANGES = {
|
||||||
@@ -51,14 +43,12 @@ const formatDuration = (seconds) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getDateRange = (days) => {
|
const getDateRange = (days) => {
|
||||||
// Create date in Eastern Time
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const easternTime = new Date(
|
const easternTime = new Date(
|
||||||
now.toLocaleString("en-US", { timeZone: "America/New_York" })
|
now.toLocaleString("en-US", { timeZone: "America/New_York" })
|
||||||
);
|
);
|
||||||
|
|
||||||
if (days === "today") {
|
if (days === "today") {
|
||||||
// For today, set the range to be the current day in Eastern Time
|
|
||||||
const start = new Date(easternTime);
|
const start = new Date(easternTime);
|
||||||
start.setHours(0, 0, 0, 0);
|
start.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
@@ -71,7 +61,6 @@ const getDateRange = (days) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// For other periods, calculate from end of previous day
|
|
||||||
const end = new Date(easternTime);
|
const end = new Date(easternTime);
|
||||||
end.setHours(23, 59, 59, 999);
|
end.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
@@ -85,28 +74,26 @@ const getDateRange = (days) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const TableSkeleton = () => (
|
// Trend cell component with arrow and color
|
||||||
<Table>
|
const TrendCell = ({ delta }) => {
|
||||||
<TableHeader>
|
if (delta === 0) return null;
|
||||||
<TableRow className="dark:border-gray-800">
|
|
||||||
<TableHead><Skeleton className="h-4 w-24 bg-muted" /></TableHead>
|
const isPositive = delta > 0;
|
||||||
<TableHead className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted" /></TableHead>
|
const colorClass = isPositive
|
||||||
<TableHead className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted" /></TableHead>
|
? "text-green-600 dark:text-green-500"
|
||||||
<TableHead className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted" /></TableHead>
|
: "text-red-600 dark:text-red-500";
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
return (
|
||||||
<TableBody>
|
<div className={`flex items-center justify-end gap-0.5 ${colorClass}`}>
|
||||||
{[...Array(5)].map((_, i) => (
|
{isPositive ? (
|
||||||
<TableRow key={i} className="dark:border-gray-800">
|
<ArrowUp className="w-3 h-3" />
|
||||||
<TableCell><Skeleton className="h-4 w-32 bg-muted" /></TableCell>
|
) : (
|
||||||
<TableCell className="text-right"><Skeleton className="h-4 w-12 ml-auto bg-muted" /></TableCell>
|
<ArrowDown className="w-3 h-3" />
|
||||||
<TableCell className="text-right"><Skeleton className="h-4 w-12 ml-auto bg-muted" /></TableCell>
|
)}
|
||||||
<TableCell className="text-right"><Skeleton className="h-4 w-16 ml-auto bg-muted" /></TableCell>
|
<span>{Math.abs(delta)}%</span>
|
||||||
</TableRow>
|
</div>
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const GorgiasOverview = () => {
|
const GorgiasOverview = () => {
|
||||||
const [timeRange, setTimeRange] = useState("7");
|
const [timeRange, setTimeRange] = useState("7");
|
||||||
@@ -153,7 +140,6 @@ const GorgiasOverview = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadStats();
|
loadStats();
|
||||||
// Set up auto-refresh every 5 minutes
|
|
||||||
const interval = setInterval(loadStats, 5 * 60 * 1000);
|
const interval = setInterval(loadStats, 5 * 60 * 1000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [loadStats]);
|
}, [loadStats]);
|
||||||
@@ -183,21 +169,79 @@ const GorgiasOverview = () => {
|
|||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
// Process channel data
|
// Process channel data
|
||||||
const channels = data.channels?.map(line => ({
|
const channels = (data.channels?.map(line => ({
|
||||||
name: line[0]?.value || '',
|
name: line[0]?.value || '',
|
||||||
total: line[1]?.value || 0,
|
total: line[1]?.value || 0,
|
||||||
percentage: line[2]?.value || 0,
|
percentage: line[2]?.value || 0,
|
||||||
delta: line[3]?.value || 0
|
delta: line[3]?.value || 0
|
||||||
})) || [];
|
})) || []).sort((a, b) => b.total - a.total);
|
||||||
|
|
||||||
// Process agent data
|
// Process agent data
|
||||||
const agents = data.agents?.map(line => ({
|
const agents = (data.agents?.map(line => ({
|
||||||
name: line[0]?.value || '',
|
name: line[0]?.value || '',
|
||||||
closed: line[1]?.value || 0,
|
closed: line[1]?.value || 0,
|
||||||
rating: line[2]?.value,
|
rating: line[2]?.value,
|
||||||
percentage: line[3]?.value || 0,
|
percentage: line[3]?.value || 0,
|
||||||
delta: line[4]?.value || 0
|
delta: line[4]?.value || 0
|
||||||
})) || [];
|
})) || []).filter(agent => agent.name !== "Unassigned");
|
||||||
|
|
||||||
|
// Column definitions for Channel Distribution table
|
||||||
|
const channelColumns = [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "Channel",
|
||||||
|
render: (value) => <span className="text-foreground">{value}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "total",
|
||||||
|
header: "Total",
|
||||||
|
align: "right",
|
||||||
|
render: (value) => <span className="text-muted-foreground">{value}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "percentage",
|
||||||
|
header: "%",
|
||||||
|
align: "right",
|
||||||
|
render: (value) => <span className="text-muted-foreground">{value}%</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delta",
|
||||||
|
header: "Change",
|
||||||
|
align: "right",
|
||||||
|
render: (value) => <TrendCell delta={value} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Column definitions for Agent Performance table
|
||||||
|
const agentColumns = [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "Agent",
|
||||||
|
render: (value) => <span className="text-foreground">{value}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "closed",
|
||||||
|
header: "Closed",
|
||||||
|
align: "right",
|
||||||
|
render: (value) => <span className="text-muted-foreground">{value}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "rating",
|
||||||
|
header: "Rating",
|
||||||
|
align: "right",
|
||||||
|
render: (value) => (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{value ? `${value}/5` : "-"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delta",
|
||||||
|
header: "Change",
|
||||||
|
align: "right",
|
||||||
|
render: (value) => <TrendCell delta={value} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
@@ -245,7 +289,6 @@ const GorgiasOverview = () => {
|
|||||||
|
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
{/* Message & Response Metrics */}
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
[...Array(7)].map((_, i) => (
|
[...Array(7)].map((_, i) => (
|
||||||
<DashboardStatCardSkeleton key={i} size="compact" />
|
<DashboardStatCardSkeleton key={i} size="compact" />
|
||||||
@@ -341,120 +384,32 @@ const GorgiasOverview = () => {
|
|||||||
{/* Channel Distribution */}
|
{/* Channel Distribution */}
|
||||||
<Card className={CARD_STYLES.base}>
|
<Card className={CARD_STYLES.base}>
|
||||||
<DashboardSectionHeader title="Channel Distribution" compact className="pb-0" />
|
<DashboardSectionHeader title="Channel Distribution" compact className="pb-0" />
|
||||||
<CardContent className="overflow-y-auto max-h-[400px] scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
|
<CardContent>
|
||||||
{loading ? (
|
<DashboardTable
|
||||||
<TableSkeleton />
|
columns={channelColumns}
|
||||||
) : (
|
data={channels}
|
||||||
<Table>
|
loading={loading}
|
||||||
<TableHeader>
|
skeletonRows={5}
|
||||||
<TableRow className="dark:border-gray-800">
|
getRowKey={(channel, index) => `${channel.name}-${index}`}
|
||||||
<TableHead className="text-left font-medium text-foreground">Channel</TableHead>
|
maxHeight="md"
|
||||||
<TableHead className="text-right font-medium text-foreground">Total</TableHead>
|
compact
|
||||||
<TableHead className="text-right font-medium text-foreground">%</TableHead>
|
/>
|
||||||
<TableHead className="text-right font-medium text-foreground">Change</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{channels
|
|
||||||
.sort((a, b) => b.total - a.total)
|
|
||||||
.map((channel, index) => (
|
|
||||||
<TableRow key={index} className="dark:border-gray-800 hover:bg-muted/50 transition-colors">
|
|
||||||
<TableCell className="text-foreground">
|
|
||||||
{channel.name}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right text-muted-foreground">
|
|
||||||
{channel.total}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right text-muted-foreground">
|
|
||||||
{channel.percentage}%
|
|
||||||
</TableCell>
|
|
||||||
<TableCell
|
|
||||||
className={`text-right ${
|
|
||||||
channel.delta > 0
|
|
||||||
? "text-green-600 dark:text-green-500"
|
|
||||||
: channel.delta < 0
|
|
||||||
? "text-red-600 dark:text-red-500"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-end gap-0.5">
|
|
||||||
{channel.delta !== 0 && (
|
|
||||||
<>
|
|
||||||
{channel.delta > 0 ? (
|
|
||||||
<ArrowUp className="w-3 h-3" />
|
|
||||||
) : (
|
|
||||||
<ArrowDown className="w-3 h-3" />
|
|
||||||
)}
|
|
||||||
<span>{Math.abs(channel.delta)}%</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Agent Performance */}
|
{/* Agent Performance */}
|
||||||
<Card className={CARD_STYLES.base}>
|
<Card className={CARD_STYLES.base}>
|
||||||
<DashboardSectionHeader title="Agent Performance" compact className="pb-0" />
|
<DashboardSectionHeader title="Agent Performance" compact className="pb-0" />
|
||||||
<CardContent className="overflow-y-auto max-h-[400px] scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
|
<CardContent>
|
||||||
{loading ? (
|
<DashboardTable
|
||||||
<TableSkeleton />
|
columns={agentColumns}
|
||||||
) : (
|
data={agents}
|
||||||
<Table>
|
loading={loading}
|
||||||
<TableHeader>
|
skeletonRows={5}
|
||||||
<TableRow className="dark:border-gray-800">
|
getRowKey={(agent, index) => `${agent.name}-${index}`}
|
||||||
<TableHead className="text-left font-medium text-foreground">Agent</TableHead>
|
maxHeight="md"
|
||||||
<TableHead className="text-right font-medium text-foreground">Closed</TableHead>
|
compact
|
||||||
<TableHead className="text-right font-medium text-foreground">Rating</TableHead>
|
/>
|
||||||
<TableHead className="text-right font-medium text-foreground">Change</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{agents
|
|
||||||
.filter((agent) => agent.name !== "Unassigned")
|
|
||||||
.map((agent, index) => (
|
|
||||||
<TableRow key={index} className="dark:border-gray-800 hover:bg-muted/50 transition-colors">
|
|
||||||
<TableCell className="text-foreground">
|
|
||||||
{agent.name}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right text-muted-foreground">
|
|
||||||
{agent.closed}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right text-muted-foreground">
|
|
||||||
{agent.rating ? `${agent.rating}/5` : "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell
|
|
||||||
className={`text-right ${
|
|
||||||
agent.delta > 0
|
|
||||||
? "text-green-600 dark:text-green-500"
|
|
||||||
: agent.delta < 0
|
|
||||||
? "text-red-600 dark:text-red-500"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-end gap-0.5">
|
|
||||||
{agent.delta !== 0 && (
|
|
||||||
<>
|
|
||||||
{agent.delta > 0 ? (
|
|
||||||
<ArrowUp className="w-3 h-3" />
|
|
||||||
) : (
|
|
||||||
<ArrowDown className="w-3 h-3" />
|
|
||||||
)}
|
|
||||||
<span>{Math.abs(agent.delta)}%</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -17,11 +17,12 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { TIME_RANGES } from "@/lib/dashboard/constants";
|
import { TIME_RANGES } from "@/lib/dashboard/constants";
|
||||||
import { Mail, MessageSquare, BookOpen } from "lucide-react";
|
import { Mail, MessageSquare, BookOpen } from "lucide-react";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
||||||
import {
|
import {
|
||||||
DashboardSectionHeader,
|
DashboardSectionHeader,
|
||||||
DashboardErrorState,
|
DashboardErrorState,
|
||||||
|
DashboardTable,
|
||||||
|
TableSkeleton,
|
||||||
} from "@/components/dashboard/shared";
|
} from "@/components/dashboard/shared";
|
||||||
|
|
||||||
// Helper functions for formatting
|
// Helper functions for formatting
|
||||||
@@ -41,83 +42,8 @@ const formatCurrency = (value) => {
|
|||||||
}).format(value);
|
}).format(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Loading skeleton component
|
// MetricCell content component for displaying campaign metrics (returns content, not <td>)
|
||||||
const TableSkeleton = () => (
|
const MetricCellContent = ({
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="p-2 text-left font-medium sticky top-0 bg-card z-10">
|
|
||||||
<Skeleton className="h-8 w-24 bg-muted" />
|
|
||||||
</th>
|
|
||||||
<th className="p-2 text-center font-medium sticky top-0 bg-card z-10">
|
|
||||||
<Skeleton className="h-8 w-20 mx-auto bg-muted" />
|
|
||||||
</th>
|
|
||||||
<th className="p-2 text-center font-medium sticky top-0 bg-card z-10">
|
|
||||||
<Skeleton className="h-8 w-20 mx-auto bg-muted" />
|
|
||||||
</th>
|
|
||||||
<th className="p-2 text-center font-medium sticky top-0 bg-card z-10">
|
|
||||||
<Skeleton className="h-8 w-20 mx-auto bg-muted" />
|
|
||||||
</th>
|
|
||||||
<th className="p-2 text-center font-medium sticky top-0 bg-card z-10">
|
|
||||||
<Skeleton className="h-8 w-20 mx-auto bg-muted" />
|
|
||||||
</th>
|
|
||||||
<th className="p-2 text-center font-medium sticky top-0 bg-card z-10">
|
|
||||||
<Skeleton className="h-8 w-20 mx-auto bg-muted" />
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border/50">
|
|
||||||
{[...Array(15)].map((_, i) => (
|
|
||||||
<tr key={i} className="hover:bg-muted/50 transition-colors">
|
|
||||||
<td className="p-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Skeleton className="h-4 w-4 bg-muted" />
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Skeleton className="h-4 w-48 bg-muted" />
|
|
||||||
<Skeleton className="h-3 w-64 bg-muted" />
|
|
||||||
<Skeleton className="h-3 w-32 bg-muted" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="p-2 text-center">
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<Skeleton className="h-4 w-16 bg-muted" />
|
|
||||||
<Skeleton className="h-3 w-24 bg-muted" />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="p-2 text-center">
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<Skeleton className="h-4 w-16 bg-muted" />
|
|
||||||
<Skeleton className="h-3 w-24 bg-muted" />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="p-2 text-center">
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<Skeleton className="h-4 w-16 bg-muted" />
|
|
||||||
<Skeleton className="h-3 w-24 bg-muted" />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="p-2 text-center">
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<Skeleton className="h-4 w-16 bg-muted" />
|
|
||||||
<Skeleton className="h-3 w-24 bg-muted" />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="p-2 text-center">
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<Skeleton className="h-4 w-16 bg-muted" />
|
|
||||||
<Skeleton className="h-3 w-24 bg-muted" />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
// MetricCell component for displaying campaign metrics
|
|
||||||
const MetricCell = ({
|
|
||||||
value,
|
value,
|
||||||
count,
|
count,
|
||||||
isMonetary = false,
|
isMonetary = false,
|
||||||
@@ -128,15 +54,15 @@ const MetricCell = ({
|
|||||||
}) => {
|
}) => {
|
||||||
if (isSMS && hideForSMS) {
|
if (isSMS && hideForSMS) {
|
||||||
return (
|
return (
|
||||||
<td className="p-2 text-center">
|
<div className="text-center">
|
||||||
<div className="text-muted-foreground text-lg font-semibold">N/A</div>
|
<div className="text-muted-foreground text-lg font-semibold">N/A</div>
|
||||||
<div className="text-muted-foreground text-sm">-</div>
|
<div className="text-muted-foreground text-sm">-</div>
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<td className="p-2 text-center">
|
<div className="text-center">
|
||||||
<div className="text-blue-600 dark:text-blue-400 text-lg font-semibold">
|
<div className="text-blue-600 dark:text-blue-400 text-lg font-semibold">
|
||||||
{isMonetary ? formatCurrency(value) : formatRate(value, isSMS, hideForSMS)}
|
{isMonetary ? formatCurrency(value) : formatRate(value, isSMS, hideForSMS)}
|
||||||
</div>
|
</div>
|
||||||
@@ -146,7 +72,56 @@ const MetricCell = ({
|
|||||||
totalRecipients > 0 &&
|
totalRecipients > 0 &&
|
||||||
` (${((count / totalRecipients) * 100).toFixed(2)}%)`}
|
` (${((count / totalRecipients) * 100).toFixed(2)}%)`}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Campaign name cell with tooltip
|
||||||
|
const CampaignCell = ({ campaign }) => {
|
||||||
|
const isBlog = campaign.name?.includes("_Blog");
|
||||||
|
const isSMS = campaign.channel === 'sms';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="cursor-default">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isBlog ? (
|
||||||
|
<BookOpen className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
) : isSMS ? (
|
||||||
|
<MessageSquare className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Mail className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="font-medium text-foreground">
|
||||||
|
{campaign.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground truncate max-w-[300px]">
|
||||||
|
{campaign.subject}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{campaign.send_time
|
||||||
|
? DateTime.fromISO(campaign.send_time).toLocaleString(DateTime.DATETIME_MED)
|
||||||
|
: "No date"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="top"
|
||||||
|
className="break-words bg-card text-foreground border dark:border-gray-800"
|
||||||
|
>
|
||||||
|
<p className="font-medium">{campaign.name}</p>
|
||||||
|
<p>{campaign.subject}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{campaign.send_time
|
||||||
|
? DateTime.fromISO(campaign.send_time).toLocaleString(DateTime.DATETIME_MED)
|
||||||
|
: "No date"}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -154,7 +129,6 @@ const KlaviyoCampaigns = ({ className }) => {
|
|||||||
const [campaigns, setCampaigns] = useState([]);
|
const [campaigns, setCampaigns] = useState([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
|
||||||
const [selectedChannels, setSelectedChannels] = useState({ email: true, sms: true, blog: true });
|
const [selectedChannels, setSelectedChannels] = useState({ email: true, sms: true, blog: true });
|
||||||
const [selectedTimeRange, setSelectedTimeRange] = useState("last7days");
|
const [selectedTimeRange, setSelectedTimeRange] = useState("last7days");
|
||||||
const [sortConfig, setSortConfig] = useState({
|
const [sortConfig, setSortConfig] = useState({
|
||||||
@@ -162,11 +136,8 @@ const KlaviyoCampaigns = ({ className }) => {
|
|||||||
direction: "desc",
|
direction: "desc",
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSort = (key) => {
|
const handleSort = (key, direction) => {
|
||||||
setSortConfig((prev) => ({
|
setSortConfig({ key, direction });
|
||||||
key,
|
|
||||||
direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc",
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchCampaigns = async () => {
|
const fetchCampaigns = async () => {
|
||||||
@@ -193,9 +164,9 @@ const KlaviyoCampaigns = ({ className }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCampaigns();
|
fetchCampaigns();
|
||||||
const interval = setInterval(fetchCampaigns, 10 * 60 * 1000); // Refresh every 10 minutes
|
const interval = setInterval(fetchCampaigns, 10 * 60 * 1000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [selectedTimeRange]); // Only refresh when time range changes
|
}, [selectedTimeRange]);
|
||||||
|
|
||||||
// Sort campaigns
|
// Sort campaigns
|
||||||
const sortedCampaigns = [...campaigns].sort((a, b) => {
|
const sortedCampaigns = [...campaigns].sort((a, b) => {
|
||||||
@@ -219,16 +190,100 @@ const KlaviyoCampaigns = ({ className }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter campaigns by search term and channels
|
// Filter campaigns by channels
|
||||||
const filteredCampaigns = sortedCampaigns.filter(
|
const filteredCampaigns = sortedCampaigns.filter(
|
||||||
(campaign) => {
|
(campaign) => {
|
||||||
const isBlog = campaign?.name?.includes("_Blog");
|
const isBlog = campaign?.name?.includes("_Blog");
|
||||||
const channelType = isBlog ? "blog" : campaign?.channel;
|
const channelType = isBlog ? "blog" : campaign?.channel;
|
||||||
return campaign?.name?.toLowerCase().includes((searchTerm || "").toLowerCase()) &&
|
return selectedChannels[channelType];
|
||||||
selectedChannels[channelType];
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Column definitions for DashboardTable
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "Campaign",
|
||||||
|
sortable: true,
|
||||||
|
sortKey: "send_time",
|
||||||
|
render: (_, campaign) => <CampaignCell campaign={campaign} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delivery_rate",
|
||||||
|
header: "Delivery",
|
||||||
|
align: "center",
|
||||||
|
sortable: true,
|
||||||
|
render: (_, campaign) => (
|
||||||
|
<MetricCellContent
|
||||||
|
value={campaign.stats.delivery_rate}
|
||||||
|
count={campaign.stats.delivered}
|
||||||
|
totalRecipients={campaign.stats.recipients}
|
||||||
|
isSMS={campaign.channel === 'sms'}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "open_rate",
|
||||||
|
header: "Opens",
|
||||||
|
align: "center",
|
||||||
|
sortable: true,
|
||||||
|
render: (_, campaign) => (
|
||||||
|
<MetricCellContent
|
||||||
|
value={campaign.stats.open_rate}
|
||||||
|
count={campaign.stats.opens_unique}
|
||||||
|
totalRecipients={campaign.stats.recipients}
|
||||||
|
isSMS={campaign.channel === 'sms'}
|
||||||
|
hideForSMS={true}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "click_rate",
|
||||||
|
header: "Clicks",
|
||||||
|
align: "center",
|
||||||
|
sortable: true,
|
||||||
|
render: (_, campaign) => (
|
||||||
|
<MetricCellContent
|
||||||
|
value={campaign.stats.click_rate}
|
||||||
|
count={campaign.stats.clicks_unique}
|
||||||
|
totalRecipients={campaign.stats.recipients}
|
||||||
|
isSMS={campaign.channel === 'sms'}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "click_to_open_rate",
|
||||||
|
header: "CTR",
|
||||||
|
align: "center",
|
||||||
|
sortable: true,
|
||||||
|
render: (_, campaign) => (
|
||||||
|
<MetricCellContent
|
||||||
|
value={campaign.stats.click_to_open_rate}
|
||||||
|
count={campaign.stats.clicks_unique}
|
||||||
|
totalRecipients={campaign.stats.opens_unique}
|
||||||
|
isSMS={campaign.channel === 'sms'}
|
||||||
|
hideForSMS={true}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "conversion_value",
|
||||||
|
header: "Orders",
|
||||||
|
align: "center",
|
||||||
|
sortable: true,
|
||||||
|
render: (_, campaign) => (
|
||||||
|
<MetricCellContent
|
||||||
|
value={campaign.stats.conversion_value}
|
||||||
|
count={campaign.stats.conversion_uniques}
|
||||||
|
isMonetary={true}
|
||||||
|
showConversionRate={true}
|
||||||
|
totalRecipients={campaign.stats.recipients}
|
||||||
|
isSMS={campaign.channel === 'sms'}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Card className={`h-full ${CARD_STYLES.base}`}>
|
<Card className={`h-full ${CARD_STYLES.base}`}>
|
||||||
@@ -240,7 +295,7 @@ const KlaviyoCampaigns = ({ className }) => {
|
|||||||
timeSelector={<div className="w-[130px]" />}
|
timeSelector={<div className="w-[130px]" />}
|
||||||
/>
|
/>
|
||||||
<CardContent className="overflow-y-auto pl-4 max-h-[400px] mb-4">
|
<CardContent className="overflow-y-auto pl-4 max-h-[400px] mb-4">
|
||||||
<TableSkeleton />
|
<TableSkeleton rows={15} columns={6} variant="detailed" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -316,150 +371,17 @@ const KlaviyoCampaigns = ({ className }) => {
|
|||||||
</Select>
|
</Select>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<CardContent className="overflow-y-auto pl-4 max-h-[400px] mb-4">
|
<CardContent className="pl-4 mb-4">
|
||||||
<table className="w-full">
|
<DashboardTable
|
||||||
<thead>
|
columns={columns}
|
||||||
<tr>
|
data={filteredCampaigns}
|
||||||
<th className="p-2 text-left font-medium sticky top-0 bg-card z-10 text-foreground">
|
getRowKey={(campaign) => campaign.id}
|
||||||
<Button
|
sortConfig={sortConfig}
|
||||||
variant="ghost"
|
onSort={handleSort}
|
||||||
onClick={() => handleSort("send_time")}
|
maxHeight="md"
|
||||||
className="w-full justify-start h-8"
|
stickyHeader
|
||||||
>
|
bordered
|
||||||
Campaign
|
|
||||||
</Button>
|
|
||||||
</th>
|
|
||||||
<th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
|
|
||||||
<Button
|
|
||||||
variant={sortConfig.key === "delivery_rate" ? "default" : "ghost"}
|
|
||||||
onClick={() => handleSort("delivery_rate")}
|
|
||||||
className="w-full justify-center h-8"
|
|
||||||
>
|
|
||||||
Delivery
|
|
||||||
</Button>
|
|
||||||
</th>
|
|
||||||
<th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
|
|
||||||
<Button
|
|
||||||
variant={sortConfig.key === "open_rate" ? "default" : "ghost"}
|
|
||||||
onClick={() => handleSort("open_rate")}
|
|
||||||
className="w-full justify-center h-8"
|
|
||||||
>
|
|
||||||
Opens
|
|
||||||
</Button>
|
|
||||||
</th>
|
|
||||||
<th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
|
|
||||||
<Button
|
|
||||||
variant={sortConfig.key === "click_rate" ? "default" : "ghost"}
|
|
||||||
onClick={() => handleSort("click_rate")}
|
|
||||||
className="w-full justify-center h-8"
|
|
||||||
>
|
|
||||||
Clicks
|
|
||||||
</Button>
|
|
||||||
</th>
|
|
||||||
<th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
|
|
||||||
<Button
|
|
||||||
variant={sortConfig.key === "click_to_open_rate" ? "default" : "ghost"}
|
|
||||||
onClick={() => handleSort("click_to_open_rate")}
|
|
||||||
className="w-full justify-center h-8"
|
|
||||||
>
|
|
||||||
CTR
|
|
||||||
</Button>
|
|
||||||
</th>
|
|
||||||
<th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
|
|
||||||
<Button
|
|
||||||
variant={sortConfig.key === "conversion_value" ? "default" : "ghost"}
|
|
||||||
onClick={() => handleSort("conversion_value")}
|
|
||||||
className="w-full justify-center h-8"
|
|
||||||
>
|
|
||||||
Orders
|
|
||||||
</Button>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border/50">
|
|
||||||
{filteredCampaigns.map((campaign) => (
|
|
||||||
<tr
|
|
||||||
key={campaign.id}
|
|
||||||
className="hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<td className="p-2 align-top">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{campaign.name?.includes("_Blog") ? (
|
|
||||||
<BookOpen className="h-4 w-4 text-muted-foreground" />
|
|
||||||
) : campaign.channel === 'sms' ? (
|
|
||||||
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
<div className="font-medium text-foreground">
|
|
||||||
{campaign.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground truncate max-w-[300px]">
|
|
||||||
{campaign.subject}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{campaign.send_time
|
|
||||||
? DateTime.fromISO(campaign.send_time).toLocaleString(DateTime.DATETIME_MED)
|
|
||||||
: "No date"}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent
|
|
||||||
side="top"
|
|
||||||
className="break-words bg-card text-foreground border dark:border-gray-800"
|
|
||||||
>
|
|
||||||
<p className="font-medium">{campaign.name}</p>
|
|
||||||
<p>{campaign.subject}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{campaign.send_time
|
|
||||||
? DateTime.fromISO(campaign.send_time).toLocaleString(DateTime.DATETIME_MED)
|
|
||||||
: "No date"}
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
<MetricCell
|
|
||||||
value={campaign.stats.delivery_rate}
|
|
||||||
count={campaign.stats.delivered}
|
|
||||||
totalRecipients={campaign.stats.recipients}
|
|
||||||
isSMS={campaign.channel === 'sms'}
|
|
||||||
/>
|
/>
|
||||||
<MetricCell
|
|
||||||
value={campaign.stats.open_rate}
|
|
||||||
count={campaign.stats.opens_unique}
|
|
||||||
totalRecipients={campaign.stats.recipients}
|
|
||||||
isSMS={campaign.channel === 'sms'}
|
|
||||||
hideForSMS={true}
|
|
||||||
/>
|
|
||||||
<MetricCell
|
|
||||||
value={campaign.stats.click_rate}
|
|
||||||
count={campaign.stats.clicks_unique}
|
|
||||||
totalRecipients={campaign.stats.recipients}
|
|
||||||
isSMS={campaign.channel === 'sms'}
|
|
||||||
/>
|
|
||||||
<MetricCell
|
|
||||||
value={campaign.stats.click_to_open_rate}
|
|
||||||
count={campaign.stats.clicks_unique}
|
|
||||||
totalRecipients={campaign.stats.opens_unique}
|
|
||||||
isSMS={campaign.channel === 'sms'}
|
|
||||||
hideForSMS={true}
|
|
||||||
/>
|
|
||||||
<MetricCell
|
|
||||||
value={campaign.stats.conversion_value}
|
|
||||||
count={campaign.stats.conversion_uniques}
|
|
||||||
isMonetary={true}
|
|
||||||
showConversionRate={true}
|
|
||||||
totalRecipients={campaign.stats.recipients}
|
|
||||||
isSMS={campaign.channel === 'sms'}
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,14 +19,14 @@ import {
|
|||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
||||||
import {
|
import {
|
||||||
DashboardStatCard,
|
DashboardStatCard,
|
||||||
DashboardStatCardSkeleton,
|
DashboardStatCardSkeleton,
|
||||||
DashboardSectionHeader,
|
DashboardSectionHeader,
|
||||||
DashboardErrorState,
|
DashboardErrorState,
|
||||||
|
DashboardTable,
|
||||||
|
TableSkeleton,
|
||||||
} from "@/components/dashboard/shared";
|
} from "@/components/dashboard/shared";
|
||||||
|
|
||||||
// Helper functions for formatting
|
// Helper functions for formatting
|
||||||
@@ -48,8 +48,8 @@ const formatNumber = (value, decimalPlaces = 0) => {
|
|||||||
const formatPercent = (value, decimalPlaces = 2) =>
|
const formatPercent = (value, decimalPlaces = 2) =>
|
||||||
`${(value || 0).toFixed(decimalPlaces)}%`;
|
`${(value || 0).toFixed(decimalPlaces)}%`;
|
||||||
|
|
||||||
|
// MetricCell content component (returns content, not <td>)
|
||||||
const MetricCell = ({ value, label, sublabel, isMonetary = false, isPercentage = false, decimalPlaces = 0 }) => {
|
const MetricCellContent = ({ value, label, sublabel, isMonetary = false, isPercentage = false, decimalPlaces = 0 }) => {
|
||||||
const formattedValue = isMonetary
|
const formattedValue = isMonetary
|
||||||
? formatCurrency(value, decimalPlaces)
|
? formatCurrency(value, decimalPlaces)
|
||||||
: isPercentage
|
: isPercentage
|
||||||
@@ -57,7 +57,7 @@ const MetricCell = ({ value, label, sublabel, isMonetary = false, isPercentage =
|
|||||||
: formatNumber(value, decimalPlaces);
|
: formatNumber(value, decimalPlaces);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<td className="p-2 text-center align-top">
|
<div className="text-center">
|
||||||
<div className="text-blue-600 dark:text-blue-400 text-lg font-semibold">
|
<div className="text-blue-600 dark:text-blue-400 text-lg font-semibold">
|
||||||
{formattedValue}
|
{formattedValue}
|
||||||
</div>
|
</div>
|
||||||
@@ -66,7 +66,7 @@ const MetricCell = ({ value, label, sublabel, isMonetary = false, isPercentage =
|
|||||||
{label || sublabel}
|
{label || sublabel}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,16 +84,27 @@ const getActionValue = (campaign, actionType) => {
|
|||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CampaignName = ({ name }) => {
|
const CampaignNameCell = ({ campaign }) => {
|
||||||
if (name.startsWith("Instagram post: ")) {
|
const name = campaign.name;
|
||||||
|
const isInstagram = name.startsWith("Instagram post: ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground break-words min-w-[200px] max-w-[300px]">
|
||||||
|
{isInstagram ? (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Instagram className="w-4 h-4" />
|
<Instagram className="w-4 h-4 flex-shrink-0" />
|
||||||
<span>{name.replace("Instagram post: ", "")}</span>
|
<span>{name.replace("Instagram post: ", "")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<span>{name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{campaign.objective}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
return <span>{name}</span>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getObjectiveAction = (campaignObjective) => {
|
const getObjectiveAction = (campaignObjective) => {
|
||||||
@@ -138,7 +149,6 @@ const processMetrics = (campaign) => {
|
|||||||
const cpm = parseFloat(insights.cpm || 0);
|
const cpm = parseFloat(insights.cpm || 0);
|
||||||
const frequency = parseFloat(insights.frequency || 0);
|
const frequency = parseFloat(insights.frequency || 0);
|
||||||
|
|
||||||
// Purchase value and total purchases
|
|
||||||
const purchaseValue = (insights.action_values || [])
|
const purchaseValue = (insights.action_values || [])
|
||||||
.filter(({ action_type }) => action_type === "purchase")
|
.filter(({ action_type }) => action_type === "purchase")
|
||||||
.reduce((sum, { value }) => sum + parseFloat(value || 0), 0);
|
.reduce((sum, { value }) => sum + parseFloat(value || 0), 0);
|
||||||
@@ -147,7 +157,6 @@ const processMetrics = (campaign) => {
|
|||||||
.filter(({ action_type }) => action_type === "purchase")
|
.filter(({ action_type }) => action_type === "purchase")
|
||||||
.reduce((sum, { value }) => sum + parseInt(value || 0), 0);
|
.reduce((sum, { value }) => sum + parseInt(value || 0), 0);
|
||||||
|
|
||||||
// Aggregate unique actions
|
|
||||||
const actionMap = new Map();
|
const actionMap = new Map();
|
||||||
(insights.actions || []).forEach(({ action_type, value }) => {
|
(insights.actions || []).forEach(({ action_type, value }) => {
|
||||||
const currentValue = actionMap.get(action_type) || 0;
|
const currentValue = actionMap.get(action_type) || 0;
|
||||||
@@ -159,13 +168,11 @@ const processMetrics = (campaign) => {
|
|||||||
value,
|
value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Map of cost per action
|
|
||||||
const costPerActionMap = new Map();
|
const costPerActionMap = new Map();
|
||||||
(insights.cost_per_action_type || []).forEach(({ action_type, value }) => {
|
(insights.cost_per_action_type || []).forEach(({ action_type, value }) => {
|
||||||
costPerActionMap.set(action_type, parseFloat(value || 0));
|
costPerActionMap.set(action_type, parseFloat(value || 0));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Total post engagements
|
|
||||||
const totalPostEngagements = actionMap.get("post_engagement") || 0;
|
const totalPostEngagements = actionMap.get("post_engagement") || 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -190,7 +197,6 @@ const processCampaignData = (campaign) => {
|
|||||||
const budget = calculateBudget(campaign);
|
const budget = calculateBudget(campaign);
|
||||||
const { action_type, label } = getObjectiveAction(campaign.objective);
|
const { action_type, label } = getObjectiveAction(campaign.objective);
|
||||||
|
|
||||||
// Get cost per result from costPerActionMap
|
|
||||||
const costPerResult = metrics.costPerActionMap.get(action_type) || 0;
|
const costPerResult = metrics.costPerActionMap.get(action_type) || 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -208,49 +214,6 @@ const processCampaignData = (campaign) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const SkeletonTable = () => (
|
|
||||||
<div className="h-full max-h-[400px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2">
|
|
||||||
<table className="min-w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-border/50">
|
|
||||||
<th className="p-2 sticky top-0 bg-card z-10">
|
|
||||||
<Skeleton className="h-4 w-32 bg-muted" />
|
|
||||||
</th>
|
|
||||||
{[...Array(8)].map((_, i) => (
|
|
||||||
<th key={i} className="p-2 text-center sticky top-0 bg-card z-10">
|
|
||||||
<Skeleton className="h-4 w-20 mx-auto bg-muted" />
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border/50">
|
|
||||||
{[...Array(5)].map((_, rowIndex) => (
|
|
||||||
<tr key={rowIndex} className="hover:bg-muted/50 transition-colors">
|
|
||||||
<td className="p-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Skeleton className="h-4 w-4 bg-muted" />
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Skeleton className="h-4 w-48 bg-muted" />
|
|
||||||
<Skeleton className="h-3 w-64 bg-muted" />
|
|
||||||
<Skeleton className="h-3 w-32 bg-muted" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
{[...Array(8)].map((_, colIndex) => (
|
|
||||||
<td key={colIndex} className="p-2 text-center">
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<Skeleton className="h-4 w-16 bg-muted" />
|
|
||||||
<Skeleton className="h-3 w-24 bg-muted" />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const MetaCampaigns = () => {
|
const MetaCampaigns = () => {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
@@ -262,30 +225,24 @@ const MetaCampaigns = () => {
|
|||||||
direction: "desc",
|
direction: "desc",
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSort = (key) => {
|
const handleSort = (key, direction) => {
|
||||||
setSortConfig((prev) => ({
|
setSortConfig({ key, direction });
|
||||||
key,
|
|
||||||
direction: prev.key === key && prev.direction === "desc" ? "asc" : "desc",
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const computeDateRange = (timeframe) => {
|
const computeDateRange = (timeframe) => {
|
||||||
// Create date in Eastern Time
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const easternTime = new Date(
|
const easternTime = new Date(
|
||||||
now.toLocaleString("en-US", { timeZone: "America/New_York" })
|
now.toLocaleString("en-US", { timeZone: "America/New_York" })
|
||||||
);
|
);
|
||||||
easternTime.setHours(0, 0, 0, 0); // Set to start of day
|
easternTime.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
let sinceDate, untilDate;
|
let sinceDate, untilDate;
|
||||||
|
|
||||||
if (timeframe === "today") {
|
if (timeframe === "today") {
|
||||||
// For today, both dates should be the current date in Eastern Time
|
|
||||||
sinceDate = untilDate = new Date(easternTime);
|
sinceDate = untilDate = new Date(easternTime);
|
||||||
} else {
|
} else {
|
||||||
// For other periods, calculate the date range
|
|
||||||
untilDate = new Date(easternTime);
|
untilDate = new Date(easternTime);
|
||||||
untilDate.setDate(untilDate.getDate() - 1); // Yesterday
|
untilDate.setDate(untilDate.getDate() - 1);
|
||||||
|
|
||||||
sinceDate = new Date(untilDate);
|
sinceDate = new Date(untilDate);
|
||||||
sinceDate.setDate(sinceDate.getDate() - parseInt(timeframe) + 1);
|
sinceDate.setDate(sinceDate.getDate() - parseInt(timeframe) + 1);
|
||||||
@@ -315,7 +272,6 @@ const MetaCampaigns = () => {
|
|||||||
accountInsights.json()
|
accountInsights.json()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Process campaigns with the new processing logic
|
|
||||||
const processedCampaigns = campaignsJson.map(processCampaignData);
|
const processedCampaigns = campaignsJson.map(processCampaignData);
|
||||||
const activeCampaigns = processedCampaigns.filter(c => c.metrics.spend > 0);
|
const activeCampaigns = processedCampaigns.filter(c => c.metrics.spend > 0);
|
||||||
setCampaigns(activeCampaigns);
|
setCampaigns(activeCampaigns);
|
||||||
@@ -367,7 +323,6 @@ const MetaCampaigns = () => {
|
|||||||
|
|
||||||
switch (sortConfig.key) {
|
switch (sortConfig.key) {
|
||||||
case "date":
|
case "date":
|
||||||
// Add date sorting using campaign ID (Meta IDs are chronological)
|
|
||||||
return direction * (parseInt(b.id) - parseInt(a.id));
|
return direction * (parseInt(b.id) - parseInt(a.id));
|
||||||
case "spend":
|
case "spend":
|
||||||
return direction * ((a.metrics.spend || 0) - (b.metrics.spend || 0));
|
return direction * ((a.metrics.spend || 0) - (b.metrics.spend || 0));
|
||||||
@@ -390,6 +345,118 @@ const MetaCampaigns = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Column definitions for DashboardTable
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "Campaign",
|
||||||
|
sortable: true,
|
||||||
|
sortKey: "date",
|
||||||
|
render: (_, campaign) => <CampaignNameCell campaign={campaign} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "spend",
|
||||||
|
header: "Spend",
|
||||||
|
align: "center",
|
||||||
|
sortable: true,
|
||||||
|
render: (_, campaign) => (
|
||||||
|
<MetricCellContent
|
||||||
|
value={campaign.metrics.spend}
|
||||||
|
isMonetary
|
||||||
|
decimalPlaces={2}
|
||||||
|
sublabel={
|
||||||
|
campaign.budget
|
||||||
|
? `${formatCurrency(campaign.budget, 0)}/${campaign.budgetType}`
|
||||||
|
: "Budget: Ad set"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "reach",
|
||||||
|
header: "Reach",
|
||||||
|
align: "center",
|
||||||
|
sortable: true,
|
||||||
|
render: (_, campaign) => (
|
||||||
|
<MetricCellContent
|
||||||
|
value={campaign.metrics.reach}
|
||||||
|
label={`${formatNumber(campaign.metrics.frequency, 2)}x freq`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "impressions",
|
||||||
|
header: "Impressions",
|
||||||
|
align: "center",
|
||||||
|
sortable: true,
|
||||||
|
render: (_, campaign) => (
|
||||||
|
<MetricCellContent value={campaign.metrics.impressions} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "cpm",
|
||||||
|
header: "CPM",
|
||||||
|
align: "center",
|
||||||
|
sortable: true,
|
||||||
|
render: (_, campaign) => (
|
||||||
|
<MetricCellContent
|
||||||
|
value={campaign.metrics.cpm}
|
||||||
|
isMonetary
|
||||||
|
decimalPlaces={2}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ctr",
|
||||||
|
header: "CTR",
|
||||||
|
align: "center",
|
||||||
|
sortable: true,
|
||||||
|
render: (_, campaign) => (
|
||||||
|
<MetricCellContent
|
||||||
|
value={campaign.metrics.ctr}
|
||||||
|
isPercentage
|
||||||
|
decimalPlaces={2}
|
||||||
|
label={`${formatCurrency(campaign.metrics.cpc, 2)} CPC`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "results",
|
||||||
|
header: "Results",
|
||||||
|
align: "center",
|
||||||
|
sortable: true,
|
||||||
|
render: (_, campaign) => (
|
||||||
|
<MetricCellContent
|
||||||
|
value={getActionValue(campaign, campaign.objectiveActionType)}
|
||||||
|
label={campaign.objective}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "value",
|
||||||
|
header: "Value",
|
||||||
|
align: "center",
|
||||||
|
sortable: true,
|
||||||
|
render: (_, campaign) => (
|
||||||
|
<MetricCellContent
|
||||||
|
value={campaign.metrics.purchaseValue}
|
||||||
|
isMonetary
|
||||||
|
decimalPlaces={2}
|
||||||
|
sublabel={campaign.metrics.costPerResult ? `${formatCurrency(campaign.metrics.costPerResult)}/result` : null}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "engagements",
|
||||||
|
header: "Engagements",
|
||||||
|
align: "center",
|
||||||
|
sortable: true,
|
||||||
|
render: (_, campaign) => (
|
||||||
|
<MetricCellContent value={campaign.metrics.totalPostEngagements} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Card className={`h-full ${CARD_STYLES.base}`}>
|
<Card className={`h-full ${CARD_STYLES.base}`}>
|
||||||
@@ -407,7 +474,7 @@ const MetaCampaigns = () => {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<SkeletonTable />
|
<TableSkeleton rows={5} columns={9} variant="detailed" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -535,162 +602,17 @@ const MetaCampaigns = () => {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="overflow-y-auto pl-4 max-h-[400px] mb-4">
|
<CardContent className="pl-4 mb-4">
|
||||||
<table className="w-full">
|
<DashboardTable
|
||||||
<thead>
|
columns={columns}
|
||||||
<tr className="border-b border-border/50">
|
data={sortedCampaigns}
|
||||||
<th className="p-2 text-left font-medium sticky top-0 bg-card z-10 text-foreground">
|
getRowKey={(campaign) => campaign.id}
|
||||||
<Button
|
sortConfig={sortConfig}
|
||||||
variant="ghost"
|
onSort={handleSort}
|
||||||
className="pl-0 justify-start w-full h-8"
|
maxHeight="md"
|
||||||
onClick={() => handleSort("date")}
|
stickyHeader
|
||||||
>
|
bordered
|
||||||
Campaign
|
|
||||||
</Button>
|
|
||||||
</th>
|
|
||||||
<th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
|
|
||||||
<Button
|
|
||||||
variant={sortConfig.key === "spend" ? "default" : "ghost"}
|
|
||||||
className="w-full justify-center h-8"
|
|
||||||
onClick={() => handleSort("spend")}
|
|
||||||
>
|
|
||||||
Spend
|
|
||||||
</Button>
|
|
||||||
</th>
|
|
||||||
<th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
|
|
||||||
<Button
|
|
||||||
variant={sortConfig.key === "reach" ? "default" : "ghost"}
|
|
||||||
className="w-full justify-center h-8"
|
|
||||||
onClick={() => handleSort("reach")}
|
|
||||||
>
|
|
||||||
Reach
|
|
||||||
</Button>
|
|
||||||
</th>
|
|
||||||
<th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
|
|
||||||
<Button
|
|
||||||
variant={sortConfig.key === "impressions" ? "default" : "ghost"}
|
|
||||||
className="w-full justify-center h-8"
|
|
||||||
onClick={() => handleSort("impressions")}
|
|
||||||
>
|
|
||||||
Impressions
|
|
||||||
</Button>
|
|
||||||
</th>
|
|
||||||
<th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
|
|
||||||
<Button
|
|
||||||
variant={sortConfig.key === "cpm" ? "default" : "ghost"}
|
|
||||||
className="w-full justify-center h-8"
|
|
||||||
onClick={() => handleSort("cpm")}
|
|
||||||
>
|
|
||||||
CPM
|
|
||||||
</Button>
|
|
||||||
</th>
|
|
||||||
<th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
|
|
||||||
<Button
|
|
||||||
variant={sortConfig.key === "ctr" ? "default" : "ghost"}
|
|
||||||
className="w-full justify-center h-8"
|
|
||||||
onClick={() => handleSort("ctr")}
|
|
||||||
>
|
|
||||||
CTR
|
|
||||||
</Button>
|
|
||||||
</th>
|
|
||||||
<th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
|
|
||||||
<Button
|
|
||||||
variant={sortConfig.key === "results" ? "default" : "ghost"}
|
|
||||||
className="w-full justify-center h-8"
|
|
||||||
onClick={() => handleSort("results")}
|
|
||||||
>
|
|
||||||
Results
|
|
||||||
</Button>
|
|
||||||
</th>
|
|
||||||
<th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
|
|
||||||
<Button
|
|
||||||
variant={sortConfig.key === "value" ? "default" : "ghost"}
|
|
||||||
className="w-full justify-center h-8"
|
|
||||||
onClick={() => handleSort("value")}
|
|
||||||
>
|
|
||||||
Value
|
|
||||||
</Button>
|
|
||||||
</th>
|
|
||||||
<th className="p-2 font-medium text-center sticky top-0 bg-card z-10 text-foreground">
|
|
||||||
<Button
|
|
||||||
variant={sortConfig.key === "engagements" ? "default" : "ghost"}
|
|
||||||
className="w-full justify-center h-8"
|
|
||||||
onClick={() => handleSort("engagements")}
|
|
||||||
>
|
|
||||||
Engagements
|
|
||||||
</Button>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border/50">
|
|
||||||
{sortedCampaigns.map((campaign) => (
|
|
||||||
<tr
|
|
||||||
key={campaign.id}
|
|
||||||
className="hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
<td className="p-2 align-top">
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-foreground break-words min-w-[200px] max-w-[300px]">
|
|
||||||
<CampaignName name={campaign.name} />
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{campaign.objective}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<MetricCell
|
|
||||||
value={campaign.metrics.spend}
|
|
||||||
isMonetary
|
|
||||||
decimalPlaces={2}
|
|
||||||
sublabel={
|
|
||||||
campaign.budget
|
|
||||||
? `${formatCurrency(campaign.budget, 0)}/${campaign.budgetType}`
|
|
||||||
: "Budget: Ad set"
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MetricCell
|
|
||||||
value={campaign.metrics.reach}
|
|
||||||
label={`${formatNumber(campaign.metrics.frequency, 2)}x freq`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MetricCell
|
|
||||||
value={campaign.metrics.impressions}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MetricCell
|
|
||||||
value={campaign.metrics.cpm}
|
|
||||||
isMonetary
|
|
||||||
decimalPlaces={2}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MetricCell
|
|
||||||
value={campaign.metrics.ctr}
|
|
||||||
isPercentage
|
|
||||||
decimalPlaces={2}
|
|
||||||
label={`${formatCurrency(campaign.metrics.cpc, 2)} CPC`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MetricCell
|
|
||||||
value={getActionValue(campaign, campaign.objectiveActionType)}
|
|
||||||
label={campaign.objective}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MetricCell
|
|
||||||
value={campaign.metrics.purchaseValue}
|
|
||||||
isMonetary
|
|
||||||
decimalPlaces={2}
|
|
||||||
sublabel={campaign.metrics.costPerResult ? `${formatCurrency(campaign.metrics.costPerResult)}/result` : null}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MetricCell
|
|
||||||
value={campaign.metrics.totalPostEngagements}
|
|
||||||
/>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,14 +15,6 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableHeader,
|
|
||||||
TableHead,
|
|
||||||
TableBody,
|
|
||||||
TableRow,
|
|
||||||
TableCell,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
|
||||||
// Import shared components and tokens
|
// Import shared components and tokens
|
||||||
@@ -30,13 +22,13 @@ import {
|
|||||||
DashboardChartTooltip,
|
DashboardChartTooltip,
|
||||||
DashboardSectionHeader,
|
DashboardSectionHeader,
|
||||||
DashboardStatCard,
|
DashboardStatCard,
|
||||||
|
DashboardTable,
|
||||||
StatCardSkeleton,
|
StatCardSkeleton,
|
||||||
ChartSkeleton,
|
ChartSkeleton,
|
||||||
TableSkeleton,
|
TableSkeleton,
|
||||||
DashboardErrorState,
|
DashboardErrorState,
|
||||||
CARD_STYLES,
|
CARD_STYLES,
|
||||||
TYPOGRAPHY,
|
TYPOGRAPHY,
|
||||||
SCROLL_STYLES,
|
|
||||||
METRIC_COLORS,
|
METRIC_COLORS,
|
||||||
} from "@/components/dashboard/shared";
|
} from "@/components/dashboard/shared";
|
||||||
|
|
||||||
@@ -344,6 +336,36 @@ export const RealtimeAnalytics = () => {
|
|||||||
};
|
};
|
||||||
}, [isPaused]);
|
}, [isPaused]);
|
||||||
|
|
||||||
|
// Column definitions for pages table
|
||||||
|
const pagesColumns = [
|
||||||
|
{
|
||||||
|
key: "path",
|
||||||
|
header: "Page",
|
||||||
|
render: (value) => <span className="font-medium text-foreground">{value}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "activeUsers",
|
||||||
|
header: "Active Users",
|
||||||
|
align: "right",
|
||||||
|
render: (value) => <span className={REALTIME_COLORS.pages.className}>{value}</span>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Column definitions for sources table
|
||||||
|
const sourcesColumns = [
|
||||||
|
{
|
||||||
|
key: "source",
|
||||||
|
header: "Source",
|
||||||
|
render: (value) => <span className="font-medium text-foreground">{value}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "activeUsers",
|
||||||
|
header: "Active Users",
|
||||||
|
align: "right",
|
||||||
|
render: (value) => <span className={REALTIME_COLORS.sources.className}>{value}</span>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
if (loading && !basicData && !detailedData) {
|
if (loading && !basicData && !detailedData) {
|
||||||
return (
|
return (
|
||||||
<Card className={`${CARD_STYLES.base} h-full`}>
|
<Card className={`${CARD_STYLES.base} h-full`}>
|
||||||
@@ -448,64 +470,28 @@ export const RealtimeAnalytics = () => {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="pages">
|
<TabsContent value="pages">
|
||||||
<div className={`h-[230px] ${SCROLL_STYLES.container}`}>
|
<div className="h-[230px]">
|
||||||
<Table>
|
<DashboardTable
|
||||||
<TableHeader>
|
columns={pagesColumns}
|
||||||
<TableRow>
|
data={detailedData.currentPages}
|
||||||
<TableHead className="text-foreground">
|
loading={loading}
|
||||||
Page
|
getRowKey={(page, index) => `${page.path}-${index}`}
|
||||||
</TableHead>
|
maxHeight="sm"
|
||||||
<TableHead className="text-right text-foreground">
|
compact
|
||||||
Active Users
|
/>
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{detailedData.currentPages.map((page, index) => (
|
|
||||||
<TableRow key={index} className="hover:bg-muted/50 transition-colors">
|
|
||||||
<TableCell className="font-medium text-foreground">
|
|
||||||
{page.path}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell
|
|
||||||
className={`text-right ${REALTIME_COLORS.pages.className}`}
|
|
||||||
>
|
|
||||||
{page.activeUsers}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="sources">
|
<TabsContent value="sources">
|
||||||
<div className={`h-[230px] ${SCROLL_STYLES.container}`}>
|
<div className="h-[230px]">
|
||||||
<Table>
|
<DashboardTable
|
||||||
<TableHeader>
|
columns={sourcesColumns}
|
||||||
<TableRow>
|
data={detailedData.sources}
|
||||||
<TableHead className="text-foreground">
|
loading={loading}
|
||||||
Source
|
getRowKey={(source, index) => `${source.source}-${index}`}
|
||||||
</TableHead>
|
maxHeight="sm"
|
||||||
<TableHead className="text-right text-foreground">
|
compact
|
||||||
Active Users
|
/>
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{detailedData.sources.map((source, index) => (
|
|
||||||
<TableRow key={index} className="hover:bg-muted/50 transition-colors">
|
|
||||||
<TableCell className="font-medium text-foreground">
|
|
||||||
{source.source}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell
|
|
||||||
className={`text-right ${REALTIME_COLORS.sources.className}`}
|
|
||||||
>
|
|
||||||
{source.activeUsers}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import React, { useState, useEffect, useMemo, useCallback, memo } from "react";
|
import React, { useState, useEffect, useCallback, memo } from "react";
|
||||||
import axios from "axios";
|
|
||||||
import { acotService } from "@/services/dashboard/acotService";
|
import { acotService } from "@/services/dashboard/acotService";
|
||||||
import {
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -15,16 +8,8 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import { TrendingUp } from "lucide-react";
|
||||||
Loader2,
|
|
||||||
TrendingUp,
|
|
||||||
TrendingDown,
|
|
||||||
Info,
|
|
||||||
AlertCircle,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
Line,
|
Line,
|
||||||
@@ -36,13 +21,7 @@ import {
|
|||||||
Legend,
|
Legend,
|
||||||
ReferenceLine,
|
ReferenceLine,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import {
|
import { TIME_RANGES } from "@/lib/dashboard/constants";
|
||||||
TIME_RANGES,
|
|
||||||
GROUP_BY_OPTIONS,
|
|
||||||
formatDateForInput,
|
|
||||||
parseDateFromInput,
|
|
||||||
} from "@/lib/dashboard/constants";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
@@ -51,75 +30,26 @@ import {
|
|||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { debounce } from "lodash";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from "@/components/ui/collapsible";
|
|
||||||
import {
|
|
||||||
CARD_STYLES,
|
|
||||||
TYPOGRAPHY,
|
|
||||||
METRIC_COLORS as SHARED_METRIC_COLORS,
|
|
||||||
} from "@/lib/dashboard/designTokens";
|
|
||||||
import {
|
import {
|
||||||
|
DashboardSectionHeader,
|
||||||
DashboardStatCard,
|
DashboardStatCard,
|
||||||
|
DashboardStatCardSkeleton,
|
||||||
|
DashboardChartTooltip,
|
||||||
ChartSkeleton,
|
ChartSkeleton,
|
||||||
TableSkeleton,
|
|
||||||
DashboardEmptyState,
|
DashboardEmptyState,
|
||||||
DashboardErrorState,
|
DashboardErrorState,
|
||||||
TOOLTIP_STYLES,
|
|
||||||
} from "@/components/dashboard/shared";
|
} from "@/components/dashboard/shared";
|
||||||
|
|
||||||
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
|
// Move formatCurrency to top and export it
|
||||||
export const formatCurrency = (value, minimumFractionDigits = 0) => {
|
export const formatCurrency = (value, minimumFractionDigits = 0) => {
|
||||||
if (!value || isNaN(value)) return "$0";
|
if (!value || isNaN(value)) return "$0";
|
||||||
@@ -131,60 +61,23 @@ export const formatCurrency = (value, minimumFractionDigits = 0) => {
|
|||||||
}).format(value);
|
}).format(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add a helper function for percentage formatting
|
// Sales chart tooltip formatter - formats revenue/AOV as currency, others as numbers
|
||||||
const formatPercentage = (value) => {
|
const salesValueFormatter = (value, name) => {
|
||||||
if (typeof value !== "number") return "0%";
|
const nameLower = (name || "").toLowerCase();
|
||||||
return `${Math.abs(Math.round(value))}%`;
|
if (nameLower.includes('revenue') || nameLower.includes('order value') || nameLower.includes('average')) {
|
||||||
|
return formatCurrency(value);
|
||||||
|
}
|
||||||
|
return typeof value === 'number' ? value.toLocaleString() : value;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add color mapping for metrics - using shared tokens where applicable
|
// Sales chart label formatter - formats timestamp as readable date
|
||||||
const METRIC_COLORS = {
|
const salesLabelFormatter = (label) => {
|
||||||
revenue: SHARED_METRIC_COLORS.aov, // Purple for revenue
|
|
||||||
orders: SHARED_METRIC_COLORS.revenue, // Emerald for orders
|
|
||||||
avgOrderValue: "#9333ea", // Deep purple for AOV
|
|
||||||
movingAverage: SHARED_METRIC_COLORS.comparison, // Amber for moving average
|
|
||||||
prevRevenue: SHARED_METRIC_COLORS.expense, // Orange for prev revenue
|
|
||||||
prevOrders: SHARED_METRIC_COLORS.secondary, // Cyan for prev orders
|
|
||||||
prevAvgOrderValue: SHARED_METRIC_COLORS.comparison, // Amber for prev AOV
|
|
||||||
};
|
|
||||||
|
|
||||||
// Export CustomTooltip
|
|
||||||
export const CustomTooltip = ({ active, payload, label }) => {
|
|
||||||
if (active && payload && payload.length) {
|
|
||||||
const date = new Date(label);
|
const date = new Date(label);
|
||||||
const formattedDate = date.toLocaleDateString("en-US", {
|
return date.toLocaleDateString("en-US", {
|
||||||
weekday: "short",
|
weekday: "short",
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={TOOLTIP_STYLES.container}>
|
|
||||||
<p className={TOOLTIP_STYLES.header}>{formattedDate}</p>
|
|
||||||
<div className={TOOLTIP_STYLES.content}>
|
|
||||||
{payload.map((entry, index) => {
|
|
||||||
const value = entry.dataKey.toLowerCase().includes('revenue') || entry.dataKey === 'avgOrderValue'
|
|
||||||
? formatCurrency(entry.value)
|
|
||||||
: entry.value.toLocaleString();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={index} className={TOOLTIP_STYLES.row}>
|
|
||||||
<div className={TOOLTIP_STYLES.rowLabel}>
|
|
||||||
<span
|
|
||||||
className={TOOLTIP_STYLES.dot}
|
|
||||||
style={{ backgroundColor: entry.stroke || "#888" }}
|
|
||||||
/>
|
|
||||||
<span className={TOOLTIP_STYLES.name}>{entry.name}</span>
|
|
||||||
</div>
|
|
||||||
<span className={TOOLTIP_STYLES.value}>{value}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculate7DayAverage = (data) => {
|
const calculate7DayAverage = (data) => {
|
||||||
@@ -434,18 +327,9 @@ SummaryStats.displayName = "SummaryStats";
|
|||||||
// Note: Using ChartSkeleton and TableSkeleton from @/components/dashboard/shared
|
// Note: Using ChartSkeleton and TableSkeleton from @/components/dashboard/shared
|
||||||
|
|
||||||
const SkeletonStats = () => (
|
const SkeletonStats = () => (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-2 md:gap-3">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 max-w-3xl">
|
||||||
{[...Array(4)].map((_, i) => (
|
{[...Array(4)].map((_, i) => (
|
||||||
<Card key={i} className="bg-card">
|
<DashboardStatCardSkeleton key={i} size="compact" hasIcon={false} hasSubtitle />
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2">
|
|
||||||
<Skeleton className="h-4 w-24 bg-muted rounded-sm" />
|
|
||||||
<Skeleton className="h-4 w-8 bg-muted rounded-sm" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-4 pt-0">
|
|
||||||
<Skeleton className="h-7 w-32 bg-muted rounded-sm mb-1" />
|
|
||||||
<Skeleton className="h-4 w-24 bg-muted rounded-sm" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -565,18 +449,27 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
|
|||||||
? data.reduce((sum, day) => sum + day.revenue, 0) / data.length
|
? data.reduce((sum, day) => sum + day.revenue, 0) / data.length
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
return (
|
// Time selector for DashboardSectionHeader
|
||||||
<Card className={`w-full ${CARD_STYLES.base}`}>
|
const timeSelector = (
|
||||||
<CardHeader className="p-6 pb-4">
|
<Select
|
||||||
<div className="flex flex-col space-y-2">
|
value={selectedTimeRange}
|
||||||
<div className="flex justify-between items-start">
|
onValueChange={handleTimeRangeChange}
|
||||||
<div>
|
>
|
||||||
<CardTitle className={TYPOGRAPHY.sectionTitle}>
|
<SelectTrigger className="w-[130px] h-9">
|
||||||
{title}
|
<SelectValue placeholder="Select time range" />
|
||||||
</CardTitle>
|
</SelectTrigger>
|
||||||
</div>
|
<SelectContent>
|
||||||
<div className="flex items-center gap-2">
|
{TIME_RANGES.map((range) => (
|
||||||
{!error && (
|
<SelectItem key={range.value} value={range.value}>
|
||||||
|
{range.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Actions (Details dialog) for DashboardSectionHeader
|
||||||
|
const headerActions = !error ? (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" className="h-9">
|
<Button variant="outline" className="h-9">
|
||||||
@@ -768,28 +661,20 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
) : null;
|
||||||
<Select
|
|
||||||
value={selectedTimeRange}
|
|
||||||
onValueChange={handleTimeRangeChange}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[130px] h-9">
|
|
||||||
<SelectValue placeholder="Select time range" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{TIME_RANGES.map((range) => (
|
|
||||||
<SelectItem key={range.value} value={range.value}>
|
|
||||||
{range.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={`w-full ${CARD_STYLES.base}`}>
|
||||||
|
<DashboardSectionHeader
|
||||||
|
title={title}
|
||||||
|
timeSelector={timeSelector}
|
||||||
|
actions={headerActions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CardContent className="p-6 pt-0 space-y-4">
|
||||||
{/* Show stats only if not in error state */}
|
{/* Show stats only if not in error state */}
|
||||||
{!error &&
|
{!error && (
|
||||||
(loading ? (
|
loading ? (
|
||||||
<SkeletonStats />
|
<SkeletonStats />
|
||||||
) : (
|
) : (
|
||||||
<SummaryStats
|
<SummaryStats
|
||||||
@@ -797,11 +682,12 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
|
|||||||
projection={projection}
|
projection={projection}
|
||||||
projectionLoading={projectionLoading}
|
projectionLoading={projectionLoading}
|
||||||
/>
|
/>
|
||||||
))}
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Show metric toggles only if not in error state */}
|
{/* Show metric toggles only if not in error state */}
|
||||||
{!error && (
|
{!error && (
|
||||||
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4 pt-2">
|
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4">
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant={metrics.revenue ? "default" : "outline"}
|
variant={metrics.revenue ? "default" : "outline"}
|
||||||
@@ -876,10 +762,6 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="p-6 pt-0">
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<ChartSkeleton height="default" withCard={false} />
|
<ChartSkeleton height="default" withCard={false} />
|
||||||
@@ -927,7 +809,7 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
|
|||||||
className="text-xs text-muted-foreground"
|
className="text-xs text-muted-foreground"
|
||||||
tick={{ fill: "currentColor" }}
|
tick={{ fill: "currentColor" }}
|
||||||
/>
|
/>
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<Tooltip content={<DashboardChartTooltip valueFormatter={salesValueFormatter} labelFormatter={salesLabelFormatter} />} />
|
||||||
<Legend />
|
<Legend />
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
y={averageRevenue}
|
y={averageRevenue}
|
||||||
|
|||||||
@@ -6,15 +6,6 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
||||||
@@ -22,6 +13,7 @@ import {
|
|||||||
DashboardSectionHeader,
|
DashboardSectionHeader,
|
||||||
DashboardErrorState,
|
DashboardErrorState,
|
||||||
DashboardBadge,
|
DashboardBadge,
|
||||||
|
DashboardTable,
|
||||||
ChartSkeleton,
|
ChartSkeleton,
|
||||||
TableSkeleton,
|
TableSkeleton,
|
||||||
SimpleTooltip,
|
SimpleTooltip,
|
||||||
@@ -166,14 +158,14 @@ const WinbackFeed = ({ responses }) => (
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{(reasonsAnswer?.choices?.labels || []).map((label, idx) => (
|
{(reasonsAnswer?.choices?.labels || []).map((label, idx) => (
|
||||||
<Badge key={idx} variant="secondary" className="text-xs">
|
<DashboardBadge key={idx} variant="default" size="sm">
|
||||||
{label}
|
{label}
|
||||||
</Badge>
|
</DashboardBadge>
|
||||||
))}
|
))}
|
||||||
{reasonsAnswer?.choices?.other && (
|
{reasonsAnswer?.choices?.other && (
|
||||||
<Badge variant="outline" className="text-xs">
|
<DashboardBadge variant="purple" size="sm">
|
||||||
{reasonsAnswer.choices.other}
|
{reasonsAnswer.choices.other}
|
||||||
</Badge>
|
</DashboardBadge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{feedbackAnswer?.text && (
|
{feedbackAnswer?.text && (
|
||||||
@@ -325,6 +317,28 @@ const TypeformDashboard = () => {
|
|||||||
|
|
||||||
const newestResponse = getNewestResponse();
|
const newestResponse = getNewestResponse();
|
||||||
|
|
||||||
|
// Column definitions for reasons table
|
||||||
|
const reasonsColumns = [
|
||||||
|
{
|
||||||
|
key: "reason",
|
||||||
|
header: "Reason",
|
||||||
|
render: (value) => <span className="font-medium text-foreground">{value}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "count",
|
||||||
|
header: "Count",
|
||||||
|
align: "right",
|
||||||
|
render: (value) => <span className="text-muted-foreground">{value}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "percentage",
|
||||||
|
header: "%",
|
||||||
|
align: "right",
|
||||||
|
width: "w-[80px]",
|
||||||
|
render: (value) => <span className="text-muted-foreground">{value}%</span>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Card className={`h-full ${CARD_STYLES.base}`}>
|
<Card className={`h-full ${CARD_STYLES.base}`}>
|
||||||
@@ -554,41 +568,13 @@ const TypeformDashboard = () => {
|
|||||||
<Card className="bg-card h-full">
|
<Card className="bg-card h-full">
|
||||||
<DashboardSectionHeader title="Reasons for Not Ordering" compact />
|
<DashboardSectionHeader title="Reasons for Not Ordering" compact />
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="overflow-y-auto max-h-[400px] scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600">
|
<DashboardTable
|
||||||
<Table>
|
columns={reasonsColumns}
|
||||||
<TableHeader>
|
data={metrics?.winback?.reasons || []}
|
||||||
<TableRow>
|
getRowKey={(reason, index) => `${reason.reason}-${index}`}
|
||||||
<TableHead className="font-medium text-foreground">
|
maxHeight="md"
|
||||||
Reason
|
compact
|
||||||
</TableHead>
|
/>
|
||||||
<TableHead className="text-right font-medium text-foreground">
|
|
||||||
Count
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="text-right w-[80px] font-medium text-foreground">
|
|
||||||
%
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{metrics.winback.reasons.map((reason, index) => (
|
|
||||||
<TableRow
|
|
||||||
key={index}
|
|
||||||
className="hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
<TableCell className="font-medium text-foreground">
|
|
||||||
{reason.reason}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right text-muted-foreground">
|
|
||||||
{reason.count}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right text-muted-foreground">
|
|
||||||
{reason.percentage}%
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,14 +8,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import {
|
import {
|
||||||
PieChart,
|
PieChart,
|
||||||
Pie,
|
Pie,
|
||||||
@@ -26,6 +18,8 @@ import {
|
|||||||
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
||||||
import {
|
import {
|
||||||
DashboardSectionHeader,
|
DashboardSectionHeader,
|
||||||
|
DashboardTable,
|
||||||
|
DashboardChartTooltip,
|
||||||
TableSkeleton,
|
TableSkeleton,
|
||||||
ChartSkeleton,
|
ChartSkeleton,
|
||||||
TOOLTIP_STYLES,
|
TOOLTIP_STYLES,
|
||||||
@@ -104,10 +98,7 @@ export const UserBehaviorDashboard = () => {
|
|||||||
throw new Error("Invalid response structure");
|
throw new Error("Invalid response structure");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle both data structures
|
|
||||||
const rawData = result.data?.data || result.data;
|
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 pageResponse = rawData?.pageResponse || rawData?.reports?.[0];
|
||||||
const deviceResponse = rawData?.deviceResponse || rawData?.reports?.[1];
|
const deviceResponse = rawData?.deviceResponse || rawData?.reports?.[1];
|
||||||
const sourceResponse = rawData?.sourceResponse || rawData?.reports?.[2];
|
const sourceResponse = rawData?.sourceResponse || rawData?.reports?.[2];
|
||||||
@@ -133,6 +124,74 @@ export const UserBehaviorDashboard = () => {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [timeRange]);
|
}, [timeRange]);
|
||||||
|
|
||||||
|
const formatDuration = (seconds) => {
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = Math.floor(seconds % 60);
|
||||||
|
return `${minutes}m ${remainingSeconds}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Column definitions for pages table
|
||||||
|
const pagesColumns = [
|
||||||
|
{
|
||||||
|
key: "path",
|
||||||
|
header: "Page Path",
|
||||||
|
render: (value) => <span className="font-medium text-foreground">{value}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "pageViews",
|
||||||
|
header: "Views",
|
||||||
|
align: "right",
|
||||||
|
render: (value) => <span className="text-muted-foreground">{value.toLocaleString()}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "bounceRate",
|
||||||
|
header: "Bounce Rate",
|
||||||
|
align: "right",
|
||||||
|
render: (value) => <span className="text-muted-foreground">{value.toFixed(1)}%</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "avgSessionDuration",
|
||||||
|
header: "Avg. Duration",
|
||||||
|
align: "right",
|
||||||
|
render: (value) => <span className="text-muted-foreground">{formatDuration(value)}</span>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Column definitions for sources table
|
||||||
|
const sourcesColumns = [
|
||||||
|
{
|
||||||
|
key: "source",
|
||||||
|
header: "Source",
|
||||||
|
width: "w-[35%] min-w-[120px]",
|
||||||
|
render: (value) => <span className="font-medium text-foreground break-words max-w-[160px]">{value}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "sessions",
|
||||||
|
header: "Sessions",
|
||||||
|
align: "right",
|
||||||
|
width: "w-[20%] min-w-[80px]",
|
||||||
|
render: (value) => <span className="text-muted-foreground whitespace-nowrap">{value.toLocaleString()}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "conversions",
|
||||||
|
header: "Conv.",
|
||||||
|
align: "right",
|
||||||
|
width: "w-[20%] min-w-[80px]",
|
||||||
|
render: (value) => <span className="text-muted-foreground whitespace-nowrap">{value.toLocaleString()}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "conversionRate",
|
||||||
|
header: "Conv. Rate",
|
||||||
|
align: "right",
|
||||||
|
width: "w-[25%] min-w-[80px]",
|
||||||
|
render: (_, row) => (
|
||||||
|
<span className="text-muted-foreground whitespace-nowrap">
|
||||||
|
{((row.conversions / row.sessions) * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Card className={`${CARD_STYLES.base} h-full`}>
|
<Card className={`${CARD_STYLES.base} h-full`}>
|
||||||
@@ -180,41 +239,33 @@ export const UserBehaviorDashboard = () => {
|
|||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload }) => {
|
// Custom item renderer for the device tooltip - renders both Views and Sessions rows
|
||||||
if (active && payload && payload.length) {
|
const deviceTooltipRenderer = (item, index) => {
|
||||||
const data = payload[0].payload;
|
if (index > 0) return null; // Only render for the first item (pie chart sends single slice)
|
||||||
const percentage = ((data.pageViews / totalViews) * 100).toFixed(1);
|
|
||||||
const sessionPercentage = ((data.sessions / totalSessions) * 100).toFixed(1);
|
const deviceData = item.payload;
|
||||||
const color = COLORS[data.device.toLowerCase()];
|
const color = COLORS[deviceData.device.toLowerCase()];
|
||||||
|
const viewsPercentage = ((deviceData.pageViews / totalViews) * 100).toFixed(1);
|
||||||
|
const sessionsPercentage = ((deviceData.sessions / totalSessions) * 100).toFixed(1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={TOOLTIP_STYLES.container}>
|
<>
|
||||||
<p className={TOOLTIP_STYLES.header}>{data.device}</p>
|
|
||||||
<div className={TOOLTIP_STYLES.content}>
|
|
||||||
<div className={TOOLTIP_STYLES.row}>
|
<div className={TOOLTIP_STYLES.row}>
|
||||||
<div className={TOOLTIP_STYLES.rowLabel}>
|
<div className={TOOLTIP_STYLES.rowLabel}>
|
||||||
<span className={TOOLTIP_STYLES.dot} style={{ backgroundColor: color }} />
|
<span className={TOOLTIP_STYLES.dot} style={{ backgroundColor: color }} />
|
||||||
<span className={TOOLTIP_STYLES.name}>Views</span>
|
<span className={TOOLTIP_STYLES.name}>Views</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={TOOLTIP_STYLES.value}>{data.pageViews.toLocaleString()} ({percentage}%)</span>
|
<span className={TOOLTIP_STYLES.value}>{deviceData.pageViews.toLocaleString()} ({viewsPercentage}%)</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={TOOLTIP_STYLES.row}>
|
<div className={TOOLTIP_STYLES.row}>
|
||||||
<div className={TOOLTIP_STYLES.rowLabel}>
|
<div className={TOOLTIP_STYLES.rowLabel}>
|
||||||
<span className={TOOLTIP_STYLES.dot} style={{ backgroundColor: color }} />
|
<span className={TOOLTIP_STYLES.dot} style={{ backgroundColor: color }} />
|
||||||
<span className={TOOLTIP_STYLES.name}>Sessions</span>
|
<span className={TOOLTIP_STYLES.name}>Sessions</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={TOOLTIP_STYLES.value}>{data.sessions.toLocaleString()} ({sessionPercentage}%)</span>
|
<span className={TOOLTIP_STYLES.value}>{deviceData.sessions.toLocaleString()} ({sessionsPercentage}%)</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDuration = (seconds) => {
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
const remainingSeconds = Math.floor(seconds % 60);
|
|
||||||
return `${minutes}m ${remainingSeconds}s`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -249,78 +300,27 @@ export const UserBehaviorDashboard = () => {
|
|||||||
<TabsTrigger value="devices">Device Usage</TabsTrigger>
|
<TabsTrigger value="devices">Device Usage</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent
|
<TabsContent value="pages" className="mt-4 space-y-2">
|
||||||
value="pages"
|
<DashboardTable
|
||||||
className="mt-4 space-y-2 h-full max-h-[540px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2"
|
columns={pagesColumns}
|
||||||
>
|
data={data?.data?.pageData?.pageData || []}
|
||||||
<Table>
|
getRowKey={(page, index) => `${page.path}-${index}`}
|
||||||
<TableHeader>
|
maxHeight="xl"
|
||||||
<TableRow className="dark:border-gray-800">
|
compact
|
||||||
<TableHead className="text-foreground">Page Path</TableHead>
|
/>
|
||||||
<TableHead className="text-right text-foreground">Views</TableHead>
|
|
||||||
<TableHead className="text-right text-foreground">Bounce Rate</TableHead>
|
|
||||||
<TableHead className="text-right text-foreground">Avg. Duration</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{data?.data?.pageData?.pageData.map((page, index) => (
|
|
||||||
<TableRow key={index} className="dark:border-gray-800">
|
|
||||||
<TableCell className="font-medium text-foreground">
|
|
||||||
{page.path}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right text-muted-foreground">
|
|
||||||
{page.pageViews.toLocaleString()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right text-muted-foreground">
|
|
||||||
{page.bounceRate.toFixed(1)}%
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right text-muted-foreground">
|
|
||||||
{formatDuration(page.avgSessionDuration)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent
|
<TabsContent value="sources" className="mt-4 space-y-2">
|
||||||
value="sources"
|
<DashboardTable
|
||||||
className="mt-4 space-y-2 h-full max-h-[540px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2"
|
columns={sourcesColumns}
|
||||||
>
|
data={data?.data?.sourceData || []}
|
||||||
<Table>
|
getRowKey={(source, index) => `${source.source}-${index}`}
|
||||||
<TableHeader>
|
maxHeight="xl"
|
||||||
<TableRow className="dark:border-gray-800">
|
compact
|
||||||
<TableHead className="text-foreground w-[35%] min-w-[120px]">Source</TableHead>
|
/>
|
||||||
<TableHead className="text-right text-foreground w-[20%] min-w-[80px]">Sessions</TableHead>
|
|
||||||
<TableHead className="text-right text-foreground w-[20%] min-w-[80px]">Conv.</TableHead>
|
|
||||||
<TableHead className="text-right text-foreground w-[25%] min-w-[80px]">Conv. Rate</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{data?.data?.sourceData?.map((source, index) => (
|
|
||||||
<TableRow key={index} className="dark:border-gray-800">
|
|
||||||
<TableCell className="font-medium text-foreground break-words max-w-[160px]">
|
|
||||||
{source.source}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right text-muted-foreground whitespace-nowrap">
|
|
||||||
{source.sessions.toLocaleString()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right text-muted-foreground whitespace-nowrap">
|
|
||||||
{source.conversions.toLocaleString()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right text-muted-foreground whitespace-nowrap">
|
|
||||||
{((source.conversions / source.sessions) * 100).toFixed(1)}%
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent
|
<TabsContent value="devices" className="mt-4 space-y-2">
|
||||||
value="devices"
|
|
||||||
className="mt-4 space-y-2 h-full max-h-[540px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent hover:scrollbar-thumb-gray-300 dark:hover:scrollbar-thumb-gray-600 pr-2"
|
|
||||||
>
|
|
||||||
<div className={`h-60 ${CARD_STYLES.base} rounded-lg p-4`}>
|
<div className={`h-60 ${CARD_STYLES.base} rounded-lg p-4`}>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<PieChart>
|
<PieChart>
|
||||||
@@ -343,7 +343,14 @@ export const UserBehaviorDashboard = () => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<Tooltip
|
||||||
|
content={
|
||||||
|
<DashboardChartTooltip
|
||||||
|
labelFormatter={(_, payload) => payload?.[0]?.payload?.device || ""}
|
||||||
|
itemRenderer={deviceTooltipRenderer}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -210,6 +210,8 @@ export const ChartSkeleton: React.FC<ChartSkeletonProps> = ({
|
|||||||
// TABLE SKELETON
|
// TABLE SKELETON
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
export type TableSkeletonVariant = "simple" | "detailed";
|
||||||
|
|
||||||
export interface TableSkeletonProps {
|
export interface TableSkeletonProps {
|
||||||
/** Number of rows to show */
|
/** Number of rows to show */
|
||||||
rows?: number;
|
rows?: number;
|
||||||
@@ -227,6 +229,14 @@ export interface TableSkeletonProps {
|
|||||||
scrollable?: boolean;
|
scrollable?: boolean;
|
||||||
/** Max height for scrollable (uses SCROLL_STYLES keys) */
|
/** Max height for scrollable (uses SCROLL_STYLES keys) */
|
||||||
maxHeight?: "sm" | "md" | "lg" | "xl";
|
maxHeight?: "sm" | "md" | "lg" | "xl";
|
||||||
|
/**
|
||||||
|
* Cell layout variant:
|
||||||
|
* - "simple": single-line cells (default)
|
||||||
|
* - "detailed": multi-line cells with icon in first column and value+sublabel in others
|
||||||
|
*/
|
||||||
|
variant?: TableSkeletonVariant;
|
||||||
|
/** Show icon placeholder in first column (only for detailed variant) */
|
||||||
|
hasIcon?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TableSkeleton: React.FC<TableSkeletonProps> = ({
|
export const TableSkeleton: React.FC<TableSkeletonProps> = ({
|
||||||
@@ -238,6 +248,8 @@ export const TableSkeleton: React.FC<TableSkeletonProps> = ({
|
|||||||
className,
|
className,
|
||||||
scrollable = false,
|
scrollable = false,
|
||||||
maxHeight = "md",
|
maxHeight = "md",
|
||||||
|
variant = "simple",
|
||||||
|
hasIcon = true,
|
||||||
}) => {
|
}) => {
|
||||||
const columnCount = Array.isArray(columns) ? columns.length : columns;
|
const columnCount = Array.isArray(columns) ? columns.length : columns;
|
||||||
const columnWidths = Array.isArray(columns)
|
const columnWidths = Array.isArray(columns)
|
||||||
@@ -245,13 +257,50 @@ export const TableSkeleton: React.FC<TableSkeletonProps> = ({
|
|||||||
: Array(columnCount).fill("w-24");
|
: Array(columnCount).fill("w-24");
|
||||||
const colors = COLOR_VARIANT_CLASSES[colorVariant];
|
const colors = COLOR_VARIANT_CLASSES[colorVariant];
|
||||||
|
|
||||||
|
// Simple variant - single line cells
|
||||||
|
const renderSimpleCell = (colIndex: number) => (
|
||||||
|
<Skeleton
|
||||||
|
className={cn(
|
||||||
|
"h-4",
|
||||||
|
colors.skeleton,
|
||||||
|
colIndex === 0 ? "w-32" : columnWidths[colIndex]
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Detailed variant - first column has icon + stacked text, others have value + sublabel
|
||||||
|
const renderDetailedFirstCell = () => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{hasIcon && <Skeleton className={cn("h-4 w-4", colors.skeleton)} />}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className={cn("h-4 w-48", colors.skeleton)} />
|
||||||
|
<Skeleton className={cn("h-3 w-64", colors.skeleton)} />
|
||||||
|
<Skeleton className={cn("h-3 w-32", colors.skeleton)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderDetailedMetricCell = () => (
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<Skeleton className={cn("h-4 w-16", colors.skeleton)} />
|
||||||
|
<Skeleton className={cn("h-3 w-24", colors.skeleton)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const tableContent = (
|
const tableContent = (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="hover:bg-transparent">
|
<TableRow className="hover:bg-transparent">
|
||||||
{Array.from({ length: columnCount }).map((_, i) => (
|
{Array.from({ length: columnCount }).map((_, i) => (
|
||||||
<TableHead key={i}>
|
<TableHead key={i} className={i === 0 ? "text-left" : "text-center"}>
|
||||||
<Skeleton className={cn("h-4", colors.skeleton, columnWidths[i])} />
|
<Skeleton
|
||||||
|
className={cn(
|
||||||
|
variant === "detailed" ? "h-8" : "h-4",
|
||||||
|
colors.skeleton,
|
||||||
|
i === 0 ? "w-24" : "w-20",
|
||||||
|
i !== 0 && "mx-auto"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -260,14 +309,12 @@ export const TableSkeleton: React.FC<TableSkeletonProps> = ({
|
|||||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||||
<TableRow key={rowIndex} className="hover:bg-muted/50 transition-colors">
|
<TableRow key={rowIndex} className="hover:bg-muted/50 transition-colors">
|
||||||
{Array.from({ length: columnCount }).map((_, colIndex) => (
|
{Array.from({ length: columnCount }).map((_, colIndex) => (
|
||||||
<TableCell key={colIndex}>
|
<TableCell key={colIndex} className={colIndex !== 0 ? "text-center" : ""}>
|
||||||
<Skeleton
|
{variant === "detailed" ? (
|
||||||
className={cn(
|
colIndex === 0 ? renderDetailedFirstCell() : renderDetailedMetricCell()
|
||||||
"h-4",
|
) : (
|
||||||
colors.skeleton,
|
renderSimpleCell(colIndex)
|
||||||
colIndex === 0 ? "w-32" : columnWidths[colIndex]
|
|
||||||
)}
|
)}
|
||||||
/>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
@@ -86,6 +87,17 @@ export interface TableColumn<T = Record<string, unknown>> {
|
|||||||
className?: string;
|
className?: string;
|
||||||
/** Whether to use tabular-nums for numeric values */
|
/** Whether to use tabular-nums for numeric values */
|
||||||
numeric?: boolean;
|
numeric?: boolean;
|
||||||
|
/** Whether this column is sortable */
|
||||||
|
sortable?: boolean;
|
||||||
|
/** Custom sort key if different from column key */
|
||||||
|
sortKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SortDirection = "asc" | "desc";
|
||||||
|
|
||||||
|
export interface SortConfig {
|
||||||
|
key: string;
|
||||||
|
direction: SortDirection;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardTableProps<T = Record<string, unknown>> {
|
export interface DashboardTableProps<T = Record<string, unknown>> {
|
||||||
@@ -119,6 +131,10 @@ export interface DashboardTableProps<T = Record<string, unknown>> {
|
|||||||
className?: string;
|
className?: string;
|
||||||
/** Additional className for the table element */
|
/** Additional className for the table element */
|
||||||
tableClassName?: string;
|
tableClassName?: string;
|
||||||
|
/** Current sort configuration (for controlled sorting) */
|
||||||
|
sortConfig?: SortConfig;
|
||||||
|
/** Callback when a sortable column header is clicked */
|
||||||
|
onSort?: (key: string, direction: SortDirection) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -159,10 +175,50 @@ export function DashboardTable<T extends Record<string, unknown>>({
|
|||||||
bordered = false,
|
bordered = false,
|
||||||
className,
|
className,
|
||||||
tableClassName,
|
tableClassName,
|
||||||
|
sortConfig,
|
||||||
|
onSort,
|
||||||
}: DashboardTableProps<T>): React.ReactElement {
|
}: DashboardTableProps<T>): React.ReactElement {
|
||||||
const paddingClass = compact ? "px-3 py-2" : "px-4 py-3";
|
const paddingClass = compact ? "px-3 py-2" : "px-4 py-3";
|
||||||
const scrollClass = maxHeight !== "none" ? MAX_HEIGHT_CLASSES[maxHeight] : "";
|
const scrollClass = maxHeight !== "none" ? MAX_HEIGHT_CLASSES[maxHeight] : "";
|
||||||
|
|
||||||
|
// Handle sort click - toggles direction or sets new sort key
|
||||||
|
const handleSortClick = (col: TableColumn<T>) => {
|
||||||
|
if (!onSort || !col.sortable) return;
|
||||||
|
|
||||||
|
const sortKey = col.sortKey || col.key;
|
||||||
|
const currentDirection = sortConfig?.key === sortKey ? sortConfig.direction : null;
|
||||||
|
const newDirection: SortDirection = currentDirection === "desc" ? "asc" : "desc";
|
||||||
|
|
||||||
|
onSort(sortKey, newDirection);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render header cell content - either plain text or sortable button
|
||||||
|
const renderHeaderContent = (col: TableColumn<T>) => {
|
||||||
|
if (!col.sortable || !onSort) {
|
||||||
|
return col.header;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortKey = col.sortKey || col.key;
|
||||||
|
const isActive = sortConfig?.key === sortKey;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant={isActive ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleSortClick(col)}
|
||||||
|
className={cn(
|
||||||
|
"h-8 font-medium",
|
||||||
|
col.align === "center" && "w-full justify-center",
|
||||||
|
col.align === "right" && "w-full justify-end",
|
||||||
|
col.align === "left" && "justify-start",
|
||||||
|
!col.align && "justify-start"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{col.header}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Loading skeleton
|
// Loading skeleton
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -241,13 +297,14 @@ export function DashboardTable<T extends Record<string, unknown>>({
|
|||||||
key={col.key}
|
key={col.key}
|
||||||
className={cn(
|
className={cn(
|
||||||
TABLE_STYLES.headerCell,
|
TABLE_STYLES.headerCell,
|
||||||
paddingClass,
|
// Reduce padding when sortable since button has its own padding
|
||||||
|
col.sortable && onSort ? "p-1" : paddingClass,
|
||||||
ALIGNMENT_CLASSES[col.align || "left"],
|
ALIGNMENT_CLASSES[col.align || "left"],
|
||||||
col.width,
|
col.width,
|
||||||
col.hideOnMobile && "hidden sm:table-cell"
|
col.hideOnMobile && "hidden sm:table-cell"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{col.header}
|
{renderHeaderContent(col)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ export {
|
|||||||
type CellAlignment,
|
type CellAlignment,
|
||||||
type SimpleTableProps,
|
type SimpleTableProps,
|
||||||
type SimpleTableRow,
|
type SimpleTableRow,
|
||||||
|
type SortDirection,
|
||||||
|
type SortConfig,
|
||||||
} from "./DashboardTable";
|
} from "./DashboardTable";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -133,6 +135,7 @@ export {
|
|||||||
// Types
|
// Types
|
||||||
type ChartSkeletonProps,
|
type ChartSkeletonProps,
|
||||||
type TableSkeletonProps,
|
type TableSkeletonProps,
|
||||||
|
type TableSkeletonVariant,
|
||||||
type StatCardSkeletonProps,
|
type StatCardSkeletonProps,
|
||||||
type GridSkeletonProps,
|
type GridSkeletonProps,
|
||||||
type ListSkeletonProps,
|
type ListSkeletonProps,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user