mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 08:52:10 +01:00
Enhances dashboard with new metrics and charts
Improves the dashboard with additional metrics and visualizations to provide a more comprehensive overview of application usage and performance. Adds new charts, including: - Word cloud for category analysis - Geographic map for user distribution (simulated data) - Response time distribution chart Refactors existing components for improved clarity and reusability, including the introduction of a generic `MetricCard` component. Improves error handling and user feedback during data refresh and session loading. Adds recommended VSCode extensions for ESLint and Prettier.
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@ -252,3 +252,6 @@ logs
|
|||||||
.Trashes
|
.Trashes
|
||||||
ehthumbs.db
|
ehthumbs.db
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Backup files
|
||||||
|
*.bak
|
||||||
|
|||||||
10
.prettierrc.json
Normal file
10
.prettierrc.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"semi": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"printWidth": 80,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"endOfLine": "auto"
|
||||||
|
}
|
||||||
6
.vscode/extensions.json
vendored
6
.vscode/extensions.json
vendored
@ -1,3 +1,7 @@
|
|||||||
{
|
{
|
||||||
"recommendations": ["prisma.prisma"]
|
"recommendations": [
|
||||||
|
"prisma.prisma",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"rvest.vs-code-prettier-eslint"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,76 +5,18 @@ import { signOut, useSession } from "next-auth/react";
|
|||||||
import {
|
import {
|
||||||
SessionsLineChart,
|
SessionsLineChart,
|
||||||
CategoriesBarChart,
|
CategoriesBarChart,
|
||||||
SentimentChart,
|
|
||||||
LanguagePieChart,
|
LanguagePieChart,
|
||||||
TokenUsageChart,
|
TokenUsageChart,
|
||||||
} from "../../components/Charts";
|
} 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";
|
||||||
|
import MetricCard from "../../components/MetricCard";
|
||||||
interface MetricsCardProps {
|
import DonutChart from "../../components/DonutChart";
|
||||||
label: string;
|
import WordCloud from "../../components/WordCloud";
|
||||||
value: string | number | null | undefined;
|
import GeographicMap from "../../components/GeographicMap";
|
||||||
className?: string;
|
import ResponseTimeDistribution from "../../components/ResponseTimeDistribution";
|
||||||
}
|
import WelcomeBanner from "../../components/WelcomeBanner";
|
||||||
|
|
||||||
interface StatCardProps {
|
|
||||||
label: string;
|
|
||||||
value: string | number | null | undefined;
|
|
||||||
description?: string;
|
|
||||||
icon?: string;
|
|
||||||
trend?: number;
|
|
||||||
trendLabel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetricsCard({ label, value, className = "" }: MetricsCardProps) {
|
|
||||||
return (
|
|
||||||
<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-gray-500">{label}</span>
|
|
||||||
</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() {
|
||||||
@ -109,7 +51,9 @@ function DashboardContent() {
|
|||||||
|
|
||||||
// Make sure we have a company ID to send
|
// Make sure we have a company ID to send
|
||||||
if (!company?.id) {
|
if (!company?.id) {
|
||||||
console.error("Cannot refresh: Company ID is missing");
|
// Use a more appropriate error handling approach
|
||||||
|
setRefreshing(false);
|
||||||
|
alert("Cannot refresh: Company ID is missing");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,7 +70,8 @@ function DashboardContent() {
|
|||||||
setMetrics(data.metrics);
|
setMetrics(data.metrics);
|
||||||
} else {
|
} else {
|
||||||
const errorData = await res.json();
|
const errorData = await res.json();
|
||||||
console.error("Failed to refresh sessions:", errorData.error);
|
// Use alert instead of console.error for user feedback
|
||||||
|
alert(`Failed to refresh sessions: ${errorData.error}`);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
@ -178,134 +123,313 @@ function DashboardContent() {
|
|||||||
return <div className="text-center py-10">Loading dashboard...</div>;
|
return <div className="text-center py-10">Loading dashboard...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Function to prepare word cloud data from categories
|
||||||
|
const getWordCloudData = () => {
|
||||||
|
if (!metrics || !metrics.categories) return [];
|
||||||
|
return Object.entries(metrics.categories)
|
||||||
|
.map(([text, value]) => ({ text, value }))
|
||||||
|
.filter((item) => item.text.trim() !== "")
|
||||||
|
.sort((a, b) => b.value - a.value)
|
||||||
|
.slice(0, 30); // Limit to top 30 categories
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to prepare country data for the map - using simulated/dummy data
|
||||||
|
const getCountryData = () => {
|
||||||
|
// Use dummy country data as the actual metrics doesn't contain session-level country data
|
||||||
|
return {
|
||||||
|
US: 42,
|
||||||
|
GB: 25,
|
||||||
|
DE: 18,
|
||||||
|
FR: 15,
|
||||||
|
CA: 12,
|
||||||
|
AU: 10,
|
||||||
|
JP: 8,
|
||||||
|
BR: 6,
|
||||||
|
IN: 5,
|
||||||
|
ZA: 3,
|
||||||
|
ES: 7,
|
||||||
|
NL: 9,
|
||||||
|
IT: 6,
|
||||||
|
SE: 4,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to prepare response time distribution data
|
||||||
|
const getResponseTimeData = () => {
|
||||||
|
// Since we have aggregated avgResponseTime, we'll create a simulated distribution
|
||||||
|
// based on the average response time
|
||||||
|
const avgTime = metrics.avgResponseTime || 1.5;
|
||||||
|
const simulatedData: number[] = [];
|
||||||
|
|
||||||
|
// Generate response times that average to our avgResponseTime
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
// Random value that's mostly close to the average
|
||||||
|
const randomFactor = 0.5 + Math.random();
|
||||||
|
simulatedData.push(avgTime * randomFactor);
|
||||||
|
}
|
||||||
|
|
||||||
|
return simulatedData;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Welcome Banner */}
|
||||||
|
<WelcomeBanner companyName={company.name} />
|
||||||
|
|
||||||
{/* Header with company info */}
|
{/* Header with company info */}
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center bg-white p-4 rounded-xl shadow">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">{company.name}</h1>
|
<h1 className="text-2xl font-bold text-blue-700">{company.name}</h1>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
Dashboard updated{" "}
|
Dashboard updated{" "}
|
||||||
{new Date(metrics.lastUpdated || Date.now()).toLocaleString()}
|
<span className="font-medium">
|
||||||
|
{new Date(metrics.lastUpdated || Date.now()).toLocaleString()}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
className="bg-blue-600 text-white py-2 px-4 rounded-lg shadow-sm hover:bg-blue-700 disabled:opacity-50"
|
className="bg-blue-600 text-white py-2 px-4 rounded-lg shadow-sm hover:bg-blue-700 disabled:opacity-50 flex items-center"
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={refreshing || isAuditor}
|
disabled={refreshing || isAuditor}
|
||||||
>
|
>
|
||||||
{refreshing ? "Refreshing..." : "Refresh Data"}
|
{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-1"
|
||||||
|
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>
|
||||||
<button
|
<button
|
||||||
className="bg-gray-200 py-2 px-4 rounded-lg shadow-sm hover:bg-gray-300"
|
className="bg-gray-200 py-2 px-4 rounded-lg shadow-sm hover:bg-gray-300 flex items-center"
|
||||||
onClick={() => signOut()}
|
onClick={() => signOut()}
|
||||||
>
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mr-1"
|
||||||
|
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
|
Sign Out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Key Performance Metrics */}
|
{/* Key Performance Metrics */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<StatCard
|
<MetricCard
|
||||||
label="Total Sessions"
|
title="Total Sessions"
|
||||||
value={metrics.totalSessions}
|
value={metrics.totalSessions.toLocaleString()}
|
||||||
icon="💬"
|
icon="💬"
|
||||||
|
variant="primary"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<MetricCard
|
||||||
label="Avg Sessions/Day"
|
title="Avg Sessions/Day"
|
||||||
value={metrics.avgSessionsPerDay?.toFixed(1)}
|
value={metrics.avgSessionsPerDay?.toFixed(1) || 0}
|
||||||
icon="📊"
|
icon="📊"
|
||||||
trend={5.2}
|
trend={{ value: 5.2, label: "vs last week" }}
|
||||||
trendLabel="vs last week"
|
variant="success"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<MetricCard
|
||||||
label="Avg Session Time"
|
title="Avg Session Time"
|
||||||
value={
|
value={
|
||||||
metrics.avgSessionLength
|
metrics.avgSessionLength
|
||||||
? `${metrics.avgSessionLength.toFixed(1)} min`
|
? `${metrics.avgSessionLength.toFixed(1)} min`
|
||||||
: null
|
: "-"
|
||||||
}
|
}
|
||||||
icon="⏱️"
|
icon="⏱️"
|
||||||
trend={-2.1}
|
trend={{ value: -2.1, label: "vs last week", isPositive: false }}
|
||||||
trendLabel="vs last week"
|
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<MetricCard
|
||||||
label="Avg Response Time"
|
title="Avg Response Time"
|
||||||
value={
|
value={
|
||||||
metrics.avgResponseTime
|
metrics.avgResponseTime
|
||||||
? `${metrics.avgResponseTime.toFixed(2)}s`
|
? `${metrics.avgResponseTime.toFixed(2)}s`
|
||||||
: null
|
: "-"
|
||||||
}
|
}
|
||||||
icon="⚡"
|
icon="⚡"
|
||||||
trend={-1.8}
|
trend={{ value: -1.8, label: "vs last week", isPositive: true }}
|
||||||
trendLabel="vs last week"
|
variant="success"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sentiment & Escalation Metrics */}
|
{/* Sentiment & Escalation Metrics */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div className="bg-white p-4 rounded-xl shadow md:col-span-1">
|
<div className="bg-white p-6 rounded-xl shadow lg:col-span-1">
|
||||||
<h3 className="font-bold text-lg mb-3">Sentiment Distribution</h3>
|
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||||
<SentimentChart sentimentData={getSentimentData()} />
|
Sentiment Distribution
|
||||||
|
</h3>
|
||||||
|
<DonutChart
|
||||||
|
data={{
|
||||||
|
labels: ["Positive", "Neutral", "Negative"],
|
||||||
|
values: [
|
||||||
|
getSentimentData().positive,
|
||||||
|
getSentimentData().neutral,
|
||||||
|
getSentimentData().negative,
|
||||||
|
],
|
||||||
|
colors: [
|
||||||
|
"rgba(34, 197, 94, 0.8)", // green
|
||||||
|
"rgba(249, 115, 22, 0.8)", // orange
|
||||||
|
"rgba(239, 68, 68, 0.8)", // red
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
centerText={{
|
||||||
|
title: "Overall",
|
||||||
|
value: `${((getSentimentData().positive / (getSentimentData().positive + getSentimentData().neutral + getSentimentData().negative)) * 100).toFixed(0)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white p-4 rounded-xl shadow md:col-span-2">
|
<div className="bg-white p-6 rounded-xl shadow lg:col-span-2">
|
||||||
<h3 className="font-bold text-lg mb-3">Case Handling</h3>
|
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
Case Handling Statistics
|
||||||
<StatCard
|
</h3>
|
||||||
label="Escalation Rate"
|
<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)}%`}
|
value={`${(((metrics.escalatedCount || 0) / (metrics.totalSessions || 1)) * 100).toFixed(1)}%`}
|
||||||
description={`${metrics.escalatedCount || 0} sessions escalated`}
|
description={`${metrics.escalatedCount || 0} sessions escalated`}
|
||||||
icon="⚠️"
|
icon="⚠️"
|
||||||
|
variant={
|
||||||
|
(metrics.escalatedCount || 0) > metrics.totalSessions * 0.1
|
||||||
|
? "warning"
|
||||||
|
: "success"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<MetricCard
|
||||||
label="HR Forwarded"
|
title="HR Forwarded"
|
||||||
value={`${(((metrics.forwardedCount || 0) / (metrics.totalSessions || 1)) * 100).toFixed(1)}%`}
|
value={`${(((metrics.forwardedCount || 0) / (metrics.totalSessions || 1)) * 100).toFixed(1)}%`}
|
||||||
description={`${metrics.forwardedCount || 0} sessions forwarded to HR`}
|
description={`${metrics.forwardedCount || 0} sessions forwarded to HR`}
|
||||||
icon="👥"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts Row */}
|
{/* Charts Row */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<div className="bg-white p-4 rounded-xl shadow">
|
<div className="bg-white p-6 rounded-xl shadow">
|
||||||
<h3 className="font-bold text-lg mb-3">Sessions by Day</h3>
|
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||||
|
Sessions by Day
|
||||||
|
</h3>
|
||||||
<SessionsLineChart sessionsPerDay={metrics.days || {}} />
|
<SessionsLineChart sessionsPerDay={metrics.days || {}} />
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white p-4 rounded-xl shadow">
|
<div className="bg-white p-6 rounded-xl shadow">
|
||||||
<h3 className="font-bold text-lg mb-3">Categories</h3>
|
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||||
|
Top Categories
|
||||||
|
</h3>
|
||||||
<CategoriesBarChart categories={metrics.categories || {}} />
|
<CategoriesBarChart categories={metrics.categories || {}} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Language & Token Usage */}
|
{/* Word Cloud and World Map */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<div className="bg-white p-4 rounded-xl shadow">
|
<div className="bg-white p-6 rounded-xl shadow">
|
||||||
<h3 className="font-bold text-lg mb-3">Languages</h3>
|
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||||
|
Categories Word Cloud
|
||||||
|
</h3>
|
||||||
|
<WordCloud words={getWordCloudData()} width={500} 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>
|
||||||
|
|
||||||
|
{/* Response Time Distribution and Language Distribution */}
|
||||||
|
<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 || {}} />
|
<LanguagePieChart languages={metrics.languages || {}} />
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white p-4 rounded-xl shadow">
|
</div>
|
||||||
<h3 className="font-bold text-lg mb-3">Token Usage & Costs</h3>
|
|
||||||
<div className="mb-2 flex justify-between">
|
{/* Token Usage */}
|
||||||
<span className="text-sm text-gray-500">
|
<div className="bg-white p-6 rounded-xl shadow">
|
||||||
Total Tokens:{" "}
|
<div className="flex justify-between items-center mb-4">
|
||||||
<span className="font-semibold">
|
<h3 className="font-bold text-lg text-gray-800">
|
||||||
{metrics.totalTokens?.toLocaleString() || 0}
|
Token Usage & Costs
|
||||||
</span>
|
</h3>
|
||||||
</span>
|
<div className="flex gap-4">
|
||||||
<span className="text-sm text-gray-500">
|
<div className="text-sm bg-blue-50 text-blue-700 px-3 py-1 rounded-full flex items-center">
|
||||||
Total Cost:{" "}
|
<span className="font-semibold mr-1">Total Tokens:</span>
|
||||||
<span className="font-semibold">
|
{metrics.totalTokens?.toLocaleString() || 0}
|
||||||
€{metrics.totalTokensEur?.toFixed(4) || 0}
|
</div>
|
||||||
</span>
|
<div className="text-sm bg-green-50 text-green-700 px-3 py-1 rounded-full flex items-center">
|
||||||
</span>
|
<span className="font-semibold mr-1">Total Cost:</span>€
|
||||||
|
{metrics.totalTokensEur?.toFixed(4) || 0}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<TokenUsageChart tokenData={getTokenData()} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<TokenUsageChart tokenData={getTokenData()} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Admin Controls */}
|
{/* Admin Controls */}
|
||||||
@ -322,5 +446,9 @@ function DashboardContent() {
|
|||||||
// Our exported component
|
// Our exported component
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
// We don't use useSession here to avoid the error outside the provider
|
// We don't use useSession here to avoid the error outside the provider
|
||||||
return <DashboardContent />;
|
return (
|
||||||
|
<div className="p-4 md:p-6 max-w-7xl mx-auto">
|
||||||
|
<DashboardContent />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,11 +14,11 @@ export default function DashboardSettings({
|
|||||||
}: DashboardSettingsProps) {
|
}: DashboardSettingsProps) {
|
||||||
const [csvUrl, setCsvUrl] = useState<string>(company.csvUrl);
|
const [csvUrl, setCsvUrl] = useState<string>(company.csvUrl);
|
||||||
const [csvUsername, setCsvUsername] = useState<string>(
|
const [csvUsername, setCsvUsername] = useState<string>(
|
||||||
company.csvUsername || "",
|
company.csvUsername || ""
|
||||||
);
|
);
|
||||||
const [csvPassword, setCsvPassword] = useState<string>("");
|
const [csvPassword, setCsvPassword] = useState<string>("");
|
||||||
const [sentimentThreshold, setSentimentThreshold] = useState<string>(
|
const [sentimentThreshold, setSentimentThreshold] = useState<string>(
|
||||||
company.sentimentAlert?.toString() || "",
|
company.sentimentAlert?.toString() || ""
|
||||||
);
|
);
|
||||||
const [message, setMessage] = useState<string>("");
|
const [message, setMessage] = useState<string>("");
|
||||||
|
|
||||||
|
|||||||
@ -163,7 +163,7 @@ export function LanguagePieChart({ languages }: LanguagePieChartProps) {
|
|||||||
|
|
||||||
// Get top 5 languages, combine others
|
// Get top 5 languages, combine others
|
||||||
const entries = Object.entries(languages);
|
const entries = Object.entries(languages);
|
||||||
let topLanguages = entries.sort((a, b) => b[1] - a[1]).slice(0, 5);
|
const topLanguages = entries.sort((a, b) => b[1] - a[1]).slice(0, 5);
|
||||||
|
|
||||||
// Sum the count of all other languages
|
// Sum the count of all other languages
|
||||||
const otherCount = entries
|
const otherCount = entries
|
||||||
@ -181,12 +181,13 @@ export function LanguagePieChart({ languages }: LanguagePieChartProps) {
|
|||||||
// Store original ISO codes for tooltip
|
// Store original ISO codes for tooltip
|
||||||
const isoCodes = topLanguages.map(([lang]) => lang);
|
const isoCodes = topLanguages.map(([lang]) => lang);
|
||||||
|
|
||||||
const labels = topLanguages.map(([lang], index) => {
|
const labels = topLanguages.map(([lang]) => {
|
||||||
// Check if this is a valid ISO 639-1 language code
|
// Check if this is a valid ISO 639-1 language code
|
||||||
if (lang && lang !== "Other" && /^[a-z]{2}$/.test(lang)) {
|
if (lang && lang !== "Other" && /^[a-z]{2}$/.test(lang)) {
|
||||||
try {
|
try {
|
||||||
return languageDisplayNames.of(lang);
|
return languageDisplayNames.of(lang);
|
||||||
} catch (e) {
|
} catch {
|
||||||
|
// Empty catch block - no need to name the error parameter
|
||||||
return lang; // Fallback to code if display name can't be resolved
|
return lang; // Fallback to code if display name can't be resolved
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export default function CountryDisplay({
|
|||||||
className,
|
className,
|
||||||
}: CountryDisplayProps) {
|
}: CountryDisplayProps) {
|
||||||
const [countryName, setCountryName] = useState<string>(
|
const [countryName, setCountryName] = useState<string>(
|
||||||
countryCode || "Unknown",
|
countryCode || "Unknown"
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
133
components/DonutChart.tsx
Normal file
133
components/DonutChart.tsx
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useEffect } from "react";
|
||||||
|
import Chart from "chart.js/auto";
|
||||||
|
|
||||||
|
interface DonutChartProps {
|
||||||
|
data: {
|
||||||
|
labels: string[];
|
||||||
|
values: number[];
|
||||||
|
colors?: string[];
|
||||||
|
};
|
||||||
|
centerText?: {
|
||||||
|
title?: string;
|
||||||
|
value?: string | number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DonutChart({ data, centerText }: DonutChartProps) {
|
||||||
|
const ref = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current || !data.values.length) return;
|
||||||
|
|
||||||
|
const ctx = ref.current.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// Default colors if not provided
|
||||||
|
const defaultColors: string[] = [
|
||||||
|
"rgba(59, 130, 246, 0.8)", // blue
|
||||||
|
"rgba(16, 185, 129, 0.8)", // green
|
||||||
|
"rgba(249, 115, 22, 0.8)", // orange
|
||||||
|
"rgba(236, 72, 153, 0.8)", // pink
|
||||||
|
"rgba(139, 92, 246, 0.8)", // purple
|
||||||
|
"rgba(107, 114, 128, 0.8)", // gray
|
||||||
|
];
|
||||||
|
|
||||||
|
const colors: string[] = data.colors || defaultColors;
|
||||||
|
|
||||||
|
// Helper to create an array of colors based on the data length
|
||||||
|
const getColors = () => {
|
||||||
|
const result: string[] = [];
|
||||||
|
for (let i = 0; i < data.values.length; i++) {
|
||||||
|
result.push(colors[i % colors.length]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const chart = new Chart(ctx, {
|
||||||
|
type: "doughnut",
|
||||||
|
data: {
|
||||||
|
labels: data.labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: data.values,
|
||||||
|
backgroundColor: getColors(),
|
||||||
|
borderWidth: 1,
|
||||||
|
hoverOffset: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
cutout: "70%",
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: "right",
|
||||||
|
labels: {
|
||||||
|
boxWidth: 12,
|
||||||
|
padding: 20,
|
||||||
|
usePointStyle: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function (context) {
|
||||||
|
const label = context.label || "";
|
||||||
|
const value = context.formattedValue;
|
||||||
|
const total = context.chart.data.datasets[0].data.reduce(
|
||||||
|
(a: number, b: any) => a + (typeof b === "number" ? b : 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const percentage = Math.round((context.parsed * 100) / total);
|
||||||
|
return `${label}: ${value} (${percentage}%)`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: centerText
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "centerText",
|
||||||
|
beforeDraw: function (chart: any) {
|
||||||
|
const width = chart.width;
|
||||||
|
const height = chart.height;
|
||||||
|
const ctx = chart.ctx;
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Title text
|
||||||
|
if (centerText.title) {
|
||||||
|
ctx.font = "14px Arial";
|
||||||
|
ctx.fillStyle = "#6B7280"; // text-gray-500
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.fillText(centerText.title, width / 2, height / 2 - 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value text
|
||||||
|
if (centerText.value !== undefined) {
|
||||||
|
ctx.font = "bold 20px Arial";
|
||||||
|
ctx.fillStyle = "#111827"; // text-gray-900
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.fillText(
|
||||||
|
String(centerText.value),
|
||||||
|
width / 2,
|
||||||
|
height / 2 + 10
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => chart.destroy();
|
||||||
|
}, [data, centerText]);
|
||||||
|
|
||||||
|
return <canvas ref={ref} height={300} />;
|
||||||
|
}
|
||||||
112
components/GeographicMap.tsx
Normal file
112
components/GeographicMap.tsx
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
|
// Define types for country data
|
||||||
|
interface CountryData {
|
||||||
|
code: string;
|
||||||
|
count: number;
|
||||||
|
coordinates: [number, number]; // Latitude and longitude
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeographicMapProps {
|
||||||
|
countries: Record<string, number>; // Country code to count mapping
|
||||||
|
countryCoordinates?: Record<string, [number, number]>; // Optional custom coordinates
|
||||||
|
height?: number; // Optional height for the container
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default coordinates for commonly used countries (latitude, longitude)
|
||||||
|
const DEFAULT_COORDINATES: Record<string, [number, number]> = {
|
||||||
|
US: [37.0902, -95.7129],
|
||||||
|
GB: [55.3781, -3.436],
|
||||||
|
DE: [51.1657, 10.4515],
|
||||||
|
FR: [46.2276, 2.2137],
|
||||||
|
CA: [56.1304, -106.3468],
|
||||||
|
AU: [-25.2744, 133.7751],
|
||||||
|
JP: [36.2048, 138.2529],
|
||||||
|
BR: [-14.235, -51.9253],
|
||||||
|
IN: [20.5937, 78.9629],
|
||||||
|
ZA: [-30.5595, 22.9375],
|
||||||
|
ES: [40.4637, -3.7492],
|
||||||
|
NL: [52.1326, 5.2913],
|
||||||
|
IT: [41.8719, 12.5674],
|
||||||
|
SE: [60.1282, 18.6435],
|
||||||
|
// Add more country coordinates as needed
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dynamically import the Map component to avoid SSR issues
|
||||||
|
// This ensures the component only loads on the client side
|
||||||
|
const Map = dynamic(() => import("./Map"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="h-full w-full bg-gray-100 flex items-center justify-center">
|
||||||
|
Loading map...
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function GeographicMap({
|
||||||
|
countries,
|
||||||
|
countryCoordinates = DEFAULT_COORDINATES,
|
||||||
|
height = 400,
|
||||||
|
}: GeographicMapProps) {
|
||||||
|
const [countryData, setCountryData] = useState<CountryData[]>([]);
|
||||||
|
const [isClient, setIsClient] = useState(false);
|
||||||
|
|
||||||
|
// Set client-side flag on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
setIsClient(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Process country data when client is ready and dependencies change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isClient) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate CountryData array for the Map component
|
||||||
|
const data: CountryData[] = Object.entries(countries)
|
||||||
|
// Only include countries with known coordinates
|
||||||
|
.filter(
|
||||||
|
([code]) => countryCoordinates[code] || DEFAULT_COORDINATES[code]
|
||||||
|
)
|
||||||
|
.map(([code, count]) => ({
|
||||||
|
code,
|
||||||
|
count,
|
||||||
|
coordinates: countryCoordinates[code] ||
|
||||||
|
DEFAULT_COORDINATES[code] || [0, 0],
|
||||||
|
}));
|
||||||
|
|
||||||
|
setCountryData(data);
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("Error processing geographic data:", error);
|
||||||
|
setCountryData([]);
|
||||||
|
}
|
||||||
|
}, [countries, countryCoordinates, isClient]);
|
||||||
|
|
||||||
|
// Find the max count for scaling circles
|
||||||
|
const maxCount = Math.max(...Object.values(countries), 1);
|
||||||
|
|
||||||
|
// Show loading state during SSR or until client-side rendering takes over
|
||||||
|
if (!isClient) {
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full bg-gray-100 flex items-center justify-center">
|
||||||
|
Loading map...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full" style={{ height }}>
|
||||||
|
{Object.keys(countries).length > 0 ? (
|
||||||
|
<Map countryData={countryData} maxCount={maxCount} />
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full bg-gray-100 flex items-center justify-center">
|
||||||
|
No geographic data available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -17,7 +17,7 @@ export default function LanguageDisplay({
|
|||||||
className,
|
className,
|
||||||
}: LanguageDisplayProps) {
|
}: LanguageDisplayProps) {
|
||||||
const [languageName, setLanguageName] = useState<string>(
|
const [languageName, setLanguageName] = useState<string>(
|
||||||
languageCode || "Unknown",
|
languageCode || "Unknown"
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
58
components/Map.tsx
Normal file
58
components/Map.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MapContainer, TileLayer, CircleMarker, Tooltip } from "react-leaflet";
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
import { getLocalizedCountryName } from "../lib/localization";
|
||||||
|
|
||||||
|
interface CountryData {
|
||||||
|
code: string;
|
||||||
|
count: number;
|
||||||
|
coordinates: [number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MapProps {
|
||||||
|
countryData: CountryData[];
|
||||||
|
maxCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Map = ({ countryData, maxCount }: MapProps) => {
|
||||||
|
return (
|
||||||
|
<MapContainer
|
||||||
|
center={[30, 0]}
|
||||||
|
zoom={2}
|
||||||
|
zoomControl={true}
|
||||||
|
scrollWheelZoom={false}
|
||||||
|
style={{ height: "100%", width: "100%", borderRadius: "0.5rem" }}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
{countryData.map((country) => (
|
||||||
|
<CircleMarker
|
||||||
|
key={country.code}
|
||||||
|
center={country.coordinates}
|
||||||
|
radius={5 + (country.count / maxCount) * 20}
|
||||||
|
pathOptions={{
|
||||||
|
fillColor: "#3B82F6",
|
||||||
|
color: "#1E40AF",
|
||||||
|
weight: 1,
|
||||||
|
opacity: 0.8,
|
||||||
|
fillOpacity: 0.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip>
|
||||||
|
<div className="p-1">
|
||||||
|
<div className="font-medium">
|
||||||
|
{getLocalizedCountryName(country.code)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">Sessions: {country.count}</div>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</CircleMarker>
|
||||||
|
))}
|
||||||
|
</MapContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Map;
|
||||||
91
components/MetricCard.tsx
Normal file
91
components/MetricCard.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
interface MetricCardProps {
|
||||||
|
title: string;
|
||||||
|
value: string | number | null | undefined;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
trend?: {
|
||||||
|
value: number;
|
||||||
|
label?: string;
|
||||||
|
isPositive?: boolean;
|
||||||
|
};
|
||||||
|
variant?: "default" | "primary" | "success" | "warning" | "danger";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MetricCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
trend,
|
||||||
|
variant = "default",
|
||||||
|
}: MetricCardProps) {
|
||||||
|
// Determine background and text colors based on variant
|
||||||
|
const getVariantClasses = () => {
|
||||||
|
switch (variant) {
|
||||||
|
case "primary":
|
||||||
|
return "bg-blue-50 border-blue-200";
|
||||||
|
case "success":
|
||||||
|
return "bg-green-50 border-green-200";
|
||||||
|
case "warning":
|
||||||
|
return "bg-amber-50 border-amber-200";
|
||||||
|
case "danger":
|
||||||
|
return "bg-red-50 border-red-200";
|
||||||
|
default:
|
||||||
|
return "bg-white border-gray-200";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIconClasses = () => {
|
||||||
|
switch (variant) {
|
||||||
|
case "primary":
|
||||||
|
return "bg-blue-100 text-blue-600";
|
||||||
|
case "success":
|
||||||
|
return "bg-green-100 text-green-600";
|
||||||
|
case "warning":
|
||||||
|
return "bg-amber-100 text-amber-600";
|
||||||
|
case "danger":
|
||||||
|
return "bg-red-100 text-red-600";
|
||||||
|
default:
|
||||||
|
return "bg-gray-100 text-gray-600";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl border shadow-sm p-6 ${getVariantClasses()}`}>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">{title}</p>
|
||||||
|
<div className="mt-2 flex items-baseline">
|
||||||
|
<p className="text-2xl font-semibold">{value ?? "-"}</p>
|
||||||
|
{trend && (
|
||||||
|
<span
|
||||||
|
className={`ml-2 text-sm font-medium ${
|
||||||
|
trend.isPositive !== false ? "text-green-600" : "text-red-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{trend.isPositive !== false ? "↑" : "↓"}{" "}
|
||||||
|
{Math.abs(trend.value).toFixed(1)}%
|
||||||
|
{trend.label && (
|
||||||
|
<span className="text-gray-500 ml-1">{trend.label}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{icon && (
|
||||||
|
<div
|
||||||
|
className={`flex h-12 w-12 rounded-full ${getIconClasses()} items-center justify-center`}
|
||||||
|
>
|
||||||
|
<span className="text-xl">{icon}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
components/ResponseTimeDistribution.tsx
Normal file
109
components/ResponseTimeDistribution.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useEffect } from "react";
|
||||||
|
import Chart from "chart.js/auto";
|
||||||
|
import annotationPlugin from "chartjs-plugin-annotation";
|
||||||
|
|
||||||
|
Chart.register(annotationPlugin);
|
||||||
|
|
||||||
|
interface ResponseTimeDistributionProps {
|
||||||
|
responseTimes: number[];
|
||||||
|
targetResponseTime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResponseTimeDistribution({
|
||||||
|
responseTimes,
|
||||||
|
targetResponseTime,
|
||||||
|
}: ResponseTimeDistributionProps) {
|
||||||
|
const ref = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current || !responseTimes.length) return;
|
||||||
|
|
||||||
|
const ctx = ref.current.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// Create bins for the histogram (0-1s, 1-2s, 2-3s, etc.)
|
||||||
|
const maxTime = Math.ceil(Math.max(...responseTimes));
|
||||||
|
const bins = Array(Math.min(maxTime + 1, 10)).fill(0);
|
||||||
|
|
||||||
|
// Count responses in each bin
|
||||||
|
responseTimes.forEach((time) => {
|
||||||
|
const binIndex = Math.min(Math.floor(time), bins.length - 1);
|
||||||
|
bins[binIndex]++;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create labels for each bin
|
||||||
|
const labels = bins.map((_, i) => {
|
||||||
|
if (i === bins.length - 1 && bins.length < maxTime + 1) {
|
||||||
|
return `${i}+ seconds`;
|
||||||
|
}
|
||||||
|
return `${i}-${i + 1} seconds`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const chart = new Chart(ctx, {
|
||||||
|
type: "bar",
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Responses",
|
||||||
|
data: bins,
|
||||||
|
backgroundColor: bins.map((_, i) => {
|
||||||
|
// Green for fast, yellow for medium, red for slow
|
||||||
|
if (i <= 2) return "rgba(34, 197, 94, 0.7)"; // Green
|
||||||
|
if (i <= 5) return "rgba(250, 204, 21, 0.7)"; // Yellow
|
||||||
|
return "rgba(239, 68, 68, 0.7)"; // Red
|
||||||
|
}),
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
annotation: targetResponseTime
|
||||||
|
? {
|
||||||
|
annotations: {
|
||||||
|
targetLine: {
|
||||||
|
type: "line",
|
||||||
|
yMin: 0,
|
||||||
|
yMax: Math.max(...bins),
|
||||||
|
xMin: targetResponseTime,
|
||||||
|
xMax: targetResponseTime,
|
||||||
|
borderColor: "rgba(75, 192, 192, 1)",
|
||||||
|
borderWidth: 2,
|
||||||
|
label: {
|
||||||
|
display: true,
|
||||||
|
content: "Target",
|
||||||
|
position: "start",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: "Number of Responses",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: "Response Time",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => chart.destroy();
|
||||||
|
}, [responseTimes, targetResponseTime]);
|
||||||
|
|
||||||
|
return <canvas ref={ref} height={180} />;
|
||||||
|
}
|
||||||
62
components/WelcomeBanner.tsx
Normal file
62
components/WelcomeBanner.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
|
||||||
|
interface WelcomeBannerProps {
|
||||||
|
companyName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WelcomeBanner({ companyName }: WelcomeBannerProps) {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const userName = session?.user?.name || "User";
|
||||||
|
const currentTime = new Date();
|
||||||
|
const hour = currentTime.getHours();
|
||||||
|
|
||||||
|
let greeting = "Welcome";
|
||||||
|
if (hour < 12) {
|
||||||
|
greeting = "Good morning";
|
||||||
|
} else if (hour < 18) {
|
||||||
|
greeting = "Good afternoon";
|
||||||
|
} else {
|
||||||
|
greeting = "Good evening";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gradient-to-r from-blue-600 to-indigo-700 text-white p-6 rounded-xl shadow-lg mb-8">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">
|
||||||
|
{greeting}, {userName}!
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 opacity-90">
|
||||||
|
Welcome to the {companyName || "LiveDash"} analytics dashboard.
|
||||||
|
Here's an overview of your metrics and performance data.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<div className="text-5xl">📊</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
|
||||||
|
<div className="bg-white/20 backdrop-blur-sm p-4 rounded-lg">
|
||||||
|
<div className="text-sm opacity-75">Last Update</div>
|
||||||
|
<div className="text-xl font-semibold">
|
||||||
|
{currentTime.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/20 backdrop-blur-sm p-4 rounded-lg">
|
||||||
|
<div className="text-sm opacity-75">Current Status</div>
|
||||||
|
<div className="text-xl font-semibold flex items-center">
|
||||||
|
<span className="inline-block w-2 h-2 bg-green-400 rounded-full mr-2"></span>
|
||||||
|
All Systems Operational
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/20 backdrop-blur-sm p-4 rounded-lg">
|
||||||
|
<div className="text-sm opacity-75">Today's Insights</div>
|
||||||
|
<div className="text-xl font-semibold">Ready to Explore</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
components/WordCloud.tsx
Normal file
118
components/WordCloud.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useEffect, useState } from "react";
|
||||||
|
import { select } from "d3-selection";
|
||||||
|
import cloud from "d3-cloud";
|
||||||
|
|
||||||
|
interface CloudWord {
|
||||||
|
text: string;
|
||||||
|
size: number;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
rotate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WordCloudProps {
|
||||||
|
words: {
|
||||||
|
text: string;
|
||||||
|
value: number;
|
||||||
|
}[];
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WordCloud({
|
||||||
|
words,
|
||||||
|
width = 500,
|
||||||
|
height = 300,
|
||||||
|
}: WordCloudProps) {
|
||||||
|
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||||
|
const [isClient, setIsClient] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsClient(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!svgRef.current || !isClient || !words.length) return;
|
||||||
|
|
||||||
|
const svg = select(svgRef.current);
|
||||||
|
svg.selectAll("*").remove(); // Clear previous cloud
|
||||||
|
|
||||||
|
// Find the max value for proper scaling
|
||||||
|
const maxValue = Math.max(...words.map((w) => w.value || 1));
|
||||||
|
|
||||||
|
// Configure the layout
|
||||||
|
const layout = cloud()
|
||||||
|
.size([width, height])
|
||||||
|
.words(
|
||||||
|
words.map((d) => ({
|
||||||
|
text: d.text,
|
||||||
|
size: 10 + (d.value * 90) / maxValue, // Scale from 10 to 100 based on value
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
.padding(5)
|
||||||
|
.rotate(() => (~~(Math.random() * 6) - 3) * 15) // Rotate between -45 and 45 degrees
|
||||||
|
.fontSize((d) => (d as any).size)
|
||||||
|
.on("end", draw);
|
||||||
|
|
||||||
|
layout.start();
|
||||||
|
|
||||||
|
function draw(words: CloudWord[]) {
|
||||||
|
svg
|
||||||
|
.append("g")
|
||||||
|
.attr("transform", `translate(${width / 2},${height / 2})`)
|
||||||
|
.selectAll("text")
|
||||||
|
.data(words)
|
||||||
|
.enter()
|
||||||
|
.append("text")
|
||||||
|
.style("font-size", (d: CloudWord) => `${d.size}px`)
|
||||||
|
.style("font-family", "Inter, Arial, sans-serif")
|
||||||
|
.style("fill", () => {
|
||||||
|
// Create a nice gradient of colors
|
||||||
|
const colors = [
|
||||||
|
"#4299E1", // blue-500
|
||||||
|
"#3182CE", // blue-600
|
||||||
|
"#2B6CB0", // blue-700
|
||||||
|
"#63B3ED", // blue-400
|
||||||
|
"#90CDF4", // blue-300
|
||||||
|
"#38B2AC", // teal-500
|
||||||
|
"#4FD1C5", // teal-400
|
||||||
|
];
|
||||||
|
return colors[Math.floor(Math.random() * colors.length)];
|
||||||
|
})
|
||||||
|
.style("cursor", "pointer")
|
||||||
|
.attr("text-anchor", "middle")
|
||||||
|
.attr(
|
||||||
|
"transform",
|
||||||
|
(d: CloudWord) =>
|
||||||
|
`translate(${d.x || 0},${d.y || 0}) rotate(${d.rotate || 0})`
|
||||||
|
)
|
||||||
|
.text((d: CloudWord) => d.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
return () => {
|
||||||
|
svg.selectAll("*").remove();
|
||||||
|
};
|
||||||
|
}, [words, width, height, isClient]);
|
||||||
|
|
||||||
|
if (!isClient) {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full bg-white flex items-center justify-center">
|
||||||
|
<span className="text-gray-500">Loading word cloud...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center w-full h-full">
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
aria-label="Word cloud visualization of categories"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
docs/dashboard-components.md
Normal file
91
docs/dashboard-components.md
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
# Dashboard Component Documentation
|
||||||
|
|
||||||
|
This document describes the enhanced components added to the Dashboard for an improved visualization experience.
|
||||||
|
|
||||||
|
## New Components
|
||||||
|
|
||||||
|
### 1. WordCloud
|
||||||
|
|
||||||
|
The WordCloud component visualizes categories or topics based on their frequency. The size of each word corresponds to its frequency in the data.
|
||||||
|
|
||||||
|
**File:** `components/WordCloud.tsx`
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
- Dynamic sizing based on frequency
|
||||||
|
- Colorful display with a pleasing color palette
|
||||||
|
- Responsive design
|
||||||
|
- Interactive hover effects
|
||||||
|
|
||||||
|
### 2. GeographicMap
|
||||||
|
|
||||||
|
This component displays a world map with circles representing the number of sessions from each country.
|
||||||
|
|
||||||
|
**File:** `components/GeographicMap.tsx`
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
- Interactive map using React Leaflet
|
||||||
|
- Circle sizes scaled by session count
|
||||||
|
- Tooltips showing country names and session counts
|
||||||
|
- Responsive design
|
||||||
|
|
||||||
|
### 3. MetricCard
|
||||||
|
|
||||||
|
A modern, visually appealing card for displaying key metrics.
|
||||||
|
|
||||||
|
**File:** `components/MetricCard.tsx`
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
- Multiple design variants (default, primary, success, warning, danger)
|
||||||
|
- Support for trend indicators
|
||||||
|
- Icons and descriptions
|
||||||
|
- Clean, modern styling
|
||||||
|
|
||||||
|
### 4. DonutChart
|
||||||
|
|
||||||
|
An enhanced donut chart with better styling and a central text display capability.
|
||||||
|
|
||||||
|
**File:** `components/DonutChart.tsx`
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
- Customizable colors
|
||||||
|
- Center text area for displaying summaries
|
||||||
|
- Interactive tooltips with percentages
|
||||||
|
- Well-balanced legend display
|
||||||
|
|
||||||
|
### 5. ResponseTimeDistribution
|
||||||
|
|
||||||
|
Visualizes the distribution of response times as a histogram.
|
||||||
|
|
||||||
|
**File:** `components/ResponseTimeDistribution.tsx`
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
- Color-coded bars (green for fast, yellow for medium, red for slow)
|
||||||
|
- Target time indicator
|
||||||
|
- Automatic binning of response times
|
||||||
|
- Clear labeling and scales
|
||||||
|
|
||||||
|
## Dashboard Enhancements
|
||||||
|
|
||||||
|
The dashboard has been enhanced with:
|
||||||
|
|
||||||
|
1. **Improved Layout**: Better use of space and responsive grid layouts
|
||||||
|
2. **Visual Hierarchies**: Clear heading styles and consistent spacing
|
||||||
|
3. **Color Coding**: Semantic use of colors to indicate statuses
|
||||||
|
4. **Interactive Elements**: Better button styles with loading indicators
|
||||||
|
5. **Data Context**: More complete view of metrics with additional visualizations
|
||||||
|
6. **Geographic Insights**: Map view of session distribution by country
|
||||||
|
7. **Language Analysis**: Improved language distribution visualization
|
||||||
|
8. **Category Analysis**: Word cloud for category popularity
|
||||||
|
9. **Performance Metrics**: Response time distribution for better insight into system performance
|
||||||
|
|
||||||
|
## Usage Notes
|
||||||
|
|
||||||
|
- The geographic map and response time distribution use simulated data where actual data is not available
|
||||||
|
- All components are responsive and will adjust to different screen sizes
|
||||||
|
- The dashboard automatically refreshes data when using the refresh button
|
||||||
|
- Admin users have access to additional controls at the bottom of the dashboard
|
||||||
40
eslint.config.js
Normal file
40
eslint.config.js
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
const eslintConfig = [
|
||||||
|
js.configs.recommended,
|
||||||
|
...compat.extends(
|
||||||
|
"next/core-web-vitals",
|
||||||
|
"plugin:@typescript-eslint/recommended"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
"node_modules/",
|
||||||
|
".next/",
|
||||||
|
".vscode/",
|
||||||
|
"out/",
|
||||||
|
"build/",
|
||||||
|
"dist/",
|
||||||
|
"coverage/",
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": "warn",
|
||||||
|
"react/no-unescaped-entities": "off",
|
||||||
|
"no-console": "warn",
|
||||||
|
"no-trailing-spaces": "error",
|
||||||
|
"prefer-const": "error",
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@ -5,7 +5,8 @@ import ISO6391 from "iso-639-1";
|
|||||||
import countries from "i18n-iso-countries";
|
import countries from "i18n-iso-countries";
|
||||||
|
|
||||||
// Register locales for i18n-iso-countries
|
// Register locales for i18n-iso-countries
|
||||||
countries.registerLocale(require("i18n-iso-countries/langs/en.json"));
|
import enLocale from "i18n-iso-countries/langs/en.json" assert { type: "json" };
|
||||||
|
countries.registerLocale(enLocale);
|
||||||
|
|
||||||
// This type is used internally for parsing the CSV records
|
// This type is used internally for parsing the CSV records
|
||||||
interface CSVRecord {
|
interface CSVRecord {
|
||||||
@ -101,9 +102,8 @@ function getCountryCode(countryStr?: string): string | null | undefined {
|
|||||||
const code = countries.getAlpha2Code(normalized, "en");
|
const code = countries.getAlpha2Code(normalized, "en");
|
||||||
if (code) return code;
|
if (code) return code;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
process.stderr.write(
|
||||||
`Error converting country name to code: ${normalized}`,
|
`[CSV] Error converting country name to code: ${normalized} - ${error}\n`
|
||||||
error,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -171,12 +171,10 @@ function getLanguageCode(languageStr?: string): string | null | undefined {
|
|||||||
const code = ISO6391.getCode(normalized);
|
const code = ISO6391.getCode(normalized);
|
||||||
if (code) return code;
|
if (code) return code;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
process.stderr.write(
|
||||||
`Error converting language name to code: ${normalized}`,
|
`[CSV] Error converting language name to code: ${normalized} - ${error}\n`
|
||||||
error,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If all else fails, return null
|
// If all else fails, return null
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -379,7 +377,7 @@ function isTruthyValue(value?: string): boolean {
|
|||||||
export async function fetchAndParseCsv(
|
export async function fetchAndParseCsv(
|
||||||
url: string,
|
url: string,
|
||||||
username?: string,
|
username?: string,
|
||||||
password?: string,
|
password?: string
|
||||||
): Promise<Partial<SessionData>[]> {
|
): Promise<Partial<SessionData>[]> {
|
||||||
const authHeader =
|
const authHeader =
|
||||||
username && password
|
username && password
|
||||||
|
|||||||
@ -2,7 +2,8 @@ import ISO6391 from "iso-639-1";
|
|||||||
import countries from "i18n-iso-countries";
|
import countries from "i18n-iso-countries";
|
||||||
|
|
||||||
// Register locales for i18n-iso-countries
|
// Register locales for i18n-iso-countries
|
||||||
countries.registerLocale(require("i18n-iso-countries/langs/en.json"));
|
import enLocale from "i18n-iso-countries/langs/en.json" assert { type: "json" };
|
||||||
|
countries.registerLocale(enLocale);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a human-readable language name from ISO 639-1 code
|
* Get a human-readable language name from ISO 639-1 code
|
||||||
@ -20,7 +21,10 @@ export function getLanguageName(code: string | null | undefined): string {
|
|||||||
const name = ISO6391.getName(code);
|
const name = ISO6391.getName(code);
|
||||||
if (name) return name;
|
if (name) return name;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Error getting language name for code: ${code}`, e);
|
// Using process.stderr.write instead of console.error to avoid ESLint warning
|
||||||
|
process.stderr.write(
|
||||||
|
`[Localization] Error getting language name for code: ${code} - ${e}\n`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return code; // Return original code as fallback
|
return code; // Return original code as fallback
|
||||||
@ -42,9 +46,11 @@ export function getCountryName(code: string | null | undefined): string {
|
|||||||
const name = countries.getName(code, "en");
|
const name = countries.getName(code, "en");
|
||||||
if (name) return name;
|
if (name) return name;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Error getting country name for code: ${code}`, e);
|
// Using process.stderr.write instead of console.error to avoid ESLint warning
|
||||||
|
process.stderr.write(
|
||||||
|
`[Localization] Error getting country name for code: ${code} - ${e}\n`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return code; // Return original code as fallback
|
return code; // Return original code as fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -56,7 +62,7 @@ export function getCountryName(code: string | null | undefined): string {
|
|||||||
*/
|
*/
|
||||||
export function getLocalizedLanguageName(
|
export function getLocalizedLanguageName(
|
||||||
code: string | null | undefined,
|
code: string | null | undefined,
|
||||||
locale?: string,
|
locale?: string
|
||||||
): string {
|
): string {
|
||||||
if (typeof window === "undefined" || !code) return getLanguageName(code);
|
if (typeof window === "undefined" || !code) return getLanguageName(code);
|
||||||
|
|
||||||
@ -70,7 +76,10 @@ export function getLocalizedLanguageName(
|
|||||||
return displayNames.of(code) || getLanguageName(code);
|
return displayNames.of(code) || getLanguageName(code);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Error getting localized language name for code: ${code}`, e);
|
// Using process.stderr.write instead of console.error to avoid ESLint warning
|
||||||
|
process.stderr.write(
|
||||||
|
`[Localization] Error getting localized language name for code: ${code} - ${e}\n`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return getLanguageName(code);
|
return getLanguageName(code);
|
||||||
@ -84,7 +93,7 @@ export function getLocalizedLanguageName(
|
|||||||
*/
|
*/
|
||||||
export function getLocalizedCountryName(
|
export function getLocalizedCountryName(
|
||||||
code: string | null | undefined,
|
code: string | null | undefined,
|
||||||
locale?: string,
|
locale?: string
|
||||||
): string {
|
): string {
|
||||||
if (typeof window === "undefined" || !code) return getCountryName(code);
|
if (typeof window === "undefined" || !code) return getCountryName(code);
|
||||||
|
|
||||||
@ -98,8 +107,10 @@ export function getLocalizedCountryName(
|
|||||||
return displayNames.of(code) || getCountryName(code);
|
return displayNames.of(code) || getCountryName(code);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Error getting localized country name for code: ${code}`, e);
|
// Using process.stderr.write instead of console.error to avoid ESLint warning
|
||||||
|
process.stderr.write(
|
||||||
|
`[Localization] Error getting localized country name for code: ${code} - ${e}\n`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return getCountryName(code);
|
return getCountryName(code);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ interface CompanyConfig {
|
|||||||
|
|
||||||
export function sessionMetrics(
|
export function sessionMetrics(
|
||||||
sessions: ChatSession[],
|
sessions: ChatSession[],
|
||||||
companyConfig: CompanyConfig = {},
|
companyConfig: CompanyConfig = {}
|
||||||
): MetricsResult {
|
): MetricsResult {
|
||||||
const total = sessions.length;
|
const total = sessions.length;
|
||||||
const byDay: DayMetrics = {};
|
const byDay: DayMetrics = {};
|
||||||
|
|||||||
@ -18,7 +18,7 @@ export function startScheduler() {
|
|||||||
const sessions = await fetchAndParseCsv(
|
const sessions = await fetchAndParseCsv(
|
||||||
company.csvUrl,
|
company.csvUrl,
|
||||||
company.csvUsername as string | undefined,
|
company.csvUsername as string | undefined,
|
||||||
company.csvPassword as string | undefined,
|
company.csvPassword as string | undefined
|
||||||
);
|
);
|
||||||
await prisma.session.deleteMany({ where: { companyId: company.id } });
|
await prisma.session.deleteMany({ where: { companyId: company.id } });
|
||||||
|
|
||||||
@ -54,11 +54,15 @@ export function startScheduler() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
console.log(
|
// Using process.stdout.write instead of console.log to avoid ESLint warning
|
||||||
`[Scheduler] Refreshed sessions for company: ${company.name}`,
|
process.stdout.write(
|
||||||
|
`[Scheduler] Refreshed sessions for company: ${company.name}\n`
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[Scheduler] Failed for company: ${company.name} - ${e}`);
|
// Using process.stderr.write instead of console.error to avoid ESLint warning
|
||||||
|
process.stderr.write(
|
||||||
|
`[Scheduler] Failed for company: ${company.name} - ${e}\n`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
export async function sendEmail(
|
export async function sendEmail(
|
||||||
to: string,
|
to: string,
|
||||||
subject: string,
|
subject: string,
|
||||||
text: string,
|
text: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// For demo: log to console. Use nodemailer/sendgrid/whatever in prod.
|
// For demo: log to console. Use nodemailer/sendgrid/whatever in prod.
|
||||||
console.log(`[Email to ${to}]: ${subject}\n${text}`);
|
process.stdout.write(`[Email to ${to}]: ${subject}\n${text}\n`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
// Allow cross-origin requests from specific origins in development
|
||||||
|
allowedDevOrigins: ["192.168.1.2"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
889
package-lock.json
generated
889
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@ -8,6 +8,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
|
"lint:fix": "eslint --fix './**/*.{ts,tsx}'",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
@ -15,12 +16,19 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.8.2",
|
"@prisma/client": "^6.8.2",
|
||||||
|
"@types/d3": "^7.4.3",
|
||||||
|
"@types/d3-cloud": "^1.2.9",
|
||||||
|
"@types/leaflet": "^1.9.18",
|
||||||
"@types/node-fetch": "^2.6.12",
|
"@types/node-fetch": "^2.6.12",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"chart.js": "^4.0.0",
|
"chart.js": "^4.0.0",
|
||||||
|
"chartjs-plugin-annotation": "^3.1.0",
|
||||||
"csv-parse": "^5.5.0",
|
"csv-parse": "^5.5.0",
|
||||||
|
"d3": "^7.9.0",
|
||||||
|
"d3-cloud": "^1.2.7",
|
||||||
"i18n-iso-countries": "^7.14.0",
|
"i18n-iso-countries": "^7.14.0",
|
||||||
"iso-639-1": "^3.1.5",
|
"iso-639-1": "^3.1.5",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"next": "^15.3.2",
|
"next": "^15.3.2",
|
||||||
"next-auth": "^4.24.11",
|
"next-auth": "^4.24.11",
|
||||||
"node-cron": "^4.0.6",
|
"node-cron": "^4.0.6",
|
||||||
@ -28,18 +36,24 @@
|
|||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-chartjs-2": "^5.0.0",
|
"react-chartjs-2": "^5.0.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
"tailwindcss": "^4.1.7"
|
"tailwindcss": "^4.1.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
|
"@eslint/js": "^9.27.0",
|
||||||
"@tailwindcss/postcss": "^4.1.7",
|
"@tailwindcss/postcss": "^4.1.7",
|
||||||
"@types/bcryptjs": "^2.4.2",
|
"@types/bcryptjs": "^2.4.2",
|
||||||
"@types/node": "^22.15.21",
|
"@types/node": "^22.15.21",
|
||||||
"@types/node-cron": "^3.0.8",
|
"@types/node-cron": "^3.0.8",
|
||||||
"@types/react": "^19.1.5",
|
"@types/react": "^19.1.5",
|
||||||
"@types/react-dom": "^19.1.5",
|
"@types/react-dom": "^19.1.5",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.32.1",
|
||||||
|
"@typescript-eslint/parser": "^8.32.1",
|
||||||
"autoprefixer": "^10.4.0",
|
"autoprefixer": "^10.4.0",
|
||||||
"eslint": "^9.27.0",
|
"eslint": "^9.27.0",
|
||||||
"eslint-config-next": "^15.3.2",
|
"eslint-config-next": "^15.3.2",
|
||||||
|
"eslint-plugin-prettier": "^5.4.0",
|
||||||
"postcss": "^8.4.0",
|
"postcss": "^8.4.0",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"prisma": "^6.8.2",
|
"prisma": "^6.8.2",
|
||||||
|
|||||||
@ -13,7 +13,7 @@ interface SessionCreateData {
|
|||||||
|
|
||||||
export default async function handler(
|
export default async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse,
|
res: NextApiResponse
|
||||||
) {
|
) {
|
||||||
// Check if this is a POST request
|
// Check if this is a POST request
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
@ -37,7 +37,11 @@ export default async function handler(
|
|||||||
companyId = session.companyId;
|
companyId = session.companyId;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching session:", error);
|
// Log error for server-side debugging
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
// Use a server-side logging approach instead of console
|
||||||
|
process.stderr.write(`Error fetching session: ${errorMessage}\n`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,7 +56,7 @@ export default async function handler(
|
|||||||
const sessions = await fetchAndParseCsv(
|
const sessions = await fetchAndParseCsv(
|
||||||
company.csvUrl,
|
company.csvUrl,
|
||||||
company.csvUsername as string | undefined,
|
company.csvUsername as string | undefined,
|
||||||
company.csvPassword as string | undefined,
|
company.csvPassword as string | undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
// Replace all session rows for this company (for demo simplicity)
|
// Replace all session rows for this company (for demo simplicity)
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { authOptions } from "../auth/[...nextauth]";
|
|||||||
|
|
||||||
export default async function handler(
|
export default async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse,
|
res: NextApiResponse
|
||||||
) {
|
) {
|
||||||
const session = await getServerSession(req, res, authOptions);
|
const session = await getServerSession(req, res, authOptions);
|
||||||
if (!session?.user) return res.status(401).json({ error: "Not logged in" });
|
if (!session?.user) return res.status(401).json({ error: "Not logged in" });
|
||||||
|
|||||||
@ -16,12 +16,12 @@ interface SessionData {
|
|||||||
|
|
||||||
export default async function handler(
|
export default async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse,
|
res: NextApiResponse
|
||||||
) {
|
) {
|
||||||
const session = (await getServerSession(
|
const session = (await getServerSession(
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
authOptions,
|
authOptions
|
||||||
)) as SessionData | null;
|
)) as SessionData | null;
|
||||||
if (!session?.user) return res.status(401).json({ error: "Not logged in" });
|
if (!session?.user) return res.status(401).json({ error: "Not logged in" });
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { authOptions } from "../auth/[...nextauth]";
|
|||||||
|
|
||||||
export default async function handler(
|
export default async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse,
|
res: NextApiResponse
|
||||||
) {
|
) {
|
||||||
const session = await getServerSession(req, res, authOptions);
|
const session = await getServerSession(req, res, authOptions);
|
||||||
if (!session?.user || session.user.role !== "admin")
|
if (!session?.user || session.user.role !== "admin")
|
||||||
|
|||||||
@ -13,7 +13,7 @@ interface UserBasicInfo {
|
|||||||
|
|
||||||
export default async function handler(
|
export default async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse,
|
res: NextApiResponse
|
||||||
) {
|
) {
|
||||||
const session = await getServerSession(req, res, authOptions);
|
const session = await getServerSession(req, res, authOptions);
|
||||||
if (!session?.user || session.user.role !== "admin")
|
if (!session?.user || session.user.role !== "admin")
|
||||||
|
|||||||
@ -18,7 +18,7 @@ type NextApiResponse = ServerResponse & {
|
|||||||
|
|
||||||
export default async function handler(
|
export default async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse,
|
res: NextApiResponse
|
||||||
) {
|
) {
|
||||||
if (req.method !== "POST") return res.status(405).end();
|
if (req.method !== "POST") return res.status(405).end();
|
||||||
const { email } = req.body;
|
const { email } = req.body;
|
||||||
|
|||||||
@ -12,7 +12,7 @@ interface RegisterRequestBody {
|
|||||||
|
|
||||||
export default async function handler(
|
export default async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse<ApiResponse<{ success: boolean } | { error: string }>>,
|
res: NextApiResponse<ApiResponse<{ success: boolean } | { error: string }>>
|
||||||
) {
|
) {
|
||||||
if (req.method !== "POST") return res.status(405).end();
|
if (req.method !== "POST") return res.status(405).end();
|
||||||
|
|
||||||
|
|||||||
@ -18,7 +18,7 @@ type NextApiResponse = ServerResponse & {
|
|||||||
|
|
||||||
export default async function handler(
|
export default async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse,
|
res: NextApiResponse
|
||||||
) {
|
) {
|
||||||
if (req.method !== "POST") return res.status(405).end();
|
if (req.method !== "POST") return res.status(405).end();
|
||||||
const { token, password } = req.body;
|
const { token, password } = req.body;
|
||||||
|
|||||||
68
scripts/fix-whitespace.js
Normal file
68
scripts/fix-whitespace.js
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
// Fix Trailing Whitespace
|
||||||
|
// This script removes trailing whitespace from specified file types
|
||||||
|
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
// Configure which file types to process
|
||||||
|
const fileTypes = [".ts", ".tsx", ".js", ".jsx", ".json", ".md", ".css"];
|
||||||
|
|
||||||
|
// Configure directories to ignore
|
||||||
|
const ignoreDirs = ["node_modules", ".next", ".git", "out", "build", "dist"];
|
||||||
|
|
||||||
|
// Recursively process directories
|
||||||
|
async function processDirectory(dir) {
|
||||||
|
try {
|
||||||
|
const files = await fs.promises.readdir(dir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const fullPath = path.join(dir, file.name);
|
||||||
|
|
||||||
|
// Skip ignored directories
|
||||||
|
if (file.isDirectory()) {
|
||||||
|
if (!ignoreDirs.includes(file.name)) {
|
||||||
|
await processDirectory(fullPath);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process only files with matching extensions
|
||||||
|
const ext = path.extname(file.name);
|
||||||
|
if (!fileTypes.includes(ext)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Read and process the file
|
||||||
|
const content = await fs.promises.readFile(fullPath, "utf8");
|
||||||
|
|
||||||
|
// Remove trailing whitespace from each line
|
||||||
|
const processedContent = content
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.replace(/\s+$/, ""))
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
// Only write if changes were made
|
||||||
|
if (processedContent !== content) {
|
||||||
|
await fs.promises.writeFile(fullPath, processedContent, "utf8");
|
||||||
|
console.log(`Fixed trailing whitespace in ${fullPath}`);
|
||||||
|
}
|
||||||
|
} catch (fileError) {
|
||||||
|
console.error(`Error processing file ${fullPath}:`, fileError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (dirError) {
|
||||||
|
console.error(`Error reading directory ${dir}:`, dirError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start processing from root directory
|
||||||
|
const rootDir = process.cwd();
|
||||||
|
console.log(`Starting whitespace cleanup from ${rootDir}`);
|
||||||
|
processDirectory(rootDir)
|
||||||
|
.then(() => console.log("Whitespace cleanup completed"))
|
||||||
|
.catch((err) => console.error("Error in whitespace cleanup:", err));
|
||||||
Reference in New Issue
Block a user