mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 10:52:08 +01:00
Refactors dashboard to use actual metrics for country data, removing dummy data for improved accuracy. Integrates the country-code-lookup package for geographic mapping, adding comprehensive country coordinates. Increases performance and data validation across API endpoints and adjusts WordCloud component size for better visualization. Enhances session handling with improved validation logic, and updates configuration for allowed origins.
441 lines
16 KiB
TypeScript
441 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { signOut, useSession } from "next-auth/react";
|
|
import { useRouter } from "next/navigation"; // Import useRouter
|
|
import {
|
|
SessionsLineChart,
|
|
CategoriesBarChart,
|
|
LanguagePieChart,
|
|
TokenUsageChart,
|
|
} from "../../components/Charts";
|
|
import DashboardSettings from "./settings";
|
|
import UserManagement from "./users";
|
|
import { Company, MetricsResult, WordCloudWord } from "../../lib/types"; // Added WordCloudWord
|
|
import MetricCard from "../../components/MetricCard";
|
|
import DonutChart from "../../components/DonutChart";
|
|
import WordCloud from "../../components/WordCloud";
|
|
import GeographicMap from "../../components/GeographicMap";
|
|
import ResponseTimeDistribution from "../../components/ResponseTimeDistribution";
|
|
import WelcomeBanner from "../../components/WelcomeBanner";
|
|
|
|
// Safely wrapped component with useSession
|
|
function DashboardContent() {
|
|
const { data: session, status } = useSession(); // Add status from useSession
|
|
const router = useRouter(); // Initialize useRouter
|
|
const [metrics, setMetrics] = useState<MetricsResult | null>(null);
|
|
const [company, setCompany] = useState<Company | null>(null);
|
|
const [, setLoading] = useState<boolean>(false);
|
|
const [refreshing, setRefreshing] = useState<boolean>(false);
|
|
|
|
const isAdmin = session?.user?.role === "admin";
|
|
const isAuditor = session?.user?.role === "auditor";
|
|
|
|
useEffect(() => {
|
|
// Redirect if not authenticated
|
|
if (status === "unauthenticated") {
|
|
router.push("/login");
|
|
return; // Stop further execution in this effect
|
|
}
|
|
|
|
// Fetch metrics and company on mount if authenticated
|
|
if (status === "authenticated") {
|
|
const fetchData = async () => {
|
|
setLoading(true);
|
|
const res = await fetch("/api/dashboard/metrics");
|
|
const data = await res.json();
|
|
setMetrics(data.metrics);
|
|
setCompany(data.company);
|
|
setLoading(false);
|
|
};
|
|
fetchData();
|
|
}
|
|
}, [status, router]); // Add status and router to dependency array
|
|
|
|
async function handleRefresh() {
|
|
if (isAuditor) return; // Prevent auditors from refreshing
|
|
try {
|
|
setRefreshing(true);
|
|
|
|
// Make sure we have a company ID to send
|
|
if (!company?.id) {
|
|
setRefreshing(false);
|
|
alert("Cannot refresh: Company ID is missing");
|
|
return;
|
|
}
|
|
|
|
const res = await fetch("/api/admin/refresh-sessions", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ companyId: company.id }),
|
|
});
|
|
|
|
if (res.ok) {
|
|
// Refetch metrics
|
|
const metricsRes = await fetch("/api/dashboard/metrics");
|
|
const data = await metricsRes.json();
|
|
setMetrics(data.metrics);
|
|
} else {
|
|
const errorData = await res.json();
|
|
alert(`Failed to refresh sessions: ${errorData.error}`);
|
|
}
|
|
} finally {
|
|
setRefreshing(false);
|
|
}
|
|
}
|
|
|
|
// Calculate sentiment distribution
|
|
const getSentimentData = () => {
|
|
if (!metrics) return { positive: 0, neutral: 0, negative: 0 };
|
|
|
|
if (
|
|
metrics.sentimentPositiveCount !== undefined &&
|
|
metrics.sentimentNeutralCount !== undefined &&
|
|
metrics.sentimentNegativeCount !== undefined
|
|
) {
|
|
return {
|
|
positive: metrics.sentimentPositiveCount,
|
|
neutral: metrics.sentimentNeutralCount,
|
|
negative: metrics.sentimentNegativeCount,
|
|
};
|
|
}
|
|
|
|
const total = metrics.totalSessions || 1;
|
|
return {
|
|
positive: Math.round(total * 0.6),
|
|
neutral: Math.round(total * 0.3),
|
|
negative: Math.round(total * 0.1),
|
|
};
|
|
};
|
|
|
|
// Prepare token usage data
|
|
const getTokenData = () => {
|
|
if (!metrics || !metrics.tokensByDay) {
|
|
return { labels: [], values: [], costs: [] };
|
|
}
|
|
|
|
const days = Object.keys(metrics.tokensByDay).sort();
|
|
const labels = days.slice(-7);
|
|
const values = labels.map((day) => metrics.tokensByDay?.[day] || 0);
|
|
const costs = labels.map((day) => metrics.tokensCostByDay?.[day] || 0);
|
|
|
|
return { labels, values, costs };
|
|
};
|
|
|
|
// Show loading state while session status is being determined
|
|
if (status === "loading") {
|
|
return <div className="text-center py-10">Loading session...</div>;
|
|
}
|
|
|
|
// If unauthenticated and not redirected yet (should be handled by useEffect, but as a fallback)
|
|
if (status === "unauthenticated") {
|
|
return <div className="text-center py-10">Redirecting to login...</div>;
|
|
}
|
|
|
|
if (!metrics || !company) {
|
|
return <div className="text-center py-10">Loading dashboard...</div>;
|
|
}
|
|
|
|
// Function to prepare word cloud data from metrics.wordCloudData
|
|
const getWordCloudData = (): WordCloudWord[] => {
|
|
if (!metrics || !metrics.wordCloudData) return [];
|
|
return metrics.wordCloudData;
|
|
};
|
|
|
|
// Function to prepare country data for the map using actual metrics
|
|
const getCountryData = () => {
|
|
if (!metrics || !metrics.countries) return {};
|
|
|
|
// Convert the countries object from metrics to the format expected by GeographicMap
|
|
const result = Object.entries(metrics.countries).reduce(
|
|
(acc, [code, count]) => {
|
|
if (code && count) {
|
|
acc[code] = count;
|
|
}
|
|
return acc;
|
|
},
|
|
{} as Record<string, number>
|
|
);
|
|
|
|
return result;
|
|
};
|
|
|
|
// Function to prepare response time distribution data
|
|
const getResponseTimeData = () => {
|
|
const avgTime = metrics.avgResponseTime || 1.5;
|
|
const simulatedData: number[] = [];
|
|
|
|
for (let i = 0; i < 50; i++) {
|
|
const randomFactor = 0.5 + Math.random();
|
|
simulatedData.push(avgTime * randomFactor);
|
|
}
|
|
|
|
return simulatedData;
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<WelcomeBanner companyName={company.name} />
|
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center bg-white p-6 rounded-2xl shadow-lg ring-1 ring-slate-200/50">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-slate-800">{company.name}</h1>
|
|
<p className="text-slate-500 mt-1">
|
|
Dashboard updated{" "}
|
|
<span className="font-medium text-slate-600">
|
|
{new Date(metrics.lastUpdated || Date.now()).toLocaleString()}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-3 mt-4 sm:mt-0">
|
|
<button
|
|
className="bg-sky-600 text-white py-2 px-5 rounded-lg shadow hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed flex items-center text-sm font-medium"
|
|
onClick={handleRefresh}
|
|
disabled={refreshing || isAuditor}
|
|
>
|
|
{refreshing ? (
|
|
<>
|
|
<svg
|
|
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<circle
|
|
className="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
strokeWidth="4"
|
|
></circle>
|
|
<path
|
|
className="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
></path>
|
|
</svg>
|
|
Refreshing...
|
|
</>
|
|
) : (
|
|
<>
|
|
<svg
|
|
className="w-4 h-4 mr-2"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
/>
|
|
</svg>
|
|
Refresh Data
|
|
</>
|
|
)}
|
|
</button>
|
|
<button
|
|
className="bg-slate-100 text-slate-700 py-2 px-5 rounded-lg shadow hover:bg-slate-200 transition-colors flex items-center text-sm font-medium"
|
|
onClick={() => signOut()}
|
|
>
|
|
<svg
|
|
className="w-4 h-4 mr-2"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
|
/>
|
|
</svg>
|
|
Sign Out
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<MetricCard
|
|
title="Total Sessions"
|
|
value={metrics.totalSessions.toLocaleString()}
|
|
icon="💬"
|
|
variant="primary"
|
|
/>
|
|
<MetricCard
|
|
title="Avg Sessions/Day"
|
|
value={metrics.avgSessionsPerDay?.toFixed(1) || 0}
|
|
icon="📊"
|
|
trend={{ value: 5.2, label: "vs last week" }}
|
|
variant="success"
|
|
/>
|
|
<MetricCard
|
|
title="Avg Session Time"
|
|
value={
|
|
metrics.avgSessionLength
|
|
? `${metrics.avgSessionLength.toFixed(1)} min`
|
|
: "-"
|
|
}
|
|
icon="⏱️"
|
|
trend={{ value: -2.1, label: "vs last week", isPositive: false }}
|
|
/>
|
|
<MetricCard
|
|
title="Avg Response Time"
|
|
value={
|
|
metrics.avgResponseTime
|
|
? `${metrics.avgResponseTime.toFixed(2)}s`
|
|
: "-"
|
|
}
|
|
icon="⚡"
|
|
trend={{ value: -1.8, label: "vs last week", isPositive: true }}
|
|
variant="success"
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
<div className="bg-white p-6 rounded-xl shadow lg:col-span-1">
|
|
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
|
Sentiment Distribution
|
|
</h3>
|
|
<DonutChart
|
|
data={{
|
|
labels: ["Positive", "Neutral", "Negative"],
|
|
values: [
|
|
getSentimentData().positive,
|
|
getSentimentData().neutral,
|
|
getSentimentData().negative,
|
|
],
|
|
colors: [
|
|
"rgba(34, 197, 94, 0.8)",
|
|
"rgba(249, 115, 22, 0.8)",
|
|
"rgba(239, 68, 68, 0.8)",
|
|
],
|
|
}}
|
|
centerText={{
|
|
title: "Overall",
|
|
value: `${((getSentimentData().positive / (getSentimentData().positive + getSentimentData().neutral + getSentimentData().negative)) * 100).toFixed(0)}%`,
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div className="bg-white p-6 rounded-xl shadow lg:col-span-2">
|
|
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
|
Case Handling Statistics
|
|
</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<MetricCard
|
|
title="Escalation Rate"
|
|
value={`${(((metrics.escalatedCount || 0) / (metrics.totalSessions || 1)) * 100).toFixed(1)}%`}
|
|
description={`${metrics.escalatedCount || 0} sessions escalated`}
|
|
icon="⚠️"
|
|
variant={
|
|
(metrics.escalatedCount || 0) > metrics.totalSessions * 0.1
|
|
? "warning"
|
|
: "success"
|
|
}
|
|
/>
|
|
<MetricCard
|
|
title="HR Forwarded"
|
|
value={`${(((metrics.forwardedCount || 0) / (metrics.totalSessions || 1)) * 100).toFixed(1)}%`}
|
|
description={`${metrics.forwardedCount || 0} sessions forwarded to HR`}
|
|
icon="👥"
|
|
variant={
|
|
(metrics.forwardedCount || 0) > metrics.totalSessions * 0.05
|
|
? "warning"
|
|
: "default"
|
|
}
|
|
/>
|
|
<MetricCard
|
|
title="Resolved Rate"
|
|
value={`${(((metrics.totalSessions - (metrics.escalatedCount || 0) - (metrics.forwardedCount || 0)) / metrics.totalSessions) * 100).toFixed(1)}%`}
|
|
description={`${metrics.totalSessions - (metrics.escalatedCount || 0) - (metrics.forwardedCount || 0)} sessions resolved`}
|
|
icon="✅"
|
|
variant="success"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<div className="bg-white p-6 rounded-xl shadow">
|
|
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
|
Sessions by Day
|
|
</h3>
|
|
<SessionsLineChart sessionsPerDay={metrics.days || {}} />
|
|
</div>
|
|
<div className="bg-white p-6 rounded-xl shadow">
|
|
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
|
Top Categories
|
|
</h3>
|
|
<CategoriesBarChart categories={metrics.categories || {}} />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<div className="bg-white p-6 rounded-xl shadow overflow-hidden">
|
|
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
|
Transcript Word Cloud
|
|
</h3>
|
|
<WordCloud words={getWordCloudData()} width={400} height={300} />
|
|
</div>
|
|
<div className="bg-white p-6 rounded-xl shadow">
|
|
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
|
Geographic Distribution
|
|
</h3>
|
|
<GeographicMap countries={getCountryData()} height={300} />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<div className="bg-white p-6 rounded-xl shadow">
|
|
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
|
Response Time Distribution
|
|
</h3>
|
|
<ResponseTimeDistribution
|
|
responseTimes={getResponseTimeData()}
|
|
targetResponseTime={2}
|
|
/>
|
|
</div>
|
|
<div className="bg-white p-6 rounded-xl shadow">
|
|
<h3 className="font-bold text-lg text-gray-800 mb-4">Languages</h3>
|
|
<LanguagePieChart languages={metrics.languages || {}} />
|
|
</div>
|
|
</div>
|
|
<div className="bg-white p-6 rounded-xl shadow">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h3 className="font-bold text-lg text-gray-800">
|
|
Token Usage & Costs
|
|
</h3>
|
|
<div className="flex gap-4">
|
|
<div className="text-sm bg-blue-50 text-blue-700 px-3 py-1 rounded-full flex items-center">
|
|
<span className="font-semibold mr-1">Total Tokens:</span>
|
|
{metrics.totalTokens?.toLocaleString() || 0}
|
|
</div>
|
|
<div className="text-sm bg-green-50 text-green-700 px-3 py-1 rounded-full flex items-center">
|
|
<span className="font-semibold mr-1">Total Cost:</span>€
|
|
{metrics.totalTokensEur?.toFixed(4) || 0}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<TokenUsageChart tokenData={getTokenData()} />
|
|
</div>
|
|
{isAdmin && (
|
|
<>
|
|
<DashboardSettings company={company} session={session} />
|
|
<UserManagement session={session} />
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Our exported component
|
|
export default function DashboardPage() {
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-sky-100 p-4 md:p-6">
|
|
<div className="max-w-7xl mx-auto">
|
|
<DashboardContent />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|