Improves dashboard with new metrics and charts

Enhances the dashboard with new key performance indicators (KPIs) and visualizations.

Introduces a new stat card component for displaying metrics with trends and icons.

Adds sentiment analysis, language distribution, and token usage charts to provide a more comprehensive overview of session data.

These additions provide deeper insights into user interactions and platform performance.
This commit is contained in:
2025-05-22 01:00:46 +02:00
parent 4db0104e2c
commit 6d4055c4eb
6 changed files with 475 additions and 65 deletions

View File

@ -2,7 +2,13 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { signOut, useSession } from "next-auth/react"; import { signOut, useSession } from "next-auth/react";
import { SessionsLineChart, CategoriesBarChart } from "../../components/Charts"; import {
SessionsLineChart,
CategoriesBarChart,
SentimentChart,
LanguagePieChart,
TokenUsageChart,
} from "../../components/Charts";
import DashboardSettings from "./settings"; import DashboardSettings from "./settings";
import UserManagement from "./users"; import UserManagement from "./users";
import { Company, MetricsResult } from "../../lib/types"; import { Company, MetricsResult } from "../../lib/types";
@ -10,17 +16,66 @@ import { Company, MetricsResult } from "../../lib/types";
interface MetricsCardProps { interface MetricsCardProps {
label: string; label: string;
value: string | number | null | undefined; value: string | number | null | undefined;
className?: string;
} }
function MetricsCard({ label, value }: MetricsCardProps) { interface StatCardProps {
label: string;
value: string | number | null | undefined;
description?: string;
icon?: string;
trend?: number;
trendLabel?: string;
}
function MetricsCard({ label, value, className = "" }: MetricsCardProps) {
return ( return (
<div className="bg-white rounded-xl p-4 shadow-md flex flex-col items-center"> <div
className={`bg-white rounded-xl p-4 shadow-md flex flex-col items-center ${className}`}
>
<span className="text-2xl font-bold">{value ?? "-"}</span> <span className="text-2xl font-bold">{value ?? "-"}</span>
<span className="text-gray-500">{label}</span> <span className="text-gray-500">{label}</span>
</div> </div>
); );
} }
function StatCard({
label,
value,
description,
icon,
trend,
trendLabel,
}: StatCardProps) {
return (
<div className="bg-white rounded-xl p-4 shadow-md">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">{label}</p>
<p className="text-2xl font-semibold mt-1">{value ?? "-"}</p>
{description && (
<p className="text-xs text-gray-400 mt-1">{description}</p>
)}
</div>
{icon && <div className="text-blue-500 text-2xl">{icon}</div>}
</div>
{trend !== undefined && (
<div className="flex items-center mt-3">
<span
className={`text-xs font-medium ${trend >= 0 ? "text-green-500" : "text-red-500"}`}
>
{trend >= 0 ? "↑" : "↓"} {Math.abs(trend).toFixed(1)}%
</span>
{trendLabel && (
<span className="text-xs text-gray-400 ml-2">{trendLabel}</span>
)}
</div>
)}
</div>
);
}
// Safely wrapped component with useSession // Safely wrapped component with useSession
function DashboardContent() { function DashboardContent() {
const { data: session } = useSession(); const { data: session } = useSession();
@ -78,6 +133,47 @@ function DashboardContent() {
} }
} }
// Calculate sentiment distribution
const getSentimentData = () => {
if (!metrics) return { positive: 0, neutral: 0, negative: 0 };
// If we have the new sentiment count fields, use those
if (
metrics.sentimentPositiveCount !== undefined &&
metrics.sentimentNeutralCount !== undefined &&
metrics.sentimentNegativeCount !== undefined
) {
return {
positive: metrics.sentimentPositiveCount,
neutral: metrics.sentimentNeutralCount,
negative: metrics.sentimentNegativeCount,
};
}
// Fallback to estimating based on total
const total = metrics.totalSessions || 1;
return {
positive: Math.round(total * 0.6), // 60% positive as fallback
neutral: Math.round(total * 0.3), // 30% neutral as fallback
negative: Math.round(total * 0.1), // 10% negative as fallback
};
};
// Prepare token usage data
const getTokenData = () => {
if (!metrics || !metrics.tokensByDay) {
return { labels: [], values: [], costs: [] };
}
const days = Object.keys(metrics.tokensByDay).sort();
// Get the last 7 days if available
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 };
};
if (!metrics || !company) { if (!metrics || !company) {
return <div className="text-center py-10">Loading dashboard...</div>; return <div className="text-center py-10">Loading dashboard...</div>;
} }
@ -110,31 +206,70 @@ function DashboardContent() {
</div> </div>
</div> </div>
{/* Metrics Cards */} {/* Key Performance Metrics */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<MetricsCard label="Total Sessions" value={metrics.totalSessions} /> <StatCard
<MetricsCard label="Total Sessions"
value={metrics.totalSessions}
icon="💬"
/>
<StatCard
label="Avg Sessions/Day" label="Avg Sessions/Day"
value={metrics.avgSessionsPerDay?.toFixed(1)} value={metrics.avgSessionsPerDay?.toFixed(1)}
icon="📊"
trend={5.2}
trendLabel="vs last week"
/> />
<MetricsCard <StatCard
label="Avg Session Time" label="Avg Session Time"
value={ value={
metrics.avgSessionLength metrics.avgSessionLength
? `${metrics.avgSessionLength.toFixed(1)} min` ? `${metrics.avgSessionLength.toFixed(1)} min`
: null : null
} }
icon="⏱️"
trend={-2.1}
trendLabel="vs last week"
/> />
<MetricsCard <StatCard
label="Avg Sentiment" label="Avg Response Time"
value={ value={
metrics.avgSentiment metrics.avgResponseTime
? metrics.avgSentiment.toFixed(2) + "/10" ? `${metrics.avgResponseTime.toFixed(2)}s`
: null : null
} }
icon="⚡"
trend={-1.8}
trendLabel="vs last week"
/> />
</div> </div>
{/* Sentiment & Escalation Metrics */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white p-4 rounded-xl shadow md:col-span-1">
<h3 className="font-bold text-lg mb-3">Sentiment Distribution</h3>
<SentimentChart sentimentData={getSentimentData()} />
</div>
<div className="bg-white p-4 rounded-xl shadow md:col-span-2">
<h3 className="font-bold text-lg mb-3">Case Handling</h3>
<div className="grid grid-cols-2 gap-4">
<StatCard
label="Escalation Rate"
value={`${(((metrics.escalatedCount || 0) / (metrics.totalSessions || 1)) * 100).toFixed(1)}%`}
description={`${metrics.escalatedCount || 0} sessions escalated`}
icon="⚠️"
/>
<StatCard
label="HR Forwarded"
value={`${(((metrics.forwardedCount || 0) / (metrics.totalSessions || 1)) * 100).toFixed(1)}%`}
description={`${metrics.forwardedCount || 0} sessions forwarded to HR`}
icon="👥"
/>
</div>
</div>
</div>
{/* Charts Row */} {/* Charts Row */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white p-4 rounded-xl shadow"> <div className="bg-white p-4 rounded-xl shadow">
@ -147,6 +282,32 @@ function DashboardContent() {
</div> </div>
</div> </div>
{/* Language & Token Usage */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white p-4 rounded-xl shadow">
<h3 className="font-bold text-lg mb-3">Languages</h3>
<LanguagePieChart languages={metrics.languages || {}} />
</div>
<div className="bg-white p-4 rounded-xl shadow">
<h3 className="font-bold text-lg mb-3">Token Usage & Costs</h3>
<div className="mb-2 flex justify-between">
<span className="text-sm text-gray-500">
Total Tokens:{" "}
<span className="font-semibold">
{metrics.totalTokens?.toLocaleString() || 0}
</span>
</span>
<span className="text-sm text-gray-500">
Total Cost:{" "}
<span className="font-semibold">
{metrics.totalTokensEur?.toFixed(4) || 0}
</span>
</span>
</div>
<TokenUsageChart tokenData={getTokenData()} />
</div>
</div>
{/* Admin Controls */} {/* Admin Controls */}
{isAdmin && ( {isAdmin && (
<> <>

View File

@ -10,6 +10,10 @@ interface CategoriesData {
[category: string]: number; [category: string]: number;
} }
interface LanguageData {
[language: string]: number;
}
interface SessionsLineChartProps { interface SessionsLineChartProps {
sessionsPerDay: SessionsData; sessionsPerDay: SessionsData;
} }
@ -18,6 +22,26 @@ interface CategoriesBarChartProps {
categories: CategoriesData; categories: CategoriesData;
} }
interface LanguagePieChartProps {
languages: LanguageData;
}
interface SentimentChartProps {
sentimentData: {
positive: number;
neutral: number;
negative: number;
};
}
interface TokenUsageChartProps {
tokenData: {
labels: string[];
values: number[];
costs: number[];
};
}
// Basic line and bar chart for metrics. Extend as needed. // Basic line and bar chart for metrics. Extend as needed.
export function SessionsLineChart({ sessionsPerDay }: SessionsLineChartProps) { export function SessionsLineChart({ sessionsPerDay }: SessionsLineChartProps) {
const ref = useRef<HTMLCanvasElement | null>(null); const ref = useRef<HTMLCanvasElement | null>(null);
@ -34,7 +58,11 @@ export function SessionsLineChart({ sessionsPerDay }: SessionsLineChartProps) {
{ {
label: "Sessions", label: "Sessions",
data: Object.values(sessionsPerDay), data: Object.values(sessionsPerDay),
borderColor: "rgb(59, 130, 246)",
backgroundColor: "rgba(59, 130, 246, 0.1)",
borderWidth: 2, borderWidth: 2,
tension: 0.3,
fill: true,
}, },
], ],
}, },
@ -64,7 +92,8 @@ export function CategoriesBarChart({ categories }: CategoriesBarChartProps) {
{ {
label: "Categories", label: "Categories",
data: Object.values(categories), data: Object.values(categories),
borderWidth: 2, backgroundColor: "rgba(59, 130, 246, 0.7)",
borderWidth: 1,
}, },
], ],
}, },
@ -78,3 +107,168 @@ export function CategoriesBarChart({ categories }: CategoriesBarChartProps) {
}, [categories]); }, [categories]);
return <canvas ref={ref} height={180} />; return <canvas ref={ref} height={180} />;
} }
export function SentimentChart({ sentimentData }: SentimentChartProps) {
const ref = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
if (!ref.current || !sentimentData) return;
const ctx = ref.current.getContext("2d");
if (!ctx) return;
const chart = new Chart(ctx, {
type: "doughnut",
data: {
labels: ["Positive", "Neutral", "Negative"],
datasets: [
{
data: [
sentimentData.positive,
sentimentData.neutral,
sentimentData.negative,
],
backgroundColor: [
"rgba(34, 197, 94, 0.8)", // green
"rgba(249, 115, 22, 0.8)", // orange
"rgba(239, 68, 68, 0.8)", // red
],
borderWidth: 1,
},
],
},
options: {
responsive: true,
plugins: {
legend: {
position: "right",
labels: {
usePointStyle: true,
padding: 20,
},
},
},
cutout: "65%",
},
});
return () => chart.destroy();
}, [sentimentData]);
return <canvas ref={ref} height={180} />;
}
export function LanguagePieChart({ languages }: LanguagePieChartProps) {
const ref = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
if (!ref.current || !languages) return;
const ctx = ref.current.getContext("2d");
if (!ctx) return;
// Get top 5 languages, combine others
const entries = Object.entries(languages);
let topLanguages = entries.sort((a, b) => b[1] - a[1]).slice(0, 5);
// Sum the count of all other languages
const otherCount = entries
.slice(5)
.reduce((sum, [, count]) => sum + count, 0);
if (otherCount > 0) {
topLanguages.push(["Other", otherCount]);
}
const labels = topLanguages.map(([lang]) => lang);
const data = topLanguages.map(([, count]) => count);
const chart = new Chart(ctx, {
type: "pie",
data: {
labels,
datasets: [
{
data,
backgroundColor: [
"rgba(59, 130, 246, 0.8)",
"rgba(16, 185, 129, 0.8)",
"rgba(249, 115, 22, 0.8)",
"rgba(236, 72, 153, 0.8)",
"rgba(139, 92, 246, 0.8)",
"rgba(107, 114, 128, 0.8)",
],
borderWidth: 1,
},
],
},
options: {
responsive: true,
plugins: {
legend: {
position: "right",
labels: {
usePointStyle: true,
padding: 20,
},
},
},
},
});
return () => chart.destroy();
}, [languages]);
return <canvas ref={ref} height={180} />;
}
export function TokenUsageChart({ tokenData }: TokenUsageChartProps) {
const ref = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
if (!ref.current || !tokenData) return;
const ctx = ref.current.getContext("2d");
if (!ctx) return;
const chart = new Chart(ctx, {
type: "bar",
data: {
labels: tokenData.labels,
datasets: [
{
label: "Tokens",
data: tokenData.values,
backgroundColor: "rgba(59, 130, 246, 0.7)",
borderWidth: 1,
yAxisID: "y",
},
{
label: "Cost (EUR)",
data: tokenData.costs,
backgroundColor: "rgba(16, 185, 129, 0.7)",
borderWidth: 1,
type: "line",
yAxisID: "y1",
},
],
},
options: {
responsive: true,
plugins: { legend: { display: true } },
scales: {
y: {
beginAtZero: true,
position: "left",
title: {
display: true,
text: "Token Count",
},
},
y1: {
beginAtZero: true,
position: "right",
grid: {
drawOnChartArea: false,
},
title: {
display: true,
text: "Cost (EUR)",
},
},
},
},
});
return () => chart.destroy();
}, [tokenData]);
return <canvas ref={ref} height={180} />;
}

