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