mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 14:12:10 +01:00
Remove Tailwind CSS configuration file
This commit is contained in:
@ -1,51 +1,50 @@
|
|||||||
// 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(
|
|
||||||
req: NextApiRequest,
|
|
||||||
res: NextApiResponse
|
|
||||||
) {
|
|
||||||
// Check if this is a POST request
|
|
||||||
if (req.method !== "POST") {
|
|
||||||
return res.status(405).json({ error: "Method not allowed" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get companyId from body or query
|
|
||||||
let { companyId } = req.body;
|
|
||||||
|
|
||||||
if (!companyId) {
|
|
||||||
// Try to get user from prisma based on session cookie
|
|
||||||
try {
|
|
||||||
const session = await prisma.session.findFirst({
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
where: {
|
|
||||||
/* Add session check criteria here */
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (session) {
|
|
||||||
companyId = session.companyId;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Log error for server-side debugging
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error ? error.message : String(error);
|
|
||||||
// Use a server-side logging approach instead of console
|
|
||||||
process.stderr.write(`Error fetching session: ${errorMessage}\n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!companyId) {
|
|
||||||
return res.status(400).json({ error: "Company ID is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const company = await prisma.company.findUnique({ where: { id: companyId } });
|
|
||||||
if (!company) return res.status(404).json({ error: "Company not found" });
|
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
let { companyId } = body;
|
||||||
|
|
||||||
|
if (!companyId) {
|
||||||
|
// Try to get user from prisma based on session cookie
|
||||||
|
try {
|
||||||
|
const session = await prisma.session.findFirst({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
where: {
|
||||||
|
/* Add session check criteria here */
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
companyId = session.companyId;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Log error for server-side debugging
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
// Use a server-side logging approach instead of console
|
||||||
|
process.stderr.write(`Error fetching session: ${errorMessage}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!companyId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Company ID is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const company = await prisma.company.findUnique({ where: { id: companyId } });
|
||||||
|
if (!company) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Company not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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",
|
{
|
||||||
details: error instanceof Error ? error.message : String(error),
|
error: "Failed to trigger processing",
|
||||||
});
|
details: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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 };
|
||||||
51
app/api/dashboard/config/route.ts
Normal file
51
app/api/dashboard/config/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
@ -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,
|
||||||
@ -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",
|
{
|
||||||
details: errorMessage,
|
error: "Failed to fetch filter options",
|
||||||
});
|
details: errorMessage,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
36
app/api/dashboard/settings/route.ts
Normal file
36
app/api/dashboard/settings/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
80
app/api/dashboard/users/route.ts
Normal file
80
app/api/dashboard/users/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
28
app/api/forgot-password/route.ts
Normal file
28
app/api/forgot-password/route.ts
Normal 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
63
app/api/register/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
63
app/api/reset-password/route.ts
Normal file
63
app/api/reset-password/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
{new Date(metrics.lastUpdated || Date.now()).toLocaleString()}
|
<Badge variant="secondary" className="text-xs">
|
||||||
</span>
|
Analytics Dashboard
|
||||||
</p>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 mt-4 sm:mt-0">
|
<p className="text-muted-foreground">
|
||||||
<button
|
Last updated{" "}
|
||||||
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"
|
<span className="font-medium">
|
||||||
onClick={handleRefresh}
|
{new Date(metrics.lastUpdated || Date.now()).toLocaleString()}
|
||||||
disabled={refreshing || isAuditor}
|
</span>
|
||||||
>
|
</p>
|
||||||
{refreshing ? (
|
</div>
|
||||||
<>
|
|
||||||
<svg
|
|
||||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
Refreshing...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Refresh Data"
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="bg-slate-100 text-slate-700 py-2 px-5 rounded-lg shadow hover:bg-slate-200 transition-colors flex items-center text-sm font-medium"
|
|
||||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
|
||||||
>
|
|
||||||
Sign out
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date Range Picker */}
|
<div className="flex items-center gap-2">
|
||||||
{dateRange && (
|
<Button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={refreshing || isAuditor}
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||||
|
{refreshing ? "Refreshing..." : "Refresh"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => signOut({ callbackUrl: "/login" })}>
|
||||||
|
<LogOut className="h-4 w-4 mr-2" />
|
||||||
|
Sign out
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Date Range Picker - Temporarily disabled to debug infinite loop */}
|
||||||
|
{/* {dateRange && (
|
||||||
<DateRangePicker
|
<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
|
centerText={{
|
||||||
data={{
|
title: "Total",
|
||||||
labels: ["Positive", "Neutral", "Negative"],
|
value: metrics.totalSessions || 0,
|
||||||
values: [
|
}}
|
||||||
getSentimentData().positive,
|
height={350}
|
||||||
getSentimentData().neutral,
|
/>
|
||||||
getSentimentData().negative,
|
|
||||||
],
|
|
||||||
colors: ["#1cad7c", "#a1a1a1", "#dc2626"],
|
|
||||||
}}
|
|
||||||
centerText={{
|
|
||||||
title: "Total",
|
|
||||||
value: metrics.totalSessions,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</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>
|
||||||
Geographic Distribution
|
<CardTitle className="flex items-center gap-2">
|
||||||
</h3>
|
<Globe className="h-5 w-5" />
|
||||||
<GeographicMap countries={getCountryData()} />
|
Geographic Distribution
|
||||||
</div>
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<GeographicMap countries={getCountryData()} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="bg-white p-6 rounded-xl shadow">
|
<Card>
|
||||||
<h3 className="font-bold text-lg text-gray-800 mb-4">
|
<CardHeader>
|
||||||
Common Topics
|
<CardTitle className="flex items-center gap-2">
|
||||||
</h3>
|
<MessageCircle className="h-5 w-5" />
|
||||||
<div className="h-[300px]">
|
Common Topics
|
||||||
<WordCloud words={getWordCloudData()} width={500} height={400} />
|
</CardTitle>
|
||||||
</div>
|
</CardHeader>
|
||||||
</div>
|
<CardContent>
|
||||||
|
<div className="h-[300px]">
|
||||||
|
<WordCloud words={getWordCloudData()} width={500} height={300} />
|
||||||
|
</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>
|
||||||
<ResponseTimeDistribution
|
</CardHeader>
|
||||||
data={getResponseTimeData()}
|
<CardContent>
|
||||||
average={metrics.avgResponseTime || 0}
|
<ResponseTimeDistribution
|
||||||
/>
|
data={getResponseTimeData()}
|
||||||
</div>
|
average={metrics.avgResponseTime || 0}
|
||||||
<div className="bg-white p-6 rounded-xl shadow">
|
/>
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 mb-4">
|
</CardContent>
|
||||||
<h3 className="font-bold text-lg text-gray-800">
|
</Card>
|
||||||
Token Usage & Costs
|
|
||||||
</h3>
|
{/* Token Usage Summary */}
|
||||||
<div className="flex flex-col sm:flex-row gap-2 sm:gap-4 w-full sm:w-auto">
|
<Card>
|
||||||
<div className="text-sm bg-blue-50 text-blue-700 px-3 py-1 rounded-full flex items-center">
|
<CardHeader>
|
||||||
<span className="font-semibold mr-1">Total Tokens:</span>
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
{metrics.totalTokens?.toLocaleString() || 0}
|
<CardTitle>AI Usage & Costs</CardTitle>
|
||||||
</div>
|
<div className="flex flex-wrap gap-2">
|
||||||
<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 Tokens:</span>
|
||||||
{metrics.totalTokensEur?.toFixed(4) || 0}
|
{metrics.totalTokens?.toLocaleString() || 0}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<span className="font-semibold">Total Cost:</span>
|
||||||
|
€{metrics.totalTokensEur?.toFixed(4) || 0}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardHeader>
|
||||||
<TokenUsageChart tokenData={getTokenData()} />
|
<CardContent>
|
||||||
</div>
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<p>Token usage chart will be implemented with historical data</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Our exported component
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
return <DashboardContent />;
|
return <DashboardContent />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"}!
|
||||||
</p>
|
</h1>
|
||||||
<button
|
<Badge variant="secondary" className="text-xs">
|
||||||
onClick={() => router.push("/dashboard/overview")}
|
{session?.user?.role}
|
||||||
className="bg-sky-500 hover:bg-sky-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
</Badge>
|
||||||
>
|
</div>
|
||||||
View Analytics
|
<p className="text-muted-foreground">
|
||||||
</button>
|
Choose a section below to explore your analytics dashboard
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gradient-to-br from-emerald-50 to-emerald-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
|
|
||||||
<h2 className="text-lg font-semibold text-emerald-700">Sessions</h2>
|
|
||||||
<p className="text-gray-600 mt-2 mb-4">
|
|
||||||
Browse and analyze conversation sessions
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => router.push("/dashboard/sessions")}
|
|
||||||
className="bg-emerald-500 hover:bg-emerald-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
|
||||||
>
|
|
||||||
View Sessions
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{session?.user?.role === "ADMIN" && (
|
|
||||||
<div className="bg-gradient-to-br from-purple-50 to-purple-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
|
|
||||||
<h2 className="text-lg font-semibold text-purple-700">
|
|
||||||
Company Settings
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-600 mt-2 mb-4">
|
|
||||||
Configure company settings and integrations
|
|
||||||
</p>
|
</p>
|
||||||
<button
|
|
||||||
onClick={() => router.push("/dashboard/company")}
|
|
||||||
className="bg-purple-500 hover:bg-purple-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
|
||||||
>
|
|
||||||
Manage Settings
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{session?.user?.role === "ADMIN" && (
|
<div className="flex items-center gap-2">
|
||||||
<div className="bg-gradient-to-br from-amber-50 to-amber-100 p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
|
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
<h2 className="text-lg font-semibold text-amber-700">
|
<Shield className="h-4 w-4" />
|
||||||
User Management
|
Secure Dashboard
|
||||||
</h2>
|
</div>
|
||||||
<p className="text-gray-600 mt-2 mb-4">
|
|
||||||
Invite and manage user accounts
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => router.push("/dashboard/users")}
|
|
||||||
className="bg-amber-500 hover:bg-amber-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
|
||||||
>
|
|
||||||
Manage Users
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Navigation Cards */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{navigationCards.map((card, index) => (
|
||||||
|
<Card
|
||||||
|
key={index}
|
||||||
|
className={`relative overflow-hidden transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 cursor-pointer ${getCardClasses(
|
||||||
|
card.variant
|
||||||
|
)}`}
|
||||||
|
onClick={() => router.push(card.href)}
|
||||||
|
>
|
||||||
|
{/* Subtle gradient overlay */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/50 to-transparent dark:from-white/5 pointer-events-none" />
|
||||||
|
|
||||||
|
<CardHeader className="relative">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-full border transition-colors ${getIconClasses(
|
||||||
|
card.variant
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{card.icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-xl font-semibold flex items-center gap-2">
|
||||||
|
{card.title}
|
||||||
|
{card.adminOnly && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
Admin
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
|
{card.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="relative space-y-4">
|
||||||
|
{/* Features List */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{card.features.map((feature, featureIndex) => (
|
||||||
|
<div key={featureIndex} className="flex items-center gap-2 text-sm">
|
||||||
|
<div className="h-1.5 w-1.5 rounded-full bg-current opacity-60" />
|
||||||
|
<span className="text-muted-foreground">{feature}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Button */}
|
||||||
|
<Button
|
||||||
|
className="w-full gap-2 mt-4"
|
||||||
|
variant={card.variant === "primary" ? "default" : "outline"}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
router.push(card.href);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{card.title === "Analytics Overview" && "View Analytics"}
|
||||||
|
{card.title === "Session Browser" && "Browse Sessions"}
|
||||||
|
{card.title === "Company Settings" && "Manage Settings"}
|
||||||
|
{card.title === "User Management" && "Manage Users"}
|
||||||
|
</span>
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Stats */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-5 w-5" />
|
||||||
|
Quick Stats
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Zap className="h-5 w-5 text-primary" />
|
||||||
|
<span className="text-2xl font-bold">Real-time</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Data updates</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Shield className="h-5 w-5 text-green-600" />
|
||||||
|
<span className="text-2xl font-bold">Secure</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Data protection</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<BarChart3 className="h-5 w-5 text-blue-600" />
|
||||||
|
<span className="text-2xl font-bold">Advanced</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Analytics</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
119
app/globals.css
119
app/globals.css
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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
21
components.json
Normal 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"
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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,114 +17,145 @@ 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 (
|
||||||
|
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||||
|
No response time data available
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
// Create bins for the histogram (0-1s, 1-2s, 2-3s, etc.)
|
||||||
if (!ref.current || !data || !data.length) return;
|
const maxTime = Math.ceil(Math.max(...data));
|
||||||
|
const bins = Array(Math.min(maxTime + 1, 10)).fill(0);
|
||||||
|
|
||||||
const ctx = ref.current.getContext("2d");
|
// Count responses in each bin
|
||||||
if (!ctx) return;
|
data.forEach((time) => {
|
||||||
|
const binIndex = Math.min(Math.floor(time), bins.length - 1);
|
||||||
|
bins[binIndex]++;
|
||||||
|
});
|
||||||
|
|
||||||
// Create bins for the histogram (0-1s, 1-2s, 2-3s, etc.)
|
// Create chart data
|
||||||
const maxTime = Math.ceil(Math.max(...data));
|
const chartData = bins.map((count, i) => {
|
||||||
const bins = Array(Math.min(maxTime + 1, 10)).fill(0);
|
let label;
|
||||||
|
if (i === bins.length - 1 && bins.length < maxTime + 1) {
|
||||||
|
label = `${i}+ sec`;
|
||||||
|
} else {
|
||||||
|
label = `${i}-${i + 1} sec`;
|
||||||
|
}
|
||||||
|
|
||||||
// Count responses in each bin
|
// Determine color based on response time
|
||||||
data.forEach((time) => {
|
let color;
|
||||||
const binIndex = Math.min(Math.floor(time), bins.length - 1);
|
if (i <= 2) color = "hsl(var(--chart-1))"; // Green for fast
|
||||||
bins[binIndex]++;
|
else if (i <= 5) color = "hsl(var(--chart-4))"; // Yellow for medium
|
||||||
});
|
else color = "hsl(var(--chart-3))"; // Red for slow
|
||||||
|
|
||||||
// Create labels for each bin
|
return {
|
||||||
const labels = bins.map((_, i) => {
|
name: label,
|
||||||
if (i === bins.length - 1 && bins.length < maxTime + 1) {
|
value: count,
|
||||||
return `${i}+ seconds`;
|
color,
|
||||||
}
|
};
|
||||||
return `${i}-${i + 1} seconds`;
|
});
|
||||||
});
|
|
||||||
|
|
||||||
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
105
components/charts/bar-chart.tsx
Normal file
105
components/charts/bar-chart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
components/charts/donut-chart.tsx
Normal file
122
components/charts/donut-chart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
components/charts/line-chart.tsx
Normal file
117
components/charts/line-chart.tsx
Normal 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
46
components/ui/badge.tsx
Normal 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
59
components/ui/button.tsx
Normal 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
92
components/ui/card.tsx
Normal 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,
|
||||||
|
}
|
||||||
257
components/ui/dropdown-menu.tsx
Normal file
257
components/ui/dropdown-menu.tsx
Normal 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,
|
||||||
|
}
|
||||||
163
components/ui/metric-card.tsx
Normal file
163
components/ui/metric-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
components/ui/separator.tsx
Normal file
28
components/ui/separator.tsx
Normal 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 }
|
||||||
13
components/ui/skeleton.tsx
Normal file
13
components/ui/skeleton.tsx
Normal 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
61
components/ui/tooltip.tsx
Normal 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
6
lib/utils.ts
Normal 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))
|
||||||
|
}
|
||||||
16
package.json
16
package.json
@ -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"
|
||||||
|
|||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
@ -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 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -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
1124
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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;
|
|
||||||
Reference in New Issue
Block a user