feat: implement comprehensive email system with rate limiting and extensive test suite

- Add robust email service with rate limiting and configuration management
- Implement shared rate limiter utility for consistent API protection
- Create comprehensive test suite for core processing pipeline
- Add API tests for dashboard metrics and authentication routes
- Fix date range picker infinite loop issue
- Improve session lookup in refresh sessions API
- Refactor session API routing with better code organization
- Update processing pipeline status monitoring
- Clean up leftover files and improve code formatting
This commit is contained in:
2025-07-05 13:42:47 +02:00
committed by Kaj Kowalski
parent 19628233ea
commit a0ac60cf04
36 changed files with 10714 additions and 5292 deletions

View File

@ -6,29 +6,7 @@ import { prisma } from "../../../../lib/prisma";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
let { companyId } = body;
if (!companyId) {
// Try to get user from prisma based on session cookie
try {
const session = await prisma.session.findFirst({
orderBy: { createdAt: "desc" },
where: {
/* Add session check criteria here */
},
});
if (session) {
companyId = session.companyId;
}
} catch (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`);
}
}
const { companyId } = body;
if (!companyId) {
return NextResponse.json(

View File

@ -1,10 +1,141 @@
import { SessionCategory, type Prisma } from "@prisma/client";
import type { Prisma, SessionCategory } from "@prisma/client";
import { type NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "../../../../lib/auth";
import { prisma } from "../../../../lib/prisma";
import type { ChatSession } from "../../../../lib/types";
/**
* Build where clause for session filtering
*/
function buildWhereClause(
companyId: string,
searchParams: URLSearchParams
): Prisma.SessionWhereInput {
const whereClause: Prisma.SessionWhereInput = { companyId };
const searchTerm = searchParams.get("searchTerm");
const category = searchParams.get("category");
const language = searchParams.get("language");
const startDate = searchParams.get("startDate");
const endDate = searchParams.get("endDate");
// Search Term
if (searchTerm && searchTerm.trim() !== "") {
const searchConditions = [
{ id: { contains: searchTerm } },
{ initialMsg: { contains: searchTerm } },
{ summary: { contains: searchTerm } },
];
whereClause.OR = searchConditions;
}
// Category Filter
if (category && category.trim() !== "") {
whereClause.category = category as SessionCategory;
}
// Language Filter
if (language && language.trim() !== "") {
whereClause.language = language;
}
// Date Range Filter
if (startDate) {
whereClause.startTime = {
...((whereClause.startTime as object) || {}),
gte: new Date(startDate),
};
}
if (endDate) {
const inclusiveEndDate = new Date(endDate);
inclusiveEndDate.setDate(inclusiveEndDate.getDate() + 1);
whereClause.startTime = {
...((whereClause.startTime as object) || {}),
lt: inclusiveEndDate,
};
}
return whereClause;
}
/**
* Build order by clause for session sorting
*/
function buildOrderByClause(
searchParams: URLSearchParams
):
| Prisma.SessionOrderByWithRelationInput
| Prisma.SessionOrderByWithRelationInput[] {
const sortKey = searchParams.get("sortKey");
const sortOrder = searchParams.get("sortOrder");
const validSortKeys: { [key: string]: string } = {
startTime: "startTime",
category: "category",
language: "language",
sentiment: "sentiment",
messagesSent: "messagesSent",
avgResponseTime: "avgResponseTime",
};
const primarySortField =
sortKey && validSortKeys[sortKey] ? validSortKeys[sortKey] : "startTime";
const primarySortOrder =
sortOrder === "asc" || sortOrder === "desc" ? sortOrder : "desc";
if (primarySortField === "startTime") {
return { [primarySortField]: primarySortOrder };
}
return [{ [primarySortField]: primarySortOrder }, { startTime: "desc" }];
}
/**
* Convert Prisma session to ChatSession format
*/
function convertPrismaSessionToChatSession(ps: {
id: string;
companyId: string;
startTime: Date;
endTime: Date | null;
createdAt: Date;
category: string | null;
language: string | null;
country: string | null;
ipAddress: string | null;
sentiment: string | null;
messagesSent: number | null;
avgResponseTime: number | null;
escalated: boolean | null;
forwardedHr: boolean | null;
initialMsg: string | null;
fullTranscriptUrl: string | null;
}): ChatSession {
return {
id: ps.id,
sessionId: ps.id,
companyId: ps.companyId,
startTime: new Date(ps.startTime),
endTime: ps.endTime ? new Date(ps.endTime) : null,
createdAt: new Date(ps.createdAt),
updatedAt: new Date(ps.createdAt),
userId: null,
category: ps.category ?? null,
language: ps.language ?? null,
country: ps.country ?? null,
ipAddress: ps.ipAddress ?? null,
sentiment: ps.sentiment ?? null,
messagesSent: ps.messagesSent ?? undefined,
avgResponseTime: ps.avgResponseTime ?? null,
escalated: ps.escalated ?? undefined,
forwardedHr: ps.forwardedHr ?? undefined,
initialMsg: ps.initialMsg ?? undefined,
fullTranscriptUrl: ps.fullTranscriptUrl ?? null,
transcriptContent: null,
};
}
export async function GET(request: NextRequest) {
const authSession = await getServerSession(authOptions);
@ -15,89 +146,14 @@ export async function GET(request: NextRequest) {
const companyId = authSession.user.companyId;
const { searchParams } = new URL(request.url);
const searchTerm = searchParams.get("searchTerm");
const category = searchParams.get("category");
const language = searchParams.get("language");
const startDate = searchParams.get("startDate");
const endDate = searchParams.get("endDate");
const sortKey = searchParams.get("sortKey");
const sortOrder = searchParams.get("sortOrder");
const queryPage = searchParams.get("page");
const queryPageSize = searchParams.get("pageSize");
const page = Number(queryPage) || 1;
const pageSize = Number(queryPageSize) || 10;
try {
const whereClause: Prisma.SessionWhereInput = { companyId };
// Search Term
if (searchTerm && searchTerm.trim() !== "") {
const searchConditions = [
{ id: { contains: searchTerm } },
{ initialMsg: { contains: searchTerm } },
{ summary: { contains: searchTerm } },
];
whereClause.OR = searchConditions;
}
// Category Filter
if (category && category.trim() !== "") {
// Cast to SessionCategory enum if it's a valid value
whereClause.category = category as SessionCategory;
}
// Language Filter
if (language && language.trim() !== "") {
whereClause.language = language;
}
// Date Range Filter
if (startDate) {
whereClause.startTime = {
...((whereClause.startTime as object) || {}),
gte: new Date(startDate),
};
}
if (endDate) {
const inclusiveEndDate = new Date(endDate);
inclusiveEndDate.setDate(inclusiveEndDate.getDate() + 1);
whereClause.startTime = {
...((whereClause.startTime as object) || {}),
lt: inclusiveEndDate,
};
}
// Sorting
const validSortKeys: { [key: string]: string } = {
startTime: "startTime",
category: "category",
language: "language",
sentiment: "sentiment",
messagesSent: "messagesSent",
avgResponseTime: "avgResponseTime",
};
let orderByCondition:
| Prisma.SessionOrderByWithRelationInput
| Prisma.SessionOrderByWithRelationInput[];
const primarySortField =
sortKey && validSortKeys[sortKey] ? validSortKeys[sortKey] : "startTime"; // Default to startTime field if sortKey is invalid/missing
const primarySortOrder =
sortOrder === "asc" || sortOrder === "desc" ? sortOrder : "desc"; // Default to desc order
if (primarySortField === "startTime") {
// If sorting by startTime, it's the only sort criteria
orderByCondition = { [primarySortField]: primarySortOrder };
} else {
// If sorting by another field, use startTime: "desc" as secondary sort
orderByCondition = [
{ [primarySortField]: primarySortOrder },
{ startTime: "desc" },
];
}
const whereClause = buildWhereClause(companyId, searchParams);
const orderByCondition = buildOrderByClause(searchParams);
const prismaSessions = await prisma.session.findMany({
where: whereClause,
@ -108,28 +164,9 @@ export async function GET(request: NextRequest) {
const totalSessions = await prisma.session.count({ where: whereClause });
const sessions: ChatSession[] = prismaSessions.map((ps) => ({
id: ps.id,
sessionId: ps.id,
companyId: ps.companyId,
startTime: new Date(ps.startTime),
endTime: ps.endTime ? new Date(ps.endTime) : null,
createdAt: new Date(ps.createdAt),
updatedAt: new Date(ps.createdAt),
userId: null,
category: ps.category ?? null,
language: ps.language ?? null,
country: ps.country ?? null,
ipAddress: ps.ipAddress ?? null,
sentiment: ps.sentiment ?? null,
messagesSent: ps.messagesSent ?? undefined,
avgResponseTime: ps.avgResponseTime ?? null,
escalated: ps.escalated ?? undefined,
forwardedHr: ps.forwardedHr ?? undefined,
initialMsg: ps.initialMsg ?? undefined,
fullTranscriptUrl: ps.fullTranscriptUrl ?? null,
transcriptContent: null, // Transcript content is now fetched from fullTranscriptUrl when needed
}));
const sessions: ChatSession[] = prismaSessions.map(
convertPrismaSessionToChatSession
);
return NextResponse.json({ sessions, totalSessions });
} catch (error) {

View File

@ -77,6 +77,17 @@ export async function POST(request: NextRequest) {
},
});
// TODO: Email user their temp password (stub, for demo) - Implement a robust and secure email sending mechanism. Consider using a transactional email service.
return NextResponse.json({ ok: true, tempPassword });
const { sendPasswordResetEmail } = await import("../../../../lib/sendEmail");
const emailResult = await sendPasswordResetEmail(email, tempPassword);
if (!emailResult.success) {
console.warn("Failed to send password email:", emailResult.error);
}
return NextResponse.json({
ok: true,
tempPassword,
emailSent: emailResult.success,
emailError: emailResult.error,
});
}

View File

@ -1,57 +1,25 @@
import crypto from "node:crypto";
import { type NextRequest, NextResponse } from "next/server";
import { prisma } from "../../../lib/prisma";
import { extractClientIP, InMemoryRateLimiter } from "../../../lib/rateLimiter";
import { sendEmail } from "../../../lib/sendEmail";
import { forgotPasswordSchema, validateInput } from "../../../lib/validation";
// In-memory rate limiting with automatic cleanup
const resetAttempts = new Map<string, { count: number; resetTime: number }>();
const CLEANUP_INTERVAL = 5 * 60 * 1000;
const MAX_ENTRIES = 10000;
setInterval(() => {
const now = Date.now();
resetAttempts.forEach((attempts, ip) => {
if (now > attempts.resetTime) {
resetAttempts.delete(ip);
}
});
}, CLEANUP_INTERVAL);
function checkRateLimit(ip: string): boolean {
const now = Date.now();
// Prevent unbounded growth
if (resetAttempts.size > MAX_ENTRIES) {
const entries = Array.from(resetAttempts.entries());
entries.sort((a, b) => a[1].resetTime - b[1].resetTime);
entries.slice(0, Math.floor(MAX_ENTRIES / 2)).forEach(([ip]) => {
resetAttempts.delete(ip);
});
}
const attempts = resetAttempts.get(ip);
if (!attempts || now > attempts.resetTime) {
resetAttempts.set(ip, { count: 1, resetTime: now + 15 * 60 * 1000 }); // 15 minute window
return true;
}
if (attempts.count >= 5) {
// Max 5 reset requests per 15 minutes per IP
return false;
}
attempts.count++;
return true;
}
// Rate limiting for password reset endpoint
const passwordResetLimiter = new InMemoryRateLimiter({
maxAttempts: 5,
windowMs: 15 * 60 * 1000, // 15 minutes
maxEntries: 10000,
cleanupIntervalMs: 5 * 60 * 1000, // 5 minutes
});
export async function POST(request: NextRequest) {
try {
// Rate limiting check
const ip =
request.headers.get("x-forwarded-for") ||
request.headers.get("x-real-ip") ||
"unknown";
if (!checkRateLimit(ip)) {
// Rate limiting check using shared utility
const ip = extractClientIP(request);
const rateLimitResult = passwordResetLimiter.checkRateLimit(ip);
if (!rateLimitResult.allowed) {
return NextResponse.json(
{
success: false,

View File

@ -1,63 +1,24 @@
import bcrypt from "bcryptjs";
import { type NextRequest, NextResponse } from "next/server";
import { prisma } from "../../../lib/prisma";
import { extractClientIP, InMemoryRateLimiter } from "../../../lib/rateLimiter";
import { registerSchema, validateInput } from "../../../lib/validation";
// In-memory rate limiting with automatic cleanup
const registrationAttempts = new Map<
string,
{ count: number; resetTime: number }
>();
// Clean up expired entries every 5 minutes
const CLEANUP_INTERVAL = 5 * 60 * 1000;
const MAX_ENTRIES = 10000; // Prevent unbounded growth
setInterval(() => {
const now = Date.now();
registrationAttempts.forEach((attempts, ip) => {
if (now > attempts.resetTime) {
registrationAttempts.delete(ip);
}
});
}, CLEANUP_INTERVAL);
function checkRateLimit(ip: string): boolean {
const now = Date.now();
// Prevent unbounded growth
if (registrationAttempts.size > MAX_ENTRIES) {
// Remove oldest entries
const entries = Array.from(registrationAttempts.entries());
entries.sort((a, b) => a[1].resetTime - b[1].resetTime);
entries.slice(0, Math.floor(MAX_ENTRIES / 2)).forEach(([ip]) => {
registrationAttempts.delete(ip);
});
}
const attempts = registrationAttempts.get(ip);
if (!attempts || now > attempts.resetTime) {
registrationAttempts.set(ip, { count: 1, resetTime: now + 60 * 60 * 1000 }); // 1 hour window
return true;
}
if (attempts.count >= 3) {
// Max 3 registrations per hour per IP
return false;
}
attempts.count++;
return true;
}
// Rate limiting for registration endpoint
const registrationLimiter = new InMemoryRateLimiter({
maxAttempts: 3,
windowMs: 60 * 60 * 1000, // 1 hour
maxEntries: 10000,
cleanupIntervalMs: 5 * 60 * 1000, // 5 minutes
});
export async function POST(request: NextRequest) {
try {
// Rate limiting check - improved IP extraction
const forwardedFor = request.headers.get("x-forwarded-for");
const ip = forwardedFor
? forwardedFor.split(",")[0].trim() // Get first IP if multiple
: request.headers.get("x-real-ip") ||
"unknown";
if (!checkRateLimit(ip)) {
// Rate limiting check using shared utility
const ip = extractClientIP(request);
const rateLimitResult = registrationLimiter.checkRateLimit(ip);
if (!rateLimitResult.allowed) {
return NextResponse.json(
{
success: false,

View File

@ -38,7 +38,433 @@ import MetricCard from "../../../components/ui/metric-card";
import WordCloud from "../../../components/WordCloud";
import type { Company, MetricsResult, WordCloudWord } from "../../../lib/types";
// Safely wrapped component with useSession
/**
* Loading states component for better organization
*/
function DashboardLoadingStates({ status }: { status: string }) {
if (status === "loading") {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center space-y-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto" />
<p className="text-muted-foreground">Loading session...</p>
</div>
</div>
);
}
if (status === "unauthenticated") {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center">
<p className="text-muted-foreground">Redirecting to login...</p>
</div>
</div>
);
}
return null;
}
/**
* Loading skeleton component
*/
function DashboardSkeleton() {
return (
<div className="space-y-8">
{/* Header Skeleton */}
<Card>
<CardHeader>
<div className="flex justify-between items-start">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<div className="flex gap-2">
<Skeleton className="h-10 w-24" />
<Skeleton className="h-10 w-20" />
</div>
</div>
</CardHeader>
</Card>
{/* Metrics Grid Skeleton */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{Array.from({ length: 8 }, (_, i) => {
const metricTypes = [
"sessions",
"users",
"time",
"response",
"costs",
"peak",
"resolution",
"languages",
];
return (
<MetricCard
key={`skeleton-${metricTypes[i] || "metric"}-card-loading`}
title=""
value=""
isLoading
/>
);
})}
</div>
{/* Charts Skeleton */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-2">
<CardHeader>
<Skeleton className="h-6 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-64 w-full" />
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-64 w-full" />
</CardContent>
</Card>
</div>
</div>
);
}
/**
* Data processing utilities
*/
function useDashboardData(metrics: MetricsResult | null) {
const getSentimentData = useCallback(() => {
if (!metrics) return [];
const sentimentData = {
positive: metrics.sentimentPositiveCount ?? 0,
neutral: metrics.sentimentNeutralCount ?? 0,
negative: metrics.sentimentNegativeCount ?? 0,
};
return [
{
name: "Positive",
value: sentimentData.positive,
color: "hsl(var(--chart-1))",
},
{
name: "Neutral",
value: sentimentData.neutral,
color: "hsl(var(--chart-2))",
},
{
name: "Negative",
value: sentimentData.negative,
color: "hsl(var(--chart-3))",
},
];
}, [metrics]);
const getSessionsOverTimeData = useCallback(() => {
if (!metrics?.days) return [];
return Object.entries(metrics.days).map(([date, value]) => ({
date: new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
}),
value: value as number,
}));
}, [metrics?.days]);
const getCategoriesData = useCallback(() => {
if (!metrics?.categories) return [];
return Object.entries(metrics.categories).map(([name, value]) => {
const formattedName = formatEnumValue(name) || name;
return {
name:
formattedName.length > 15
? `${formattedName.substring(0, 15)}...`
: formattedName,
value: value as number,
};
});
}, [metrics?.categories]);
const getLanguagesData = useCallback(() => {
if (!metrics?.languages) return [];
return Object.entries(metrics.languages).map(([name, value]) => ({
name,
value: value as number,
}));
}, [metrics?.languages]);
const getWordCloudData = useCallback((): WordCloudWord[] => {
if (!metrics?.wordCloudData) return [];
return metrics.wordCloudData;
}, [metrics?.wordCloudData]);
const getCountryData = useCallback(() => {
if (!metrics?.countries) return {};
return Object.entries(metrics.countries).reduce(
(acc, [code, count]) => {
if (code && count) {
acc[code] = count;
}
return acc;
},
{} as Record<string, number>
);
}, [metrics?.countries]);
const getResponseTimeData = useCallback(() => {
const avgTime = metrics?.avgResponseTime || 1.5;
const simulatedData: number[] = [];
for (let i = 0; i < 50; i++) {
const randomFactor = 0.5 + Math.random();
simulatedData.push(avgTime * randomFactor);
}
return simulatedData;
}, [metrics?.avgResponseTime]);
return {
getSentimentData,
getSessionsOverTimeData,
getCategoriesData,
getLanguagesData,
getWordCloudData,
getCountryData,
getResponseTimeData,
};
}
/**
* Dashboard header component
*/
function DashboardHeader({
company,
metrics,
isAuditor,
refreshing,
onRefresh,
}: {
company: Company;
metrics: MetricsResult;
isAuditor: boolean;
refreshing: boolean;
onRefresh: () => void;
}) {
const refreshStatusId = useId();
return (
<Card className="border-0 bg-linear-to-r from-primary/5 via-primary/10 to-primary/5">
<CardHeader>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div className="space-y-2">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">
{company.name}
</h1>
<Badge variant="secondary" className="text-xs">
Analytics Dashboard
</Badge>
</div>
<p className="text-muted-foreground">
Last updated{" "}
<span className="font-medium">
{new Date(metrics.lastUpdated || Date.now()).toLocaleString()}
</span>
</p>
</div>
<div className="flex items-center gap-2">
<Button
onClick={onRefresh}
disabled={refreshing || isAuditor}
size="sm"
className="gap-2"
aria-label={
refreshing
? "Refreshing dashboard data"
: "Refresh dashboard data"
}
aria-describedby={refreshing ? refreshStatusId : undefined}
>
<RefreshCw
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
aria-hidden="true"
/>
{refreshing ? "Refreshing..." : "Refresh"}
</Button>
{refreshing && (
<div id={refreshStatusId} className="sr-only" aria-live="polite">
Dashboard data is being refreshed
</div>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" aria-label="Account menu">
<MoreVertical className="h-4 w-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => signOut({ callbackUrl: "/login" })}
>
<LogOut className="h-4 w-4 mr-2" aria-hidden="true" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardHeader>
</Card>
);
}
/**
* Individual metric card components for better organization
*/
function SessionMetricCard({ metrics }: { metrics: MetricsResult }) {
return (
<MetricCard
title="Total Sessions"
value={metrics.totalSessions?.toLocaleString()}
icon={<MessageSquare className="h-5 w-5" />}
trend={{
value: metrics.sessionTrend ?? 0,
isPositive: (metrics.sessionTrend ?? 0) >= 0,
}}
variant="primary"
/>
);
}
function UsersMetricCard({ metrics }: { metrics: MetricsResult }) {
return (
<MetricCard
title="Unique Users"
value={metrics.uniqueUsers?.toLocaleString()}
icon={<Users className="h-5 w-5" />}
trend={{
value: metrics.usersTrend ?? 0,
isPositive: (metrics.usersTrend ?? 0) >= 0,
}}
variant="success"
/>
);
}
function SessionTimeMetricCard({ metrics }: { metrics: MetricsResult }) {
return (
<MetricCard
title="Avg. Session Time"
value={`${Math.round(metrics.avgSessionLength || 0)}s`}
icon={<Clock className="h-5 w-5" />}
trend={{
value: metrics.avgSessionTimeTrend ?? 0,
isPositive: (metrics.avgSessionTimeTrend ?? 0) >= 0,
}}
/>
);
}
function ResponseTimeMetricCard({ metrics }: { metrics: MetricsResult }) {
return (
<MetricCard
title="Avg. Response Time"
value={`${metrics.avgResponseTime?.toFixed(1) || 0}s`}
icon={<Zap className="h-5 w-5" />}
trend={{
value: metrics.avgResponseTimeTrend ?? 0,
isPositive: (metrics.avgResponseTimeTrend ?? 0) <= 0,
}}
variant="warning"
/>
);
}
function CostsMetricCard({ metrics }: { metrics: MetricsResult }) {
return (
<MetricCard
title="Daily Costs"
value={`${metrics.avgDailyCosts?.toFixed(4) || "0.0000"}`}
icon={<Euro className="h-5 w-5" />}
description="Average per day"
/>
);
}
function PeakUsageMetricCard({ metrics }: { metrics: MetricsResult }) {
return (
<MetricCard
title="Peak Usage"
value={metrics.peakUsageTime || "N/A"}
icon={<TrendingUp className="h-5 w-5" />}
description="Busiest hour"
/>
);
}
function ResolutionRateMetricCard({ metrics }: { metrics: MetricsResult }) {
return (
<MetricCard
title="Resolution Rate"
value={`${metrics.resolvedChatsPercentage?.toFixed(1) || "0.0"}%`}
icon={<CheckCircle className="h-5 w-5" />}
trend={{
value: metrics.resolvedChatsPercentage ?? 0,
isPositive: (metrics.resolvedChatsPercentage ?? 0) >= 80,
}}
variant={
metrics.resolvedChatsPercentage && metrics.resolvedChatsPercentage >= 80
? "success"
: "warning"
}
/>
);
}
function LanguagesMetricCard({ metrics }: { metrics: MetricsResult }) {
return (
<MetricCard
title="Active Languages"
value={Object.keys(metrics.languages || {}).length}
icon={<Globe className="h-5 w-5" />}
description="Languages detected"
/>
);
}
/**
* Simplified metrics grid component
*/
function MetricsGrid({ metrics }: { metrics: MetricsResult }) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<SessionMetricCard metrics={metrics} />
<UsersMetricCard metrics={metrics} />
<SessionTimeMetricCard metrics={metrics} />
<ResponseTimeMetricCard metrics={metrics} />
<CostsMetricCard metrics={metrics} />
<PeakUsageMetricCard metrics={metrics} />
<ResolutionRateMetricCard metrics={metrics} />
<LanguagesMetricCard metrics={metrics} />
</div>
);
}
/**
* Main dashboard content with reduced complexity
*/
function DashboardContent() {
const { data: session, status } = useSession();
const router = useRouter();
@ -48,8 +474,8 @@ function DashboardContent() {
const [refreshing, setRefreshing] = useState<boolean>(false);
const [isInitialLoad, setIsInitialLoad] = useState<boolean>(true);
const refreshStatusId = useId();
const isAuditor = session?.user?.role === "AUDITOR";
const dataHelpers = useDashboardData(metrics);
// Function to fetch metrics with optional date range
const fetchMetrics = useCallback(
@ -124,261 +550,24 @@ function DashboardContent() {
}
// Show loading state while session status is being determined
if (status === "loading") {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center space-y-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto" />
<p className="text-muted-foreground">Loading session...</p>
</div>
</div>
);
}
if (status === "unauthenticated") {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center">
<p className="text-muted-foreground">Redirecting to login...</p>
</div>
</div>
);
}
const loadingState = DashboardLoadingStates({ status });
if (loadingState) return loadingState;
if (loading || !metrics || !company) {
return (
<div className="space-y-8">
{/* Header Skeleton */}
<Card>
<CardHeader>
<div className="flex justify-between items-start">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<div className="flex gap-2">
<Skeleton className="h-10 w-24" />
<Skeleton className="h-10 w-20" />
</div>
</div>
</CardHeader>
</Card>
{/* Metrics Grid Skeleton */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{Array.from({ length: 8 }, (_, i) => {
const metricTypes = [
"sessions",
"users",
"time",
"response",
"costs",
"peak",
"resolution",
"languages",
];
return (
<MetricCard
key={`skeleton-${metricTypes[i] || "metric"}-card-loading`}
title=""
value=""
isLoading
/>
);
})}
</div>
{/* Charts Skeleton */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-2">
<CardHeader>
<Skeleton className="h-6 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-64 w-full" />
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-64 w-full" />
</CardContent>
</Card>
</div>
</div>
);
return <DashboardSkeleton />;
}
// Data preparation functions
const getSentimentData = () => {
if (!metrics) return [];
const sentimentData = {
positive: metrics.sentimentPositiveCount ?? 0,
neutral: metrics.sentimentNeutralCount ?? 0,
negative: metrics.sentimentNegativeCount ?? 0,
};
return [
{
name: "Positive",
value: sentimentData.positive,
color: "hsl(var(--chart-1))",
},
{
name: "Neutral",
value: sentimentData.neutral,
color: "hsl(var(--chart-2))",
},
{
name: "Negative",
value: sentimentData.negative,
color: "hsl(var(--chart-3))",
},
];
};
const getSessionsOverTimeData = () => {
if (!metrics?.days) return [];
return Object.entries(metrics.days).map(([date, value]) => ({
date: new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
}),
value: value as number,
}));
};
const getCategoriesData = () => {
if (!metrics?.categories) return [];
return Object.entries(metrics.categories).map(([name, value]) => {
const formattedName = formatEnumValue(name) || name;
return {
name:
formattedName.length > 15
? `${formattedName.substring(0, 15)}...`
: formattedName,
value: value as number,
};
});
};
const getLanguagesData = () => {
if (!metrics?.languages) return [];
return Object.entries(metrics.languages).map(([name, value]) => ({
name,
value: value as number,
}));
};
const getWordCloudData = (): WordCloudWord[] => {
if (!metrics?.wordCloudData) return [];
return metrics.wordCloudData;
};
const getCountryData = () => {
if (!metrics?.countries) return {};
return Object.entries(metrics.countries).reduce(
(acc, [code, count]) => {
if (code && count) {
acc[code] = count;
}
return acc;
},
{} as Record<string, number>
);
};
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">
{/* Modern Header */}
<Card className="border-0 bg-linear-to-r from-primary/5 via-primary/10 to-primary/5">
<CardHeader>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div className="space-y-2">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">
{company.name}
</h1>
<Badge variant="secondary" className="text-xs">
Analytics Dashboard
</Badge>
</div>
<p className="text-muted-foreground">
Last updated{" "}
<span className="font-medium">
{new Date(metrics.lastUpdated || Date.now()).toLocaleString()}
</span>
</p>
</div>
<DashboardHeader
company={company}
metrics={metrics}
isAuditor={isAuditor}
refreshing={refreshing}
onRefresh={handleRefresh}
/>
<div className="flex items-center gap-2">
<Button
onClick={handleRefresh}
disabled={refreshing || isAuditor}
size="sm"
className="gap-2"
aria-label={
refreshing
? "Refreshing dashboard data"
: "Refresh dashboard data"
}
aria-describedby={refreshing ? refreshStatusId : undefined}
>
<RefreshCw
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
aria-hidden="true"
/>
{refreshing ? "Refreshing..." : "Refresh"}
</Button>
{refreshing && (
<div
id={refreshStatusId}
className="sr-only"
aria-live="polite"
>
Dashboard data is being refreshed
</div>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" aria-label="Account menu">
<MoreVertical className="h-4 w-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => signOut({ callbackUrl: "/login" })}
>
<LogOut className="h-4 w-4 mr-2" aria-hidden="true" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardHeader>
</Card>
{/* Date Range Picker - Temporarily disabled to debug infinite loop */}
{/* Date Range Picker */}
{/* {dateRange && (
<DateRangePicker
minDate={dateRange.minDate}
@ -389,100 +578,19 @@ function DashboardContent() {
/>
)} */}
{/* Modern Metrics Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<MetricCard
title="Total Sessions"
value={metrics.totalSessions?.toLocaleString()}
icon={<MessageSquare className="h-5 w-5" />}
trend={{
value: metrics.sessionTrend ?? 0,
isPositive: (metrics.sessionTrend ?? 0) >= 0,
}}
variant="primary"
/>
<MetricCard
title="Unique Users"
value={metrics.uniqueUsers?.toLocaleString()}
icon={<Users className="h-5 w-5" />}
trend={{
value: metrics.usersTrend ?? 0,
isPositive: (metrics.usersTrend ?? 0) >= 0,
}}
variant="success"
/>
<MetricCard
title="Avg. Session Time"
value={`${Math.round(metrics.avgSessionLength || 0)}s`}
icon={<Clock className="h-5 w-5" />}
trend={{
value: metrics.avgSessionTimeTrend ?? 0,
isPositive: (metrics.avgSessionTimeTrend ?? 0) >= 0,
}}
/>
<MetricCard
title="Avg. Response Time"
value={`${metrics.avgResponseTime?.toFixed(1) || 0}s`}
icon={<Zap className="h-5 w-5" />}
trend={{
value: metrics.avgResponseTimeTrend ?? 0,
isPositive: (metrics.avgResponseTimeTrend ?? 0) <= 0,
}}
variant="warning"
/>
<MetricCard
title="Daily Costs"
value={`${metrics.avgDailyCosts?.toFixed(4) || "0.0000"}`}
icon={<Euro className="h-5 w-5" />}
description="Average per day"
/>
<MetricCard
title="Peak Usage"
value={metrics.peakUsageTime || "N/A"}
icon={<TrendingUp className="h-5 w-5" />}
description="Busiest hour"
/>
<MetricCard
title="Resolution Rate"
value={`${metrics.resolvedChatsPercentage?.toFixed(1) || "0.0"}%`}
icon={<CheckCircle className="h-5 w-5" />}
trend={{
value: metrics.resolvedChatsPercentage ?? 0,
isPositive: (metrics.resolvedChatsPercentage ?? 0) >= 80,
}}
variant={
metrics.resolvedChatsPercentage &&
metrics.resolvedChatsPercentage >= 80
? "success"
: "warning"
}
/>
<MetricCard
title="Active Languages"
value={Object.keys(metrics.languages || {}).length}
icon={<Globe className="h-5 w-5" />}
description="Languages detected"
/>
</div>
<MetricsGrid metrics={metrics} />
{/* Charts Section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<ModernLineChart
data={getSessionsOverTimeData()}
data={dataHelpers.getSessionsOverTimeData()}
title="Sessions Over Time"
className="lg:col-span-2"
height={350}
/>
<ModernDonutChart
data={getSentimentData()}
data={dataHelpers.getSentimentData()}
title="Conversation Sentiment"
centerText={{
title: "Total",
@ -494,13 +602,13 @@ function DashboardContent() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<ModernBarChart
data={getCategoriesData()}
data={dataHelpers.getCategoriesData()}
title="Sessions by Category"
height={350}
/>
<ModernDonutChart
data={getLanguagesData()}
data={dataHelpers.getLanguagesData()}
title="Languages Used"
height={350}
/>
@ -516,7 +624,7 @@ function DashboardContent() {
</CardTitle>
</CardHeader>
<CardContent>
<GeographicMap countries={getCountryData()} />
<GeographicMap countries={dataHelpers.getCountryData()} />
</CardContent>
</Card>
@ -529,7 +637,11 @@ function DashboardContent() {
</CardHeader>
<CardContent>
<div className="h-[300px]">
<WordCloud words={getWordCloudData()} width={500} height={300} />
<WordCloud
words={dataHelpers.getWordCloudData()}
width={500}
height={300}
/>
</div>
</CardContent>
</Card>
@ -545,7 +657,7 @@ function DashboardContent() {
</CardHeader>
<CardContent>
<ResponseTimeDistribution
data={getResponseTimeData()}
data={dataHelpers.getResponseTimeData()}
average={metrics.avgResponseTime || 0}
/>
</CardContent>

View File

@ -22,16 +22,408 @@ import { Label } from "@/components/ui/label";
import { formatCategory } from "@/lib/format-enums";
import type { ChatSession } from "../../../lib/types";
// Placeholder for a SessionListItem component to be created later
// For now, we'll display some basic info directly.
// import SessionListItem from "../../../components/SessionListItem";
// TODO: Consider moving filter/sort types to lib/types.ts if they become complex
interface FilterOptions {
categories: string[];
languages: string[];
}
interface FilterSectionProps {
filtersExpanded: boolean;
setFiltersExpanded: (expanded: boolean) => void;
searchTerm: string;
setSearchTerm: (term: string) => void;
selectedCategory: string;
setSelectedCategory: (category: string) => void;
selectedLanguage: string;
setSelectedLanguage: (language: string) => void;
startDate: string;
setStartDate: (date: string) => void;
endDate: string;
setEndDate: (date: string) => void;
sortKey: string;
setSortKey: (key: string) => void;
sortOrder: string;
setSortOrder: (order: string) => void;
filterOptions: FilterOptions;
searchHeadingId: string;
filtersHeadingId: string;
filterContentId: string;
categoryFilterId: string;
categoryHelpId: string;
languageFilterId: string;
languageHelpId: string;
sortOrderId: string;
sortOrderHelpId: string;
}
function FilterSection({
filtersExpanded,
setFiltersExpanded,
searchTerm,
setSearchTerm,
selectedCategory,
setSelectedCategory,
selectedLanguage,
setSelectedLanguage,
startDate,
setStartDate,
endDate,
setEndDate,
sortKey,
setSortKey,
sortOrder,
setSortOrder,
filterOptions,
searchHeadingId,
filtersHeadingId,
filterContentId,
categoryFilterId,
categoryHelpId,
languageFilterId,
languageHelpId,
sortOrderId,
sortOrderHelpId,
}: FilterSectionProps) {
return (
<section aria-labelledby={searchHeadingId}>
<h2 id={searchHeadingId} className="sr-only">
Search and Filter Sessions
</h2>
<Card>
<CardHeader>
<div className="space-y-4">
<div className="relative">
<Label htmlFor="search-sessions" className="sr-only">
Search sessions
</Label>
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="search-sessions"
type="text"
placeholder="Search sessions..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
<Button
variant="outline"
onClick={() => setFiltersExpanded(!filtersExpanded)}
className="w-full justify-between"
aria-expanded={filtersExpanded}
aria-controls={filterContentId}
aria-describedby={filtersHeadingId}
>
<span id={filtersHeadingId}>Advanced Filters</span>
{filtersExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
</div>
</CardHeader>
{filtersExpanded && (
<CardContent id={filterContentId}>
<fieldset>
<legend className="sr-only">Filter and sort options</legend>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<Label htmlFor={categoryFilterId}>Category</Label>
<select
id={categoryFilterId}
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="w-full mt-1 p-2 border border-gray-300 rounded-md"
aria-describedby={categoryHelpId}
>
<option value="">All Categories</option>
{filterOptions.categories.map((category) => (
<option key={category} value={category}>
{formatCategory(category)}
</option>
))}
</select>
<div id={categoryHelpId} className="sr-only">
Filter sessions by category
</div>
</div>
<div>
<Label htmlFor={languageFilterId}>Language</Label>
<select
id={languageFilterId}
value={selectedLanguage}
onChange={(e) => setSelectedLanguage(e.target.value)}
className="w-full mt-1 p-2 border border-gray-300 rounded-md"
aria-describedby={languageHelpId}
>
<option value="">All Languages</option>
{filterOptions.languages.map((language) => (
<option key={language} value={language}>
{language.toUpperCase()}
</option>
))}
</select>
<div id={languageHelpId} className="sr-only">
Filter sessions by language
</div>
</div>
<div>
<Label htmlFor="start-date">Start Date</Label>
<Input
id="start-date"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="end-date">End Date</Label>
<Input
id="end-date"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="sort-by">Sort By</Label>
<select
id="sort-by"
value={sortKey}
onChange={(e) => setSortKey(e.target.value)}
className="w-full mt-1 p-2 border border-gray-300 rounded-md"
>
<option value="startTime">Start Time</option>
<option value="sessionId">Session ID</option>
<option value="category">Category</option>
<option value="language">Language</option>
</select>
</div>
<div>
<Label htmlFor={sortOrderId}>Sort Order</Label>
<select
id={sortOrderId}
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value)}
className="w-full mt-1 p-2 border border-gray-300 rounded-md"
aria-describedby={sortOrderHelpId}
>
<option value="desc">Newest First</option>
<option value="asc">Oldest First</option>
</select>
<div id={sortOrderHelpId} className="sr-only">
Choose ascending or descending order
</div>
</div>
</div>
</fieldset>
</CardContent>
)}
</Card>
</section>
);
}
interface SessionListProps {
sessions: ChatSession[];
loading: boolean;
error: string | null;
resultsHeadingId: string;
}
function SessionList({
sessions,
loading,
error,
resultsHeadingId,
}: SessionListProps) {
return (
<section aria-labelledby={resultsHeadingId}>
<h2 id={resultsHeadingId} className="sr-only">
Session Results
</h2>
<output aria-live="polite" className="sr-only">
{loading && "Loading sessions..."}
{error && `Error loading sessions: ${error}`}
{!loading &&
!error &&
sessions.length > 0 &&
`Found ${sessions.length} sessions`}
{!loading && !error && sessions.length === 0 && "No sessions found"}
</output>
{loading && (
<Card>
<CardContent className="pt-6">
<div
className="text-center py-8 text-muted-foreground"
aria-hidden="true"
>
Loading sessions...
</div>
</CardContent>
</Card>
)}
{error && (
<Card>
<CardContent className="pt-6">
<div
className="text-center py-8 text-destructive"
role="alert"
aria-hidden="true"
>
Error loading sessions: {error}
</div>
</CardContent>
</Card>
)}
{!loading && !error && sessions.length === 0 && (
<Card>
<CardContent className="pt-6">
<div className="text-center py-8 text-muted-foreground">
No sessions found. Try adjusting your search criteria.
</div>
</CardContent>
</Card>
)}
{!loading && !error && sessions.length > 0 && (
<ul className="space-y-4" role="list">
{sessions.map((session) => (
<li key={session.id}>
<Card>
<CardContent className="pt-6">
<article>
<header className="flex justify-between items-start mb-3">
<div>
<h3 className="font-medium text-base mb-1">
Session{" "}
{session.sessionId ||
session.id.substring(0, 8) + "..."}
</h3>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
<Clock
className="h-3 w-3 mr-1"
aria-hidden="true"
/>
{new Date(session.startTime).toLocaleDateString()}
</Badge>
<span className="text-xs text-muted-foreground">
{new Date(session.startTime).toLocaleTimeString()}
</span>
</div>
</div>
<Link href={`/dashboard/sessions/${session.id}`}>
<Button
variant="outline"
size="sm"
className="gap-2"
aria-label={`View details for session ${session.sessionId || session.id}`}
>
<Eye className="h-4 w-4" aria-hidden="true" />
<span className="hidden sm:inline">View Details</span>
</Button>
</Link>
</header>
<div className="flex flex-wrap gap-2 mb-3">
{session.category && (
<Badge variant="secondary" className="gap-1">
<Filter className="h-3 w-3" aria-hidden="true" />
{formatCategory(session.category)}
</Badge>
)}
{session.language && (
<Badge variant="outline" className="gap-1">
<Globe className="h-3 w-3" aria-hidden="true" />
{session.language.toUpperCase()}
</Badge>
)}
</div>
{session.summary ? (
<p className="text-sm text-muted-foreground line-clamp-2">
{session.summary}
</p>
) : session.initialMsg ? (
<p className="text-sm text-muted-foreground line-clamp-2">
{session.initialMsg}
</p>
) : null}
</article>
</CardContent>
</Card>
</li>
))}
</ul>
)}
</section>
);
}
interface PaginationProps {
currentPage: number;
totalPages: number;
setCurrentPage: (page: number | ((prev: number) => number)) => void;
}
function Pagination({
currentPage,
totalPages,
setCurrentPage,
}: PaginationProps) {
if (totalPages === 0) return null;
return (
<Card>
<CardContent className="pt-6">
<div className="flex justify-center items-center gap-4">
<Button
variant="outline"
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
className="gap-2"
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</span>
<Button
variant="outline"
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
disabled={currentPage === totalPages}
className="gap-2"
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
);
}
export default function SessionsPage() {
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [loading, setLoading] = useState(true);
@ -48,45 +440,29 @@ export default function SessionsPage() {
const sortOrderId = useId();
const sortOrderHelpId = useId();
const resultsHeadingId = useId();
const startDateFilterId = useId();
const startDateHelpId = useId();
const endDateFilterId = useId();
const endDateHelpId = useId();
const sortKeyId = useId();
const sortKeyHelpId = useId();
// Filter states
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
const [selectedCategory, setSelectedCategory] = useState("");
const [selectedLanguage, setSelectedLanguage] = useState("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [sortKey, setSortKey] = useState("startTime");
const [sortOrder, setSortOrder] = useState("desc");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [pageSize] = useState(10);
const [filtersExpanded, setFiltersExpanded] = useState(false);
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
categories: [],
languages: [],
});
const [selectedCategory, setSelectedCategory] = useState<string>("");
const [selectedLanguage, setSelectedLanguage] = useState<string>("");
const [startDate, setStartDate] = useState<string>("");
const [endDate, setEndDate] = useState<string>("");
// Sort states
const [sortKey, setSortKey] = useState<string>("startTime"); // Default sort key
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); // Default sort order
// Debounce search term to avoid excessive API calls
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm);
// Pagination states
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [pageSize, _setPageSize] = useState(10); // Or make this configurable
// UI states
const [filtersExpanded, setFiltersExpanded] = useState(false);
useEffect(() => {
const timerId = setTimeout(() => {
setDebouncedSearchTerm(searchTerm);
}, 500); // 500ms delay
return () => {
clearTimeout(timerId);
};
}, 500);
return () => clearTimeout(timerId);
}, [searchTerm]);
const fetchFilterOptions = useCallback(async () => {
@ -158,10 +534,8 @@ export default function SessionsPage() {
return (
<div className="space-y-6">
{/* Page heading for screen readers */}
<h1 className="sr-only">Sessions Management</h1>
{/* Header */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
@ -171,376 +545,47 @@ export default function SessionsPage() {
</CardHeader>
</Card>
{/* Search Input */}
<section aria-labelledby={searchHeadingId}>
<h2 id={searchHeadingId} className="sr-only">
Search Sessions
</h2>
<Card>
<CardContent className="pt-6">
<div className="relative">
<Search
className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"
aria-hidden="true"
/>
<Input
placeholder="Search sessions (ID, category, initial message...)"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
aria-label="Search sessions by ID, category, or message content"
/>
</div>
</CardContent>
</Card>
</section>
<FilterSection
filtersExpanded={filtersExpanded}
setFiltersExpanded={setFiltersExpanded}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
selectedCategory={selectedCategory}
setSelectedCategory={setSelectedCategory}
selectedLanguage={selectedLanguage}
setSelectedLanguage={setSelectedLanguage}
startDate={startDate}
setStartDate={setStartDate}
endDate={endDate}
setEndDate={setEndDate}
sortKey={sortKey}
setSortKey={setSortKey}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
filterOptions={filterOptions}
searchHeadingId={searchHeadingId}
filtersHeadingId={filtersHeadingId}
filterContentId={filterContentId}
categoryFilterId={categoryFilterId}
categoryHelpId={categoryHelpId}
languageFilterId={languageFilterId}
languageHelpId={languageHelpId}
sortOrderId={sortOrderId}
sortOrderHelpId={sortOrderHelpId}
/>
{/* Filter and Sort Controls */}
<section aria-labelledby={filtersHeadingId}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="h-5 w-5" aria-hidden="true" />
<CardTitle id={filtersHeadingId} className="text-lg">
Filters & Sorting
</CardTitle>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setFiltersExpanded(!filtersExpanded)}
className="gap-2"
aria-expanded={filtersExpanded}
aria-controls={filterContentId}
>
{filtersExpanded ? (
<>
<ChevronUp className="h-4 w-4" />
Hide
</>
) : (
<>
<ChevronDown className="h-4 w-4" />
Show
</>
)}
</Button>
</div>
</CardHeader>
{filtersExpanded && (
<CardContent id={filterContentId}>
<fieldset>
<legend className="sr-only">
Session Filters and Sorting Options
</legend>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
{/* Category Filter */}
<div className="space-y-2">
<Label htmlFor={categoryFilterId}>Category</Label>
<select
id={categoryFilterId}
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
aria-describedby={categoryHelpId}
>
<option value="">All Categories</option>
{filterOptions.categories.map((cat) => (
<option key={cat} value={cat}>
{formatCategory(cat)}
</option>
))}
</select>
<div id={categoryHelpId} className="sr-only">
Filter sessions by category type
</div>
</div>
<SessionList
sessions={sessions}
loading={loading}
error={error}
resultsHeadingId={resultsHeadingId}
/>
{/* Language Filter */}
<div className="space-y-2">
<Label htmlFor={languageFilterId}>Language</Label>
<select
id={languageFilterId}
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
value={selectedLanguage}
onChange={(e) => setSelectedLanguage(e.target.value)}
aria-describedby={languageHelpId}
>
<option value="">All Languages</option>
{filterOptions.languages.map((lang) => (
<option key={lang} value={lang}>
{lang.toUpperCase()}
</option>
))}
</select>
<div id={languageHelpId} className="sr-only">
Filter sessions by language
</div>
</div>
{/* Start Date Filter */}
<div className="space-y-2">
<Label htmlFor={startDateFilterId}>Start Date</Label>
<Input
type="date"
id={startDateFilterId}
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
aria-describedby={startDateHelpId}
/>
<div id={startDateHelpId} className="sr-only">
Filter sessions from this date onwards
</div>
</div>
{/* End Date Filter */}
<div className="space-y-2">
<Label htmlFor={endDateFilterId}>End Date</Label>
<Input
type="date"
id={endDateFilterId}
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
aria-describedby={endDateHelpId}
/>
<div id={endDateHelpId} className="sr-only">
Filter sessions up to this date
</div>
</div>
{/* Sort Key */}
<div className="space-y-2">
<Label htmlFor={sortKeyId}>Sort By</Label>
<select
id={sortKeyId}
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
value={sortKey}
onChange={(e) => setSortKey(e.target.value)}
aria-describedby={sortKeyHelpId}
>
<option value="startTime">Start Time</option>
<option value="category">Category</option>
<option value="language">Language</option>
<option value="sentiment">Sentiment</option>
<option value="messagesSent">Messages Sent</option>
<option value="avgResponseTime">
Avg. Response Time
</option>
</select>
<div id={sortKeyHelpId} className="sr-only">
Choose field to sort sessions by
</div>
</div>
{/* Sort Order */}
<div className="space-y-2">
<Label htmlFor={sortOrderId}>Order</Label>
<select
id={sortOrderId}
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
value={sortOrder}
onChange={(e) =>
setSortOrder(e.target.value as "asc" | "desc")
}
aria-describedby={sortOrderHelpId}
>
<option value="desc">Descending</option>
<option value="asc">Ascending</option>
</select>
<div id={sortOrderHelpId} className="sr-only">
Choose ascending or descending order
</div>
</div>
</div>
</fieldset>
</CardContent>
)}
</Card>
</section>
{/* Results section */}
<section aria-labelledby={resultsHeadingId}>
<h2 id={resultsHeadingId} className="sr-only">
Session Results
</h2>
{/* Live region for screen reader announcements */}
<output aria-live="polite" className="sr-only">
{loading && "Loading sessions..."}
{error && `Error loading sessions: ${error}`}
{!loading &&
!error &&
sessions.length > 0 &&
`Found ${sessions.length} sessions`}
{!loading && !error && sessions.length === 0 && "No sessions found"}
</output>
{/* Loading State */}
{loading && (
<Card>
<CardContent className="pt-6">
<div
className="text-center py-8 text-muted-foreground"
aria-hidden="true"
>
Loading sessions...
</div>
</CardContent>
</Card>
)}
{/* Error State */}
{error && (
<Card>
<CardContent className="pt-6">
<div
className="text-center py-8 text-destructive"
role="alert"
aria-hidden="true"
>
Error: {error}
</div>
</CardContent>
</Card>
)}
{/* Empty State */}
{!loading && !error && sessions.length === 0 && (
<Card>
<CardContent className="pt-6">
<div className="text-center py-8 text-muted-foreground">
{debouncedSearchTerm
? `No sessions found for "${debouncedSearchTerm}".`
: "No sessions found."}
</div>
</CardContent>
</Card>
)}
{/* Sessions List */}
{!loading && !error && sessions.length > 0 && (
<ul aria-label="Chat sessions" className="grid gap-4">
{sessions.map((session) => (
<li key={session.id}>
<Card className="hover:shadow-md transition-shadow">
<CardContent className="pt-6">
<article aria-labelledby={`session-${session.id}-title`}>
<header className="flex justify-between items-start mb-4">
<div className="space-y-2 flex-1">
<h3
id={`session-${session.id}-title`}
className="sr-only"
>
Session {session.sessionId || session.id} from{" "}
{new Date(session.startTime).toLocaleDateString()}
</h3>
<div className="flex items-center gap-3">
<Badge
variant="outline"
className="font-mono text-xs"
>
ID
</Badge>
<code className="text-sm text-muted-foreground font-mono truncate max-w-24">
{session.sessionId || session.id}
</code>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
<Clock
className="h-3 w-3 mr-1"
aria-hidden="true"
/>
{new Date(session.startTime).toLocaleDateString()}
</Badge>
<span className="text-xs text-muted-foreground">
{new Date(session.startTime).toLocaleTimeString()}
</span>
</div>
</div>
<Link href={`/dashboard/sessions/${session.id}`}>
<Button
variant="outline"
size="sm"
className="gap-2"
aria-label={`View details for session ${session.sessionId || session.id}`}
>
<Eye className="h-4 w-4" aria-hidden="true" />
<span className="hidden sm:inline">
View Details
</span>
</Button>
</Link>
</header>
<div className="flex flex-wrap gap-2 mb-3">
{session.category && (
<Badge variant="secondary" className="gap-1">
<Filter className="h-3 w-3" aria-hidden="true" />
{formatCategory(session.category)}
</Badge>
)}
{session.language && (
<Badge variant="outline" className="gap-1">
<Globe className="h-3 w-3" aria-hidden="true" />
{session.language.toUpperCase()}
</Badge>
)}
</div>
{session.summary ? (
<p className="text-sm text-muted-foreground line-clamp-2">
{session.summary}
</p>
) : session.initialMsg ? (
<p className="text-sm text-muted-foreground line-clamp-2">
{session.initialMsg}
</p>
) : null}
</article>
</CardContent>
</Card>
</li>
))}
</ul>
)}
{/* Pagination */}
{totalPages > 0 && (
<Card>
<CardContent className="pt-6">
<div className="flex justify-center items-center gap-4">
<Button
variant="outline"
onClick={() =>
setCurrentPage((prev) => Math.max(prev - 1, 1))
}
disabled={currentPage === 1}
className="gap-2"
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</span>
<Button
variant="outline"
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
disabled={currentPage === totalPages}
className="gap-2"
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)}
</section>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
setCurrentPage={setCurrentPage}
/>
</div>
);
}