mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 18:32:10 +01:00
- Fixed accessibility in audit logs with keyboard navigation and ARIA attributes - Refactored ThreatAnalysisResults interface to module level for reusability - Added BatchOperation enum validation and proper CSV escaping in batch monitoring - Removed unused company state causing skeleton view in dashboard overview - Enhanced error handling with user-facing messages for metrics loading - Replaced hardcoded timeouts with condition-based waits in E2E tests - Removed duplicate state management in security monitoring hooks - Fixed CSRF documentation to show proper secret fallback pattern - Updated CSP metrics docs with GDPR Article 6(1)(f) legal basis clarification - Fixed React hooks order to prevent conditional execution after early returns - Added explicit button type to prevent form submission behavior
782 lines
22 KiB
TypeScript
782 lines
22 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
CheckCircle,
|
|
Clock,
|
|
Euro,
|
|
Globe,
|
|
LogOut,
|
|
MessageCircle,
|
|
MessageSquare,
|
|
MoreVertical,
|
|
RefreshCw,
|
|
TrendingUp,
|
|
Users,
|
|
Zap,
|
|
} from "lucide-react";
|
|
import { useRouter } from "next/navigation";
|
|
import { signOut, useSession } from "next-auth/react";
|
|
import { useCallback, useEffect, useId, useState } from "react";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { formatEnumValue } from "@/lib/format-enums";
|
|
import { trpc } from "@/lib/trpc-client";
|
|
import ModernBarChart from "../../../components/charts/bar-chart";
|
|
import ModernDonutChart from "../../../components/charts/donut-chart";
|
|
import ModernLineChart from "../../../components/charts/line-chart";
|
|
import GeographicMap from "../../../components/GeographicMap";
|
|
import ResponseTimeDistribution from "../../../components/ResponseTimeDistribution";
|
|
import TopQuestionsChart from "../../../components/TopQuestionsChart";
|
|
import MetricCard from "../../../components/ui/metric-card";
|
|
import WordCloud from "../../../components/WordCloud";
|
|
import type { Company, MetricsResult, WordCloudWord } from "../../../lib/types";
|
|
|
|
/**
|
|
* Loading states component for better organization
|
|
*/
|
|
function DashboardLoadingStates({ status }: { status: string }) {
|
|
if (status === "loading") {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-[60vh]">
|
|
<div className="text-center space-y-4">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto" />
|
|
<p className="text-muted-foreground">Loading session...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (status === "unauthenticated") {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-[60vh]">
|
|
<div className="text-center">
|
|
<p className="text-muted-foreground">Redirecting to login...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Loading skeleton component
|
|
*/
|
|
function DashboardSkeleton() {
|
|
return (
|
|
<div className="space-y-8">
|
|
{/* Header Skeleton */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex justify-between items-start">
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-8 w-48" />
|
|
<Skeleton className="h-4 w-64" />
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Skeleton className="h-10 w-24" />
|
|
<Skeleton className="h-10 w-20" />
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
</Card>
|
|
|
|
{/* Metrics Grid Skeleton */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
{Array.from({ length: 8 }, (_, i) => {
|
|
const metricTypes = [
|
|
"sessions",
|
|
"users",
|
|
"time",
|
|
"response",
|
|
"costs",
|
|
"peak",
|
|
"resolution",
|
|
"languages",
|
|
];
|
|
return (
|
|
<MetricCard
|
|
key={`skeleton-${metricTypes[i] || "metric"}-card-loading`}
|
|
title=""
|
|
value=""
|
|
isLoading
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Charts Skeleton */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
<Card className="lg:col-span-2">
|
|
<CardHeader>
|
|
<Skeleton className="h-6 w-32" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Skeleton className="h-64 w-full" />
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader>
|
|
<Skeleton className="h-6 w-32" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Skeleton className="h-64 w-full" />
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Data processing utilities
|
|
*/
|
|
function useDashboardData(metrics: MetricsResult | null) {
|
|
const getSentimentData = useCallback(() => {
|
|
if (!metrics) return [];
|
|
|
|
const sentimentData = {
|
|
positive: metrics.sentimentPositiveCount ?? 0,
|
|
neutral: metrics.sentimentNeutralCount ?? 0,
|
|
negative: metrics.sentimentNegativeCount ?? 0,
|
|
};
|
|
|
|
return [
|
|
{
|
|
name: "Positive",
|
|
value: sentimentData.positive,
|
|
color: "hsl(var(--chart-1))",
|
|
},
|
|
{
|
|
name: "Neutral",
|
|
value: sentimentData.neutral,
|
|
color: "hsl(var(--chart-2))",
|
|
},
|
|
{
|
|
name: "Negative",
|
|
value: sentimentData.negative,
|
|
color: "hsl(var(--chart-3))",
|
|
},
|
|
];
|
|
}, [metrics]);
|
|
|
|
const getSessionsOverTimeData = useCallback(() => {
|
|
if (!metrics?.days) return [];
|
|
|
|
return Object.entries(metrics.days).map(([date, value]) => ({
|
|
date: new Date(date).toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
}),
|
|
value: value as number,
|
|
}));
|
|
}, [metrics?.days]);
|
|
|
|
const getCategoriesData = useCallback(() => {
|
|
if (!metrics?.categories) return [];
|
|
|
|
return Object.entries(metrics.categories).map(([name, value]) => {
|
|
const formattedName = formatEnumValue(name) || name;
|
|
return {
|
|
name:
|
|
formattedName.length > 15
|
|
? `${formattedName.substring(0, 15)}...`
|
|
: formattedName,
|
|
value: value as number,
|
|
};
|
|
});
|
|
}, [metrics?.categories]);
|
|
|
|
const getLanguagesData = useCallback(() => {
|
|
if (!metrics?.languages) return [];
|
|
|
|
return Object.entries(metrics.languages).map(([name, value]) => ({
|
|
name,
|
|
value: value as number,
|
|
}));
|
|
}, [metrics?.languages]);
|
|
|
|
const getWordCloudData = useCallback((): WordCloudWord[] => {
|
|
if (!metrics?.wordCloudData) return [];
|
|
return metrics.wordCloudData;
|
|
}, [metrics?.wordCloudData]);
|
|
|
|
const getCountryData = useCallback(() => {
|
|
if (!metrics?.countries) return {};
|
|
return Object.entries(metrics.countries).reduce(
|
|
(acc, [code, count]) => {
|
|
if (code && count) {
|
|
acc[code] = count;
|
|
}
|
|
return acc;
|
|
},
|
|
{} as Record<string, number>
|
|
);
|
|
}, [metrics?.countries]);
|
|
|
|
const getResponseTimeData = useCallback(() => {
|
|
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;
|
|
}, [metrics?.avgResponseTime]);
|
|
|
|
return {
|
|
getSentimentData,
|
|
getSessionsOverTimeData,
|
|
getCategoriesData,
|
|
getLanguagesData,
|
|
getWordCloudData,
|
|
getCountryData,
|
|
getResponseTimeData,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Dashboard header component
|
|
*/
|
|
function DashboardHeader({
|
|
company,
|
|
metrics,
|
|
isAuditor,
|
|
refreshing,
|
|
onRefresh,
|
|
}: {
|
|
company: Company;
|
|
metrics: MetricsResult;
|
|
isAuditor: boolean;
|
|
refreshing: boolean;
|
|
onRefresh: () => void;
|
|
}) {
|
|
const refreshStatusId = useId();
|
|
|
|
return (
|
|
<Card className="border-0 bg-linear-to-r from-primary/5 via-primary/10 to-primary/5">
|
|
<CardHeader>
|
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-3xl font-bold tracking-tight">
|
|
{company.name}
|
|
</h1>
|
|
<Badge variant="secondary" className="text-xs">
|
|
Analytics Dashboard
|
|
</Badge>
|
|
</div>
|
|
<p className="text-muted-foreground">
|
|
Last updated{" "}
|
|
<span className="font-medium">
|
|
{new Date(metrics.lastUpdated || Date.now()).toLocaleString()}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
onClick={onRefresh}
|
|
disabled={refreshing || isAuditor}
|
|
size="sm"
|
|
className="gap-2"
|
|
aria-label={
|
|
refreshing
|
|
? "Refreshing dashboard data"
|
|
: "Refresh dashboard data"
|
|
}
|
|
aria-describedby={refreshing ? refreshStatusId : undefined}
|
|
>
|
|
<RefreshCw
|
|
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
|
|
aria-hidden="true"
|
|
/>
|
|
{refreshing ? "Refreshing..." : "Refresh"}
|
|
</Button>
|
|
{refreshing && (
|
|
<div id={refreshStatusId} className="sr-only" aria-live="polite">
|
|
Dashboard data is being refreshed
|
|
</div>
|
|
)}
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" size="sm" aria-label="Account menu">
|
|
<MoreVertical className="h-4 w-4" aria-hidden="true" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem
|
|
onClick={() => signOut({ callbackUrl: "/login" })}
|
|
>
|
|
<LogOut className="h-4 w-4 mr-2" aria-hidden="true" />
|
|
Sign out
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Individual metric card components for better organization
|
|
*/
|
|
function SessionMetricCard({ metrics }: { metrics: MetricsResult }) {
|
|
return (
|
|
<MetricCard
|
|
title="Total Sessions"
|
|
value={metrics.totalSessions?.toLocaleString()}
|
|
icon={<MessageSquare className="h-5 w-5" />}
|
|
trend={{
|
|
value: metrics.sessionTrend ?? 0,
|
|
isPositive: (metrics.sessionTrend ?? 0) >= 0,
|
|
}}
|
|
variant="primary"
|
|
/>
|
|
);
|
|
}
|
|
|
|
function UsersMetricCard({ metrics }: { metrics: MetricsResult }) {
|
|
return (
|
|
<MetricCard
|
|
title="Unique Users"
|
|
value={metrics.uniqueUsers?.toLocaleString()}
|
|
icon={<Users className="h-5 w-5" />}
|
|
trend={{
|
|
value: metrics.usersTrend ?? 0,
|
|
isPositive: (metrics.usersTrend ?? 0) >= 0,
|
|
}}
|
|
variant="success"
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SessionTimeMetricCard({ metrics }: { metrics: MetricsResult }) {
|
|
return (
|
|
<MetricCard
|
|
title="Avg. Session Time"
|
|
value={`${Math.round(metrics.avgSessionLength || 0)}s`}
|
|
icon={<Clock className="h-5 w-5" />}
|
|
trend={{
|
|
value: metrics.avgSessionTimeTrend ?? 0,
|
|
isPositive: (metrics.avgSessionTimeTrend ?? 0) >= 0,
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ResponseTimeMetricCard({ metrics }: { metrics: MetricsResult }) {
|
|
return (
|
|
<MetricCard
|
|
title="Avg. Response Time"
|
|
value={`${metrics.avgResponseTime?.toFixed(1) || 0}s`}
|
|
icon={<Zap className="h-5 w-5" />}
|
|
trend={{
|
|
value: metrics.avgResponseTimeTrend ?? 0,
|
|
isPositive: (metrics.avgResponseTimeTrend ?? 0) <= 0,
|
|
}}
|
|
variant="warning"
|
|
/>
|
|
);
|
|
}
|
|
|
|
function CostsMetricCard({ metrics }: { metrics: MetricsResult }) {
|
|
return (
|
|
<MetricCard
|
|
title="Daily Costs"
|
|
value={`€${metrics.avgDailyCosts?.toFixed(4) || "0.0000"}`}
|
|
icon={<Euro className="h-5 w-5" />}
|
|
description="Average per day"
|
|
/>
|
|
);
|
|
}
|
|
|
|
function PeakUsageMetricCard({ metrics }: { metrics: MetricsResult }) {
|
|
return (
|
|
<MetricCard
|
|
title="Peak Usage"
|
|
value={metrics.peakUsageTime || "N/A"}
|
|
icon={<TrendingUp className="h-5 w-5" />}
|
|
description="Busiest hour"
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ResolutionRateMetricCard({ metrics }: { metrics: MetricsResult }) {
|
|
return (
|
|
<MetricCard
|
|
title="Resolution Rate"
|
|
value={`${metrics.resolvedChatsPercentage?.toFixed(1) || "0.0"}%`}
|
|
icon={<CheckCircle className="h-5 w-5" />}
|
|
trend={{
|
|
value: metrics.resolvedChatsPercentage ?? 0,
|
|
isPositive: (metrics.resolvedChatsPercentage ?? 0) >= 80,
|
|
}}
|
|
variant={
|
|
metrics.resolvedChatsPercentage && metrics.resolvedChatsPercentage >= 80
|
|
? "success"
|
|
: "warning"
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function LanguagesMetricCard({ metrics }: { metrics: MetricsResult }) {
|
|
return (
|
|
<MetricCard
|
|
title="Active Languages"
|
|
value={Object.keys(metrics.languages || {}).length}
|
|
icon={<Globe className="h-5 w-5" />}
|
|
description="Languages detected"
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Simplified metrics grid component
|
|
*/
|
|
function MetricsGrid({ metrics }: { metrics: MetricsResult }) {
|
|
return (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
<SessionMetricCard metrics={metrics} />
|
|
<UsersMetricCard metrics={metrics} />
|
|
<SessionTimeMetricCard metrics={metrics} />
|
|
<ResponseTimeMetricCard metrics={metrics} />
|
|
<CostsMetricCard metrics={metrics} />
|
|
<PeakUsageMetricCard metrics={metrics} />
|
|
<ResolutionRateMetricCard metrics={metrics} />
|
|
<LanguagesMetricCard metrics={metrics} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Main dashboard content with reduced complexity
|
|
*/
|
|
function DashboardContent() {
|
|
const { data: session, status } = useSession();
|
|
const router = useRouter();
|
|
const [metrics, setMetrics] = useState<MetricsResult | null>(null);
|
|
// Remove unused company state that was causing skeleton view to always show
|
|
const [refreshing, setRefreshing] = useState<boolean>(false);
|
|
const [isInitialLoad, setIsInitialLoad] = useState<boolean>(true);
|
|
|
|
const isAuditor = session?.user?.role === "AUDITOR";
|
|
const dataHelpers = useDashboardData(metrics);
|
|
|
|
// Function to fetch metrics with optional date range
|
|
// tRPC query for dashboard metrics
|
|
const {
|
|
data: overviewData,
|
|
isLoading: isLoadingMetrics,
|
|
refetch: refetchMetrics,
|
|
error: metricsError,
|
|
} = trpc.dashboard.getOverview.useQuery(
|
|
{
|
|
// Add date range parameters when implemented
|
|
// startDate: dateRange?.startDate,
|
|
// endDate: dateRange?.endDate,
|
|
},
|
|
{
|
|
enabled: status === "authenticated",
|
|
}
|
|
);
|
|
|
|
// Update state when data changes
|
|
useEffect(() => {
|
|
if (overviewData) {
|
|
// Map overview data to metrics format expected by the component
|
|
const mappedMetrics: Partial<MetricsResult> = {
|
|
totalSessions: overviewData.totalSessions,
|
|
avgSessionsPerDay: overviewData.avgSessionsPerDay || 0,
|
|
avgSessionLength: overviewData.avgSessionLength || 0,
|
|
days:
|
|
overviewData.timeSeriesData?.reduce(
|
|
(acc, item) => {
|
|
if (item.date) {
|
|
acc[item.date] = item.sessionCount || 0;
|
|
}
|
|
return acc;
|
|
},
|
|
{} as Record<string, number>
|
|
) || {},
|
|
languages:
|
|
overviewData.languageDistribution?.reduce(
|
|
(acc, item) => {
|
|
if (item.language) {
|
|
acc[item.language] = item.count;
|
|
}
|
|
return acc;
|
|
},
|
|
{} as Record<string, number>
|
|
) || {},
|
|
countries:
|
|
overviewData.geographicDistribution?.reduce(
|
|
(acc, item) => {
|
|
if (item.country) {
|
|
acc[item.country] = item.count;
|
|
}
|
|
return acc;
|
|
},
|
|
{} as Record<string, number>
|
|
) || {},
|
|
belowThresholdCount: overviewData.belowThresholdCount || 0,
|
|
// Map sentiment data to individual counts
|
|
sentimentPositiveCount:
|
|
overviewData.sentimentDistribution?.find(
|
|
(s) => s.sentiment === "POSITIVE"
|
|
)?.count || 0,
|
|
sentimentNeutralCount:
|
|
overviewData.sentimentDistribution?.find(
|
|
(s) => s.sentiment === "NEUTRAL"
|
|
)?.count || 0,
|
|
sentimentNegativeCount:
|
|
overviewData.sentimentDistribution?.find(
|
|
(s) => s.sentiment === "NEGATIVE"
|
|
)?.count || 0,
|
|
// Map category data to CategoryMetrics format
|
|
...(overviewData.categoryDistribution && {
|
|
categories: overviewData.categoryDistribution.reduce(
|
|
(acc, item) => {
|
|
if (item.category) {
|
|
acc[item.category] = item.count;
|
|
}
|
|
return acc;
|
|
},
|
|
{} as Record<string, number>
|
|
),
|
|
}),
|
|
};
|
|
setMetrics(mappedMetrics as MetricsResult);
|
|
|
|
if (isInitialLoad) {
|
|
setIsInitialLoad(false);
|
|
}
|
|
}
|
|
}, [overviewData, isInitialLoad]);
|
|
|
|
// Admin refresh sessions mutation
|
|
const refreshSessionsMutation = trpc.admin.refreshSessions.useMutation({
|
|
onSuccess: () => {
|
|
// Refetch metrics after successful refresh
|
|
refetchMetrics();
|
|
},
|
|
onError: (error) => {
|
|
alert(`Failed to refresh sessions: ${error.message}`);
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
// Redirect if not authenticated
|
|
if (status === "unauthenticated") {
|
|
router.push("/login");
|
|
return;
|
|
}
|
|
// tRPC queries handle data fetching automatically
|
|
}, [status, router]);
|
|
|
|
// Enhanced error handling with user feedback
|
|
if (metricsError) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-[400px]">
|
|
<div className="text-center space-y-4">
|
|
<div className="text-red-600 text-lg font-semibold">
|
|
Failed to load dashboard data
|
|
</div>
|
|
<p className="text-gray-600">
|
|
There was an error loading your dashboard metrics. Please try
|
|
refreshing the page.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => window.location.reload()}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
|
>
|
|
Refresh Page
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
async function handleRefresh() {
|
|
if (isAuditor) return;
|
|
|
|
setRefreshing(true);
|
|
try {
|
|
await refreshSessionsMutation.mutateAsync();
|
|
} finally {
|
|
setRefreshing(false);
|
|
}
|
|
}
|
|
|
|
// Show loading state while session status is being determined
|
|
const loadingState = DashboardLoadingStates({ status });
|
|
if (loadingState) return loadingState;
|
|
|
|
// Show loading state while data is being fetched
|
|
if (isLoadingMetrics && !metrics) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-[60vh]">
|
|
<div className="text-center space-y-4">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto" />
|
|
<p className="text-muted-foreground">Loading dashboard data...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!metrics) {
|
|
return <DashboardSkeleton />;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<DashboardHeader
|
|
company={{ name: "Analytics Dashboard" } as Company}
|
|
metrics={metrics}
|
|
isAuditor={isAuditor}
|
|
refreshing={refreshing}
|
|
onRefresh={handleRefresh}
|
|
/>
|
|
|
|
{/* Date Range Picker */}
|
|
{/* {dateRange && (
|
|
<DateRangePicker
|
|
minDate={dateRange.minDate}
|
|
maxDate={dateRange.maxDate}
|
|
onDateRangeChange={handleDateRangeChange}
|
|
initialStartDate={selectedStartDate}
|
|
initialEndDate={selectedEndDate}
|
|
/>
|
|
)} */}
|
|
|
|
<MetricsGrid metrics={metrics} />
|
|
|
|
{/* Charts Section */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
<ModernLineChart
|
|
data={dataHelpers.getSessionsOverTimeData()}
|
|
title="Sessions Over Time"
|
|
className="lg:col-span-2"
|
|
height={350}
|
|
/>
|
|
|
|
<ModernDonutChart
|
|
data={dataHelpers.getSentimentData()}
|
|
title="Conversation Sentiment"
|
|
centerText={{
|
|
title: "Total",
|
|
value: metrics.totalSessions || 0,
|
|
}}
|
|
height={350}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<ModernBarChart
|
|
data={dataHelpers.getCategoriesData()}
|
|
title="Sessions by Category"
|
|
height={350}
|
|
/>
|
|
|
|
<ModernDonutChart
|
|
data={dataHelpers.getLanguagesData()}
|
|
title="Languages Used"
|
|
height={350}
|
|
/>
|
|
</div>
|
|
|
|
{/* Geographic and Topics Section */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Globe className="h-5 w-5" />
|
|
Geographic Distribution
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<GeographicMap countries={dataHelpers.getCountryData()} />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<MessageCircle className="h-5 w-5" />
|
|
Common Topics
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="h-[300px]">
|
|
<WordCloud
|
|
words={dataHelpers.getWordCloudData()}
|
|
width={500}
|
|
height={300}
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Top Questions Chart */}
|
|
<TopQuestionsChart data={metrics.topQuestions || []} />
|
|
|
|
{/* Response Time Distribution */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Response Time Distribution</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ResponseTimeDistribution
|
|
data={dataHelpers.getResponseTimeData()}
|
|
average={metrics.avgResponseTime || 0}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Token Usage Summary */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
|
<CardTitle>AI Usage & Costs</CardTitle>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Badge variant="outline" className="gap-1">
|
|
<span className="font-semibold">Total Tokens:</span>
|
|
{metrics.totalTokens?.toLocaleString() || 0}
|
|
</Badge>
|
|
<Badge variant="outline" className="gap-1">
|
|
<span className="font-semibold">Total Cost:</span>€
|
|
{metrics.totalTokensEur?.toFixed(4) || 0}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
<p>Token usage chart will be implemented with historical data</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function DashboardPage() {
|
|
return <DashboardContent />;
|
|
}
|