mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 11:12:11 +01:00
Add comprehensive dashboard features
Introduce company settings, user management, and layout components Implement session-based Company and User pages for admin access Integrate chart components for dynamic data visualization Add Sidebar for modular navigation Revamp global styles with Tailwind CSS Enhances user experience and administrative control
This commit is contained in:
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
* @kjanat
|
||||||
173
app/dashboard/company/page.tsx
Normal file
173
app/dashboard/company/page.tsx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { Company } from "../../lib/types";
|
||||||
|
|
||||||
|
export default function CompanySettingsPage() {
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
const [company, setCompany] = useState<Company | null>(null);
|
||||||
|
const [csvUrl, setCsvUrl] = useState<string>("");
|
||||||
|
const [csvUsername, setCsvUsername] = useState<string>("");
|
||||||
|
const [csvPassword, setCsvPassword] = useState<string>("");
|
||||||
|
const [sentimentThreshold, setSentimentThreshold] = useState<string>("");
|
||||||
|
const [message, setMessage] = useState<string>("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "authenticated") {
|
||||||
|
const fetchCompany = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/dashboard/config");
|
||||||
|
const data = await res.json();
|
||||||
|
setCompany(data.company);
|
||||||
|
setCsvUrl(data.company.csvUrl || "");
|
||||||
|
setCsvUsername(data.company.csvUsername || "");
|
||||||
|
setSentimentThreshold(data.company.sentimentAlert?.toString() || "");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch company settings:", error);
|
||||||
|
setMessage("Failed to load company settings.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchCompany();
|
||||||
|
}
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setMessage("");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/dashboard/settings", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
csvUrl,
|
||||||
|
csvUsername,
|
||||||
|
csvPassword,
|
||||||
|
sentimentThreshold,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage("Settings saved successfully!");
|
||||||
|
// Update local state if needed
|
||||||
|
const data = await res.json();
|
||||||
|
setCompany(data.company);
|
||||||
|
} else {
|
||||||
|
const error = await res.json();
|
||||||
|
setMessage(
|
||||||
|
`Failed to save settings: ${error.message || "Unknown error"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setMessage("Failed to save settings. Please try again.");
|
||||||
|
console.error("Error saving settings:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-center py-10">Loading settings...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for admin access
|
||||||
|
if (session?.user?.role !== "admin") {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-10 bg-white rounded-xl shadow p-6">
|
||||||
|
<h2 className="font-bold text-xl text-red-600 mb-2">Access Denied</h2>
|
||||||
|
<p>You don't have permission to view company settings.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800 mb-6">
|
||||||
|
Company Settings
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div
|
||||||
|
className={`p-4 rounded mb-6 ${message.includes("Failed") ? "bg-red-100 text-red-700" : "bg-green-100 text-green-700"}`}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="grid gap-6"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="font-medium text-gray-700">
|
||||||
|
CSV Data Source URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||||
|
value={csvUrl}
|
||||||
|
onChange={(e) => setCsvUrl(e.target.value)}
|
||||||
|
placeholder="https://example.com/data.csv"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="font-medium text-gray-700">CSV Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||||
|
value={csvUsername}
|
||||||
|
onChange={(e) => setCsvUsername(e.target.value)}
|
||||||
|
placeholder="Username for CSV access (if needed)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="font-medium text-gray-700">CSV Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||||
|
value={csvPassword}
|
||||||
|
onChange={(e) => setCsvPassword(e.target.value)}
|
||||||
|
placeholder="Password will be updated only if provided"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Leave blank to keep current password
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="font-medium text-gray-700">
|
||||||
|
Sentiment Alert Threshold
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||||
|
value={sentimentThreshold}
|
||||||
|
onChange={(e) => setSentimentThreshold(e.target.value)}
|
||||||
|
placeholder="Threshold value (0-100)"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Percentage of negative sentiment sessions to trigger alert (0-100)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-sky-600 hover:bg-sky-700 text-white py-2 px-4 rounded-lg shadow transition-colors w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Save Settings
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
app/dashboard/layout.tsx
Normal file
46
app/dashboard/layout.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import { useSession, signOut } from "next-auth/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Sidebar from "../../components/Sidebar";
|
||||||
|
|
||||||
|
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Redirect if not authenticated
|
||||||
|
if (status === "unauthenticated") {
|
||||||
|
router.push("/login");
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center">
|
||||||
|
<div className="text-center">Redirecting to login...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state while session status is being determined
|
||||||
|
if (status === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center">
|
||||||
|
<div className="text-center">Loading session...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
signOut({ callbackUrl: "/login" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-gray-100">
|
||||||
|
{/* Sidebar with logout handler passed as prop */}
|
||||||
|
<Sidebar />
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1 overflow-auto p-8">
|
||||||
|
<div className="mx-auto max-w-7xl">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
437
app/dashboard/overview/page.tsx
Normal file
437
app/dashboard/overview/page.tsx
Normal file
@ -0,0 +1,437 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { signOut, useSession } from "next-auth/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
SessionsLineChart,
|
||||||
|
CategoriesBarChart,
|
||||||
|
LanguagePieChart,
|
||||||
|
TokenUsageChart,
|
||||||
|
} from "../../../components/Charts";
|
||||||
|
import { Company, MetricsResult, WordCloudWord } from "../../../lib/types";
|
||||||
|
import MetricCard from "../../../components/MetricCard";
|
||||||
|
import DonutChart from "../../../components/DonutChart";
|
||||||
|
import WordCloud from "../../../components/WordCloud";
|
||||||
|
import GeographicMap from "../../../components/GeographicMap";
|
||||||
|
import ResponseTimeDistribution from "../../../components/ResponseTimeDistribution";
|
||||||
|
import WelcomeBanner from "../../../components/WelcomeBanner";
|
||||||
|
|
||||||
|
// Safely wrapped component with useSession
|
||||||
|
function DashboardContent() {
|
||||||
|
const { data: session, status } = useSession(); // Add status from useSession
|
||||||
|
const router = useRouter(); // Initialize useRouter
|
||||||
|
const [metrics, setMetrics] = useState<MetricsResult | null>(null);
|
||||||
|
const [company, setCompany] = useState<Company | null>(null);
|
||||||
|
const [, setLoading] = useState<boolean>(false);
|
||||||
|
const [refreshing, setRefreshing] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const isAuditor = session?.user?.role === "auditor";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Redirect if not authenticated
|
||||||
|
if (status === "unauthenticated") {
|
||||||
|
router.push("/login");
|
||||||
|
return; // Stop further execution in this effect
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch metrics and company on mount if authenticated
|
||||||
|
if (status === "authenticated") {
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch("/api/dashboard/metrics");
|
||||||
|
const data = await res.json();
|
||||||
|
setMetrics(data.metrics);
|
||||||
|
setCompany(data.company);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
}, [status, router]); // Add status and router to dependency array
|
||||||
|
|
||||||
|
async function handleRefresh() {
|
||||||
|
if (isAuditor) return; // Prevent auditors from refreshing
|
||||||
|
try {
|
||||||
|
setRefreshing(true);
|
||||||
|
|
||||||
|
// Make sure we have a company ID to send
|
||||||
|
if (!company?.id) {
|
||||||
|
setRefreshing(false);
|
||||||
|
alert("Cannot refresh: Company ID is missing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch("/api/admin/refresh-sessions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ companyId: company.id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
// Refetch metrics
|
||||||
|
const metricsRes = await fetch("/api/dashboard/metrics");
|
||||||
|
const data = await metricsRes.json();
|
||||||
|
setMetrics(data.metrics);
|
||||||
|
} else {
|
||||||
|
const errorData = await res.json();
|
||||||
|
alert(`Failed to refresh sessions: ${errorData.error}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate sentiment distribution
|
||||||
|
const getSentimentData = () => {
|
||||||
|
if (!metrics) return { positive: 0, neutral: 0, negative: 0 };
|
||||||
|
|
||||||
|
if (
|
||||||
|
metrics.sentimentPositiveCount !== undefined &&
|
||||||
|
metrics.sentimentNeutralCount !== undefined &&
|
||||||
|
metrics.sentimentNegativeCount !== undefined
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
positive: metrics.sentimentPositiveCount,
|
||||||
|
neutral: metrics.sentimentNeutralCount,
|
||||||
|
negative: metrics.sentimentNegativeCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = metrics.totalSessions || 1;
|
||||||
|
return {
|
||||||
|
positive: Math.round(total * 0.6),
|
||||||
|
neutral: Math.round(total * 0.3),
|
||||||
|
negative: Math.round(total * 0.1),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prepare token usage data
|
||||||
|
const getTokenData = () => {
|
||||||
|
if (!metrics || !metrics.tokensByDay) {
|
||||||
|
return { labels: [], values: [], costs: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = Object.keys(metrics.tokensByDay).sort();
|
||||||
|
const labels = days.slice(-7);
|
||||||
|
const values = labels.map((day) => metrics.tokensByDay?.[day] || 0);
|
||||||
|
const costs = labels.map((day) => metrics.tokensCostByDay?.[day] || 0);
|
||||||
|
|
||||||
|
return { labels, values, costs };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show loading state while session status is being determined
|
||||||
|
if (status === "loading") {
|
||||||
|
return <div className="text-center py-10">Loading session...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If unauthenticated and not redirected yet (should be handled by useEffect, but as a fallback)
|
||||||
|
if (status === "unauthenticated") {
|
||||||
|
return <div className="text-center py-10">Redirecting to login...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!metrics || !company) {
|
||||||
|
return <div className="text-center py-10">Loading dashboard...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to prepare word cloud data from metrics.wordCloudData
|
||||||
|
const getWordCloudData = (): WordCloudWord[] => {
|
||||||
|
if (!metrics || !metrics.wordCloudData) return [];
|
||||||
|
return metrics.wordCloudData;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to prepare country data for the map using actual metrics
|
||||||
|
const getCountryData = () => {
|
||||||
|
if (!metrics || !metrics.countries) return {};
|
||||||
|
|
||||||
|
// Convert the countries object from metrics to the format expected by GeographicMap
|
||||||
|
const result = Object.entries(metrics.countries).reduce(
|
||||||
|
(acc, [code, count]) => {
|
||||||
|
if (code && count) {
|
||||||
|
acc[code] = count;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, number>
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to prepare response time distribution data
|
||||||
|
const getResponseTimeData = () => {
|
||||||
|
const avgTime = metrics.avgResponseTime || 1.5;
|
||||||
|
const simulatedData: number[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const randomFactor = 0.5 + Math.random();
|
||||||
|
simulatedData.push(avgTime * randomFactor);
|
||||||
|
}
|
||||||
|
|
||||||
|
return simulatedData;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<WelcomeBanner companyName={company.name} />
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center bg-white p-6 rounded-2xl shadow-lg ring-1 ring-slate-200/50">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-800">{company.name}</h1>
|
||||||
|
<p className="text-slate-500 mt-1">
|
||||||
|
Dashboard updated{" "}
|
||||||
|
<span className="font-medium text-slate-600">
|
||||||
|
{new Date(metrics.lastUpdated || Date.now()).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-4 sm:mt-0">
|
||||||
|
<button
|
||||||
|
className="bg-sky-600 text-white py-2 px-5 rounded-lg shadow hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed flex items-center text-sm font-medium"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={refreshing || isAuditor}
|
||||||
|
>
|
||||||
|
{refreshing ? (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
Refreshing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Refresh Data"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="bg-slate-100 text-slate-700 py-2 px-5 rounded-lg shadow hover:bg-slate-200 transition-colors flex items-center text-sm font-medium"
|
||||||
|
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<MetricCard
|
||||||
|
title="Total Sessions"
|
||||||
|
value={metrics.totalSessions}
|
||||||
|
icon={
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
trend={{
|
||||||
|
value: metrics.sessionTrend,
|
||||||
|
label:
|
||||||
|
metrics.sessionTrend > 0
|
||||||
|
? `${metrics.sessionTrend}% increase`
|
||||||
|
: `${Math.abs(metrics.sessionTrend || 0)}% decrease`,
|
||||||
|
positive: metrics.sessionTrend >= 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="Unique Users"
|
||||||
|
value={metrics.uniqueUsers}
|
||||||
|
icon={
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
trend={{
|
||||||
|
value: metrics.usersTrend,
|
||||||
|
label:
|
||||||
|
metrics.usersTrend > 0
|
||||||
|
? `${metrics.usersTrend}% increase`
|
||||||
|
: `${Math.abs(metrics.usersTrend || 0)}% decrease`,
|
||||||
|
positive: metrics.usersTrend >= 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="Avg. Session Time"
|
||||||
|
value={`${Math.round(metrics.avgSessionTime || 0)}m`}
|
||||||
|
icon={
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
trend={{
|
||||||
|
value: metrics.sessionTimeTrend,
|
||||||
|
label:
|
||||||
|
metrics.sessionTimeTrend > 0
|
||||||
|
? `${metrics.sessionTimeTrend}% increase`
|
||||||
|
: `${Math.abs(metrics.sessionTimeTrend || 0)}% decrease`,
|
||||||
|
positive: metrics.sessionTimeTrend >= 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="Avg. Response Time"
|
||||||
|
value={`${metrics.avgResponseTime?.toFixed(1) || 0}s`}
|
||||||
|
icon={
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
trend={{
|
||||||
|
value: metrics.responseTimeTrend,
|
||||||
|
label:
|
||||||
|
metrics.responseTimeTrend > 0
|
||||||
|
? `${metrics.responseTimeTrend}% increase`
|
||||||
|
: `${Math.abs(metrics.responseTimeTrend || 0)}% decrease`,
|
||||||
|
positive: metrics.responseTimeTrend <= 0, // Lower response time is better
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow lg:col-span-2">
|
||||||
|
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||||
|
Sessions Over Time
|
||||||
|
</h3>
|
||||||
|
<SessionsLineChart sessions={metrics.sessionsByDate || {}} />
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow">
|
||||||
|
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||||
|
Conversation Sentiment
|
||||||
|
</h3>
|
||||||
|
<DonutChart
|
||||||
|
data={{
|
||||||
|
labels: ["Positive", "Neutral", "Negative"],
|
||||||
|
values: [
|
||||||
|
getSentimentData().positive,
|
||||||
|
getSentimentData().neutral,
|
||||||
|
getSentimentData().negative,
|
||||||
|
],
|
||||||
|
colors: ["#1cad7c", "#a1a1a1", "#dc2626"],
|
||||||
|
}}
|
||||||
|
centerText={{
|
||||||
|
title: "Total",
|
||||||
|
value: metrics.totalSessions,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow">
|
||||||
|
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||||
|
Sessions by Category
|
||||||
|
</h3>
|
||||||
|
<CategoriesBarChart categories={metrics.categories || {}} />
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow">
|
||||||
|
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||||
|
Languages Used
|
||||||
|
</h3>
|
||||||
|
<LanguagePieChart languages={metrics.languages || {}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow">
|
||||||
|
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||||
|
Geographic Distribution
|
||||||
|
</h3>
|
||||||
|
<GeographicMap countries={getCountryData()} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow">
|
||||||
|
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
||||||
|
Common Topics
|
||||||
|
</h3>
|
||||||
|
<WordCloud words={getWordCloudData()} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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
|
||||||
|
data={getResponseTimeData()}
|
||||||
|
average={metrics.avgResponseTime || 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="font-bold text-lg text-gray-800">
|
||||||
|
Token Usage & Costs
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="text-sm bg-blue-50 text-blue-700 px-3 py-1 rounded-full flex items-center">
|
||||||
|
<span className="font-semibold mr-1">Total Tokens:</span>
|
||||||
|
{metrics.totalTokens?.toLocaleString() || 0}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm bg-green-50 text-green-700 px-3 py-1 rounded-full flex items-center">
|
||||||
|
<span className="font-semibold mr-1">Total Cost:</span>€
|
||||||
|
{metrics.totalTokensEur?.toFixed(4) || 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TokenUsageChart tokenData={getTokenData()} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Our exported component
|
||||||
|
export default function DashboardPage() {
|
||||||
|
return <DashboardContent />;
|
||||||
|
}
|
||||||
@ -1,440 +1,104 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { signOut, useSession } from "next-auth/react";
|
import { FC } from "react";
|
||||||
import { useRouter } from "next/navigation"; // Import useRouter
|
|
||||||
import {
|
|
||||||
SessionsLineChart,
|
|
||||||
CategoriesBarChart,
|
|
||||||
LanguagePieChart,
|
|
||||||
TokenUsageChart,
|
|
||||||
} from "../../components/Charts";
|
|
||||||
import DashboardSettings from "./settings";
|
|
||||||
import UserManagement from "./users";
|
|
||||||
import { Company, MetricsResult, WordCloudWord } from "../../lib/types"; // Added WordCloudWord
|
|
||||||
import MetricCard from "../../components/MetricCard";
|
|
||||||
import DonutChart from "../../components/DonutChart";
|
|
||||||
import WordCloud from "../../components/WordCloud";
|
|
||||||
import GeographicMap from "../../components/GeographicMap";
|
|
||||||
import ResponseTimeDistribution from "../../components/ResponseTimeDistribution";
|
|
||||||
import WelcomeBanner from "../../components/WelcomeBanner";
|
|
||||||
|
|
||||||
// Safely wrapped component with useSession
|
const DashboardPage: FC = () => {
|
||||||
function DashboardContent() {
|
const { data: session, status } = useSession();
|
||||||
const { data: session, status } = useSession(); // Add status from useSession
|
const router = useRouter();
|
||||||
const router = useRouter(); // Initialize useRouter
|
const [loading, setLoading] = useState(true);
|
||||||
const [metrics, setMetrics] = useState<MetricsResult | null>(null);
|
|
||||||
const [company, setCompany] = useState<Company | null>(null);
|
|
||||||
const [, setLoading] = useState<boolean>(false);
|
|
||||||
const [refreshing, setRefreshing] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const isAdmin = session?.user?.role === "admin";
|
|
||||||
const isAuditor = session?.user?.role === "auditor";
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Redirect if not authenticated
|
// Once session is loaded, redirect appropriately
|
||||||
if (status === "unauthenticated") {
|
if (status === "unauthenticated") {
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
return; // Stop further execution in this effect
|
} else if (status === "authenticated") {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
}, [status, router]);
|
||||||
|
|
||||||
// Fetch metrics and company on mount if authenticated
|
if (loading) {
|
||||||
if (status === "authenticated") {
|
return (
|
||||||
const fetchData = async () => {
|
<div className="flex items-center justify-center min-h-[40vh]">
|
||||||
setLoading(true);
|
<div className="text-center">
|
||||||
const res = await fetch("/api/dashboard/metrics");
|
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-sky-500 mx-auto mb-4"></div>
|
||||||
const data = await res.json();
|
<p className="text-lg text-gray-600">Loading dashboard...</p>
|
||||||
setMetrics(data.metrics);
|
</div>
|
||||||
setCompany(data.company);
|
</div>
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
fetchData();
|
|
||||||
}
|
|
||||||
}, [status, router]); // Add status and router to dependency array
|
|
||||||
|
|
||||||
async function handleRefresh() {
|
|
||||||
if (isAuditor) return; // Prevent auditors from refreshing
|
|
||||||
try {
|
|
||||||
setRefreshing(true);
|
|
||||||
|
|
||||||
// Make sure we have a company ID to send
|
|
||||||
if (!company?.id) {
|
|
||||||
setRefreshing(false);
|
|
||||||
alert("Cannot refresh: Company ID is missing");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch("/api/admin/refresh-sessions", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ companyId: company.id }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
// Refetch metrics
|
|
||||||
const metricsRes = await fetch("/api/dashboard/metrics");
|
|
||||||
const data = await metricsRes.json();
|
|
||||||
setMetrics(data.metrics);
|
|
||||||
} else {
|
|
||||||
const errorData = await res.json();
|
|
||||||
alert(`Failed to refresh sessions: ${errorData.error}`);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setRefreshing(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate sentiment distribution
|
|
||||||
const getSentimentData = () => {
|
|
||||||
if (!metrics) return { positive: 0, neutral: 0, negative: 0 };
|
|
||||||
|
|
||||||
if (
|
|
||||||
metrics.sentimentPositiveCount !== undefined &&
|
|
||||||
metrics.sentimentNeutralCount !== undefined &&
|
|
||||||
metrics.sentimentNegativeCount !== undefined
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
positive: metrics.sentimentPositiveCount,
|
|
||||||
neutral: metrics.sentimentNeutralCount,
|
|
||||||
negative: metrics.sentimentNegativeCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = metrics.totalSessions || 1;
|
|
||||||
return {
|
|
||||||
positive: Math.round(total * 0.6),
|
|
||||||
neutral: Math.round(total * 0.3),
|
|
||||||
negative: Math.round(total * 0.1),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Prepare token usage data
|
|
||||||
const getTokenData = () => {
|
|
||||||
if (!metrics || !metrics.tokensByDay) {
|
|
||||||
return { labels: [], values: [], costs: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const days = Object.keys(metrics.tokensByDay).sort();
|
|
||||||
const labels = days.slice(-7);
|
|
||||||
const values = labels.map((day) => metrics.tokensByDay?.[day] || 0);
|
|
||||||
const costs = labels.map((day) => metrics.tokensCostByDay?.[day] || 0);
|
|
||||||
|
|
||||||
return { labels, values, costs };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show loading state while session status is being determined
|
|
||||||
if (status === "loading") {
|
|
||||||
return <div className="text-center py-10">Loading session...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If unauthenticated and not redirected yet (should be handled by useEffect, but as a fallback)
|
|
||||||
if (status === "unauthenticated") {
|
|
||||||
return <div className="text-center py-10">Redirecting to login...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!metrics || !company) {
|
|
||||||
return <div className="text-center py-10">Loading dashboard...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to prepare word cloud data from metrics.wordCloudData
|
|
||||||
const getWordCloudData = (): WordCloudWord[] => {
|
|
||||||
if (!metrics || !metrics.wordCloudData) return [];
|
|
||||||
return metrics.wordCloudData;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function to prepare country data for the map using actual metrics
|
|
||||||
const getCountryData = () => {
|
|
||||||
if (!metrics || !metrics.countries) return {};
|
|
||||||
|
|
||||||
// Convert the countries object from metrics to the format expected by GeographicMap
|
|
||||||
const result = Object.entries(metrics.countries).reduce(
|
|
||||||
(acc, [code, count]) => {
|
|
||||||
if (code && count) {
|
|
||||||
acc[code] = count;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, number>
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function to prepare response time distribution data
|
|
||||||
const getResponseTimeData = () => {
|
|
||||||
const avgTime = metrics.avgResponseTime || 1.5;
|
|
||||||
const simulatedData: number[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < 50; i++) {
|
|
||||||
const randomFactor = 0.5 + Math.random();
|
|
||||||
simulatedData.push(avgTime * randomFactor);
|
|
||||||
}
|
|
||||||
|
|
||||||
return simulatedData;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-6">
|
||||||
<WelcomeBanner companyName={company.name} />
|
<div className="bg-white rounded-xl shadow p-6">
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center bg-white p-6 rounded-2xl shadow-lg ring-1 ring-slate-200/50">
|
<h1 className="text-2xl font-bold mb-4">Dashboard</h1>
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-slate-800">{company.name}</h1>
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<p className="text-slate-500 mt-1">
|
<div className="bg-gradient-to-br from-sky-50 to-sky-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
|
||||||
Dashboard updated{" "}
|
<h2 className="text-lg font-semibold text-sky-700">Analytics</h2>
|
||||||
<span className="font-medium text-slate-600">
|
<p className="text-gray-600 mt-2 mb-4">
|
||||||
{new Date(metrics.lastUpdated || Date.now()).toLocaleString()}
|
View your chat session metrics and analytics
|
||||||
</span>
|
</p>
|
||||||
</p>
|
<button
|
||||||
</div>
|
onClick={() => router.push("/dashboard/overview")}
|
||||||
<div className="flex items-center gap-3 mt-4 sm:mt-0">
|
className="bg-sky-500 hover:bg-sky-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||||
<button
|
|
||||||
className="bg-sky-600 text-white py-2 px-5 rounded-lg shadow hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed flex items-center text-sm font-medium"
|
|
||||||
onClick={handleRefresh}
|
|
||||||
disabled={refreshing || isAuditor}
|
|
||||||
>
|
|
||||||
{refreshing ? (
|
|
||||||
<>
|
|
||||||
<svg
|
|
||||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
Refreshing...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4 mr-2"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Refresh Data
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="bg-slate-100 text-slate-700 py-2 px-5 rounded-lg shadow hover:bg-slate-200 transition-colors flex items-center text-sm font-medium"
|
|
||||||
onClick={() => signOut()}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4 mr-2"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
>
|
||||||
<path
|
View Analytics
|
||||||
strokeLinecap="round"
|
</button>
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Sign Out
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<MetricCard
|
|
||||||
title="Total Sessions"
|
|
||||||
value={metrics.totalSessions.toLocaleString()}
|
|
||||||
icon="💬"
|
|
||||||
variant="primary"
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
title="Avg Sessions/Day"
|
|
||||||
value={metrics.avgSessionsPerDay?.toFixed(1) || 0}
|
|
||||||
icon="📊"
|
|
||||||
trend={{ value: 5.2, label: "vs last week" }}
|
|
||||||
variant="success"
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
title="Avg Session Time"
|
|
||||||
value={
|
|
||||||
metrics.avgSessionLength
|
|
||||||
? `${metrics.avgSessionLength.toFixed(1)} min`
|
|
||||||
: "-"
|
|
||||||
}
|
|
||||||
icon="⏱️"
|
|
||||||
trend={{ value: -2.1, label: "vs last week", isPositive: false }}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
title="Avg Response Time"
|
|
||||||
value={
|
|
||||||
metrics.avgResponseTime
|
|
||||||
? `${metrics.avgResponseTime.toFixed(2)}s`
|
|
||||||
: "-"
|
|
||||||
}
|
|
||||||
icon="⚡"
|
|
||||||
trend={{ value: -1.8, label: "vs last week", isPositive: true }}
|
|
||||||
variant="success"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
<div className="bg-white p-6 rounded-xl shadow lg:col-span-1">
|
|
||||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
|
||||||
Sentiment Distribution
|
|
||||||
</h3>
|
|
||||||
<DonutChart
|
|
||||||
data={{
|
|
||||||
labels: ["Positive", "Neutral", "Negative"],
|
|
||||||
values: [
|
|
||||||
getSentimentData().positive,
|
|
||||||
getSentimentData().neutral,
|
|
||||||
getSentimentData().negative,
|
|
||||||
],
|
|
||||||
colors: [
|
|
||||||
"rgba(34, 197, 94, 0.8)",
|
|
||||||
"rgba(249, 115, 22, 0.8)",
|
|
||||||
"rgba(239, 68, 68, 0.8)",
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
centerText={{
|
|
||||||
title: "Overall",
|
|
||||||
value: `${((getSentimentData().positive / (getSentimentData().positive + getSentimentData().neutral + getSentimentData().negative)) * 100).toFixed(0)}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white p-6 rounded-xl shadow lg:col-span-2">
|
|
||||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
|
||||||
Case Handling Statistics
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<MetricCard
|
|
||||||
title="Escalation Rate"
|
|
||||||
value={`${(((metrics.escalatedCount || 0) / (metrics.totalSessions || 1)) * 100).toFixed(1)}%`}
|
|
||||||
description={`${metrics.escalatedCount || 0} sessions escalated`}
|
|
||||||
icon="⚠️"
|
|
||||||
variant={
|
|
||||||
(metrics.escalatedCount || 0) > metrics.totalSessions * 0.1
|
|
||||||
? "warning"
|
|
||||||
: "success"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
title="HR Forwarded"
|
|
||||||
value={`${(((metrics.forwardedCount || 0) / (metrics.totalSessions || 1)) * 100).toFixed(1)}%`}
|
|
||||||
description={`${metrics.forwardedCount || 0} sessions forwarded to HR`}
|
|
||||||
icon="👥"
|
|
||||||
variant={
|
|
||||||
(metrics.forwardedCount || 0) > metrics.totalSessions * 0.05
|
|
||||||
? "warning"
|
|
||||||
: "default"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
title="Resolved Rate"
|
|
||||||
value={`${(((metrics.totalSessions - (metrics.escalatedCount || 0) - (metrics.forwardedCount || 0)) / metrics.totalSessions) * 100).toFixed(1)}%`}
|
|
||||||
description={`${metrics.totalSessions - (metrics.escalatedCount || 0) - (metrics.forwardedCount || 0)} sessions resolved`}
|
|
||||||
icon="✅"
|
|
||||||
variant="success"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<div className="bg-white p-6 rounded-xl shadow">
|
|
||||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
|
||||||
Sessions by Day
|
|
||||||
</h3>
|
|
||||||
<SessionsLineChart sessionsPerDay={metrics.days || {}} />
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-6 rounded-xl shadow">
|
|
||||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
|
||||||
Top Categories
|
|
||||||
</h3>
|
|
||||||
<CategoriesBarChart categories={metrics.categories || {}} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<div className="bg-white p-6 rounded-xl shadow overflow-hidden">
|
|
||||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
|
||||||
Transcript Word Cloud
|
|
||||||
</h3>
|
|
||||||
<WordCloud words={getWordCloudData()} width={400} height={300} />
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-6 rounded-xl shadow">
|
|
||||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
|
||||||
Geographic Distribution
|
|
||||||
</h3>
|
|
||||||
<GeographicMap countries={getCountryData()} height={300} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<div className="bg-white p-6 rounded-xl shadow">
|
|
||||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
|
||||||
Response Time Distribution
|
|
||||||
</h3>
|
|
||||||
<ResponseTimeDistribution
|
|
||||||
responseTimes={getResponseTimeData()}
|
|
||||||
targetResponseTime={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-6 rounded-xl shadow">
|
|
||||||
<h3 className="font-bold text-lg text-gray-800 mb-4">Languages</h3>
|
|
||||||
<LanguagePieChart languages={metrics.languages || {}} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-6 rounded-xl shadow">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h3 className="font-bold text-lg text-gray-800">
|
|
||||||
Token Usage & Costs
|
|
||||||
</h3>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<div className="text-sm bg-blue-50 text-blue-700 px-3 py-1 rounded-full flex items-center">
|
|
||||||
<span className="font-semibold mr-1">Total Tokens:</span>
|
|
||||||
{metrics.totalTokens?.toLocaleString() || 0}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm bg-green-50 text-green-700 px-3 py-1 rounded-full flex items-center">
|
|
||||||
<span className="font-semibold mr-1">Total Cost:</span>€
|
|
||||||
{metrics.totalTokensEur?.toFixed(4) || 0}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<TokenUsageChart tokenData={getTokenData()} />
|
|
||||||
</div>
|
|
||||||
{isAdmin && (
|
|
||||||
<>
|
|
||||||
<DashboardSettings company={company} session={session} />
|
|
||||||
<UserManagement session={session} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Our exported component
|
<div className="bg-gradient-to-br from-emerald-50 to-emerald-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
|
||||||
export default function DashboardPage() {
|
<h2 className="text-lg font-semibold text-emerald-700">Sessions</h2>
|
||||||
return (
|
<p className="text-gray-600 mt-2 mb-4">
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-sky-100 p-4 md:p-6">
|
Browse and analyze conversation sessions
|
||||||
<div className="max-w-7xl mx-auto">
|
</p>
|
||||||
<DashboardContent />
|
<button
|
||||||
|
onClick={() => router.push("/dashboard/sessions")}
|
||||||
|
className="bg-emerald-500 hover:bg-emerald-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
View Sessions
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{session?.user?.role === "admin" && (
|
||||||
|
<div className="bg-gradient-to-br from-purple-50 to-purple-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<h2 className="text-lg font-semibold text-purple-700">
|
||||||
|
Company Settings
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 mt-2 mb-4">
|
||||||
|
Configure company settings and integrations
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/dashboard/company")}
|
||||||
|
className="bg-purple-500 hover:bg-purple-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Manage Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{session?.user?.role === "admin" && (
|
||||||
|
<div className="bg-gradient-to-br from-amber-50 to-amber-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<h2 className="text-lg font-semibold text-amber-700">
|
||||||
|
User Management
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 mt-2 mb-4">
|
||||||
|
Invite and manage user accounts
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/dashboard/users")}
|
||||||
|
className="bg-amber-500 hover:bg-amber-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Manage Users
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default DashboardPage;
|
||||||
|
|||||||
211
app/dashboard/users/page.tsx
Normal file
211
app/dashboard/users/page.tsx
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { UserSession } from "../../../lib/types";
|
||||||
|
|
||||||
|
interface UserItem {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserManagementPage() {
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
const [users, setUsers] = useState<UserItem[]>([]);
|
||||||
|
const [email, setEmail] = useState<string>("");
|
||||||
|
const [role, setRole] = useState<string>("user");
|
||||||
|
const [message, setMessage] = useState<string>("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "authenticated") {
|
||||||
|
fetchUsers();
|
||||||
|
}
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/dashboard/users");
|
||||||
|
const data = await res.json();
|
||||||
|
setUsers(data.users);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch users:", error);
|
||||||
|
setMessage("Failed to load users.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function inviteUser() {
|
||||||
|
setMessage("");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/dashboard/users", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, role }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage("User invited successfully!");
|
||||||
|
setEmail(""); // Clear the form
|
||||||
|
// Refresh the user list
|
||||||
|
fetchUsers();
|
||||||
|
} else {
|
||||||
|
const error = await res.json();
|
||||||
|
setMessage(
|
||||||
|
`Failed to invite user: ${error.message || "Unknown error"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setMessage("Failed to invite user. Please try again.");
|
||||||
|
console.error("Error inviting user:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-center py-10">Loading users...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for admin access
|
||||||
|
if (session?.user?.role !== "admin") {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-10 bg-white rounded-xl shadow p-6">
|
||||||
|
<h2 className="font-bold text-xl text-red-600 mb-2">Access Denied</h2>
|
||||||
|
<p>You don't have permission to view user management.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800 mb-6">
|
||||||
|
User Management
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div
|
||||||
|
className={`p-4 rounded mb-6 ${message.includes("Failed") ? "bg-red-100 text-red-700" : "bg-green-100 text-green-700"}`}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Invite New User</h2>
|
||||||
|
<form
|
||||||
|
className="grid grid-cols-1 sm:grid-cols-3 gap-4 items-end"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
inviteUser();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="font-medium text-gray-700">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||||
|
placeholder="user@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="font-medium text-gray-700">Role</label>
|
||||||
|
<select
|
||||||
|
className="border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500 bg-white"
|
||||||
|
value={role}
|
||||||
|
onChange={(e) => setRole(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="user">User</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
<option value="auditor">Auditor</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-sky-600 hover:bg-sky-700 text-white py-2 px-4 rounded-lg shadow transition-colors"
|
||||||
|
>
|
||||||
|
Invite User
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Current Users</h2>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Email
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Role
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={3}
|
||||||
|
className="px-6 py-4 text-center text-sm text-gray-500"
|
||||||
|
>
|
||||||
|
No users found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
users.map((user) => (
|
||||||
|
<tr key={user.id}>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||||
|
{user.email}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
<span
|
||||||
|
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||||
|
user.role === "admin"
|
||||||
|
? "bg-purple-100 text-purple-800"
|
||||||
|
: user.role === "auditor"
|
||||||
|
? "bg-blue-100 text-blue-800"
|
||||||
|
: "bg-green-100 text-green-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{user.role}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{/* For future: Add actions like edit, delete, etc. */}
|
||||||
|
<span className="text-gray-400">
|
||||||
|
No actions available
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,11 +1 @@
|
|||||||
body {
|
|
||||||
font-family: system-ui, sans-serif;
|
|
||||||
background: #f3f4f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
|
||||||
button {
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|||||||
@ -21,9 +21,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className="bg-gray-100 min-h-screen font-sans">
|
<body className="bg-gray-100 min-h-screen font-sans">
|
||||||
<Providers>
|
<Providers>{children}</Providers>
|
||||||
<div className="max-w-5xl mx-auto py-8">{children}</div>
|
|
||||||
</Providers>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -79,11 +79,11 @@ export default function GeographicMap({
|
|||||||
|
|
||||||
// Process country data when client is ready and dependencies change
|
// Process country data when client is ready and dependencies change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isClient) return;
|
if (!isClient || !countries) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Generate CountryData array for the Map component
|
// Generate CountryData array for the Map component
|
||||||
const data: CountryData[] = Object.entries(countries)
|
const data: CountryData[] = Object.entries(countries || {})
|
||||||
// Only include countries with known coordinates
|
// Only include countries with known coordinates
|
||||||
.filter(([code]) => {
|
.filter(([code]) => {
|
||||||
// If no coordinates found, log to help with debugging
|
// If no coordinates found, log to help with debugging
|
||||||
@ -115,8 +115,8 @@ export default function GeographicMap({
|
|||||||
}
|
}
|
||||||
}, [countries, countryCoordinates, isClient]);
|
}, [countries, countryCoordinates, isClient]);
|
||||||
|
|
||||||
// Find the max count for scaling circles - handle empty countries object
|
// Find the max count for scaling circles - handle empty or null countries object
|
||||||
const countryValues = Object.values(countries);
|
const countryValues = countries ? Object.values(countries) : [];
|
||||||
const maxCount = countryValues.length > 0 ? Math.max(...countryValues, 1) : 1;
|
const maxCount = countryValues.length > 0 ? Math.max(...countryValues, 1) : 1;
|
||||||
|
|
||||||
// Show loading state during SSR or until client-side rendering takes over
|
// Show loading state during SSR or until client-side rendering takes over
|
||||||
|
|||||||
@ -7,28 +7,30 @@ import annotationPlugin from "chartjs-plugin-annotation";
|
|||||||
Chart.register(annotationPlugin);
|
Chart.register(annotationPlugin);
|
||||||
|
|
||||||
interface ResponseTimeDistributionProps {
|
interface ResponseTimeDistributionProps {
|
||||||
responseTimes: number[];
|
data: number[];
|
||||||
|
average: number;
|
||||||
targetResponseTime?: number;
|
targetResponseTime?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ResponseTimeDistribution({
|
export default function ResponseTimeDistribution({
|
||||||
responseTimes,
|
data,
|
||||||
|
average,
|
||||||
targetResponseTime,
|
targetResponseTime,
|
||||||
}: ResponseTimeDistributionProps) {
|
}: ResponseTimeDistributionProps) {
|
||||||
const ref = useRef<HTMLCanvasElement | null>(null);
|
const ref = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref.current || !responseTimes.length) return;
|
if (!ref.current || !data || !data.length) return;
|
||||||
|
|
||||||
const ctx = ref.current.getContext("2d");
|
const ctx = ref.current.getContext("2d");
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
// Create bins for the histogram (0-1s, 1-2s, 2-3s, etc.)
|
// Create bins for the histogram (0-1s, 1-2s, 2-3s, etc.)
|
||||||
const maxTime = Math.ceil(Math.max(...responseTimes));
|
const maxTime = Math.ceil(Math.max(...data));
|
||||||
const bins = Array(Math.min(maxTime + 1, 10)).fill(0);
|
const bins = Array(Math.min(maxTime + 1, 10)).fill(0);
|
||||||
|
|
||||||
// Count responses in each bin
|
// Count responses in each bin
|
||||||
responseTimes.forEach((time) => {
|
data.forEach((time) => {
|
||||||
const binIndex = Math.min(Math.floor(time), bins.length - 1);
|
const binIndex = Math.min(Math.floor(time), bins.length - 1);
|
||||||
bins[binIndex]++;
|
bins[binIndex]++;
|
||||||
});
|
});
|
||||||
@ -63,26 +65,40 @@ export default function ResponseTimeDistribution({
|
|||||||
responsive: true,
|
responsive: true,
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: { display: false },
|
legend: { display: false },
|
||||||
annotation: targetResponseTime
|
annotation: {
|
||||||
? {
|
annotations: {
|
||||||
annotations: {
|
averageLine: {
|
||||||
targetLine: {
|
type: "line",
|
||||||
|
yMin: 0,
|
||||||
|
yMax: Math.max(...bins),
|
||||||
|
xMin: average,
|
||||||
|
xMax: average,
|
||||||
|
borderColor: "rgba(75, 192, 192, 1)",
|
||||||
|
borderWidth: 2,
|
||||||
|
label: {
|
||||||
|
display: true,
|
||||||
|
content: "Avg: " + average.toFixed(1) + "s",
|
||||||
|
position: "start",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
targetLine: targetResponseTime
|
||||||
|
? {
|
||||||
type: "line",
|
type: "line",
|
||||||
yMin: 0,
|
yMin: 0,
|
||||||
yMax: Math.max(...bins),
|
yMax: Math.max(...bins),
|
||||||
xMin: targetResponseTime,
|
xMin: targetResponseTime,
|
||||||
xMax: targetResponseTime,
|
xMax: targetResponseTime,
|
||||||
borderColor: "rgba(75, 192, 192, 1)",
|
borderColor: "rgba(75, 192, 192, 0.7)",
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
label: {
|
label: {
|
||||||
display: true,
|
display: true,
|
||||||
content: "Target",
|
content: "Target",
|
||||||
position: "start",
|
position: "end",
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
},
|
: undefined,
|
||||||
}
|
},
|
||||||
: undefined,
|
},
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
y: {
|
y: {
|
||||||
@ -103,7 +119,7 @@ export default function ResponseTimeDistribution({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => chart.destroy();
|
return () => chart.destroy();
|
||||||
}, [responseTimes, targetResponseTime]);
|
}, [data, average, targetResponseTime]);
|
||||||
|
|
||||||
return <canvas ref={ref} height={180} />;
|
return <canvas ref={ref} height={180} />;
|
||||||
}
|
}
|
||||||
|
|||||||
306
components/Sidebar.tsx
Normal file
306
components/Sidebar.tsx
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { signOut } from "next-auth/react";
|
||||||
|
|
||||||
|
// Icons for the sidebar
|
||||||
|
const DashboardIcon = () => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CompanyIcon = () => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const UsersIcon = () => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SessionsIcon = () => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const LogoutIcon = () => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ToggleIcon = ({ isExpanded }: { isExpanded: boolean }) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={`h-5 w-5 transform transition-transform ${isExpanded ? "rotate-180" : ""}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface NavItemProps {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
isExpanded: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NavItem: React.FC<NavItemProps> = ({
|
||||||
|
href,
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
isExpanded,
|
||||||
|
isActive,
|
||||||
|
}) => (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={`relative flex items-center p-3 my-1 rounded-lg transition-all group ${
|
||||||
|
isActive
|
||||||
|
? "bg-sky-100 text-sky-800 font-medium"
|
||||||
|
: "hover:bg-gray-100 text-gray-700 hover:text-gray-900"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`flex-shrink-0 ${isExpanded ? "mr-3" : "mx-auto"}`}>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
{isExpanded ? (
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="fixed ml-6 w-auto p-2 min-w-max rounded-md shadow-md text-xs font-medium
|
||||||
|
text-white bg-gray-800 z-50
|
||||||
|
invisible opacity-0 -translate-x-3 transition-all
|
||||||
|
group-hover:visible group-hover:opacity-100 group-hover:translate-x-0"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function Sidebar() {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
const pathname = usePathname() || "";
|
||||||
|
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
setIsExpanded(!isExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
signOut({ callbackUrl: "/login" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative h-screen bg-white shadow-md transition-all duration-300 ${
|
||||||
|
isExpanded ? "w-56" : "w-16"
|
||||||
|
} flex flex-col overflow-visible`}
|
||||||
|
>
|
||||||
|
{/* Logo section - now above toggle button */}
|
||||||
|
<div className="flex flex-col items-center pt-5 pb-3">
|
||||||
|
<div
|
||||||
|
className={`relative ${isExpanded ? "w-16" : "w-10"} aspect-square mb-1`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/favicon.svg"
|
||||||
|
alt="LiveDash Logo"
|
||||||
|
className="w-full h-full transition-all duration-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isExpanded && (
|
||||||
|
<span className="text-lg font-bold text-sky-700 mt-1">LiveDash</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggle button */}
|
||||||
|
<div className="flex justify-center border-b border-t py-2">
|
||||||
|
<button
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
className="p-1.5 rounded-lg bg-gray-100 hover:bg-gray-200 transition-colors relative group"
|
||||||
|
title={isExpanded ? "Collapse sidebar" : "Expand sidebar"}
|
||||||
|
>
|
||||||
|
<ToggleIcon isExpanded={isExpanded} />
|
||||||
|
{!isExpanded && (
|
||||||
|
<div
|
||||||
|
className="fixed ml-6 w-auto p-2 min-w-max rounded-md shadow-md text-xs font-medium
|
||||||
|
text-white bg-gray-800 z-50
|
||||||
|
invisible opacity-0 -translate-x-3 transition-all
|
||||||
|
group-hover:visible group-hover:opacity-100 group-hover:translate-x-0"
|
||||||
|
>
|
||||||
|
{isExpanded ? "Collapse sidebar" : "Expand sidebar"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation items */}
|
||||||
|
<nav className="flex-1 py-4 px-2 overflow-y-auto overflow-x-visible">
|
||||||
|
<NavItem
|
||||||
|
href="/dashboard"
|
||||||
|
label="Dashboard"
|
||||||
|
icon={<DashboardIcon />}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
isActive={pathname === "/dashboard"}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href="/dashboard/overview"
|
||||||
|
label="Analytics"
|
||||||
|
icon={
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
isActive={pathname === "/dashboard/overview"}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href="/dashboard/sessions"
|
||||||
|
label="Sessions"
|
||||||
|
icon={<SessionsIcon />}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
isActive={pathname.startsWith("/dashboard/sessions")}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href="/dashboard/company"
|
||||||
|
label="Company Settings"
|
||||||
|
icon={<CompanyIcon />}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
isActive={pathname === "/dashboard/company"}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href="/dashboard/users"
|
||||||
|
label="User Management"
|
||||||
|
icon={<UsersIcon />}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
isActive={pathname === "/dashboard/users"}
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Logout at the bottom */}
|
||||||
|
<div className="p-4 border-t mt-auto">
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className={`relative flex items-center p-3 w-full rounded-lg text-gray-700 hover:bg-gray-100 hover:text-gray-900 transition-all group ${
|
||||||
|
isExpanded ? "" : "justify-center"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`flex-shrink-0 ${isExpanded ? "mr-3" : ""}`}>
|
||||||
|
<LogoutIcon />
|
||||||
|
</span>
|
||||||
|
{isExpanded ? (
|
||||||
|
<span>Logout</span>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="fixed ml-6 w-auto p-2 min-w-max rounded-md shadow-md text-xs font-medium
|
||||||
|
text-white bg-gray-800 z-50
|
||||||
|
invisible opacity-0 -translate-x-3 transition-all
|
||||||
|
group-hover:visible group-hover:opacity-100 group-hover:translate-x-0"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -24,6 +24,12 @@ export default async function handler(
|
|||||||
data: { csvUrl },
|
data: { csvUrl },
|
||||||
});
|
});
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
|
} else if (req.method === "GET") {
|
||||||
|
// Get company data
|
||||||
|
const company = await prisma.company.findUnique({
|
||||||
|
where: { id: user.companyId },
|
||||||
|
});
|
||||||
|
res.json({ company });
|
||||||
} else {
|
} else {
|
||||||
res.status(405).end();
|
res.status(405).end();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user