Remove Tailwind CSS configuration file

This commit is contained in:
Max Kowalski
2025-06-28 00:23:23 +02:00
parent a6632d6dfc
commit 1be9ce9dd9
42 changed files with 3475 additions and 1028 deletions

View File

@ -1,20 +1,12 @@
// API route to refresh (fetch+parse+update) session data for a company
import { NextApiRequest, NextApiResponse } from "next";
import { fetchAndParseCsv } from "../../../lib/csvFetcher";
import { processQueuedImports } from "../../../lib/importProcessor";
import { prisma } from "../../../lib/prisma";
import { NextRequest, NextResponse } from "next/server";
import { fetchAndParseCsv } from "../../../../lib/csvFetcher";
import { processQueuedImports } from "../../../../lib/importProcessor";
import { prisma } from "../../../../lib/prisma";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
// Check if this is a POST request
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
// Get companyId from body or query
let { companyId } = req.body;
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
@ -39,13 +31,20 @@ export default async function handler(
}
if (!companyId) {
return res.status(400).json({ error: "Company ID is required" });
return NextResponse.json(
{ error: "Company ID is required" },
{ status: 400 }
);
}
const company = await prisma.company.findUnique({ where: { id: companyId } });
if (!company) return res.status(404).json({ error: "Company not found" });
if (!company) {
return NextResponse.json(
{ error: "Company not found" },
{ status: 404 }
);
}
try {
const rawSessionData = await fetchAndParseCsv(
company.csvUrl,
company.csvUsername as string | undefined,
@ -123,7 +122,7 @@ export default async function handler(
where: { companyId: company.id }
});
res.json({
return NextResponse.json({
ok: true,
imported: importedCount,
total: rawSessionData.length,
@ -132,6 +131,6 @@ export default async function handler(
});
} catch (e) {
const error = e instanceof Error ? e.message : "An unknown error occurred";
res.status(500).json({ error });
return NextResponse.json({ error }, { status: 500 });
}
}

View File

@ -1,9 +1,9 @@
import { NextApiRequest, NextApiResponse } from "next";
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "../auth/[...nextauth]";
import { prisma } from "../../../lib/prisma";
import { processUnprocessedSessions } from "../../../lib/processingScheduler";
import { ProcessingStatusManager } from "../../../lib/processingStatusManager";
import { authOptions } from "../../auth/[...nextauth]/route";
import { prisma } from "../../../../lib/prisma";
import { processUnprocessedSessions } from "../../../../lib/processingScheduler";
import { ProcessingStatusManager } from "../../../../lib/processingStatusManager";
import { ProcessingStage } from "@prisma/client";
interface SessionUser {
@ -15,22 +15,11 @@ interface SessionData {
user: SessionUser;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
const session = (await getServerSession(
req,
res,
authOptions
)) as SessionData | null;
export async function POST(request: NextRequest) {
const session = (await getServerSession(authOptions)) as SessionData | null;
if (!session?.user) {
return res.status(401).json({ error: "Not logged in" });
return NextResponse.json({ error: "Not logged in" }, { status: 401 });
}
const user = await prisma.user.findUnique({
@ -39,17 +28,21 @@ export default async function handler(
});
if (!user) {
return res.status(401).json({ error: "No user found" });
return NextResponse.json({ error: "No user found" }, { status: 401 });
}
// Check if user has ADMIN role
if (user.role !== "ADMIN") {
return res.status(403).json({ error: "Admin access required" });
return NextResponse.json(
{ error: "Admin access required" },
{ status: 403 }
);
}
try {
// Get optional parameters from request body
const { batchSize, maxConcurrency } = req.body;
const body = await request.json();
const { batchSize, maxConcurrency } = body;
// Validate parameters
const validatedBatchSize = batchSize && batchSize > 0 ? parseInt(batchSize) : null;
@ -69,7 +62,7 @@ export default async function handler(
const unprocessedCount = companySessionsNeedingAI.length;
if (unprocessedCount === 0) {
return res.json({
return NextResponse.json({
success: true,
message: "No sessions requiring AI processing found",
unprocessedCount: 0,
@ -90,7 +83,7 @@ export default async function handler(
console.error(`[Manual Trigger] Processing failed for company ${user.companyId}:`, error);
});
return res.json({
return NextResponse.json({
success: true,
message: `Started processing ${unprocessedCount} unprocessed sessions`,
unprocessedCount,
@ -101,9 +94,12 @@ export default async function handler(
} catch (error) {
console.error("[Manual Trigger] Error:", error);
return res.status(500).json({
return NextResponse.json(
{
error: "Failed to trigger processing",
details: error instanceof Error ? error.message : String(error),
});
},
{ status: 500 }
);
}
}

View File

@ -1,6 +1,6 @@
import NextAuth, { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { prisma } from "../../../lib/prisma";
import { prisma } from "../../../../lib/prisma";
import bcrypt from "bcryptjs";
// Define the shape of the JWT token
@ -101,4 +101,6 @@ export const authOptions: NextAuthOptions = {
debug: process.env.NODE_ENV === "development",
};
export default NextAuth(authOptions);
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { prisma } from "../../../../lib/prisma";
import { authOptions } from "../../auth/[...nextauth]/route";
export async function GET(request: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Not logged in" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { email: session.user.email as string },
});
if (!user) {
return NextResponse.json({ error: "No user" }, { status: 401 });
}
// Get company data
const company = await prisma.company.findUnique({
where: { id: user.companyId },
});
return NextResponse.json({ company });
}
export async function POST(request: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Not logged in" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { email: session.user.email as string },
});
if (!user) {
return NextResponse.json({ error: "No user" }, { status: 401 });
}
const body = await request.json();
const { csvUrl } = body;
await prisma.company.update({
where: { id: user.companyId },
data: { csvUrl },
});
return NextResponse.json({ ok: true });
}

View File

@ -1,10 +1,9 @@
// API endpoint: return metrics for current company
import { NextApiRequest, NextApiResponse } from "next";
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { prisma } from "../../../lib/prisma";
import { sessionMetrics } from "../../../lib/metrics";
import { authOptions } from "../auth/[...nextauth]";
import { ChatSession } from "../../../lib/types"; // Import ChatSession
import { prisma } from "../../../../lib/prisma";
import { sessionMetrics } from "../../../../lib/metrics";
import { authOptions } from "../../auth/[...nextauth]/route";
import { ChatSession } from "../../../../lib/types";
interface SessionUser {
email: string;
@ -15,26 +14,25 @@ interface SessionData {
user: SessionUser;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const session = (await getServerSession(
req,
res,
authOptions
)) as SessionData | null;
if (!session?.user) return res.status(401).json({ error: "Not logged in" });
export async function GET(request: NextRequest) {
const session = (await getServerSession(authOptions)) as SessionData | null;
if (!session?.user) {
return NextResponse.json({ error: "Not logged in" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { email: session.user.email },
include: { company: true },
});
if (!user) return res.status(401).json({ error: "No user" });
if (!user) {
return NextResponse.json({ error: "No user" }, { status: 401 });
}
// Get date range from query parameters
const { startDate, endDate } = req.query;
const { searchParams } = new URL(request.url);
const startDate = searchParams.get("startDate");
const endDate = searchParams.get("endDate");
// Build where clause with optional date filtering
const whereClause: any = {
@ -43,8 +41,8 @@ export default async function handler(
if (startDate && endDate) {
whereClause.startTime = {
gte: new Date(startDate as string),
lte: new Date(endDate as string + 'T23:59:59.999Z'), // Include full end date
gte: new Date(startDate),
lte: new Date(endDate + 'T23:59:59.999Z'), // Include full end date
};
}
@ -103,7 +101,7 @@ export default async function handler(
};
}
res.json({
return NextResponse.json({
metrics,
csvUrl: user.company.csvUrl,
company: user.company,

View File

@ -1,23 +1,14 @@
import { NextApiRequest, NextApiResponse } from "next";
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]";
import { prisma } from "../../../lib/prisma";
import { SessionFilterOptions } from "../../../lib/types";
import { authOptions } from "../../auth/[...nextauth]/route";
import { prisma } from "../../../../lib/prisma";
import { SessionFilterOptions } from "../../../../lib/types";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<
SessionFilterOptions | { error: string; details?: string }
>
) {
if (req.method !== "GET") {
return res.status(405).json({ error: "Method not allowed" });
}
const authSession = await getServerSession(req, res, authOptions);
export async function GET(request: NextRequest) {
const authSession = await getServerSession(authOptions);
if (!authSession || !authSession.user?.companyId) {
return res.status(401).json({ error: "Unauthorized" });
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const companyId = authSession.user.companyId;
@ -62,15 +53,19 @@ export default async function handler(
.map((s) => s.language)
.filter(Boolean) as string[]; // Filter out any nulls and assert as string[]
return res
.status(200)
.json({ categories: distinctCategories, languages: distinctLanguages });
return NextResponse.json({
categories: distinctCategories,
languages: distinctLanguages
});
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "An unknown error occurred";
return res.status(500).json({
return NextResponse.json(
{
error: "Failed to fetch filter options",
details: errorMessage,
});
},
{ status: 500 }
);
}
}

View File

@ -1,19 +1,18 @@
import { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "../../../../lib/prisma";
import { ChatSession } from "../../../../lib/types";
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "../../../../../lib/prisma";
import { ChatSession } from "../../../../../lib/types";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
if (req.method !== "GET") {
return res.status(405).json({ error: "Method not allowed" });
}
const { id } = params;
const { id } = req.query;
if (!id || typeof id !== "string") {
return res.status(400).json({ error: "Session ID is required" });
if (!id) {
return NextResponse.json(
{ error: "Session ID is required" },
{ status: 400 }
);
}
try {
@ -27,7 +26,10 @@ export default async function handler(
});
if (!prismaSession) {
return res.status(404).json({ error: "Session not found" });
return NextResponse.json(
{ error: "Session not found" },
{ status: 404 }
);
}
// Map Prisma session object to ChatSession type
@ -71,12 +73,13 @@ export default async function handler(
})) ?? [], // New field - parsed messages
};
return res.status(200).json({ session });
return NextResponse.json({ session });
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "An unknown error occurred";
return res
.status(500)
.json({ error: "Failed to fetch session", details: errorMessage });
return NextResponse.json(
{ error: "Failed to fetch session", details: errorMessage },
{ status: 500 }
);
}
}

View File

@ -1,40 +1,33 @@
import { NextApiRequest, NextApiResponse } from "next";
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]";
import { prisma } from "../../../lib/prisma";
import { authOptions } from "../../auth/[...nextauth]/route";
import { prisma } from "../../../../lib/prisma";
import {
ChatSession,
SessionApiResponse,
SessionQuery,
} from "../../../lib/types";
} from "../../../../lib/types";
import { Prisma } from "@prisma/client";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<SessionApiResponse | { error: string; details?: string }>
) {
if (req.method !== "GET") {
return res.status(405).json({ error: "Method not allowed" });
}
const authSession = await getServerSession(req, res, authOptions);
export async function GET(request: NextRequest) {
const authSession = await getServerSession(authOptions);
if (!authSession || !authSession.user?.companyId) {
return res.status(401).json({ error: "Unauthorized" });
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const companyId = authSession.user.companyId;
const {
searchTerm,
category,
language,
startDate,
endDate,
sortKey,
sortOrder,
page: queryPage,
pageSize: queryPageSize,
} = req.query as SessionQuery;
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;
@ -43,11 +36,7 @@ export default async function handler(
const whereClause: Prisma.SessionWhereInput = { companyId };
// Search Term
if (
searchTerm &&
typeof searchTerm === "string" &&
searchTerm.trim() !== ""
) {
if (searchTerm && searchTerm.trim() !== "") {
const searchConditions = [
{ id: { contains: searchTerm } },
{ initialMsg: { contains: searchTerm } },
@ -57,24 +46,24 @@ export default async function handler(
}
// Category Filter
if (category && typeof category === "string" && category.trim() !== "") {
if (category && category.trim() !== "") {
// Cast to SessionCategory enum if it's a valid value
whereClause.category = category as any;
}
// Language Filter
if (language && typeof language === "string" && language.trim() !== "") {
if (language && language.trim() !== "") {
whereClause.language = language;
}
// Date Range Filter
if (startDate && typeof startDate === "string") {
if (startDate) {
whereClause.startTime = {
...((whereClause.startTime as object) || {}),
gte: new Date(startDate),
};
}
if (endDate && typeof endDate === "string") {
if (endDate) {
const inclusiveEndDate = new Date(endDate);
inclusiveEndDate.setDate(inclusiveEndDate.getDate() + 1);
whereClause.startTime = {
@ -98,7 +87,7 @@ export default async function handler(
| Prisma.SessionOrderByWithRelationInput[];
const primarySortField =
sortKey && typeof sortKey === "string" && validSortKeys[sortKey]
sortKey && validSortKeys[sortKey]
? validSortKeys[sortKey]
: "startTime"; // Default to startTime field if sortKey is invalid/missing
@ -115,9 +104,6 @@ export default async function handler(
{ startTime: "desc" },
];
}
// Note: If sortKey was initially undefined or invalid, primarySortField defaults to "startTime",
// and primarySortOrder defaults to "desc". This makes orderByCondition = { startTime: "desc" },
// which is the correct overall default sort.
const prismaSessions = await prisma.session.findMany({
where: whereClause,
@ -151,12 +137,13 @@ export default async function handler(
transcriptContent: null, // Transcript content is now fetched from fullTranscriptUrl when needed
}));
return res.status(200).json({ sessions, totalSessions });
return NextResponse.json({ sessions, totalSessions });
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "An unknown error occurred";
return res
.status(500)
.json({ error: "Failed to fetch sessions", details: errorMessage });
return NextResponse.json(
{ error: "Failed to fetch sessions", details: errorMessage },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { prisma } from "../../../../lib/prisma";
import { authOptions } from "../../auth/[...nextauth]/route";
export async function POST(request: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user || session.user.role !== "ADMIN") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const user = await prisma.user.findUnique({
where: { email: session.user.email as string },
});
if (!user) {
return NextResponse.json({ error: "No user" }, { status: 401 });
}
const body = await request.json();
const { csvUrl, csvUsername, csvPassword, sentimentThreshold } = body;
await prisma.company.update({
where: { id: user.companyId },
data: {
csvUrl,
csvUsername,
...(csvPassword ? { csvPassword } : {}),
sentimentAlert: sentimentThreshold
? parseFloat(sentimentThreshold)
: null,
},
});
return NextResponse.json({ ok: true });
}

View File

@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { getServerSession } from "next-auth";
import { prisma } from "../../../../lib/prisma";
import bcrypt from "bcryptjs";
import { authOptions } from "../../auth/[...nextauth]/route";
interface UserBasicInfo {
id: string;
email: string;
role: string;
}
export async function GET(request: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user || session.user.role !== "ADMIN") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const user = await prisma.user.findUnique({
where: { email: session.user.email as string },
});
if (!user) {
return NextResponse.json({ error: "No user" }, { status: 401 });
}
const users = await prisma.user.findMany({
where: { companyId: user.companyId },
});
const mappedUsers: UserBasicInfo[] = users.map((u) => ({
id: u.id,
email: u.email,
role: u.role,
}));
return NextResponse.json({ users: mappedUsers });
}
export async function POST(request: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user || session.user.role !== "ADMIN") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const user = await prisma.user.findUnique({
where: { email: session.user.email as string },
});
if (!user) {
return NextResponse.json({ error: "No user" }, { status: 401 });
}
const body = await request.json();
const { email, role } = body;
if (!email || !role) {
return NextResponse.json({ error: "Missing fields" }, { status: 400 });
}
const exists = await prisma.user.findUnique({ where: { email } });
if (exists) {
return NextResponse.json({ error: "Email exists" }, { status: 409 });
}
const tempPassword = crypto.randomBytes(12).toString("base64").slice(0, 12); // secure random initial password
await prisma.user.create({
data: {
email,
password: await bcrypt.hash(tempPassword, 10),
companyId: user.companyId,
role,
},
});
// 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 });
}

View File

@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "../../../lib/prisma";
import { sendEmail } from "../../../lib/sendEmail";
import crypto from "crypto";
export async function POST(request: NextRequest) {
const body = await request.json();
const { email } = body as { email: string };
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
// Always return 200 for privacy (don't reveal if email exists)
return NextResponse.json({ success: true }, { status: 200 });
}
const token = crypto.randomBytes(32).toString("hex");
const expiry = new Date(Date.now() + 1000 * 60 * 30); // 30 min expiry
await prisma.user.update({
where: { email },
data: { resetToken: token, resetTokenExpiry: expiry },
});
const resetUrl = `${process.env.NEXTAUTH_URL || "http://localhost:3000"}/reset-password?token=${token}`;
await sendEmail(email, "Password Reset", `Reset your password: ${resetUrl}`);
return NextResponse.json({ success: true }, { status: 200 });
}

63
app/api/register/route.ts Normal file
View File

@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "../../../lib/prisma";
import bcrypt from "bcryptjs";
interface RegisterRequestBody {
email: string;
password: string;
company: string;
csvUrl?: string;
}
export async function POST(request: NextRequest) {
const body = await request.json();
const { email, password, company, csvUrl } = body as RegisterRequestBody;
if (!email || !password || !company) {
return NextResponse.json(
{
success: false,
error: "Missing required fields",
},
{ status: 400 }
);
}
// Check if email exists
const exists = await prisma.user.findUnique({
where: { email },
});
if (exists) {
return NextResponse.json(
{
success: false,
error: "Email already exists",
},
{ status: 409 }
);
}
const newCompany = await prisma.company.create({
data: { name: company, csvUrl: csvUrl || "" },
});
const hashed = await bcrypt.hash(password, 10);
await prisma.user.create({
data: {
email,
password: hashed,
companyId: newCompany.id,
role: "ADMIN",
},
});
return NextResponse.json(
{
success: true,
data: { success: true },
},
{ status: 201 }
);
}

View File

@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "../../../lib/prisma";
import bcrypt from "bcryptjs";
export async function POST(request: NextRequest) {
const body = await request.json();
const { token, password } = body as { token?: string; password?: string };
if (!token || !password) {
return NextResponse.json(
{ error: "Token and password are required." },
{ status: 400 }
);
}
if (password.length < 8) {
return NextResponse.json(
{ error: "Password must be at least 8 characters long." },
{ status: 400 }
);
}
try {
const user = await prisma.user.findFirst({
where: {
resetToken: token,
resetTokenExpiry: { gte: new Date() },
},
});
if (!user) {
return NextResponse.json(
{
error: "Invalid or expired token. Please request a new password reset.",
},
{ status: 400 }
);
}
const hash = await bcrypt.hash(password, 10);
await prisma.user.update({
where: { id: user.id },
data: {
password: hash,
resetToken: null,
resetTokenExpiry: null,
},
});
return NextResponse.json(
{ message: "Password has been reset successfully." },
{ status: 200 }
);
} catch (error) {
console.error("Reset password error:", error);
return NextResponse.json(
{
error: "An internal server error occurred. Please try again later.",
},
{ status: 500 }
);
}
}

View File

@ -1,40 +1,62 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState, useCallback, useRef } 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 MetricCard from "../../../components/ui/metric-card";
import ModernLineChart from "../../../components/charts/line-chart";
import ModernBarChart from "../../../components/charts/bar-chart";
import ModernDonutChart from "../../../components/charts/donut-chart";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
MessageSquare,
Users,
Clock,
Zap,
Euro,
TrendingUp,
CheckCircle,
RefreshCw,
LogOut,
Calendar,
MoreVertical,
Globe,
MessageCircle,
} from "lucide-react";
import WordCloud from "../../../components/WordCloud";
import GeographicMap from "../../../components/GeographicMap";
import ResponseTimeDistribution from "../../../components/ResponseTimeDistribution";
import WelcomeBanner from "../../../components/WelcomeBanner";
import DateRangePicker from "../../../components/DateRangePicker";
import TopQuestionsChart from "../../../components/TopQuestionsChart";
// Safely wrapped component with useSession
function DashboardContent() {
const { data: session, status } = useSession(); // Add status from useSession
const router = useRouter(); // Initialize useRouter
const { data: session, status } = useSession();
const router = useRouter();
const [metrics, setMetrics] = useState<MetricsResult | null>(null);
const [company, setCompany] = useState<Company | null>(null);
const [, setLoading] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [refreshing, setRefreshing] = useState<boolean>(false);
const [dateRange, setDateRange] = useState<{ minDate: string; maxDate: string } | null>(null);
const [selectedStartDate, setSelectedStartDate] = useState<string>("");
const [selectedEndDate, setSelectedEndDate] = useState<string>("");
const [isInitialLoad, setIsInitialLoad] = useState<boolean>(true);
const isAuditor = session?.user?.role === "AUDITOR";
// Function to fetch metrics with optional date range
const fetchMetrics = useCallback(async (startDate?: string, endDate?: string) => {
const fetchMetrics = async (startDate?: string, endDate?: string, isInitial = false) => {
setLoading(true);
try {
let url = "/api/dashboard/metrics";
@ -49,44 +71,44 @@ function DashboardContent() {
setCompany(data.company);
// Set date range from API response (only on initial load)
if (data.dateRange && !dateRange) {
if (data.dateRange && isInitial) {
setDateRange(data.dateRange);
setSelectedStartDate(data.dateRange.minDate);
setSelectedEndDate(data.dateRange.maxDate);
setIsInitialLoad(false);
}
} catch (error) {
console.error("Error fetching metrics:", error);
} finally {
setLoading(false);
}
}, [dateRange]);
};
// Handle date range changes
const handleDateRangeChange = useCallback((startDate: string, endDate: string) => {
setSelectedStartDate(startDate);
setSelectedEndDate(endDate);
fetchMetrics(startDate, endDate);
}, [fetchMetrics]);
}, []);
useEffect(() => {
// Redirect if not authenticated
if (status === "unauthenticated") {
router.push("/login");
return; // Stop further execution in this effect
return;
}
// Fetch metrics and company on mount if authenticated
if (status === "authenticated") {
fetchMetrics();
if (status === "authenticated" && isInitialLoad) {
fetchMetrics(undefined, undefined, true);
}
}, [status, router, fetchMetrics]); // Add fetchMetrics to dependency array
}, [status, router, isInitialLoad]);
async function handleRefresh() {
if (isAuditor) return; // Prevent auditors from refreshing
if (isAuditor) return;
try {
setRefreshing(true);
// Make sure we have a company ID to send
if (!company?.id) {
setRefreshing(false);
alert("Cannot refresh: Company ID is missing");
@ -100,7 +122,6 @@ function DashboardContent() {
});
if (res.ok) {
// Refetch metrics
const metricsRes = await fetch("/api/dashboard/metrics");
const data = await metricsRes.json();
setMetrics(data.metrics);
@ -113,70 +134,129 @@ function DashboardContent() {
}
}
// 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>;
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"></div>
<p className="text-muted-foreground">Loading session...</p>
</div>
</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>;
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>
);
}
if (!metrics || !company) {
return <div className="text-center py-10">Loading dashboard...</div>;
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 }).map((_, i) => (
<MetricCard key={i} 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>
);
}
// Function to prepare word cloud data from metrics.wordCloudData
// 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]) => ({
name: name.length > 15 ? name.substring(0, 15) + '...' : name,
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 || !metrics.wordCloudData) return [];
if (!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(
if (!metrics?.countries) return {};
return Object.entries(metrics.countries).reduce(
(acc, [code, count]) => {
if (code && count) {
acc[code] = count;
@ -185,11 +265,8 @@ function DashboardContent() {
},
{} as Record<string, number>
);
return result;
};
// Function to prepare response time distribution data
const getResponseTimeData = () => {
const avgTime = metrics.avgResponseTime || 1.5;
const simulatedData: number[] = [];
@ -204,62 +281,56 @@ function DashboardContent() {
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">
{/* Modern Header */}
<Card className="border-0 bg-gradient-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-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"
<div className="flex items-center gap-2">
<Button
onClick={handleRefresh}
disabled={refreshing || isAuditor}
size="sm"
className="gap-2"
>
{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>
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
{refreshing ? "Refreshing..." : "Refresh"}
</Button>
{/* Date Range Picker */}
{dateRange && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => signOut({ callbackUrl: "/login" })}>
<LogOut className="h-4 w-4 mr-2" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardHeader>
</Card>
{/* Date Range Picker - Temporarily disabled to debug infinite loop */}
{/* {dateRange && (
<DateRangePicker
minDate={dateRange.minDate}
maxDate={dateRange.maxDate}
@ -267,268 +338,192 @@ function DashboardContent() {
initialStartDate={selectedStartDate}
initialEndDate={selectedEndDate}
/>
)}
)} */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-7 gap-4">
{/* 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}
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>
}
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}
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>
}
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={
<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>
}
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={
<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>
}
icon={<Zap className="h-5 w-5" />}
trend={{
value: metrics.avgResponseTimeTrend ?? 0,
isPositive: (metrics.avgResponseTimeTrend ?? 0) <= 0, // Lower response time is better
isPositive: (metrics.avgResponseTimeTrend ?? 0) <= 0,
}}
variant="warning"
/>
<MetricCard
title="Avg. Daily Costs"
title="Daily Costs"
value={`${metrics.avgDailyCosts?.toFixed(4) || '0.0000'}`}
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 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
}
icon={<Euro className="h-5 w-5" />}
description="Average per day"
/>
<MetricCard
title="Peak Usage Time"
title="Peak Usage"
value={metrics.peakUsageTime || 'N/A'}
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="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>
}
icon={<TrendingUp className="h-5 w-5" />}
description="Busiest hour"
/>
<MetricCard
title="Resolved Chats"
title="Resolution Rate"
value={`${metrics.resolvedChatsPercentage?.toFixed(1) || '0.0'}%`}
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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
}
icon={<CheckCircle className="h-5 w-5" />}
trend={{
value: metrics.resolvedChatsPercentage ?? 0,
isPositive: (metrics.resolvedChatsPercentage ?? 0) >= 80, // 80%+ resolution rate is good
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>
{/* Charts Section */}
<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 sessionsPerDay={metrics.days} />
</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"],
}}
<ModernLineChart
data={getSessionsOverTimeData()}
title="Sessions Over Time"
className="lg:col-span-2"
height={350}
/>
<ModernDonutChart
data={getSentimentData()}
title="Conversation Sentiment"
centerText={{
title: "Total",
value: metrics.totalSessions,
value: metrics.totalSessions || 0,
}}
height={350}
/>
</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>
<ModernBarChart
data={getCategoriesData()}
title="Sessions by Category"
height={350}
/>
<ModernDonutChart
data={getLanguagesData()}
title="Languages Used"
height={350}
/>
</div>
{/* Geographic and Topics Section */}
<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">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5" />
Geographic Distribution
</h3>
</CardTitle>
</CardHeader>
<CardContent>
<GeographicMap countries={getCountryData()} />
</div>
</CardContent>
</Card>
<div className="bg-white p-6 rounded-xl shadow">
<h3 className="font-bold text-lg text-gray-800 mb-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageCircle className="h-5 w-5" />
Common Topics
</h3>
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<WordCloud words={getWordCloudData()} width={500} height={400} />
</div>
<WordCloud words={getWordCloudData()} width={500} height={300} />
</div>
</CardContent>
</Card>
</div>
{/* Top Questions Chart */}
<TopQuestionsChart data={metrics.topQuestions || []} />
<div className="bg-white p-6 rounded-xl shadow">
<h3 className="font-bold text-lg text-gray-800 mb-4">
Response Time Distribution
</h3>
{/* Response Time Distribution */}
<Card>
<CardHeader>
<CardTitle>Response Time Distribution</CardTitle>
</CardHeader>
<CardContent>
<ResponseTimeDistribution
data={getResponseTimeData()}
average={metrics.avgResponseTime || 0}
/>
</div>
<div className="bg-white p-6 rounded-xl shadow">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 mb-4">
<h3 className="font-bold text-lg text-gray-800">
Token Usage & Costs
</h3>
<div className="flex flex-col sm:flex-row gap-2 sm:gap-4 w-full sm:w-auto">
<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>
</CardContent>
</Card>
{/* Token Usage Summary */}
<Card>
<CardHeader>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<CardTitle>AI Usage & Costs</CardTitle>
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="gap-1">
<span className="font-semibold">Total Tokens:</span>
{metrics.totalTokens?.toLocaleString() || 0}
</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}
</Badge>
<Badge variant="outline" className="gap-1">
<span className="font-semibold">Total Cost:</span>
{metrics.totalTokensEur?.toFixed(4) || 0}
</Badge>
</div>
</div>
</CardHeader>
<CardContent>
<div className="text-center py-8 text-muted-foreground">
<p>Token usage chart will be implemented with historical data</p>
</div>
<TokenUsageChart tokenData={getTokenData()} />
</div>
</CardContent>
</Card>
</div>
);
}
// Our exported component
export default function DashboardPage() {
return <DashboardContent />;
}

View File

@ -4,6 +4,19 @@ import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { FC } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
BarChart3,
MessageSquare,
Settings,
Users,
ArrowRight,
TrendingUp,
Shield,
Zap,
} from "lucide-react";
const DashboardPage: FC = () => {
const { data: session, status } = useSession();
@ -21,82 +34,223 @@ const DashboardPage: FC = () => {
if (loading) {
return (
<div className="flex items-center justify-center min-h-[40vh]">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-sky-500 mx-auto mb-4"></div>
<p className="text-lg text-gray-600">Loading dashboard...</p>
<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"></div>
<p className="text-lg text-muted-foreground">Loading dashboard...</p>
</div>
</div>
);
}
const navigationCards = [
{
title: "Analytics Overview",
description: "View comprehensive metrics, charts, and insights from your chat sessions",
icon: <BarChart3 className="h-6 w-6" />,
href: "/dashboard/overview",
variant: "primary" as const,
features: ["Real-time metrics", "Interactive charts", "Trend analysis"],
},
{
title: "Session Browser",
description: "Browse, search, and analyze individual conversation sessions",
icon: <MessageSquare className="h-6 w-6" />,
href: "/dashboard/sessions",
variant: "success" as const,
features: ["Session search", "Conversation details", "Export data"],
},
...(session?.user?.role === "ADMIN"
? [
{
title: "Company Settings",
description: "Configure company settings, integrations, and API connections",
icon: <Settings className="h-6 w-6" />,
href: "/dashboard/company",
variant: "warning" as const,
features: ["API configuration", "Integration settings", "Data management"],
adminOnly: true,
},
{
title: "User Management",
description: "Invite team members and manage user accounts and permissions",
icon: <Users className="h-6 w-6" />,
href: "/dashboard/users",
variant: "default" as const,
features: ["User invitations", "Role management", "Access control"],
adminOnly: true,
},
]
: []),
];
const getCardClasses = (variant: string) => {
switch (variant) {
case "primary":
return "border-primary/20 bg-gradient-to-br from-primary/5 to-primary/10 hover:from-primary/10 hover:to-primary/15";
case "success":
return "border-green-200 bg-gradient-to-br from-green-50 to-green-100 hover:from-green-100 hover:to-green-150 dark:border-green-800 dark:from-green-950 dark:to-green-900";
case "warning":
return "border-amber-200 bg-gradient-to-br from-amber-50 to-amber-100 hover:from-amber-100 hover:to-amber-150 dark:border-amber-800 dark:from-amber-950 dark:to-amber-900";
default:
return "border-border bg-gradient-to-br from-card to-muted/20 hover:from-muted/30 hover:to-muted/40";
}
};
const getIconClasses = (variant: string) => {
switch (variant) {
case "primary":
return "bg-primary/10 text-primary border-primary/20";
case "success":
return "bg-green-100 text-green-600 border-green-200 dark:bg-green-900 dark:text-green-400 dark:border-green-800";
case "warning":
return "bg-amber-100 text-amber-600 border-amber-200 dark:bg-amber-900 dark:text-amber-400 dark:border-amber-800";
default:
return "bg-muted text-muted-foreground border-border";
}
};
return (
<div className="space-y-6">
<div className="bg-white rounded-xl shadow p-6">
<h1 className="text-2xl font-bold mb-4">Dashboard</h1>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="bg-gradient-to-br from-sky-50 to-sky-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
<h2 className="text-lg font-semibold text-sky-700">Analytics</h2>
<p className="text-gray-600 mt-2 mb-4">
View your chat session metrics and analytics
<div className="space-y-8">
{/* Welcome Header */}
<Card className="border-0 bg-gradient-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">
Welcome back, {session?.user?.name || "User"}!
</h1>
<Badge variant="secondary" className="text-xs">
{session?.user?.role}
</Badge>
</div>
<p className="text-muted-foreground">
Choose a section below to explore your analytics dashboard
</p>
<button
onClick={() => router.push("/dashboard/overview")}
className="bg-sky-500 hover:bg-sky-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
View Analytics
</button>
</div>
<div className="bg-gradient-to-br from-emerald-50 to-emerald-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
<h2 className="text-lg font-semibold text-emerald-700">Sessions</h2>
<p className="text-gray-600 mt-2 mb-4">
Browse and analyze conversation sessions
</p>
<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 className="flex items-center gap-2">
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Shield className="h-4 w-4" />
Secure Dashboard
</div>
</div>
</div>
</CardHeader>
</Card>
{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"
{/* Navigation Cards */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{navigationCards.map((card, index) => (
<Card
key={index}
className={`relative overflow-hidden transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 cursor-pointer ${getCardClasses(
card.variant
)}`}
onClick={() => router.push(card.href)}
>
Manage Settings
</button>
{/* Subtle gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-br from-white/50 to-transparent dark:from-white/5 pointer-events-none" />
<CardHeader className="relative">
<div className="flex items-start justify-between">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div
className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-full border transition-colors ${getIconClasses(
card.variant
)}`}
>
{card.icon}
</div>
<div>
<CardTitle className="text-xl font-semibold flex items-center gap-2">
{card.title}
{card.adminOnly && (
<Badge variant="outline" className="text-xs">
Admin
</Badge>
)}
{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
</CardTitle>
</div>
</div>
<p className="text-muted-foreground leading-relaxed">
{card.description}
</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"
</div>
</div>
</CardHeader>
<CardContent className="relative space-y-4">
{/* Features List */}
<div className="space-y-2">
{card.features.map((feature, featureIndex) => (
<div key={featureIndex} className="flex items-center gap-2 text-sm">
<div className="h-1.5 w-1.5 rounded-full bg-current opacity-60" />
<span className="text-muted-foreground">{feature}</span>
</div>
))}
</div>
{/* Action Button */}
<Button
className="w-full gap-2 mt-4"
variant={card.variant === "primary" ? "default" : "outline"}
onClick={(e) => {
e.stopPropagation();
router.push(card.href);
}}
>
Manage Users
</button>
<span>
{card.title === "Analytics Overview" && "View Analytics"}
{card.title === "Session Browser" && "Browse Sessions"}
{card.title === "Company Settings" && "Manage Settings"}
{card.title === "User Management" && "Manage Users"}
</span>
<ArrowRight className="h-4 w-4" />
</Button>
</CardContent>
</Card>
))}
</div>
)}
{/* Quick Stats */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Quick Stats
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
<div className="text-center space-y-2">
<div className="flex items-center justify-center gap-2">
<Zap className="h-5 w-5 text-primary" />
<span className="text-2xl font-bold">Real-time</span>
</div>
<p className="text-sm text-muted-foreground">Data updates</p>
</div>
<div className="text-center space-y-2">
<div className="flex items-center justify-center gap-2">
<Shield className="h-5 w-5 text-green-600" />
<span className="text-2xl font-bold">Secure</span>
</div>
<p className="text-sm text-muted-foreground">Data protection</p>
</div>
<div className="text-center space-y-2">
<div className="flex items-center justify-center gap-2">
<BarChart3 className="h-5 w-5 text-blue-600" />
<span className="text-2xl font-bold">Advanced</span>
</div>
<p className="text-sm text-muted-foreground">Analytics</p>
</div>
</div>
</CardContent>
</Card>
</div>
);
};

View File

@ -1 +1,120 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -1,6 +1,6 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "../pages/api/auth/[...nextauth]";
import { authOptions } from "./api/auth/[...nextauth]/route";
export default async function HomePage() {
const session = await getServerSession(authOptions);

View File

@ -7,9 +7,9 @@ export function Providers({ children }: { children: ReactNode }) {
// Including error handling and refetch interval for better user experience
return (
<SessionProvider
// Re-fetch session every 10 minutes
refetchInterval={10 * 60}
refetchOnWindowFocus={true}
// Re-fetch session every 30 minutes (reduced from 10)
refetchInterval={30 * 60}
refetchOnWindowFocus={false}
>
{children}
</SessionProvider>

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@ -21,9 +21,9 @@ export default function DateRangePicker({
const [endDate, setEndDate] = useState(initialEndDate || maxDate);
useEffect(() => {
// Notify parent component when dates change
// Only notify parent component when dates change, not when the callback changes
onDateRangeChange(startDate, endDate);
}, [startDate, endDate, onDateRangeChange]);
}, [startDate, endDate]);
const handleStartDateChange = (newStartDate: string) => {
// Ensure start date is not before min date

View File

@ -1,10 +1,15 @@
"use client";
import { useRef, useEffect } from "react";
import Chart from "chart.js/auto";
import annotationPlugin from "chartjs-plugin-annotation";
Chart.register(annotationPlugin);
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from "recharts";
interface ResponseTimeDistributionProps {
data: number[];
@ -12,18 +17,35 @@ interface ResponseTimeDistributionProps {
targetResponseTime?: number;
}
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
return (
<div className="rounded-lg border bg-background p-3 shadow-md">
<p className="text-sm font-medium">{label}</p>
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">
{payload[0].value}
</span>{" "}
responses
</p>
</div>
);
}
return null;
};
export default function ResponseTimeDistribution({
data,
average,
targetResponseTime,
}: ResponseTimeDistributionProps) {
const ref = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
if (!ref.current || !data || !data.length) return;
const ctx = ref.current.getContext("2d");
if (!ctx) return;
if (!data || !data.length) {
return (
<div className="flex items-center justify-center h-64 text-muted-foreground">
No response time data available
</div>
);
}
// Create bins for the histogram (0-1s, 1-2s, 2-3s, etc.)
const maxTime = Math.ceil(Math.max(...data));
@ -35,91 +57,105 @@ export default function ResponseTimeDistribution({
bins[binIndex]++;
});
// Create labels for each bin
const labels = bins.map((_, i) => {
// Create chart data
const chartData = bins.map((count, i) => {
let label;
if (i === bins.length - 1 && bins.length < maxTime + 1) {
return `${i}+ seconds`;
label = `${i}+ sec`;
} else {
label = `${i}-${i + 1} sec`;
}
return `${i}-${i + 1} seconds`;
// Determine color based on response time
let color;
if (i <= 2) color = "hsl(var(--chart-1))"; // Green for fast
else if (i <= 5) color = "hsl(var(--chart-4))"; // Yellow for medium
else color = "hsl(var(--chart-3))"; // Red for slow
return {
name: label,
value: count,
color,
};
});
const chart = new Chart(ctx, {
type: "bar",
data: {
labels,
datasets: [
{
label: "Responses",
data: bins,
backgroundColor: bins.map((_, i) => {
// Green for fast, yellow for medium, red for slow
if (i <= 2) return "rgba(34, 197, 94, 0.7)"; // Green
if (i <= 5) return "rgba(250, 204, 21, 0.7)"; // Yellow
return "rgba(239, 68, 68, 0.7)"; // Red
}),
borderWidth: 1,
},
],
},
options: {
responsive: true,
plugins: {
legend: { display: false },
annotation: {
annotations: {
averageLine: {
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",
yMin: 0,
yMax: Math.max(...bins),
xMin: targetResponseTime,
xMax: targetResponseTime,
borderColor: "rgba(75, 192, 192, 0.7)",
borderWidth: 2,
label: {
display: true,
content: "Target",
position: "end",
},
return (
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
strokeOpacity={0.3}
/>
<XAxis
dataKey="name"
stroke="hsl(var(--muted-foreground))"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="hsl(var(--muted-foreground))"
fontSize={12}
tickLine={false}
axisLine={false}
label={{
value: 'Number of Responses',
angle: -90,
position: 'insideLeft',
style: { textAnchor: 'middle' }
}}
/>
<Tooltip content={<CustomTooltip />} />
<Bar
dataKey="value"
radius={[4, 4, 0, 0]}
fill="hsl(var(--chart-1))"
>
{chartData.map((entry, index) => (
<Bar key={`cell-${index}`} fill={entry.color} />
))}
</Bar>
{/* Average line */}
<ReferenceLine
x={Math.floor(average)}
stroke="hsl(var(--primary))"
strokeWidth={2}
strokeDasharray="5 5"
label={{
value: `Avg: ${average.toFixed(1)}s`,
position: "top" as const,
style: {
fill: "hsl(var(--primary))",
fontSize: "12px",
fontWeight: "500"
}
: undefined,
},
},
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: "Number of Responses",
},
},
x: {
title: {
display: true,
text: "Response Time",
},
},
},
},
});
}}
/>
return () => chart.destroy();
}, [data, average, targetResponseTime]);
return <canvas ref={ref} height={180} />;
{/* Target line (if provided) */}
{targetResponseTime && (
<ReferenceLine
x={Math.floor(targetResponseTime)}
stroke="hsl(var(--chart-2))"
strokeWidth={2}
strokeDasharray="3 3"
label={{
value: `Target: ${targetResponseTime}s`,
position: "top" as const,
style: {
fill: "hsl(var(--chart-2))",
fontSize: "12px",
fontWeight: "500"
}
}}
/>
)}
</BarChart>
</ResponsiveContainer>
</div>
);
}

View File

@ -0,0 +1,105 @@
"use client";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface BarChartProps {
data: Array<{ name: string; value: number; [key: string]: any }>;
title?: string;
dataKey?: string;
colors?: string[];
height?: number;
className?: string;
}
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
return (
<div className="rounded-lg border bg-background p-3 shadow-md">
<p className="text-sm font-medium">{label}</p>
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">
{payload[0].value}
</span>{" "}
sessions
</p>
</div>
);
}
return null;
};
export default function ModernBarChart({
data,
title,
dataKey = "value",
colors = [
"hsl(var(--chart-1))",
"hsl(var(--chart-2))",
"hsl(var(--chart-3))",
"hsl(var(--chart-4))",
"hsl(var(--chart-5))",
],
height = 300,
className,
}: BarChartProps) {
return (
<Card className={className}>
{title && (
<CardHeader>
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
</CardHeader>
)}
<CardContent>
<ResponsiveContainer width="100%" height={height}>
<BarChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
strokeOpacity={0.3}
/>
<XAxis
dataKey="name"
stroke="hsl(var(--muted-foreground))"
fontSize={12}
tickLine={false}
axisLine={false}
angle={-45}
textAnchor="end"
height={80}
/>
<YAxis
stroke="hsl(var(--muted-foreground))"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<Tooltip content={<CustomTooltip />} />
<Bar
dataKey={dataKey}
radius={[4, 4, 0, 0]}
className="transition-all duration-200"
>
{data.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={colors[index % colors.length]}
className="hover:opacity-80"
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,122 @@
"use client";
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface DonutChartProps {
data: Array<{ name: string; value: number; color?: string }>;
title?: string;
centerText?: {
title: string;
value: string | number;
};
colors?: string[];
height?: number;
className?: string;
}
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0];
return (
<div className="rounded-lg border bg-background p-3 shadow-md">
<p className="text-sm font-medium">{data.name}</p>
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">
{data.value}
</span>{" "}
sessions ({((data.value / data.payload.total) * 100).toFixed(1)}%)
</p>
</div>
);
}
return null;
};
const CustomLegend = ({ payload }: any) => {
return (
<div className="flex flex-wrap justify-center gap-4 mt-4">
{payload.map((entry: any, index: number) => (
<div key={index} className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-sm text-muted-foreground">{entry.value}</span>
</div>
))}
</div>
);
};
const CenterLabel = ({ centerText, total }: any) => {
if (!centerText) return null;
return (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center">
<p className="text-2xl font-bold">{centerText.value}</p>
<p className="text-sm text-muted-foreground">{centerText.title}</p>
</div>
</div>
);
};
export default function ModernDonutChart({
data,
title,
centerText,
colors = [
"hsl(var(--chart-1))",
"hsl(var(--chart-2))",
"hsl(var(--chart-3))",
"hsl(var(--chart-4))",
"hsl(var(--chart-5))",
],
height = 300,
className,
}: DonutChartProps) {
const total = data.reduce((sum, item) => sum + item.value, 0);
const dataWithTotal = data.map(item => ({ ...item, total }));
return (
<Card className={className}>
{title && (
<CardHeader>
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
</CardHeader>
)}
<CardContent>
<div className="relative">
<ResponsiveContainer width="100%" height={height}>
<PieChart>
<Pie
data={dataWithTotal}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={2}
dataKey="value"
className="transition-all duration-200"
>
{dataWithTotal.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={entry.color || colors[index % colors.length]}
className="hover:opacity-80 cursor-pointer"
stroke="hsl(var(--background))"
strokeWidth={2}
/>
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
<Legend content={<CustomLegend />} />
</PieChart>
</ResponsiveContainer>
<CenterLabel centerText={centerText} total={total} />
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,117 @@
"use client";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Area,
AreaChart,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface LineChartProps {
data: Array<{ date: string; value: number; [key: string]: any }>;
title?: string;
dataKey?: string;
color?: string;
gradient?: boolean;
height?: number;
className?: string;
}
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
return (
<div className="rounded-lg border bg-background p-3 shadow-md">
<p className="text-sm font-medium">{label}</p>
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">
{payload[0].value}
</span>{" "}
sessions
</p>
</div>
);
}
return null;
};
export default function ModernLineChart({
data,
title,
dataKey = "value",
color = "hsl(var(--primary))",
gradient = true,
height = 300,
className,
}: LineChartProps) {
const ChartComponent = gradient ? AreaChart : LineChart;
return (
<Card className={className}>
{title && (
<CardHeader>
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
</CardHeader>
)}
<CardContent>
<ResponsiveContainer width="100%" height={height}>
<ChartComponent data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<defs>
{gradient && (
<linearGradient id="colorGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
<stop offset="95%" stopColor={color} stopOpacity={0.05} />
</linearGradient>
)}
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
strokeOpacity={0.3}
/>
<XAxis
dataKey="date"
stroke="hsl(var(--muted-foreground))"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="hsl(var(--muted-foreground))"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<Tooltip content={<CustomTooltip />} />
{gradient ? (
<Area
type="monotone"
dataKey={dataKey}
stroke={color}
strokeWidth={2}
fill="url(#colorGradient)"
dot={{ fill: color, strokeWidth: 2, r: 4 }}
activeDot={{ r: 6, stroke: color, strokeWidth: 2 }}
/>
) : (
<Line
type="monotone"
dataKey={dataKey}
stroke={color}
strokeWidth={2}
dot={{ fill: color, strokeWidth: 2, r: 4 }}
activeDot={{ r: 6, stroke: color, strokeWidth: 2 }}
/>
)}
</ChartComponent>
</ResponsiveContainer>
</CardContent>
</Card>
);
}

46
components/ui/badge.tsx Normal file
View File

@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

59
components/ui/button.tsx Normal file
View File

@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

92
components/ui/card.tsx Normal file
View File

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@ -0,0 +1,163 @@
"use client";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
interface MetricCardProps {
title: string;
value: string | number | null | undefined;
description?: string;
icon?: React.ReactNode;
trend?: {
value: number;
label?: string;
isPositive?: boolean;
};
variant?: "default" | "primary" | "success" | "warning" | "danger";
isLoading?: boolean;
className?: string;
}
export default function MetricCard({
title,
value,
description,
icon,
trend,
variant = "default",
isLoading = false,
className,
}: MetricCardProps) {
if (isLoading) {
return (
<Card className={cn("relative overflow-hidden", className)}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-10 rounded-full" />
</div>
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16 mb-2" />
<Skeleton className="h-3 w-20" />
</CardContent>
</Card>
);
}
const getVariantClasses = () => {
switch (variant) {
case "primary":
return "border-primary/20 bg-gradient-to-br from-primary/5 to-primary/10";
case "success":
return "border-green-200 bg-gradient-to-br from-green-50 to-green-100 dark:border-green-800 dark:from-green-950 dark:to-green-900";
case "warning":
return "border-amber-200 bg-gradient-to-br from-amber-50 to-amber-100 dark:border-amber-800 dark:from-amber-950 dark:to-amber-900";
case "danger":
return "border-red-200 bg-gradient-to-br from-red-50 to-red-100 dark:border-red-800 dark:from-red-950 dark:to-red-900";
default:
return "border-border bg-gradient-to-br from-card to-muted/20";
}
};
const getIconClasses = () => {
switch (variant) {
case "primary":
return "bg-primary/10 text-primary border-primary/20";
case "success":
return "bg-green-100 text-green-600 border-green-200 dark:bg-green-900 dark:text-green-400 dark:border-green-800";
case "warning":
return "bg-amber-100 text-amber-600 border-amber-200 dark:bg-amber-900 dark:text-amber-400 dark:border-amber-800";
case "danger":
return "bg-red-100 text-red-600 border-red-200 dark:bg-red-900 dark:text-red-400 dark:border-red-800";
default:
return "bg-muted text-muted-foreground border-border";
}
};
const getTrendIcon = () => {
if (!trend) return null;
if (trend.value === 0) {
return <Minus className="h-3 w-3" />;
}
return trend.isPositive !== false ? (
<TrendingUp className="h-3 w-3" />
) : (
<TrendingDown className="h-3 w-3" />
);
};
const getTrendColor = () => {
if (!trend || trend.value === 0) return "text-muted-foreground";
return trend.isPositive !== false ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400";
};
return (
<Card
className={cn(
"relative overflow-hidden transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5",
getVariantClasses(),
className
)}
>
{/* Subtle gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-br from-white/50 to-transparent dark:from-white/5 pointer-events-none" />
<CardHeader className="pb-3 relative">
<div className="flex items-start justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground leading-none">
{title}
</p>
{description && (
<p className="text-xs text-muted-foreground/80">
{description}
</p>
)}
</div>
{icon && (
<div
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-full border transition-colors",
getIconClasses()
)}
>
<span className="text-lg">{icon}</span>
</div>
)}
</div>
</CardHeader>
<CardContent className="relative">
<div className="flex items-end justify-between">
<div className="space-y-1">
<p className="text-2xl font-bold tracking-tight">
{value ?? "—"}
</p>
{trend && (
<Badge
variant="secondary"
className={cn(
"text-xs font-medium px-2 py-0.5 gap-1",
getTrendColor(),
"bg-background/50 border-current/20"
)}
>
{getTrendIcon()}
{Math.abs(trend.value).toFixed(1)}%
{trend.label && ` ${trend.label}`}
</Badge>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

61
components/ui/tooltip.tsx Normal file
View File

@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

6
lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -25,7 +25,12 @@
"lint:md:fix": "markdownlint-cli2 --fix \"**/*.md\" \"!.trunk/**\" \"!.venv/**\" \"!node_modules/**\""
},
"dependencies": {
"@prisma/adapter-pg": "^6.10.1",
"@prisma/client": "^6.10.1",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.7",
"@rapideditor/country-coder": "^5.4.0",
"@types/d3": "^7.4.3",
"@types/d3-cloud": "^1.2.9",
@ -34,8 +39,8 @@
"@types/leaflet": "^1.9.18",
"@types/node-fetch": "^2.6.12",
"bcryptjs": "^3.0.2",
"chart.js": "^4.0.0",
"chartjs-plugin-annotation": "^3.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"csv-parse": "^5.5.0",
"d3": "^7.9.0",
"d3-cloud": "^1.2.7",
@ -43,16 +48,18 @@
"i18n-iso-countries": "^7.14.0",
"iso-639-1": "^3.1.5",
"leaflet": "^1.9.4",
"lucide-react": "^0.525.0",
"next": "^15.3.2",
"next-auth": "^4.24.11",
"node-cron": "^4.0.7",
"node-fetch": "^3.3.2",
"react": "^19.1.0",
"react-chartjs-2": "^5.0.0",
"react-dom": "^19.1.0",
"react-leaflet": "^5.0.0",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0"
"recharts": "^3.0.2",
"rehype-raw": "^7.0.0",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
@ -82,6 +89,7 @@
"tailwindcss": "^4.1.7",
"ts-node": "^10.9.2",
"tsx": "^4.20.3",
"tw-animate-css": "^1.3.4",
"typescript": "^5.0.0",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4"

View File

@ -1,36 +0,0 @@
// API endpoint: update company CSV URL config
import { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth";
import { prisma } from "../../../lib/prisma";
import { authOptions } from "../auth/[...nextauth]";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const session = await getServerSession(req, res, authOptions);
if (!session?.user) return res.status(401).json({ error: "Not logged in" });
const user = await prisma.user.findUnique({
where: { email: session.user.email as string },
});
if (!user) return res.status(401).json({ error: "No user" });
if (req.method === "POST") {
const { csvUrl } = req.body;
await prisma.company.update({
where: { id: user.companyId },
data: { csvUrl },
});
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 {
res.status(405).end();
}
}

View File

@ -1,37 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth";
import { prisma } from "../../../lib/prisma";
import { authOptions } from "../auth/[...nextauth]";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const session = await getServerSession(req, res, authOptions);
if (!session?.user || session.user.role !== "ADMIN")
return res.status(403).json({ error: "Forbidden" });
const user = await prisma.user.findUnique({
where: { email: session.user.email as string },
});
if (!user) return res.status(401).json({ error: "No user" });
if (req.method === "POST") {
const { csvUrl, csvUsername, csvPassword, sentimentThreshold } = req.body;
await prisma.company.update({
where: { id: user.companyId },
data: {
csvUrl,
csvUsername,
...(csvPassword ? { csvPassword } : {}),
sentimentAlert: sentimentThreshold
? parseFloat(sentimentThreshold)
: null,
},
});
res.json({ ok: true });
} else {
res.status(405).end();
}
}

View File

@ -1,59 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import crypto from "crypto";
import { getServerSession } from "next-auth";
import { prisma } from "../../../lib/prisma";
import bcrypt from "bcryptjs";
import { authOptions } from "../auth/[...nextauth]";
// User type from prisma is used instead of the one in lib/types
interface UserBasicInfo {
id: string;
email: string;
role: string;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const session = await getServerSession(req, res, authOptions);
if (!session?.user || session.user.role !== "ADMIN")
return res.status(403).json({ error: "Forbidden" });
const user = await prisma.user.findUnique({
where: { email: session.user.email as string },
});
if (!user) return res.status(401).json({ error: "No user" });
if (req.method === "GET") {
const users = await prisma.user.findMany({
where: { companyId: user.companyId },
});
const mappedUsers: UserBasicInfo[] = users.map((u) => ({
id: u.id,
email: u.email,
role: u.role,
}));
res.json({ users: mappedUsers });
} else if (req.method === "POST") {
const { email, role } = req.body;
if (!email || !role)
return res.status(400).json({ error: "Missing fields" });
const exists = await prisma.user.findUnique({ where: { email } });
if (exists) return res.status(409).json({ error: "Email exists" });
const tempPassword = crypto.randomBytes(12).toString("base64").slice(0, 12); // secure random initial password
await prisma.user.create({
data: {
email,
password: await bcrypt.hash(tempPassword, 10),
companyId: user.companyId,
role,
},
});
// TODO: Email user their temp password (stub, for demo) - Implement a robust and secure email sending mechanism. Consider using a transactional email service.
res.json({ ok: true, tempPassword });
} else res.status(405).end();
}

View File

@ -1,31 +0,0 @@
import { prisma } from "../../lib/prisma";
import { sendEmail } from "../../lib/sendEmail";
import crypto from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "POST") {
res.setHeader("Allow", ["POST"]);
return res.status(405).end(`Method ${req.method} Not Allowed`);
}
// Type the body with a type assertion
const { email } = req.body as { email: string };
const user = await prisma.user.findUnique({ where: { email } });
if (!user) return res.status(200).end(); // always 200 for privacy
const token = crypto.randomBytes(32).toString("hex");
const expiry = new Date(Date.now() + 1000 * 60 * 30); // 30 min expiry
await prisma.user.update({
where: { email },
data: { resetToken: token, resetTokenExpiry: expiry },
});
const resetUrl = `${process.env.NEXTAUTH_URL || "http://localhost:3000"}/reset-password?token=${token}`;
await sendEmail(email, "Password Reset", `Reset your password: ${resetUrl}`);
res.status(200).end();
}

View File

@ -1,56 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "../../lib/prisma";
import bcrypt from "bcryptjs";
import { ApiResponse } from "../../lib/types";
interface RegisterRequestBody {
email: string;
password: string;
company: string;
csvUrl?: string;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<ApiResponse<{ success: boolean } | { error: string }>>
) {
if (req.method !== "POST") return res.status(405).end();
const { email, password, company, csvUrl } = req.body as RegisterRequestBody;
if (!email || !password || !company) {
return res.status(400).json({
success: false,
error: "Missing required fields",
});
}
// Check if email exists
const exists = await prisma.user.findUnique({
where: { email },
});
if (exists) {
return res.status(409).json({
success: false,
error: "Email already exists",
});
}
const newCompany = await prisma.company.create({
data: { name: company, csvUrl: csvUrl || "" },
});
const hashed = await bcrypt.hash(password, 10);
await prisma.user.create({
data: {
email,
password: hashed,
companyId: newCompany.id,
role: "ADMIN",
},
});
res.status(201).json({
success: true,
data: { success: true },
});
}

View File

@ -1,63 +0,0 @@
import { prisma } from "../../lib/prisma";
import bcrypt from "bcryptjs";
import type { NextApiRequest, NextApiResponse } from "next"; // Import official Next.js types
export default async function handler(
req: NextApiRequest, // Use official NextApiRequest
res: NextApiResponse // Use official NextApiResponse
) {
if (req.method !== "POST") {
res.setHeader("Allow", ["POST"]); // Good practice to set Allow header for 405
return res.status(405).end(`Method ${req.method} Not Allowed`);
}
// It's good practice to explicitly type the expected body for clarity and safety
const { token, password } = req.body as { token?: string; password?: string };
if (!token || !password) {
return res.status(400).json({ error: "Token and password are required." });
}
if (password.length < 8) {
// Example: Add password complexity rule
return res
.status(400)
.json({ error: "Password must be at least 8 characters long." });
}
try {
const user = await prisma.user.findFirst({
where: {
resetToken: token,
resetTokenExpiry: { gte: new Date() },
},
});
if (!user) {
return res.status(400).json({
error: "Invalid or expired token. Please request a new password reset.",
});
}
const hash = await bcrypt.hash(password, 10);
await prisma.user.update({
where: { id: user.id },
data: {
password: hash,
resetToken: null,
resetTokenExpiry: null,
},
});
// Instead of just res.status(200).end(), send a success message
return res
.status(200)
.json({ message: "Password has been reset successfully." });
} catch (error) {
console.error("Reset password error:", error); // Log the error for server-side debugging
// Provide a generic error message to the client
return res.status(500).json({
error: "An internal server error occurred. Please try again later.",
});
}
}

1124
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +0,0 @@
/** @type {import('tailwindcss').Config} */
const config = {
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {},
},
plugins: [],
};
export default config;