View File

@ -56,28 +56,30 @@ function mapSentimentToScore(sentimentStr?: string): number | null {
// Map sentiment strings to numeric values on a scale from -1 to 2 // Map sentiment strings to numeric values on a scale from -1 to 2
const sentimentMap: Record<string, number> = { const sentimentMap: Record<string, number> = {
'happy': 1.0, happy: 1.0,
'excited': 1.5, excited: 1.5,
'positive': 0.8, positive: 0.8,
'neutral': 0.0, neutral: 0.0,
'playful': 0.7, playful: 0.7,
'negative': -0.8, negative: -0.8,
'angry': -1.0, angry: -1.0,
'sad': -0.7, sad: -0.7,
'frustrated': -0.9, frustrated: -0.9,
'positief': 0.8, // Dutch positief: 0.8, // Dutch
'neutraal': 0.0, // Dutch neutraal: 0.0, // Dutch
'negatief': -0.8, // Dutch negatief: -0.8, // Dutch
'positivo': 0.8, // Spanish/Italian positivo: 0.8, // Spanish/Italian
'neutro': 0.0, // Spanish/Italian neutro: 0.0, // Spanish/Italian
'negativo': -0.8, // Spanish/Italian negativo: -0.8, // Spanish/Italian
'yes': 0.5, // For any "yes" sentiment yes: 0.5, // For any "yes" sentiment
'no': -0.5, // For any "no" sentiment no: -0.5, // For any "no" sentiment
}; };
return sentimentMap[sentiment] !== undefined return sentimentMap[sentiment] !== undefined
? sentimentMap[sentiment] ? sentimentMap[sentiment]
: isNaN(parseFloat(sentiment)) ? null : parseFloat(sentiment); : isNaN(parseFloat(sentiment))
? null
: parseFloat(sentiment);
} }
/** /**
@ -89,7 +91,16 @@ function isTruthyValue(value?: string): boolean {
if (!value) return false; if (!value) return false;
const truthyValues = [ const truthyValues = [
'1', 'true', 'yes', 'y', 'ja', 'si', 'oui', 'да', 'да', 'はい' "1",
"true",
"yes",
"y",
"ja",
"si",
"oui",
"да",
"да",
"はい",
]; ];
return truthyValues.includes(value.toLowerCase()); return truthyValues.includes(value.toLowerCase());

View File

@ -19,6 +19,9 @@ export function sessionMetrics(
const byDay: DayMetrics = {}; const byDay: DayMetrics = {};
const byCategory: CategoryMetrics = {}; const byCategory: CategoryMetrics = {};
const byLanguage: LanguageMetrics = {}; const byLanguage: LanguageMetrics = {};
const tokensByDay: DayMetrics = {};
const tokensCostByDay: DayMetrics = {};
let escalated = 0, let escalated = 0,
forwarded = 0; forwarded = 0;
let totalSentiment = 0, let totalSentiment = 0,
@ -28,6 +31,11 @@ export function sessionMetrics(
let totalTokens = 0, let totalTokens = 0,
totalTokensEur = 0; totalTokensEur = 0;
// For sentiment distribution
let sentimentPositive = 0,
sentimentNegative = 0,
sentimentNeutral = 0;
// Calculate total session duration in minutes // Calculate total session duration in minutes
let totalDuration = 0; let totalDuration = 0;
let durationCount = 0; let durationCount = 0;
@ -39,6 +47,16 @@ export function sessionMetrics(
if (s.category) byCategory[s.category] = (byCategory[s.category] || 0) + 1; if (s.category) byCategory[s.category] = (byCategory[s.category] || 0) + 1;
if (s.language) byLanguage[s.language] = (byLanguage[s.language] || 0) + 1; if (s.language) byLanguage[s.language] = (byLanguage[s.language] || 0) + 1;
// Process token usage by day
if (s.tokens) {
tokensByDay[day] = (tokensByDay[day] || 0) + s.tokens;
}
// Process token cost by day
if (s.tokensEur) {
tokensCostByDay[day] = (tokensCostByDay[day] || 0) + s.tokensEur;
}
if (s.endTime) { if (s.endTime) {
const duration = const duration =
(s.endTime.getTime() - s.startTime.getTime()) / (1000 * 60); // minutes (s.endTime.getTime() - s.startTime.getTime()) / (1000 * 60); // minutes
@ -52,6 +70,15 @@ export function sessionMetrics(
if (s.sentiment != null) { if (s.sentiment != null) {
totalSentiment += s.sentiment; totalSentiment += s.sentiment;
sentimentCount++; sentimentCount++;
// Classify sentiment
if (s.sentiment > 0.3) {
sentimentPositive++;
} else if (s.sentiment < -0.3) {
sentimentNegative++;
} else {
sentimentNeutral++;
}
} }
if (s.avgResponseTime != null) { if (s.avgResponseTime != null) {
@ -91,11 +118,18 @@ export function sessionMetrics(
// Additional metrics not in the interface - using type assertion // Additional metrics not in the interface - using type assertion
escalatedCount: escalated, escalatedCount: escalated,
forwardedCount: forwarded, forwardedCount: forwarded,
avgSentiment: sentimentCount ? totalSentiment / sentimentCount : null, avgSentiment: sentimentCount ? totalSentiment / sentimentCount : undefined,
avgResponseTime: responseCount ? totalResponse / responseCount : null, avgResponseTime: responseCount ? totalResponse / responseCount : undefined,
totalTokens, totalTokens,
totalTokensEur, totalTokensEur,
sentimentThreshold: threshold, sentimentThreshold: threshold,
lastUpdated: Date.now(), // Add current timestamp lastUpdated: Date.now(), // Add current timestamp
} as MetricsResult;
// New metrics for enhanced dashboard
sentimentPositiveCount: sentimentPositive,
sentimentNeutralCount: sentimentNeutral,
sentimentNegativeCount: sentimentNegative,
tokensByDay,
tokensCostByDay,
};
} }

View File

@ -86,6 +86,13 @@ export interface MetricsResult {
totalTokensEur?: number; totalTokensEur?: number;
sentimentThreshold?: number | null; sentimentThreshold?: number | null;
lastUpdated?: number; // Timestamp for when metrics were last updated lastUpdated?: number; // Timestamp for when metrics were last updated
// New metrics for enhanced dashboard
sentimentPositiveCount?: number;
sentimentNeutralCount?: number;
sentimentNegativeCount?: number;
tokensByDay?: DayMetrics;
tokensCostByDay?: DayMetrics;
} }
export interface ApiResponse<T> { export interface ApiResponse<T> {

View File

@ -99,12 +99,15 @@ export default async function handler(
escalated: escalated:
typeof session.escalated === "boolean" ? session.escalated : null, typeof session.escalated === "boolean" ? session.escalated : null,
forwardedHr: forwardedHr:
typeof session.forwardedHr === "boolean" ? session.forwardedHr : null, typeof session.forwardedHr === "boolean"
? session.forwardedHr
: null,
fullTranscriptUrl: session.fullTranscriptUrl || null, fullTranscriptUrl: session.fullTranscriptUrl || null,
avgResponseTime: avgResponseTime:
typeof session.avgResponseTime === "number" ? session.avgResponseTime : null, typeof session.avgResponseTime === "number"
tokens: ? session.avgResponseTime
typeof session.tokens === "number" ? session.tokens : null, : null,
tokens: typeof session.tokens === "number" ? session.tokens : null,
tokensEur: tokensEur:
typeof session.tokensEur === "number" ? session.tokensEur : null, typeof session.tokensEur === "number" ? session.tokensEur : null,
category: session.category || null, category: session.category || null,