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 { NextRequest, NextResponse } from "next/server";
import { NextApiRequest, NextApiResponse } from "next"; import { fetchAndParseCsv } from "../../../../lib/csvFetcher";
import { fetchAndParseCsv } from "../../../lib/csvFetcher"; import { processQueuedImports } from "../../../../lib/importProcessor";
import { processQueuedImports } from "../../../lib/importProcessor"; import { prisma } from "../../../../lib/prisma";
import { prisma } from "../../../lib/prisma";
export default async function handler( export async function POST(request: NextRequest) {
req: NextApiRequest, try {
res: NextApiResponse const body = await request.json();
) { let { companyId } = body;
// 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;
if (!companyId) { if (!companyId) {
// Try to get user from prisma based on session cookie // Try to get user from prisma based on session cookie
@ -39,13 +31,20 @@ export default async function handler(
} }
if (!companyId) { 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 } }); 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( const rawSessionData = await fetchAndParseCsv(
company.csvUrl, company.csvUrl,
company.csvUsername as string | undefined, company.csvUsername as string | undefined,
@ -123,7 +122,7 @@ export default async function handler(
where: { companyId: company.id } where: { companyId: company.id }
}); });
res.json({ return NextResponse.json({
ok: true, ok: true,
imported: importedCount, imported: importedCount,
total: rawSessionData.length, total: rawSessionData.length,
@ -132,6 +131,6 @@ export default async function handler(
}); });
} catch (e) { } catch (e) {
const error = e instanceof Error ? e.message : "An unknown error occurred"; 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 { getServerSession } from "next-auth";
import { authOptions } from "../auth/[...nextauth]"; import { authOptions } from "../../auth/[...nextauth]/route";
import { prisma } from "../../../lib/prisma"; import { prisma } from "../../../../lib/prisma";
import { processUnprocessedSessions } from "../../../lib/processingScheduler"; import { processUnprocessedSessions } from "../../../../lib/processingScheduler";
import { ProcessingStatusManager } from "../../../lib/processingStatusManager"; import { ProcessingStatusManager } from "../../../../lib/processingStatusManager";
import { ProcessingStage } from "@prisma/client"; import { ProcessingStage } from "@prisma/client";
interface SessionUser { interface SessionUser {
@ -15,22 +15,11 @@ interface SessionData {
user: SessionUser; user: SessionUser;
} }
export default async function handler( export async function POST(request: NextRequest) {
req: NextApiRequest, const session = (await getServerSession(authOptions)) as SessionData | null;
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;
if (!session?.user) { 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({ const user = await prisma.user.findUnique({
@ -39,17 +28,21 @@ export default async function handler(
}); });
if (!user) { 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 // Check if user has ADMIN role
if (user.role !== "ADMIN") { if (user.role !== "ADMIN") {
return res.status(403).json({ error: "Admin access required" }); return NextResponse.json(
{ error: "Admin access required" },
{ status: 403 }
);
} }
try { try {
// Get optional parameters from request body // Get optional parameters from request body
const { batchSize, maxConcurrency } = req.body; const body = await request.json();
const { batchSize, maxConcurrency } = body;
// Validate parameters // Validate parameters
const validatedBatchSize = batchSize && batchSize > 0 ? parseInt(batchSize) : null; const validatedBatchSize = batchSize && batchSize > 0 ? parseInt(batchSize) : null;
@ -69,7 +62,7 @@ export default async function handler(
const unprocessedCount = companySessionsNeedingAI.length; const unprocessedCount = companySessionsNeedingAI.length;
if (unprocessedCount === 0) { if (unprocessedCount === 0) {
return res.json({ return NextResponse.json({
success: true, success: true,
message: "No sessions requiring AI processing found", message: "No sessions requiring AI processing found",
unprocessedCount: 0, unprocessedCount: 0,
@ -90,7 +83,7 @@ export default async function handler(
console.error(`[Manual Trigger] Processing failed for company ${user.companyId}:`, error); console.error(`[Manual Trigger] Processing failed for company ${user.companyId}:`, error);
}); });
return res.json({ return NextResponse.json({
success: true, success: true,
message: `Started processing ${unprocessedCount} unprocessed sessions`, message: `Started processing ${unprocessedCount} unprocessed sessions`,
unprocessedCount, unprocessedCount,
@ -101,9 +94,12 @@ export default async function handler(
} catch (error) { } catch (error) {
console.error("[Manual Trigger] Error:", error); console.error("[Manual Trigger] Error:", error);
return res.status(500).json({ return NextResponse.json(
{
error: "Failed to trigger processing", error: "Failed to trigger processing",
details: error instanceof Error ? error.message : String(error), details: error instanceof Error ? error.message : String(error),
}); },
{ status: 500 }
);
} }
} }

View File

@ -1,6 +1,6 @@
import NextAuth, { NextAuthOptions } from "next-auth"; import NextAuth, { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials"; import CredentialsProvider from "next-auth/providers/credentials";
import { prisma } from "../../../lib/prisma"; import { prisma } from "../../../../lib/prisma";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
// Define the shape of the JWT token // Define the shape of the JWT token
@ -101,4 +101,6 @@ export const authOptions: NextAuthOptions = {
debug: process.env.NODE_ENV === "development", 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 { NextRequest, NextResponse } from "next/server";
import { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { prisma } from "../../../lib/prisma"; import { prisma } from "../../../../lib/prisma";
import { sessionMetrics } from "../../../lib/metrics"; import { sessionMetrics } from "../../../../lib/metrics";
import { authOptions } from "../auth/[...nextauth]"; import { authOptions } from "../../auth/[...nextauth]/route";
import { ChatSession } from "../../../lib/types"; // Import ChatSession import { ChatSession } from "../../../../lib/types";
interface SessionUser { interface SessionUser {
email: string; email: string;
@ -15,26 +14,25 @@ interface SessionData {
user: SessionUser; user: SessionUser;
} }
export default async function handler( export async function GET(request: NextRequest) {
req: NextApiRequest, const session = (await getServerSession(authOptions)) as SessionData | null;
res: NextApiResponse if (!session?.user) {
) { return NextResponse.json({ error: "Not logged in" }, { status: 401 });
const session = (await getServerSession( }
req,
res,
authOptions
)) as SessionData | null;
if (!session?.user) return res.status(401).json({ error: "Not logged in" });
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { email: session.user.email }, where: { email: session.user.email },
include: { company: true }, 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 // 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 // Build where clause with optional date filtering
const whereClause: any = { const whereClause: any = {
@ -43,8 +41,8 @@ export default async function handler(
if (startDate && endDate) { if (startDate && endDate) {
whereClause.startTime = { whereClause.startTime = {
gte: new Date(startDate as string), gte: new Date(startDate),
lte: new Date(endDate as string + 'T23:59:59.999Z'), // Include full end date 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, metrics,
csvUrl: user.company.csvUrl, csvUrl: user.company.csvUrl,
company: user.company, 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 { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]"; import { authOptions } from "../../auth/[...nextauth]/route";
import { prisma } from "../../../lib/prisma"; import { prisma } from "../../../../lib/prisma";
import { SessionFilterOptions } from "../../../lib/types"; import { SessionFilterOptions } from "../../../../lib/types";
export default async function handler( export async function GET(request: NextRequest) {
req: NextApiRequest, const authSession = await getServerSession(authOptions);
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);
if (!authSession || !authSession.user?.companyId) { 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 companyId = authSession.user.companyId;
@ -62,15 +53,19 @@ export default async function handler(
.map((s) => s.language) .map((s) => s.language)
.filter(Boolean) as string[]; // Filter out any nulls and assert as string[] .filter(Boolean) as string[]; // Filter out any nulls and assert as string[]
return res return NextResponse.json({
.status(200) categories: distinctCategories,
.json({ categories: distinctCategories, languages: distinctLanguages }); languages: distinctLanguages
});
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
error instanceof Error ? error.message : "An unknown error occurred"; error instanceof Error ? error.message : "An unknown error occurred";
return res.status(500).json({ return NextResponse.json(
{
error: "Failed to fetch filter options", error: "Failed to fetch filter options",
details: errorMessage, details: errorMessage,
}); },
{ status: 500 }
);
} }
} }

View File

@ -1,19 +1,18 @@
import { NextApiRequest, NextApiResponse } from "next"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "../../../../lib/prisma"; import { prisma } from "../../../../../lib/prisma";
import { ChatSession } from "../../../../lib/types"; import { ChatSession } from "../../../../../lib/types";
export default async function handler( export async function GET(
req: NextApiRequest, request: NextRequest,
res: NextApiResponse { params }: { params: { id: string } }
) { ) {
if (req.method !== "GET") { const { id } = params;
return res.status(405).json({ error: "Method not allowed" });
}
const { id } = req.query; if (!id) {
return NextResponse.json(
if (!id || typeof id !== "string") { { error: "Session ID is required" },
return res.status(400).json({ error: "Session ID is required" }); { status: 400 }
);
} }
try { try {
@ -27,7 +26,10 @@ export default async function handler(
}); });
if (!prismaSession) { 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 // Map Prisma session object to ChatSession type
@ -71,12 +73,13 @@ export default async function handler(
})) ?? [], // New field - parsed messages })) ?? [], // New field - parsed messages
}; };
return res.status(200).json({ session }); return NextResponse.json({ session });
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
error instanceof Error ? error.message : "An unknown error occurred"; error instanceof Error ? error.message : "An unknown error occurred";
return res return NextResponse.json(
.status(500) { error: "Failed to fetch session", details: errorMessage },
.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 { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]"; import { authOptions } from "../../auth/[...nextauth]/route";
import { prisma } from "../../../lib/prisma"; import { prisma } from "../../../../lib/prisma";
import { import {
ChatSession, ChatSession,
SessionApiResponse, SessionApiResponse,
SessionQuery, SessionQuery,
} from "../../../lib/types"; } from "../../../../lib/types";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
export default async function handler( export async function GET(request: NextRequest) {
req: NextApiRequest, const authSession = await getServerSession(authOptions);
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);
if (!authSession || !authSession.user?.companyId) { 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 companyId = authSession.user.companyId;
const { const { searchParams } = new URL(request.url);
searchTerm,
category, const searchTerm = searchParams.get("searchTerm");
language, const category = searchParams.get("category");
startDate, const language = searchParams.get("language");
endDate, const startDate = searchParams.get("startDate");
sortKey, const endDate = searchParams.get("endDate");
sortOrder, const sortKey = searchParams.get("sortKey");
page: queryPage, const sortOrder = searchParams.get("sortOrder");
pageSize: queryPageSize, const queryPage = searchParams.get("page");
} = req.query as SessionQuery; const queryPageSize = searchParams.get("pageSize");
const page = Number(queryPage) || 1; const page = Number(queryPage) || 1;
const pageSize = Number(queryPageSize) || 10; const pageSize = Number(queryPageSize) || 10;
@ -43,11 +36,7 @@ export default async function handler(
const whereClause: Prisma.SessionWhereInput = { companyId }; const whereClause: Prisma.SessionWhereInput = { companyId };
// Search Term // Search Term
if ( if (searchTerm && searchTerm.trim() !== "") {
searchTerm &&
typeof searchTerm === "string" &&
searchTerm.trim() !== ""
) {
const searchConditions = [ const searchConditions = [
{ id: { contains: searchTerm } }, { id: { contains: searchTerm } },
{ initialMsg: { contains: searchTerm } }, { initialMsg: { contains: searchTerm } },
@ -57,24 +46,24 @@ export default async function handler(
} }
// Category Filter // Category Filter
if (category && typeof category === "string" && category.trim() !== "") { if (category && category.trim() !== "") {
// Cast to SessionCategory enum if it's a valid value // Cast to SessionCategory enum if it's a valid value
whereClause.category = category as any; whereClause.category = category as any;
} }
// Language Filter // Language Filter
if (language && typeof language === "string" && language.trim() !== "") { if (language && language.trim() !== "") {
whereClause.language = language; whereClause.language = language;
} }
// Date Range Filter // Date Range Filter
if (startDate && typeof startDate === "string") { if (startDate) {
whereClause.startTime = { whereClause.startTime = {
...((whereClause.startTime as object) || {}), ...((whereClause.startTime as object) || {}),
gte: new Date(startDate), gte: new Date(startDate),
}; };
} }
if (endDate && typeof endDate === "string") { if (endDate) {
const inclusiveEndDate = new Date(endDate); const inclusiveEndDate = new Date(endDate);
inclusiveEndDate.setDate(inclusiveEndDate.getDate() + 1); inclusiveEndDate.setDate(inclusiveEndDate.getDate() + 1);
whereClause.startTime = { whereClause.startTime = {
@ -98,7 +87,7 @@ export default async function handler(
| Prisma.SessionOrderByWithRelationInput[]; | Prisma.SessionOrderByWithRelationInput[];
const primarySortField = const primarySortField =
sortKey && typeof sortKey === "string" && validSortKeys[sortKey] sortKey && validSortKeys[sortKey]
? validSortKeys[sortKey] ? validSortKeys[sortKey]
: "startTime"; // Default to startTime field if sortKey is invalid/missing : "startTime"; // Default to startTime field if sortKey is invalid/missing
@ -115,9 +104,6 @@ export default async function handler(
{ startTime: "desc" }, { 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({ const prismaSessions = await prisma.session.findMany({
where: whereClause, where: whereClause,
@ -151,12 +137,13 @@ export default async function handler(
transcriptContent: null, // Transcript content is now fetched from fullTranscriptUrl when needed 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) { } catch (error) {
const errorMessage = const errorMessage =
error instanceof Error ? error.message : "An unknown error occurred"; error instanceof Error ? error.message : "An unknown error occurred";
return res return NextResponse.json(
.status(500) { error: "Failed to fetch sessions", details: errorMessage },
.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"; "use client";
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback, useRef } from "react";
import { signOut, useSession } from "next-auth/react"; import { signOut, useSession } from "next-auth/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import {
SessionsLineChart,
CategoriesBarChart,
LanguagePieChart,
TokenUsageChart,
} from "../../../components/Charts";
import { Company, MetricsResult, WordCloudWord } from "../../../lib/types"; import { Company, MetricsResult, WordCloudWord } from "../../../lib/types";
import MetricCard from "../../../components/MetricCard"; import MetricCard from "../../../components/ui/metric-card";
import DonutChart from "../../../components/DonutChart"; 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 WordCloud from "../../../components/WordCloud";
import GeographicMap from "../../../components/GeographicMap"; import GeographicMap from "../../../components/GeographicMap";
import ResponseTimeDistribution from "../../../components/ResponseTimeDistribution"; import ResponseTimeDistribution from "../../../components/ResponseTimeDistribution";
import WelcomeBanner from "../../../components/WelcomeBanner";
import DateRangePicker from "../../../components/DateRangePicker"; import DateRangePicker from "../../../components/DateRangePicker";
import TopQuestionsChart from "../../../components/TopQuestionsChart"; import TopQuestionsChart from "../../../components/TopQuestionsChart";
// Safely wrapped component with useSession // Safely wrapped component with useSession
function DashboardContent() { function DashboardContent() {
const { data: session, status } = useSession(); // Add status from useSession const { data: session, status } = useSession();
const router = useRouter(); // Initialize useRouter const router = useRouter();
const [metrics, setMetrics] = useState<MetricsResult | null>(null); const [metrics, setMetrics] = useState<MetricsResult | null>(null);
const [company, setCompany] = useState<Company | 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 [refreshing, setRefreshing] = useState<boolean>(false);
const [dateRange, setDateRange] = useState<{ minDate: string; maxDate: string } | null>(null); const [dateRange, setDateRange] = useState<{ minDate: string; maxDate: string } | null>(null);
const [selectedStartDate, setSelectedStartDate] = useState<string>(""); const [selectedStartDate, setSelectedStartDate] = useState<string>("");
const [selectedEndDate, setSelectedEndDate] = useState<string>(""); const [selectedEndDate, setSelectedEndDate] = useState<string>("");
const [isInitialLoad, setIsInitialLoad] = useState<boolean>(true);
const isAuditor = session?.user?.role === "AUDITOR"; const isAuditor = session?.user?.role === "AUDITOR";
// Function to fetch metrics with optional date range // 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); setLoading(true);
try { try {
let url = "/api/dashboard/metrics"; let url = "/api/dashboard/metrics";
@ -49,44 +71,44 @@ function DashboardContent() {
setCompany(data.company); setCompany(data.company);
// Set date range from API response (only on initial load) // Set date range from API response (only on initial load)
if (data.dateRange && !dateRange) { if (data.dateRange && isInitial) {
setDateRange(data.dateRange); setDateRange(data.dateRange);
setSelectedStartDate(data.dateRange.minDate); setSelectedStartDate(data.dateRange.minDate);
setSelectedEndDate(data.dateRange.maxDate); setSelectedEndDate(data.dateRange.maxDate);
setIsInitialLoad(false);
} }
} catch (error) { } catch (error) {
console.error("Error fetching metrics:", error); console.error("Error fetching metrics:", error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [dateRange]); };
// Handle date range changes // Handle date range changes
const handleDateRangeChange = useCallback((startDate: string, endDate: string) => { const handleDateRangeChange = useCallback((startDate: string, endDate: string) => {
setSelectedStartDate(startDate); setSelectedStartDate(startDate);
setSelectedEndDate(endDate); setSelectedEndDate(endDate);
fetchMetrics(startDate, endDate); fetchMetrics(startDate, endDate);
}, [fetchMetrics]); }, []);
useEffect(() => { useEffect(() => {
// Redirect if not authenticated // Redirect if not authenticated
if (status === "unauthenticated") { if (status === "unauthenticated") {
router.push("/login"); router.push("/login");
return; // Stop further execution in this effect return;
} }
// Fetch metrics and company on mount if authenticated // Fetch metrics and company on mount if authenticated
if (status === "authenticated") { if (status === "authenticated" && isInitialLoad) {
fetchMetrics(); fetchMetrics(undefined, undefined, true);
} }
}, [status, router, fetchMetrics]); // Add fetchMetrics to dependency array }, [status, router, isInitialLoad]);
async function handleRefresh() { async function handleRefresh() {
if (isAuditor) return; // Prevent auditors from refreshing if (isAuditor) return;
try { try {
setRefreshing(true); setRefreshing(true);
// Make sure we have a company ID to send
if (!company?.id) { if (!company?.id) {
setRefreshing(false); setRefreshing(false);
alert("Cannot refresh: Company ID is missing"); alert("Cannot refresh: Company ID is missing");
@ -100,7 +122,6 @@ function DashboardContent() {
}); });
if (res.ok) { if (res.ok) {
// Refetch metrics
const metricsRes = await fetch("/api/dashboard/metrics"); const metricsRes = await fetch("/api/dashboard/metrics");
const data = await metricsRes.json(); const data = await metricsRes.json();
setMetrics(data.metrics); 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 // Show loading state while session status is being determined
if (status === "loading") { 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") { 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) { if (loading || !metrics || !company) {
return <div className="text-center py-10">Loading dashboard...</div>; 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[] => { const getWordCloudData = (): WordCloudWord[] => {
if (!metrics || !metrics.wordCloudData) return []; if (!metrics?.wordCloudData) return [];
return metrics.wordCloudData; return metrics.wordCloudData;
}; };
// Function to prepare country data for the map using actual metrics
const getCountryData = () => { const getCountryData = () => {
if (!metrics || !metrics.countries) return {}; if (!metrics?.countries) return {};
return Object.entries(metrics.countries).reduce(
// Convert the countries object from metrics to the format expected by GeographicMap
const result = Object.entries(metrics.countries).reduce(
(acc, [code, count]) => { (acc, [code, count]) => {
if (code && count) { if (code && count) {
acc[code] = count; acc[code] = count;
@ -185,11 +265,8 @@ function DashboardContent() {
}, },
{} as Record<string, number> {} as Record<string, number>
); );
return result;
}; };
// Function to prepare response time distribution data
const getResponseTimeData = () => { const getResponseTimeData = () => {
const avgTime = metrics.avgResponseTime || 1.5; const avgTime = metrics.avgResponseTime || 1.5;
const simulatedData: number[] = []; const simulatedData: number[] = [];
@ -204,62 +281,56 @@ function DashboardContent() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<WelcomeBanner companyName={company.name} /> {/* Modern Header */}
<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"> <Card className="border-0 bg-gradient-to-r from-primary/5 via-primary/10 to-primary/5">
<div> <CardHeader>
<h1 className="text-3xl font-bold text-slate-800">{company.name}</h1> <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<p className="text-slate-500 mt-1"> <div className="space-y-2">
Dashboard updated{" "} <div className="flex items-center gap-3">
<span className="font-medium text-slate-600"> <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()} {new Date(metrics.lastUpdated || Date.now()).toLocaleString()}
</span> </span>
</p> </p>
</div> </div>
<div className="flex items-center gap-3 mt-4 sm:mt-0">
<button <div className="flex items-center gap-2">
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" <Button
onClick={handleRefresh} onClick={handleRefresh}
disabled={refreshing || isAuditor} disabled={refreshing || isAuditor}
size="sm"
className="gap-2"
> >
{refreshing ? ( <RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
<> {refreshing ? "Refreshing..." : "Refresh"}
<svg </Button>
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>
{/* Date Range Picker */} <DropdownMenu>
{dateRange && ( <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 <DateRangePicker
minDate={dateRange.minDate} minDate={dateRange.minDate}
maxDate={dateRange.maxDate} maxDate={dateRange.maxDate}
@ -267,268 +338,192 @@ function DashboardContent() {
initialStartDate={selectedStartDate} initialStartDate={selectedStartDate}
initialEndDate={selectedEndDate} 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 <MetricCard
title="Total Sessions" title="Total Sessions"
value={metrics.totalSessions} value={metrics.totalSessions?.toLocaleString()}
icon={ icon={<MessageSquare className="h-5 w-5" />}
<svg
className="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="1"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"
/>
</svg>
}
trend={{ trend={{
value: metrics.sessionTrend ?? 0, value: metrics.sessionTrend ?? 0,
isPositive: (metrics.sessionTrend ?? 0) >= 0, isPositive: (metrics.sessionTrend ?? 0) >= 0,
}} }}
variant="primary"
/> />
<MetricCard <MetricCard
title="Unique Users" title="Unique Users"
value={metrics.uniqueUsers} value={metrics.uniqueUsers?.toLocaleString()}
icon={ icon={<Users className="h-5 w-5" />}
<svg
className="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="1"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
}
trend={{ trend={{
value: metrics.usersTrend ?? 0, value: metrics.usersTrend ?? 0,
isPositive: (metrics.usersTrend ?? 0) >= 0, isPositive: (metrics.usersTrend ?? 0) >= 0,
}} }}
variant="success"
/> />
<MetricCard <MetricCard
title="Avg. Session Time" title="Avg. Session Time"
value={`${Math.round(metrics.avgSessionLength || 0)}s`} value={`${Math.round(metrics.avgSessionLength || 0)}s`}
icon={ icon={<Clock className="h-5 w-5" />}
<svg
className="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="1"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
}
trend={{ trend={{
value: metrics.avgSessionTimeTrend ?? 0, value: metrics.avgSessionTimeTrend ?? 0,
isPositive: (metrics.avgSessionTimeTrend ?? 0) >= 0, isPositive: (metrics.avgSessionTimeTrend ?? 0) >= 0,
}} }}
/> />
<MetricCard <MetricCard
title="Avg. Response Time" title="Avg. Response Time"
value={`${metrics.avgResponseTime?.toFixed(1) || 0}s`} value={`${metrics.avgResponseTime?.toFixed(1) || 0}s`}
icon={ icon={<Zap className="h-5 w-5" />}
<svg
className="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="1"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
}
trend={{ trend={{
value: metrics.avgResponseTimeTrend ?? 0, value: metrics.avgResponseTimeTrend ?? 0,
isPositive: (metrics.avgResponseTimeTrend ?? 0) <= 0, // Lower response time is better isPositive: (metrics.avgResponseTimeTrend ?? 0) <= 0,
}} }}
variant="warning"
/> />
<MetricCard <MetricCard
title="Avg. Daily Costs" title="Daily Costs"
value={`${metrics.avgDailyCosts?.toFixed(4) || '0.0000'}`} value={`${metrics.avgDailyCosts?.toFixed(4) || '0.0000'}`}
icon={ icon={<Euro className="h-5 w-5" />}
<svg description="Average per day"
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>
}
/> />
<MetricCard <MetricCard
title="Peak Usage Time" title="Peak Usage"
value={metrics.peakUsageTime || 'N/A'} value={metrics.peakUsageTime || 'N/A'}
icon={ icon={<TrendingUp className="h-5 w-5" />}
<svg description="Busiest hour"
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>
}
/> />
<MetricCard <MetricCard
title="Resolved Chats" title="Resolution Rate"
value={`${metrics.resolvedChatsPercentage?.toFixed(1) || '0.0'}%`} value={`${metrics.resolvedChatsPercentage?.toFixed(1) || '0.0'}%`}
icon={ icon={<CheckCircle className="h-5 w-5" />}
<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>
}
trend={{ trend={{
value: metrics.resolvedChatsPercentage ?? 0, 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> </div>
{/* Charts Section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <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"> <ModernLineChart
<h3 className="font-bold text-lg text-gray-800 mb-4"> data={getSessionsOverTimeData()}
Sessions Over Time title="Sessions Over Time"
</h3> className="lg:col-span-2"
<SessionsLineChart sessionsPerDay={metrics.days} /> height={350}
</div> />
<div className="bg-white p-6 rounded-xl shadow">
<h3 className="font-bold text-lg text-gray-800 mb-4"> <ModernDonutChart
Conversation Sentiment data={getSentimentData()}
</h3> title="Conversation Sentiment"
<DonutChart
data={{
labels: ["Positive", "Neutral", "Negative"],
values: [
getSentimentData().positive,
getSentimentData().neutral,
getSentimentData().negative,
],
colors: ["#1cad7c", "#a1a1a1", "#dc2626"],
}}
centerText={{ centerText={{
title: "Total", title: "Total",
value: metrics.totalSessions, value: metrics.totalSessions || 0,
}} }}
height={350}
/> />
</div> </div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white p-6 rounded-xl shadow"> <ModernBarChart
<h3 className="font-bold text-lg text-gray-800 mb-4"> data={getCategoriesData()}
Sessions by Category title="Sessions by Category"
</h3> height={350}
<CategoriesBarChart categories={metrics.categories || {}} /> />
</div>
<div className="bg-white p-6 rounded-xl shadow"> <ModernDonutChart
<h3 className="font-bold text-lg text-gray-800 mb-4"> data={getLanguagesData()}
Languages Used title="Languages Used"
</h3> height={350}
<LanguagePieChart languages={metrics.languages || {}} /> />
</div>
</div> </div>
{/* Geographic and Topics Section */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white p-6 rounded-xl shadow"> <Card>
<h3 className="font-bold text-lg text-gray-800 mb-4"> <CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5" />
Geographic Distribution Geographic Distribution
</h3> </CardTitle>
</CardHeader>
<CardContent>
<GeographicMap countries={getCountryData()} /> <GeographicMap countries={getCountryData()} />
</div> </CardContent>
</Card>
<div className="bg-white p-6 rounded-xl shadow"> <Card>
<h3 className="font-bold text-lg text-gray-800 mb-4"> <CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageCircle className="h-5 w-5" />
Common Topics Common Topics
</h3> </CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]"> <div className="h-[300px]">
<WordCloud words={getWordCloudData()} width={500} height={400} /> <WordCloud words={getWordCloudData()} width={500} height={300} />
</div>
</div> </div>
</CardContent>
</Card>
</div> </div>
{/* Top Questions Chart */} {/* Top Questions Chart */}
<TopQuestionsChart data={metrics.topQuestions || []} /> <TopQuestionsChart data={metrics.topQuestions || []} />
<div className="bg-white p-6 rounded-xl shadow"> {/* Response Time Distribution */}
<h3 className="font-bold text-lg text-gray-800 mb-4"> <Card>
Response Time Distribution <CardHeader>
</h3> <CardTitle>Response Time Distribution</CardTitle>
</CardHeader>
<CardContent>
<ResponseTimeDistribution <ResponseTimeDistribution
data={getResponseTimeData()} data={getResponseTimeData()}
average={metrics.avgResponseTime || 0} average={metrics.avgResponseTime || 0}
/> />
</div> </CardContent>
<div className="bg-white p-6 rounded-xl shadow"> </Card>
<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 Summary */}
Token Usage & Costs <Card>
</h3> <CardHeader>
<div className="flex flex-col sm:flex-row gap-2 sm:gap-4 w-full sm:w-auto"> <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div className="text-sm bg-blue-50 text-blue-700 px-3 py-1 rounded-full flex items-center"> <CardTitle>AI Usage & Costs</CardTitle>
<span className="font-semibold mr-1">Total Tokens:</span> <div className="flex flex-wrap gap-2">
<Badge variant="outline" className="gap-1">
<span className="font-semibold">Total Tokens:</span>
{metrics.totalTokens?.toLocaleString() || 0} {metrics.totalTokens?.toLocaleString() || 0}
</div> </Badge>
<div className="text-sm bg-green-50 text-green-700 px-3 py-1 rounded-full flex items-center"> <Badge variant="outline" className="gap-1">
<span className="font-semibold mr-1">Total Cost:</span> <span className="font-semibold">Total Cost:</span>
{metrics.totalTokensEur?.toFixed(4) || 0} {metrics.totalTokensEur?.toFixed(4) || 0}
</Badge>
</div> </div>
</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> </div>
<TokenUsageChart tokenData={getTokenData()} /> </CardContent>
</div> </Card>
</div> </div>
); );
} }
// Our exported component
export default function DashboardPage() { export default function DashboardPage() {
return <DashboardContent />; return <DashboardContent />;
} }

View File

@ -4,6 +4,19 @@ import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { FC } 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 DashboardPage: FC = () => {
const { data: session, status } = useSession(); const { data: session, status } = useSession();
@ -21,82 +34,223 @@ const DashboardPage: FC = () => {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center min-h-[40vh]"> <div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center"> <div className="text-center space-y-4">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-sky-500 mx-auto mb-4"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<p className="text-lg text-gray-600">Loading dashboard...</p> <p className="text-lg text-muted-foreground">Loading dashboard...</p>
</div> </div>
</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 ( return (
<div className="space-y-6"> <div className="space-y-8">
<div className="bg-white rounded-xl shadow p-6"> {/* Welcome Header */}
<h1 className="text-2xl font-bold mb-4">Dashboard</h1> <Card className="border-0 bg-gradient-to-r from-primary/5 via-primary/10 to-primary/5">
<CardHeader>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div className="bg-gradient-to-br from-sky-50 to-sky-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow"> <div className="space-y-2">
<h2 className="text-lg font-semibold text-sky-700">Analytics</h2> <div className="flex items-center gap-3">
<p className="text-gray-600 mt-2 mb-4"> <h1 className="text-3xl font-bold tracking-tight">
View your chat session metrics and analytics 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> </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>
<div className="bg-gradient-to-br from-emerald-50 to-emerald-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow"> <div className="flex items-center gap-2">
<h2 className="text-lg font-semibold text-emerald-700">Sessions</h2> <div className="flex items-center gap-1 text-sm text-muted-foreground">
<p className="text-gray-600 mt-2 mb-4"> <Shield className="h-4 w-4" />
Browse and analyze conversation sessions Secure Dashboard
</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> </div>
</div>
</div>
</CardHeader>
</Card>
{session?.user?.role === "ADMIN" && ( {/* Navigation Cards */}
<div className="bg-gradient-to-br from-purple-50 to-purple-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<h2 className="text-lg font-semibold text-purple-700"> {navigationCards.map((card, index) => (
Company Settings <Card
</h2> key={index}
<p className="text-gray-600 mt-2 mb-4"> className={`relative overflow-hidden transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 cursor-pointer ${getCardClasses(
Configure company settings and integrations card.variant
</p> )}`}
<button onClick={() => router.push(card.href)}
onClick={() => router.push("/dashboard/company")}
className="bg-purple-500 hover:bg-purple-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
> >
Manage Settings {/* Subtle gradient overlay */}
</button> <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>
<div>
<CardTitle className="text-xl font-semibold flex items-center gap-2">
{card.title}
{card.adminOnly && (
<Badge variant="outline" className="text-xs">
Admin
</Badge>
)} )}
</CardTitle>
{session?.user?.role === "ADMIN" && ( </div>
<div className="bg-gradient-to-br from-amber-50 to-amber-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow"> </div>
<h2 className="text-lg font-semibold text-amber-700"> <p className="text-muted-foreground leading-relaxed">
User Management {card.description}
</h2>
<p className="text-gray-600 mt-2 mb-4">
Invite and manage user accounts
</p> </p>
<button </div>
onClick={() => router.push("/dashboard/users")} </div>
className="bg-amber-500 hover:bg-amber-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors" </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 <span>
</button> {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> </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>
</div> </div>
</CardContent>
</Card>
</div> </div>
); );
}; };

View File

@ -1 +1,120 @@
@import "tailwindcss"; @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 { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { authOptions } from "../pages/api/auth/[...nextauth]"; import { authOptions } from "./api/auth/[...nextauth]/route";
export default async function HomePage() { export default async function HomePage() {
const session = await getServerSession(authOptions); 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 // Including error handling and refetch interval for better user experience
return ( return (
<SessionProvider <SessionProvider
// Re-fetch session every 10 minutes // Re-fetch session every 30 minutes (reduced from 10)
refetchInterval={10 * 60} refetchInterval={30 * 60}
refetchOnWindowFocus={true} refetchOnWindowFocus={false}
> >
{children} {children}
</SessionProvider> </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); const [endDate, setEndDate] = useState(initialEndDate || maxDate);
useEffect(() => { useEffect(() => {
// Notify parent component when dates change // Only notify parent component when dates change, not when the callback changes
onDateRangeChange(startDate, endDate); onDateRangeChange(startDate, endDate);
}, [startDate, endDate, onDateRangeChange]); }, [startDate, endDate]);
const handleStartDateChange = (newStartDate: string) => { const handleStartDateChange = (newStartDate: string) => {
// Ensure start date is not before min date // Ensure start date is not before min date

View File

@ -1,10 +1,15 @@
"use client"; "use client";
import { useRef, useEffect } from "react"; import {
import Chart from "chart.js/auto"; BarChart,
import annotationPlugin from "chartjs-plugin-annotation"; Bar,
XAxis,
Chart.register(annotationPlugin); YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from "recharts";
interface ResponseTimeDistributionProps { interface ResponseTimeDistributionProps {
data: number[]; data: number[];
@ -12,18 +17,35 @@ interface ResponseTimeDistributionProps {
targetResponseTime?: number; 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({ export default function ResponseTimeDistribution({
data, data,
average, average,
targetResponseTime, targetResponseTime,
}: ResponseTimeDistributionProps) { }: ResponseTimeDistributionProps) {
const ref = useRef<HTMLCanvasElement | null>(null); if (!data || !data.length) {
return (
useEffect(() => { <div className="flex items-center justify-center h-64 text-muted-foreground">
if (!ref.current || !data || !data.length) return; No response time data available
</div>
const ctx = ref.current.getContext("2d"); );
if (!ctx) return; }
// Create bins for the histogram (0-1s, 1-2s, 2-3s, etc.) // Create bins for the histogram (0-1s, 1-2s, 2-3s, etc.)
const maxTime = Math.ceil(Math.max(...data)); const maxTime = Math.ceil(Math.max(...data));
@ -35,91 +57,105 @@ export default function ResponseTimeDistribution({
bins[binIndex]++; bins[binIndex]++;
}); });
// Create labels for each bin // Create chart data
const labels = bins.map((_, i) => { const chartData = bins.map((count, i) => {
let label;
if (i === bins.length - 1 && bins.length < maxTime + 1) { 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, { return (
type: "bar", <div className="h-64">
data: { <ResponsiveContainer width="100%" height="100%">
labels, <BarChart data={chartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
datasets: [ <CartesianGrid
{ strokeDasharray="3 3"
label: "Responses", stroke="hsl(var(--border))"
data: bins, strokeOpacity={0.3}
backgroundColor: bins.map((_, i) => { />
// Green for fast, yellow for medium, red for slow <XAxis
if (i <= 2) return "rgba(34, 197, 94, 0.7)"; // Green dataKey="name"
if (i <= 5) return "rgba(250, 204, 21, 0.7)"; // Yellow stroke="hsl(var(--muted-foreground))"
return "rgba(239, 68, 68, 0.7)"; // Red fontSize={12}
}), tickLine={false}
borderWidth: 1, axisLine={false}
}, />
], <YAxis
}, stroke="hsl(var(--muted-foreground))"
options: { fontSize={12}
responsive: true, tickLine={false}
plugins: { axisLine={false}
legend: { display: false }, label={{
annotation: { value: 'Number of Responses',
annotations: { angle: -90,
averageLine: { position: 'insideLeft',
type: "line", style: { textAnchor: 'middle' }
yMin: 0, }}
yMax: Math.max(...bins), />
xMin: average, <Tooltip content={<CustomTooltip />} />
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",
},
}
: undefined,
},
},
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: "Number of Responses",
},
},
x: {
title: {
display: true,
text: "Response Time",
},
},
},
},
});
return () => chart.destroy(); <Bar
}, [data, average, targetResponseTime]); dataKey="value"
radius={[4, 4, 0, 0]}
fill="hsl(var(--chart-1))"
>
{chartData.map((entry, index) => (
<Bar key={`cell-${index}`} fill={entry.color} />
))}
</Bar>
return <canvas ref={ref} height={180} />; {/* 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"
}
}}
/>
{/* 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/**\"" "lint:md:fix": "markdownlint-cli2 --fix \"**/*.md\" \"!.trunk/**\" \"!.venv/**\" \"!node_modules/**\""
}, },
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^6.10.1",
"@prisma/client": "^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", "@rapideditor/country-coder": "^5.4.0",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/d3-cloud": "^1.2.9", "@types/d3-cloud": "^1.2.9",
@ -34,8 +39,8 @@
"@types/leaflet": "^1.9.18", "@types/leaflet": "^1.9.18",
"@types/node-fetch": "^2.6.12", "@types/node-fetch": "^2.6.12",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"chart.js": "^4.0.0", "class-variance-authority": "^0.7.1",
"chartjs-plugin-annotation": "^3.1.0", "clsx": "^2.1.1",
"csv-parse": "^5.5.0", "csv-parse": "^5.5.0",
"d3": "^7.9.0", "d3": "^7.9.0",
"d3-cloud": "^1.2.7", "d3-cloud": "^1.2.7",
@ -43,16 +48,18 @@
"i18n-iso-countries": "^7.14.0", "i18n-iso-countries": "^7.14.0",
"iso-639-1": "^3.1.5", "iso-639-1": "^3.1.5",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.525.0",
"next": "^15.3.2", "next": "^15.3.2",
"next-auth": "^4.24.11", "next-auth": "^4.24.11",
"node-cron": "^4.0.7", "node-cron": "^4.0.7",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"react": "^19.1.0", "react": "^19.1.0",
"react-chartjs-2": "^5.0.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"react-markdown": "^10.1.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": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
@ -82,6 +89,7 @@
"tailwindcss": "^4.1.7", "tailwindcss": "^4.1.7",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.20.3", "tsx": "^4.20.3",
"tw-animate-css": "^1.3.4",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.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;