Add gorgias component and services
This commit is contained in:
401
examples DO NOT USE OR EDIT/EXAMPLE ONLY GorgiasSummary.jsx
Normal file
401
examples DO NOT USE OR EDIT/EXAMPLE ONLY GorgiasSummary.jsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import gorgiasService from "../../services/gorgiasService";
|
||||
import { getDateRange } from "../../utils/dateUtils";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Clock, Star, Users, MessageSquare, Mail, Send } from "lucide-react";
|
||||
|
||||
const TIME_RANGES = {
|
||||
7: "Last 7 Days",
|
||||
14: "Last 14 Days",
|
||||
30: "Last 30 Days",
|
||||
90: "Last 90 Days",
|
||||
};
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
||||
};
|
||||
|
||||
const MetricCard = ({
|
||||
title,
|
||||
value,
|
||||
delta,
|
||||
suffix = "",
|
||||
icon: Icon,
|
||||
colorClass = "blue",
|
||||
more_is_better = true,
|
||||
loading = false,
|
||||
}) => {
|
||||
const getDeltaColor = (d) => {
|
||||
if (d === 0) return "text-gray-600 dark:text-gray-400";
|
||||
const isPositive = d > 0;
|
||||
return isPositive === more_is_better
|
||||
? "text-green-600 dark:text-green-500"
|
||||
: "text-red-600 dark:text-red-500";
|
||||
};
|
||||
|
||||
const formatDelta = (d) => {
|
||||
if (d === undefined || d === null) return null;
|
||||
if (d === 0) return "0";
|
||||
return (d > 0 ? "+" : "") + d + suffix;
|
||||
};
|
||||
|
||||
const colorMapping = {
|
||||
blue: "bg-blue-50 dark:bg-blue-900/20 border-blue-100 dark:border-blue-800/50 text-blue-600 dark:text-blue-400",
|
||||
green:
|
||||
"bg-green-50 dark:bg-green-900/20 border-green-100 dark:border-green-800/50 text-green-600 dark:text-green-400",
|
||||
purple:
|
||||
"bg-purple-50 dark:bg-purple-900/20 border-purple-100 dark:border-purple-800/50 text-purple-600 dark:text-purple-400",
|
||||
indigo:
|
||||
"bg-indigo-50 dark:bg-indigo-900/20 border-indigo-100 dark:border-indigo-800/50 text-indigo-600 dark:text-indigo-400",
|
||||
orange:
|
||||
"bg-orange-50 dark:bg-orange-900/20 border-orange-100 dark:border-orange-800/50 text-orange-600 dark:text-orange-400",
|
||||
teal: "bg-teal-50 dark:bg-teal-900/20 border-teal-100 dark:border-teal-800/50 text-teal-600 dark:text-teal-400",
|
||||
cyan: "bg-cyan-50 dark:bg-cyan-900/20 border-cyan-100 dark:border-cyan-800/50 text-cyan-600 dark:text-cyan-400",
|
||||
};
|
||||
|
||||
const baseColors = colorMapping[colorClass];
|
||||
|
||||
return (
|
||||
<div className={`p-3 rounded-lg border transition-colors ${baseColors}`}>
|
||||
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
{loading ? (
|
||||
<Skeleton className="h-8 w-24 dark:bg-gray-700" />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="w-4 h-4" />}
|
||||
<p className="text-xl font-bold">
|
||||
{typeof value === "number"
|
||||
? value.toLocaleString() + suffix
|
||||
: value}
|
||||
</p>
|
||||
</div>
|
||||
{delta !== undefined && (
|
||||
<p className={`text-xs font-medium ${getDeltaColor(delta)}`}>
|
||||
{formatDelta(delta)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TableSkeleton = () => (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-full dark:bg-gray-700" />
|
||||
<Skeleton className="h-8 w-full dark:bg-gray-700" />
|
||||
<Skeleton className="h-8 w-full dark:bg-gray-700" />
|
||||
<Skeleton className="h-8 w-full dark:bg-gray-700" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const GorgiasSummary = () => {
|
||||
const [timeRange, setTimeRange] = useState(7);
|
||||
const [data, setData] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const filters = getDateRange(timeRange);
|
||||
|
||||
try {
|
||||
const [overview, channelStats, agentStats, satisfaction, selfService] =
|
||||
await Promise.all([
|
||||
gorgiasService.fetchStatistics("overview", filters),
|
||||
gorgiasService.fetchStatistics(
|
||||
"tickets-created-per-channel",
|
||||
filters
|
||||
),
|
||||
gorgiasService.fetchStatistics("tickets-closed-per-agent", filters),
|
||||
gorgiasService.fetchStatistics("satisfaction-surveys", filters),
|
||||
gorgiasService.fetchStatistics("self-service-overview", filters),
|
||||
]);
|
||||
|
||||
setData({
|
||||
overview: overview.data.data.data || [],
|
||||
channels: channelStats.data.data.data.lines || [],
|
||||
agents: agentStats.data.data.data.lines || [],
|
||||
satisfaction: satisfaction.data.data.data || [],
|
||||
selfService: selfService.data.data.data || [],
|
||||
});
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error("Error loading stats:", err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [timeRange]);
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
// Set up auto-refresh every 5 minutes
|
||||
const interval = setInterval(loadStats, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadStats]);
|
||||
|
||||
// Convert overview array to object for easier access
|
||||
const stats =
|
||||
data.overview?.reduce((acc, item) => {
|
||||
acc[item.name] = item;
|
||||
return acc;
|
||||
}, {}) || {};
|
||||
|
||||
// Process satisfaction data
|
||||
const satisfactionStats =
|
||||
data.satisfaction?.reduce((acc, item) => {
|
||||
acc[item.name] = item;
|
||||
return acc;
|
||||
}, {}) || {};
|
||||
|
||||
// Process self-service data
|
||||
const selfServiceStats =
|
||||
data.selfService?.reduce((acc, item) => {
|
||||
acc[item.name] = item;
|
||||
return acc;
|
||||
}, {}) || {};
|
||||
|
||||
if (error) return <p className="text-red-500">Error: {error}</p>;
|
||||
|
||||
return (
|
||||
<Card className="bg-white dark:bg-gray-900">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Customer Service
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={String(timeRange)}
|
||||
onValueChange={(value) => setTimeRange(Number(value))}
|
||||
>
|
||||
<SelectTrigger className="w-[140px] bg-white dark:bg-gray-800">
|
||||
{TIME_RANGES[timeRange]}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(TIME_RANGES).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Message & Response Metrics */}
|
||||
<MetricCard
|
||||
title="Messages Received"
|
||||
value={stats.total_messages_received?.value}
|
||||
delta={stats.total_messages_received?.delta}
|
||||
icon={Mail}
|
||||
colorClass="blue"
|
||||
loading={loading}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Messages Sent"
|
||||
value={stats.total_messages_sent?.value}
|
||||
delta={stats.total_messages_sent?.delta}
|
||||
icon={Send}
|
||||
colorClass="green"
|
||||
loading={loading}
|
||||
/>
|
||||
<MetricCard
|
||||
title="First Response"
|
||||
value={formatDuration(stats.median_first_response_time?.value)}
|
||||
delta={stats.median_first_response_time?.delta}
|
||||
icon={Clock}
|
||||
colorClass="purple"
|
||||
more_is_better={false}
|
||||
loading={loading}
|
||||
/>
|
||||
<MetricCard
|
||||
title="One-Touch Rate"
|
||||
value={stats.total_one_touch_tickets?.value}
|
||||
delta={stats.total_one_touch_tickets?.delta}
|
||||
suffix="%"
|
||||
icon={MessageSquare}
|
||||
colorClass="indigo"
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Satisfaction & Efficiency */}
|
||||
<MetricCard
|
||||
title="Customer Satisfaction"
|
||||
value={`${satisfactionStats.average_rating?.value}/5`}
|
||||
delta={satisfactionStats.average_rating?.delta}
|
||||
suffix="%"
|
||||
icon={Star}
|
||||
colorClass="orange"
|
||||
loading={loading}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Survey Response Rate"
|
||||
value={satisfactionStats.response_rate?.value}
|
||||
delta={satisfactionStats.response_rate?.delta}
|
||||
suffix="%"
|
||||
colorClass="orange"
|
||||
loading={loading}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Resolution Time"
|
||||
value={formatDuration(stats.median_resolution_time?.value)}
|
||||
delta={stats.median_resolution_time?.delta}
|
||||
icon={Clock}
|
||||
colorClass="teal"
|
||||
more_is_better={false}
|
||||
loading={loading}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Self-Service Rate"
|
||||
value={selfServiceStats.self_service_automation_rate?.value}
|
||||
delta={selfServiceStats.self_service_automation_rate?.delta}
|
||||
suffix="%"
|
||||
colorClass="cyan"
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Channel Distribution */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg border dark:border-gray-800 p-4 pt-0">
|
||||
<div className="p-4 pl-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Channel Distribution
|
||||
</h3>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="p-4">
|
||||
<TableSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="dark:border-gray-800">
|
||||
<TableHead>Channel</TableHead>
|
||||
<TableHead className="text-right">Total</TableHead>
|
||||
<TableHead className="text-right">%</TableHead>
|
||||
<TableHead className="text-right">Δ</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.channels
|
||||
.sort((a, b) => b[1].value - a[1].value)
|
||||
.map((line, index) => (
|
||||
<TableRow key={index} className="dark:border-gray-800">
|
||||
<TableCell className="dark:text-gray-300">
|
||||
{line[0].value}
|
||||
</TableCell>
|
||||
<TableCell className="text-right dark:text-gray-300">
|
||||
{line[1].value}
|
||||
</TableCell>
|
||||
<TableCell className="text-right dark:text-gray-300">
|
||||
{line[2].value}%
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={`text-right ${
|
||||
line[3].value > 0
|
||||
? "text-green-600 dark:text-green-500"
|
||||
: line[3].value < 0
|
||||
? "text-red-600 dark:text-red-500"
|
||||
: "dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{line[3].value > 0 ? "+" : ""}
|
||||
{line[3].value}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Agent Performance - Same dark mode updates */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg border dark:border-gray-800 p-4 pt-0">
|
||||
<div className="p-4 pl-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Agent Performance
|
||||
</h3>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="p-4">
|
||||
<TableSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="dark:border-gray-800">
|
||||
<TableHead>Agent</TableHead>
|
||||
<TableHead className="text-right">Closed</TableHead>
|
||||
<TableHead className="text-right">Rating</TableHead>
|
||||
<TableHead className="text-right">Δ</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.agents
|
||||
.filter((line) => line[0].value !== "Unassigned")
|
||||
.map((line, index) => (
|
||||
<TableRow key={index} className="dark:border-gray-800">
|
||||
<TableCell className="dark:text-gray-300">
|
||||
{line[0].value}
|
||||
</TableCell>
|
||||
<TableCell className="text-right dark:text-gray-300">
|
||||
{line[1].value}
|
||||
</TableCell>
|
||||
<TableCell className="text-right dark:text-gray-300">
|
||||
{line[2].value ? `${line[2].value}/5` : "-"}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={`text-right ${
|
||||
line[4].value > 0
|
||||
? "text-green-600 dark:text-green-500"
|
||||
: line[4].value < 0
|
||||
? "text-red-600 dark:text-red-500"
|
||||
: "dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{line[4].value > 0 ? "+" : ""}
|
||||
{line[4].value}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default GorgiasSummary;
|
||||
Reference in New Issue
Block a user