From 2252ada913a1260c987f33821a37b7d2b87a872f Mon Sep 17 00:00:00 2001 From: Muditha <89349527+muditha20@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:12:55 +0530 Subject: [PATCH 01/23] Developed side panel and report page --- server/src/app/admin/reports/page.tsx | 265 +++++++++++++++++++++++ server/src/components/AsideSidebar.tsx | 125 +++++++++++ server/src/components/adminDashboard.tsx | 6 +- server/src/components/ui/badge.tsx | 32 +++ server/src/components/ui/pagination.tsx | 106 +++++++++ 5 files changed, 532 insertions(+), 2 deletions(-) create mode 100644 server/src/app/admin/reports/page.tsx create mode 100644 server/src/components/AsideSidebar.tsx create mode 100644 server/src/components/ui/badge.tsx create mode 100644 server/src/components/ui/pagination.tsx diff --git a/server/src/app/admin/reports/page.tsx b/server/src/app/admin/reports/page.tsx new file mode 100644 index 00000000..848955a5 --- /dev/null +++ b/server/src/app/admin/reports/page.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { useState } from "react"; +import AsideSidebar from "@/components/AsideSidebar"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; +import { FileText, Clock, CheckCircle, Settings2 } from "lucide-react"; + +type ReportStatus = "completed" | "pending" | "failed" | "wip"; + +interface Report { + projectName: string; + mentor: string; + studentsCount: number; + status: ReportStatus; + date: string; +} + +const MOCK_REPORTS: Report[] = [ + { + projectName: "Alpha Initiative", + mentor: "Dr. Aris Thorne", + studentsCount: 24, + status: "completed", + date: "Oct 24, 2023", + }, + { + projectName: "Beta Growth Phase", + mentor: "Sarah Jenkins", + studentsCount: 18, + status: "pending", + date: "Oct 25, 2023", + }, + { + projectName: "Quantum Leap", + mentor: "Marcus Vane", + studentsCount: 42, + status: "completed", + date: "Oct 22, 2023", + }, + { + projectName: "Eco-Sustain Project", + mentor: "Elena Rossi", + studentsCount: 12, + status: "failed", + date: "Oct 21, 2023", + }, +]; + +const statusBadge: Record< + ReportStatus, + { variant: "completed" | "pending" | "failed" | "wip"; label: string } +> = { + completed: { variant: "completed", label: "Completed" }, + pending: { variant: "pending", label: "Pending" }, + failed: { variant: "failed", label: "Failed" }, + wip: { variant: "wip", label: "In Progress" }, +}; + +export default function ReportsPage() { + const [page, setPage] = useState(1); + + return ( +
+ + +
+ {/* Header */} +
+

Reports

+ +
+ + {/* Stat Cards */} +
+ +
+

Total Generated

+

1,284

+
+ +
+ +
+

Pending Reports

+

42

+
+ +
+ +
+

Completed Reports

+

1,242

+
+ +
+
+ + {/* Filters */} + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + {/* Table */} + + + + + + Project Name + + + Mentor + + + Students Count + + + Status + + + Date + + + + + {MOCK_REPORTS.map((report) => ( + + +
+ {report.projectName} +
+
+
+ + {report.mentor} + + + {report.studentsCount} + + + + {statusBadge[report.status].label} + + + + {report.date} + +
+ ))} +
+
+ + {/* Pagination */} +
+

+ Showing 1 to 4 of 1,284 entries +

+ + + + setPage((p) => Math.max(1, p - 1))} + /> + + {[1, 2, 3].map((p) => ( + + setPage(p)} + > + {p} + + + ))} + + + + + setPage((p) => p + 1)} /> + + + +
+
+
+
+ ); +} diff --git a/server/src/components/AsideSidebar.tsx b/server/src/components/AsideSidebar.tsx new file mode 100644 index 00000000..d6a2ad7a --- /dev/null +++ b/server/src/components/AsideSidebar.tsx @@ -0,0 +1,125 @@ +"use client"; + +import React, { useState } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { cn } from "@/lib/utils"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + LayoutDashboard, + Users, + Mail, + FolderOpen, + Award, + BarChart2, + Settings, + LogOut, + BookOpen, +} from "lucide-react"; + +interface NavItem { + label: string; + icon: React.ReactNode; + href: string; +} + +const mainMenu: NavItem[] = [ + { label: "Dashboard", icon: , href: "/admin" }, + { label: "Users", icon: , href: "/admin/users" }, + { + label: "Invitations", + icon: , + href: "/admin/invitations", + }, + { + label: "Projects", + icon: , + href: "/admin/projects", + }, + { label: "Badges", icon: , href: "/admin/badges" }, + { label: "Reports", icon: , href: "/admin/reports" }, +]; + +const systemMenu: NavItem[] = [ + { label: "Settings", icon: , href: "/admin/settings" }, +]; + +export default function AsideSidebar() { + const pathname = usePathname(); + const router = useRouter(); + + const isActive = (href: string) => pathname === href; + + const NavLink = ({ item }: { item: NavItem }) => ( + + ); + + return ( + + ); +} diff --git a/server/src/components/adminDashboard.tsx b/server/src/components/adminDashboard.tsx index 0c4a5e48..cf66a7a2 100644 --- a/server/src/components/adminDashboard.tsx +++ b/server/src/components/adminDashboard.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import getSession from "@/server_actions/getSession"; +import AsideSidebar from "@/components/AsideSidebar"; interface AdminUser { id: string; @@ -48,8 +49,9 @@ export default function AdminDashboard() { } return ( -
-
+
+ +
{/* Header */}
diff --git a/server/src/components/ui/badge.tsx b/server/src/components/ui/badge.tsx new file mode 100644 index 00000000..77dd43d0 --- /dev/null +++ b/server/src/components/ui/badge.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground", + completed: "bg-green-100 text-green-700", + pending: "bg-yellow-100 text-yellow-700", + failed: "bg-red-100 text-red-700", + wip: "bg-blue-100 text-blue-700", + }, + }, + defaultVariants: { variant: "default" }, + }, +); + +export interface BadgeProps + extends + React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/server/src/components/ui/pagination.tsx b/server/src/components/ui/pagination.tsx new file mode 100644 index 00000000..13e66e6e --- /dev/null +++ b/server/src/components/ui/pagination.tsx @@ -0,0 +1,106 @@ +import * as React from "react"; +import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { ButtonProps, buttonVariants } from "@/components/ui/button"; + +const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( +
+
+
+
+ + {/* Recent Invitations Section */} +
+
+

Recent Invitations

+ +
+ +
+
+ + + + + + + + + + + + {isInvLoading && ( + + + + )} + {isInvError && ( + + + + )} + {recentInvitations?.map((inv: any) =>( + + ))} + +
Email AddressRoleProjectStatusActions
Loading...
Failed to load invitations
+
+
+ +
+

+ Showing {recentInvitations?.length || 0} total invitations +

+
+ +
+
+
+ + ); +} + +/* --- Subcomponents --- */ + +function TableRow({ + email, + role, + project, + status, + joined +}: { + email: string, + role: string, + project: string, + status: 'Pending' | 'Accepted' | 'Expired', + joined?: string +}) { + return ( + + {email} + + + {role} + + + {project} + + + + + {joined ? ( + {joined} + ) : ( +
+ + {status !== 'Expired' && ( + + )} + {status === 'Expired' ? ( + + ) : ( + + )} +
+ )} + + + ); +} + +function StatusBadge({ status }: { status: 'Pending' | 'Accepted' | 'Expired' }) { + const styles = { + Pending: "bg-amber-50 text-amber-600 border-amber-200/60", + Accepted: "bg-emerald-50 text-emerald-600 border-emerald-200/60", + Expired: "bg-rose-50 text-rose-600 border-rose-200/60", + }; + + const dotColors = { + Pending: "bg-amber-500", + Accepted: "bg-emerald-500", + Expired: "bg-rose-500", + }; + + return ( + + + {status} + + ); +} \ No newline at end of file diff --git a/server/src/app/admin/reports/page.tsx b/server/src/app/admin/reports/page.tsx index 848955a5..db7d87f9 100644 --- a/server/src/app/admin/reports/page.tsx +++ b/server/src/app/admin/reports/page.tsx @@ -201,7 +201,7 @@ export default function ReportsPage() { {MOCK_REPORTS.map((report) => ( - +
{report.projectName} diff --git a/server/src/app/api/invitations/route.ts b/server/src/app/api/invitations/route.ts index 08e7311d..5ca17905 100644 --- a/server/src/app/api/invitations/route.ts +++ b/server/src/app/api/invitations/route.ts @@ -6,6 +6,7 @@ import { Role } from "@prisma/client"; import { NextRequest, NextResponse } from "next/server"; import { sendEmail } from "@/lib/email"; +import prisma from "@/lib/prisma"; const invitationRepository = new InvitationRepository(); const userRepository = new UserRepository(); @@ -20,13 +21,13 @@ export async function POST(req: NextRequest) { return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); } - // 2. Check for permissions (super_admin and mentor can invite) + // 2. Check for permissions (super_admin) const userRole = session.getRole(); - if (userRole !== Role.superAdmin && userRole !== Role.mentor) { + if (userRole !== Role.superAdmin ) { return NextResponse.json( { message: - "Forbidden: Only Super Admins or Mentors can create invitations", + "Forbidden: Only Super Admins can create invitations", }, { status: 403 }, ); @@ -138,56 +139,98 @@ export async function POST(req: NextRequest) { export async function GET(req: NextRequest) { try { const { searchParams } = new URL(req.url); + + // If a token is passed, return **single invitation validation** const token = searchParams.get("token"); + if (token) { + const invitations = await invitationRepository.getAll({ + where: { token }, + }); - if (!token) { - return NextResponse.json( - { message: "Token is required" }, - { status: 400 }, - ); + if (invitations.length === 0) { + return NextResponse.json( + { valid: false, message: "Invalid token" }, + { status: 404 } + ); + } + + const invitation = invitations[0]; + + if (invitation.accepted) { + return NextResponse.json( + { valid: false, message: "Token has already been used" }, + { status: 400 } + ); + } + + if (new Date() > new Date(invitation.expiresAt)) { + return NextResponse.json( + { valid: false, message: "Token has expired" }, + { status: 400 } + ); + } + + return NextResponse.json({ + valid: true, + email: invitation.email, + role: invitation.role, + }); } - // Use getAll from BaseRepository to find by token without modifying the repository file - // The token is unique, so we expect at most one result + // If no token, return **all invitations for the table** const invitations = await invitationRepository.getAll({ - where: { token }, + orderBy: { createdAt: "desc" }, + include: { inviter: true}, // optional, if you want inviter info + }); + // 2. Fetch all projects + const now = new Date(); + + + // Fetch all users whose emails match invitations + const emails = invitations.map(inv => inv.email); + const users = await prisma.user.findMany({ + where: { email: { in: emails } }, + select: { id: true, email: true }, }); - if (invitations.length === 0) { - return NextResponse.json( - { valid: false, message: "Invalid token" }, - { status: 404 }, - ); - } + // Map email → userId + const emailToUserId: Record = {}; + users.forEach(user => { + emailToUserId[user.email] = user.id; + }); - const invitation = invitations[0]; + // Fetch project allocations for all invited users who exist + const userIds = Object.values(emailToUserId); + const allocations = await prisma.projectAllocation.findMany({ + where: { studentId: { in: userIds } }, + include: { project: true }, + }); - // Check if used - if (invitation.accepted) { - return NextResponse.json( - { valid: false, message: "Token has already been used" }, - { status: 400 }, - ); - } + // Map userId → project name + const projectMap: Record = {}; + allocations.forEach(allocation => { + projectMap[allocation.studentId] = allocation.project.name; + }); - // Check if expired - if (new Date() > new Date(invitation.expiresAt)) { - return NextResponse.json( - { valid: false, message: "Token has expired" }, - { status: 400 }, - ); - } + const mapped = invitations.map(inv => { + const status: "Pending" | "Accepted" | "Expired" = + inv.accepted ? "Accepted" : (now > new Date(inv.expiresAt) ? "Expired" : "Pending"); + + const userId = emailToUserId[inv.email]; + const projectName = userId ? projectMap[userId] || "-" : "-"; - return NextResponse.json({ - valid: true, - email: invitation.email, - role: invitation.role, + return { + email: inv.email, + role: inv.role, + project: projectName, + status, + }; }); - } catch (error) { - console.error("Error validating invitation:", error); - return NextResponse.json( - { message: "Internal Server Error" }, - { status: 500 }, - ); + + return NextResponse.json(mapped, { status: 200 }); + } catch (err) { + console.error("Error fetching invitations:", err); + return NextResponse.json({ message: "Internal Server Error" }, { status: 500 }); } } + diff --git a/server/src/components/AsideSidebar.tsx b/server/src/components/AsideSidebar.tsx index d6a2ad7a..287b2510 100644 --- a/server/src/components/AsideSidebar.tsx +++ b/server/src/components/AsideSidebar.tsx @@ -25,16 +25,8 @@ interface NavItem { const mainMenu: NavItem[] = [ { label: "Dashboard", icon: , href: "/admin" }, { label: "Users", icon: , href: "/admin/users" }, - { - label: "Invitations", - icon: , - href: "/admin/invitations", - }, - { - label: "Projects", - icon: , - href: "/admin/projects", - }, + {label: "Invitations", icon: , href: "/admin/invitation",}, + {label: "Projects",icon: ,href: "/admin/projects",}, { label: "Badges", icon: , href: "/admin/badges" }, { label: "Reports", icon: , href: "/admin/reports" }, ]; diff --git a/server/src/hooks/admin/useInvitation.ts b/server/src/hooks/admin/useInvitation.ts index beb81998..708c97fc 100644 --- a/server/src/hooks/admin/useInvitation.ts +++ b/server/src/hooks/admin/useInvitation.ts @@ -1,6 +1,6 @@ "use client"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -53,3 +53,26 @@ export const useInvitation = () => { return mutation; }; + +export const useRecentInvitations = () => { + return useQuery({ + queryKey: ["invitations"], + queryFn: async () => { + const res = await fetch("/api/invitations"); + if (!res.ok) throw new Error("Failed to fetch invitations"); + return res.json(); + }, + }); +}; + +export const useValidateInvitation = (token: string) => { + return useQuery({ + queryKey: ["invitation", token], + queryFn: async () => { + const res = await fetch(`/api/invitations?token=${token}`); + if (!res.ok) throw new Error("Invalid token"); + return res.json(); + }, + enabled: !!token, + }); +}; \ No newline at end of file From a81acedbfaabb863c020c75518c9eb8257afbc40 Mon Sep 17 00:00:00 2001 From: arshvinw5 Date: Thu, 19 Mar 2026 16:03:45 +0530 Subject: [PATCH 03/23] add use project --- docs/api/API_PROJECTS.md | 415 ++++++++++++++++++++++++ server/src/hooks/admin/useInvitation.ts | 34 +- server/src/hooks/projects/index.ts | 9 + server/src/hooks/projects/useProject.ts | 262 +++++++++++++++ 4 files changed, 719 insertions(+), 1 deletion(-) create mode 100644 docs/api/API_PROJECTS.md create mode 100644 server/src/hooks/projects/index.ts create mode 100644 server/src/hooks/projects/useProject.ts diff --git a/docs/api/API_PROJECTS.md b/docs/api/API_PROJECTS.md new file mode 100644 index 00000000..4bade40a --- /dev/null +++ b/docs/api/API_PROJECTS.md @@ -0,0 +1,415 @@ +# Projects API Documentation + +## POST `/api/project` + +Create a new project. + +**Request Body:** + +```json +{ + "name": "string", + "description": "string (optional)", + "domain": "string" +} +``` + +**Success Response (201):** + +```json +{ + "success": true, + "message": "Project created successfully", + "data": { + "id": "string", + "name": "string", + "description": "string", + "domain": "string", + "createdBy": "string", + "createdAt": "ISO 8601", + "updatedAt": "ISO 8601" + } +} +``` + +**Authentication Required:** ✓ + +--- + +## PATCH `/api/project` + +Assign a student or mentor to a project. + +**Request Body:** + +```json +{ + "projectId": "string", + "studentId": "string (optional)", + "mentorId": "string (optional)" +} +``` + +**Success Response (200):** + +```json +{ + "success": true, + "message": "Student/Mentor assigned successfully" +} +``` + +**Authentication Required:** ✓ + +--- + +## DELETE `/api/project` + +Remove a student or mentor from a project. + +**Request Body:** + +```json +{ + "projectId": "string", + "studentId": "string (optional)", + "mentorId": "string (optional)" +} +``` + +**Success Response (200):** + +```json +{ + "success": true, + "message": "Student/Mentor removed successfully" +} +``` + +**Authentication Required:** ✓ + +--- + +## GET `/api/project` + +Get all projects. + +**Success Response (200):** + +```json +[ + { + "id": "string", + "name": "string", + "description": "string", + "domain": "string", + "createdBy": "string", + "createdAt": "ISO 8601", + "updatedAt": "ISO 8601", + "studentCount": "number", + "mentorCount": "number" + } +] +``` + +--- + +## GET `/api/project?id=` + +Get project details. + +**Query Parameters:** + +- `id` (required) - Project ID + +**Success Response (200):** + +```json +{ + "id": "string", + "name": "string", + "description": "string", + "domain": "string", + "createdBy": "string", + "createdAt": "ISO 8601", + "updatedAt": "ISO 8601", + "students": [ + { + "id": "string", + "firstName": "string", + "lastName": "string", + "email": "string" + } + ], + "mentors": [ + { + "id": "string", + "firstName": "string", + "lastName": "string", + "email": "string" + } + ] +} +``` + +--- + +## GET `/api/project?id=&view=students` + +Get students assigned to a project. + +**Query Parameters:** + +- `id` (required) - Project ID +- `view` (required) - "students" + +**Success Response (200):** + +```json +[ + { + "id": "string", + "firstName": "string", + "lastName": "string", + "email": "string" + } +] +``` + +--- + +## GET `/api/project?id=&view=mentors` + +Get mentors assigned to a project. + +**Query Parameters:** + +- `id` (required) - Project ID +- `view` (required) - "mentors" + +**Success Response (200):** + +```json +[ + { + "id": "string", + "firstName": "string", + "lastName": "string", + "email": "string" + } +] +``` + +--- + +## Frontend Hooks: `useProject` + +**Location:** `hooks/projects/useProject.ts` + +```typescript +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +interface CreateProjectRequest { + name: string; + description?: string; + domain: string; +} + +interface CreateProjectResponse { + success: boolean; + message: string; + data: { + id: string; + name: string; + description?: string; + domain: string; + createdBy: string; + createdAt: string; + updatedAt: string; + }; +} + +interface AssignToProjectRequest { + projectId: string; + studentId?: string; + mentorId?: string; +} + +interface AssignToProjectResponse { + success: boolean; + message: string; +} + +interface RemoveFromProjectRequest { + projectId: string; + studentId?: string; + mentorId?: string; +} + +interface Project { + id: string; + name: string; + description?: string; + domain: string; + createdBy: string; + createdAt: string; + updatedAt: string; + studentCount?: number; + mentorCount?: number; +} +``` + +### `useCreateProject()` + +Create a new project. + +**Usage:** + +```typescript +const createProject = useCreateProject(); + +await createProject.mutateAsync({ + name: "Web Development 101", + description: "Learn web development", + domain: "web", +}); +``` + +--- + +### `useAssignToProject()` + +Assign a student or mentor to a project. + +**Usage:** + +```typescript +const assignToProject = useAssignToProject(); + +// Assign student +await assignToProject.mutateAsync({ + projectId: "proj_123", + studentId: "user_456", +}); + +// Assign mentor +await assignToProject.mutateAsync({ + projectId: "proj_123", + mentorId: "user_789", +}); +``` + +--- + +### `useRemoveFromProject()` + +Remove a student or mentor from a project. + +**Usage:** + +```typescript +const removeFromProject = useRemoveFromProject(); + +// Remove student +await removeFromProject.mutateAsync({ + projectId: "proj_123", + studentId: "user_456", +}); + +// Remove mentor +await removeFromProject.mutateAsync({ + projectId: "proj_123", + mentorId: "user_789", +}); +``` + +--- + +### `useGetProjects()` + +Fetch all projects. + +**Usage:** + +```typescript +const { data: projects, isLoading, error } = useGetProjects(); + +if (isLoading) return

Loading...

; +if (error) return

Error: {error.message}

; + +return ( +
+ {projects?.map((project) => ( +
+

{project.name}

+

{project.description}

+
+ ))} +
+); +``` + +--- + +### `useGetProject(projectId)` + +Fetch a single project with students and mentors. + +**Usage:** + +```typescript +const { data: project, isLoading, error } = useGetProject(projectId); + +if (isLoading) return

Loading...

; +if (error) return

Error: {error.message}

; + +return ( +
+

{project?.name}

+

{project?.description}

+

Students: {project?.students?.length}

+

Mentors: {project?.mentors?.length}

+
+); +``` + +--- + +### `useGetProjectStudents(projectId)` + +Fetch students assigned to a project. + +**Usage:** + +```typescript +const { data: students, isLoading } = useGetProjectStudents(projectId); + +return ( +
    + {students?.map((student) => ( +
  • {student.firstName} {student.lastName}
  • + ))} +
+); +``` + +--- + +### `useGetProjectMentors(projectId)` + +Fetch mentors assigned to a project. + +**Usage:** + +```typescript +const { data: mentors, isLoading } = useGetProjectMentors(projectId); + +return ( +
    + {mentors?.map((mentor) => ( +
  • {mentor.firstName} {mentor.lastName}
  • + ))} +
+); +``` diff --git a/server/src/hooks/admin/useInvitation.ts b/server/src/hooks/admin/useInvitation.ts index beb81998..b21a54bd 100644 --- a/server/src/hooks/admin/useInvitation.ts +++ b/server/src/hooks/admin/useInvitation.ts @@ -1,6 +1,6 @@ "use client"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -24,6 +24,13 @@ interface InvitationResponse { }; } +interface GetInvitationResponse { + valid: boolean; + email?: string; + role?: string; + message?: string; +} + export const useInvitation = () => { const queryClient = useQueryClient(); @@ -53,3 +60,28 @@ export const useInvitation = () => { return mutation; }; + +export const useGetInvitation = (token: string | null) => { + const query = useQuery({ + queryKey: ["invitation", token], + queryFn: async () => { + if (!token) throw new Error("Token is required"); + + const res = await fetch(`/api/invitations?token=${token}`, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + + if (!res.ok) { + const errorData = await res.json(); + toast.error("Failed to fetch invitation"); + throw new Error(errorData.message || "Failed to fetch invitation"); + } + + return res.json(); + }, + enabled: !!token, // Only runs when token exists + }); + + return query; +}; diff --git a/server/src/hooks/projects/index.ts b/server/src/hooks/projects/index.ts new file mode 100644 index 00000000..77bae912 --- /dev/null +++ b/server/src/hooks/projects/index.ts @@ -0,0 +1,9 @@ +export { + useCreateProject, + useAssignToProject, + useRemoveFromProject, + useGetProjects, + useGetProject, + useGetProjectStudents, + useGetProjectMentors, +} from "./useProject"; diff --git a/server/src/hooks/projects/useProject.ts b/server/src/hooks/projects/useProject.ts new file mode 100644 index 00000000..4006b1df --- /dev/null +++ b/server/src/hooks/projects/useProject.ts @@ -0,0 +1,262 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { toast } from "sonner"; + +// ============ Types ============ + +interface CreateProjectRequest { + name: string; + description?: string; + domain: string; +} + +interface CreateProjectResponse { + success: boolean; + message: string; + data: { + id: string; + name: string; + description?: string; + domain: string; + createdBy: string; + createdAt: string; + updatedAt: string; + }; +} + +interface AssignToProjectRequest { + projectId: string; + studentId?: string; + mentorId?: string; +} + +interface AssignToProjectResponse { + success: boolean; + message: string; +} + +interface RemoveFromProjectRequest { + projectId: string; + studentId?: string; + mentorId?: string; +} + +interface RemoveFromProjectResponse { + success: boolean; + message: string; +} + +interface Project { + id: string; + name: string; + description?: string; + domain: string; + createdBy: string; + createdAt: string; + updatedAt: string; + studentCount?: number; + mentorCount?: number; +} + +interface ProjectWithDetails extends Project { + students?: Array; + mentors?: Array; +} + +// ============ Create Project Hook ============ + +export const useCreateProject = () => { + const queryClient = useQueryClient(); + + const mutation = useMutation< + CreateProjectResponse, + Error, + CreateProjectRequest + >({ + mutationFn: async (data) => { + const res = await fetch("/api/project", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.message || "Failed to create project"); + } + + return res.json(); + }, + onSuccess: () => { + toast.success("Project created successfully!"); + // Invalidate project list queries + queryClient.invalidateQueries({ queryKey: ["projects"] }); + }, + onError: (error) => { + toast.error(error.message || "Failed to create project"); + }, + }); + + return mutation; +}; + +// ============ Assign to Project Hook ============ + +export const useAssignToProject = () => { + const queryClient = useQueryClient(); + + const mutation = useMutation< + AssignToProjectResponse, + Error, + AssignToProjectRequest + >({ + mutationFn: async (data) => { + const res = await fetch("/api/project", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.message || "Failed to assign to project"); + } + + return res.json(); + }, + onSuccess: (data, variables) => { + const assignType = variables.studentId ? "Student" : "Mentor"; + toast.success(`${assignType} assigned successfully!`); + + // Invalidate relevant queries + queryClient.invalidateQueries({ queryKey: ["projects"] }); + queryClient.invalidateQueries({ + queryKey: ["project", variables.projectId], + }); + }, + onError: (error) => { + toast.error(error.message || "Failed to assign to project"); + }, + }); + + return mutation; +}; + +// ============ Remove from Project Hook ============ + +export const useRemoveFromProject = () => { + const queryClient = useQueryClient(); + + const mutation = useMutation< + RemoveFromProjectResponse, + Error, + RemoveFromProjectRequest + >({ + mutationFn: async (data) => { + const res = await fetch("/api/project", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.message || "Failed to remove from project"); + } + + return res.json(); + }, + onSuccess: (data, variables) => { + const removeType = variables.studentId ? "Student" : "Mentor"; + toast.success(`${removeType} removed successfully!`); + + // Invalidate relevant queries + queryClient.invalidateQueries({ queryKey: ["projects"] }); + queryClient.invalidateQueries({ + queryKey: ["project", variables.projectId], + }); + }, + onError: (error) => { + toast.error(error.message || "Failed to remove from project"); + }, + }); + + return mutation; +}; + +// ============ Get All Projects Hook ============ + +export const useGetProjects = () => { + return useQuery({ + queryKey: ["projects"], + queryFn: async () => { + const res = await fetch("/api/project"); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.error || "Failed to fetch projects"); + } + + return res.json(); + }, + }); +}; + +// ============ Get Single Project Hook ============ + +export const useGetProject = (projectId: string | null) => { + return useQuery({ + queryKey: ["project", projectId], + queryFn: async () => { + const res = await fetch(`/api/project?id=${projectId}`); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.error || "Failed to fetch project"); + } + + return res.json(); + }, + enabled: !!projectId, // Only run query if projectId is provided + }); +}; + +// ============ Get Project Students Hook ============ + +export const useGetProjectStudents = (projectId: string | null) => { + return useQuery({ + queryKey: ["project", projectId, "students"], + queryFn: async () => { + const res = await fetch(`/api/project?id=${projectId}&view=students`); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.error || "Failed to fetch students"); + } + + return res.json(); + }, + enabled: !!projectId, + }); +}; + +// ============ Get Project Mentors Hook ============ + +export const useGetProjectMentors = (projectId: string | null) => { + return useQuery({ + queryKey: ["project", projectId, "mentors"], + queryFn: async () => { + const res = await fetch(`/api/project?id=${projectId}&view=mentors`); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.error || "Failed to fetch mentors"); + } + + return res.json(); + }, + enabled: !!projectId, + }); +}; From 626a2100160c41f51182c4bbb4bb7af836f65515 Mon Sep 17 00:00:00 2001 From: arshvinw5 Date: Thu, 19 Mar 2026 16:15:28 +0530 Subject: [PATCH 04/23] correct use project fn --- server/src/hooks/projects/useProject.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/hooks/projects/useProject.ts b/server/src/hooks/projects/useProject.ts index 4006b1df..fd5f7e0f 100644 --- a/server/src/hooks/projects/useProject.ts +++ b/server/src/hooks/projects/useProject.ts @@ -126,7 +126,7 @@ export const useAssignToProject = () => { return res.json(); }, - onSuccess: (data, variables) => { + onSuccess: (_, variables) => { const assignType = variables.studentId ? "Student" : "Mentor"; toast.success(`${assignType} assigned successfully!`); @@ -168,7 +168,7 @@ export const useRemoveFromProject = () => { return res.json(); }, - onSuccess: (data, variables) => { + onSuccess: (_, variables) => { const removeType = variables.studentId ? "Student" : "Mentor"; toast.success(`${removeType} removed successfully!`); From b842a9eff81901776b41256475e0fae5f03de81d Mon Sep 17 00:00:00 2001 From: Azeez1999 Date: Thu, 19 Mar 2026 19:57:51 +0530 Subject: [PATCH 05/23] Implemented user managment section --- server/.env | 4 +- server/src/app/admin/users/page.tsx | 523 ++++++++++++++++++++++++ server/src/app/api/admin/users/route.ts | 42 ++ 3 files changed, 567 insertions(+), 2 deletions(-) create mode 100644 server/src/app/admin/users/page.tsx create mode 100644 server/src/app/api/admin/users/route.ts diff --git a/server/.env b/server/.env index 669f6c01..7ba5df24 100644 --- a/server/.env +++ b/server/.env @@ -19,5 +19,5 @@ AUDIENCE=http://localhost:3000 NEXT_PUBLIC_BASE_URL=http://localhost:3000 # get the email pass key from https://myaccount.google.com/apppasswords -EMAIL_USER= "ruchiralakshanm@gmail.com" -EMAIL_PASS= "xwlr rbex igvs bxhp" \ No newline at end of file +EMAIL_USER= "sample@gmail.com" +EMAIL_PASS= "AABB CCDD EEFF GGHH" \ No newline at end of file diff --git a/server/src/app/admin/users/page.tsx b/server/src/app/admin/users/page.tsx new file mode 100644 index 00000000..ff70521c --- /dev/null +++ b/server/src/app/admin/users/page.tsx @@ -0,0 +1,523 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import AsideSidebar from "@/components/AsideSidebar"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, +} from "@/components/ui/pagination"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Bell, + Download, + EllipsisVertical, + Plus, + Search, + SlidersHorizontal, +} from "lucide-react"; + +type UserRole = "Student" | "Mentor" | "SuperAdmin"; +type UserStatus = "Active" | "Inactive"; + +type ApiUserRole = "student" | "mentor" | "superAdmin"; + +interface ApiUserRecord { + id: string; + email: string; + firstName: string; + lastName: string; + role: ApiUserRole; + isActive: boolean; + createdAt: string; +} + +interface UserRecord { + id: string; + name: string; + email: string; + role: UserRole; + status: UserStatus; + createdAt: string; + avatar?: string; +} + +const ITEMS_PER_PAGE = 5; + +function toUserRole(role: ApiUserRole): UserRole { + if (role === "student") { + return "Student"; + } + if (role === "mentor") { + return "Mentor"; + } + return "SuperAdmin"; +} + +function formatDate(dateString: string) { + const date = new Date(dateString); + if (Number.isNaN(date.getTime())) { + return "-"; + } + return date.toLocaleDateString("en-US", { + month: "short", + day: "2-digit", + year: "numeric", + }); +} + +function mapApiUserToRecord(user: ApiUserRecord): UserRecord { + return { + id: user.id, + name: `${user.firstName} ${user.lastName}`.trim(), + email: user.email, + role: toUserRole(user.role), + status: user.isActive ? "Active" : "Inactive", + createdAt: formatDate(user.createdAt), + }; +} + +function getInitials(name: string) { + return name + .split(" ") + .map((part) => part[0]) + .join("") + .slice(0, 2) + .toUpperCase(); +} + +function roleClass(role: UserRole) { + if (role === "Student") { + return "bg-slate-100 text-slate-700 border-slate-200"; + } + if (role === "Mentor") { + return "bg-emerald-50 text-emerald-700 border-emerald-200"; + } + return "bg-indigo-50 text-indigo-700 border-indigo-200"; +} + +function statusClass(status: UserStatus) { + if (status === "Active") { + return "text-blue-600"; + } + return "text-red-500"; +} + +export default function UsersPage() { + const [users, setUsers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [fetchError, setFetchError] = useState(null); + const [search, setSearch] = useState(""); + const [roleFilter, setRoleFilter] = useState<"all" | UserRole>("all"); + const [statusFilter, setStatusFilter] = useState<"all" | UserStatus>("all"); + const [page, setPage] = useState(1); + + useEffect(() => { + let mounted = true; + + const fetchUsers = async () => { + setIsLoading(true); + setFetchError(null); + + try { + const response = await fetch("/api/admin/users", { + cache: "no-store", + }); + + if (!response.ok) { + const payload = await response + .json() + .catch(() => ({ message: "Failed to fetch users" })); + throw new Error(payload.message ?? "Failed to fetch users"); + } + + const data = (await response.json()) as ApiUserRecord[]; + if (!mounted) { + return; + } + + setUsers(data.map(mapApiUserToRecord)); + } catch (error) { + if (!mounted) { + return; + } + setUsers([]); + setFetchError( + error instanceof Error ? error.message : "Failed to fetch users", + ); + } finally { + if (mounted) { + setIsLoading(false); + } + } + }; + + fetchUsers(); + + return () => { + mounted = false; + }; + }, []); + + const filteredUsers = useMemo(() => { + return users.filter((user) => { + const isRoleMatch = roleFilter === "all" || user.role === roleFilter; + const isStatusMatch = + statusFilter === "all" || user.status === statusFilter; + const query = search.trim().toLowerCase(); + const isSearchMatch = + query.length === 0 || + user.name.toLowerCase().includes(query) || + user.email.toLowerCase().includes(query) || + user.id.toLowerCase().includes(query); + + return isRoleMatch && isStatusMatch && isSearchMatch; + }); + }, [users, roleFilter, statusFilter, search]); + + const totalPages = Math.max(1, Math.ceil(filteredUsers.length / ITEMS_PER_PAGE)); + const safePage = Math.min(page, totalPages); + const startIndex = (safePage - 1) * ITEMS_PER_PAGE; + const endIndex = startIndex + ITEMS_PER_PAGE; + const visibleUsers = filteredUsers.slice(startIndex, endIndex); + + const startCount = filteredUsers.length === 0 ? 0 : startIndex + 1; + const endCount = Math.min(endIndex, filteredUsers.length); + + const goToPage = (nextPage: number) => { + if (nextPage < 1 || nextPage > totalPages) { + return; + } + setPage(nextPage); + }; + + const resetFilters = () => { + setSearch(""); + setRoleFilter("all"); + setStatusFilter("all"); + setPage(1); + }; + + return ( +
+ + +
+ +
+
+
+
+ + { + setSearch(event.target.value); + setPage(1); + }} + placeholder="Search by name, email, or ID..." + /> +
+ +
+ + +
+
+ +
+
+

User Administration

+

+ Manage system users, define their platform roles, and monitor + account statuses. +

+
+ +
+
+
+

Role

+ +
+ +
+

Status

+ +
+
+ +
+ + + +
+
+ +
+ + + + + User Identity + + + Role + + + Status + + + Created Date + + + Actions + + + + + + {isLoading ? ( + + + Loading users... + + + ) : fetchError ? ( + + + {fetchError} + + + ) : visibleUsers.length === 0 ? ( + + + No users found for the selected filters. + + + ) : ( + visibleUsers.map((user) => ( + + +
+ + {user.avatar ? : null} + + {getInitials(user.name)} + + +
+

{user.name}

+

{user.email}

+
+
+
+ + + + {user.role} + + + + + + + {user.status} + + + + + {user.createdAt} + + + + + +
+ )) + )} +
+
+ +
+

+ Showing {startCount} to {endCount} of {filteredUsers.length} users +

+ + + + + goToPage(safePage - 1)} + disabled={safePage <= 1} + aria-label="Previous page" + > + {"<"} + + + + {safePage > 2 ? ( + <> + + goToPage(1)} + > + 1 + + + {safePage > 3 ? ( + + + + ) : null} + + ) : null} + + {Array.from({ length: totalPages }, (_, idx) => idx + 1) + .filter((pageNumber) => Math.abs(pageNumber - safePage) <= 1) + .map((pageNumber) => ( + + goToPage(pageNumber)} + > + {pageNumber} + + + ))} + + {safePage < totalPages - 1 ? ( + <> + {safePage < totalPages - 2 ? ( + + + + ) : null} + + goToPage(totalPages)} + > + {totalPages} + + + + ) : null} + + + goToPage(safePage + 1)} + disabled={safePage >= totalPages} + aria-label="Next page" + > + {">"} + + + + +
+
+
+
+
+
+ ); +} diff --git a/server/src/app/api/admin/users/route.ts b/server/src/app/api/admin/users/route.ts new file mode 100644 index 00000000..35c51dac --- /dev/null +++ b/server/src/app/api/admin/users/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { Role } from "@prisma/client"; +import getSession from "@/server_actions/getSession"; +import prisma from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const session = await getSession(); + if (!session || !session.isAuthenticated()) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + + if (session.getRole() !== Role.superAdmin) { + return NextResponse.json({ message: "Forbidden" }, { status: 403 }); + } + + const users = await prisma.user.findMany({ + select: { + id: true, + email: true, + firstName: true, + lastName: true, + role: true, + isActive: true, + createdAt: true, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return NextResponse.json(users, { status: 200 }); + } catch (error) { + console.error("Error fetching admin users:", error); + return NextResponse.json( + { message: "Internal Server Error" }, + { status: 500 }, + ); + } +} \ No newline at end of file From d0873284a0e550e5ebb0fb08a255f427989ed7b3 Mon Sep 17 00:00:00 2001 From: ruchiralak Date: Thu, 19 Mar 2026 20:42:14 +0530 Subject: [PATCH 06/23] Update-invitationAPI-documentations --- docs/api/API_INVITATIONS.md | 77 +++++++++++++++++++++++++++++++++++++ server/.env | 2 +- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/docs/api/API_INVITATIONS.md b/docs/api/API_INVITATIONS.md index b8ab40ef..bbaf2d25 100644 --- a/docs/api/API_INVITATIONS.md +++ b/docs/api/API_INVITATIONS.md @@ -134,3 +134,80 @@ await invitation.mutateAsync({ role: "student", }); ``` + +--- + +## Frontend Hook: `useRecentInvitations` + +**Location:** `hooks/admin/useInvitation.ts` + +Fetches the list of all recent invitations. Automatically caches and deduplicates requests using `["invitations"]` as the query key — the same key invalidated by `useInvitation` on success, so the list refreshes after every new invitation is sent. + +**Returns:** `UseQueryResult` — the raw response from `GET /api/invitations`. + +**Usage:** + +```typescript +const { data, isLoading, isError } = useRecentInvitations(); + +if (isLoading) return ; +if (isError) return

Failed to load invitations.

; + +return data.invitations.map((inv) => ); +``` + +**Notes:** + +- No authentication parameters are passed from the hook; auth is handled server-side via session cookies. +- Throws `"Failed to fetch invitations"` on a non-OK response, which React Query surfaces via `error`. + +--- + +## Frontend Hook: `useValidateInvitation` + +**Location:** `hooks/admin/useInvitation.ts` + +Validates a single invitation token by calling `GET /api/invitations?token=`. The query is only executed when `token` is a non-empty string (`enabled: !!token`), making it safe to call unconditionally even before the token is available. + +**Parameters:** + +| Name | Type | Description | +|---------|----------|------------------------------------| +| `token` | `string` | The invitation token to validate. | + +**Returns:** `UseQueryResult` — the response from `GET /api/invitations?token=`. + +**Success response shape:** + +```json +{ + "valid": true, + "invitation": { + "id": "string", + "email": "string", + "role": "string", + "token": "string", + "createdAt": "ISO 8601", + "expiresAt": "ISO 8601", + "accepted": false + } +} +``` + +**Usage:** + +```typescript +// token typically comes from URL search params on the registration page +const token = searchParams.get("token") ?? ""; +const { data, isLoading, isError } = useValidateInvitation(token); + +if (isLoading) return ; +if (isError || !data?.valid) return

Invalid or expired invitation.

; + +return ; +``` + +**Notes:** + +- Throws `"Invalid token"` on a non-OK response; this is surfaced via `error` from React Query. +- Uses a scoped query key `["invitation", token]` so each token is cached independently. diff --git a/server/.env b/server/.env index 669f6c01..99de923e 100644 --- a/server/.env +++ b/server/.env @@ -20,4 +20,4 @@ AUDIENCE=http://localhost:3000 NEXT_PUBLIC_BASE_URL=http://localhost:3000 # get the email pass key from https://myaccount.google.com/apppasswords EMAIL_USER= "ruchiralakshanm@gmail.com" -EMAIL_PASS= "xwlr rbex igvs bxhp" \ No newline at end of file +EMAIL_PASS= "cztz ryic lqdk tfle" \ No newline at end of file From 1a1f2f0adcc9a0736f2ecd2ccd64601ee8c024e0 Mon Sep 17 00:00:00 2001 From: ruchiralak Date: Thu, 19 Mar 2026 20:54:44 +0530 Subject: [PATCH 07/23] new changes --- server/.env | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/.env b/server/.env index 669f6c01..85112e52 100644 --- a/server/.env +++ b/server/.env @@ -19,5 +19,5 @@ AUDIENCE=http://localhost:3000 NEXT_PUBLIC_BASE_URL=http://localhost:3000 # get the email pass key from https://myaccount.google.com/apppasswords -EMAIL_USER= "ruchiralakshanm@gmail.com" -EMAIL_PASS= "xwlr rbex igvs bxhp" \ No newline at end of file +EMAIL_USER= "sample@gmail.com" +EMAIL_PASS= "aaaa bbbb cccc dddd" \ No newline at end of file From 12c767b9b243d8ddb3e8bba1c748a7f90c109ce9 Mon Sep 17 00:00:00 2001 From: ruchiralak Date: Thu, 19 Mar 2026 20:59:22 +0530 Subject: [PATCH 08/23] remove .env from tracking --- server/.env | 23 ----------------------- 1 file changed, 23 deletions(-) delete mode 100644 server/.env diff --git a/server/.env b/server/.env deleted file mode 100644 index 85112e52..00000000 --- a/server/.env +++ /dev/null @@ -1,23 +0,0 @@ -BASE_URL=http://localhost:3000 - -# This was inserted by `prisma init`: -# Environment variables declared in this file are automatically made available to Prisma. -# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema - -# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. -# See the documentation for all the connection string options: https://pris.ly/d/connection-strings - -JWT_SECRET=hftgry5486 -DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres?schema=public" -JWT_SECRET="thisismysecretkey" -JWT_EXPIRY="60 min" -ISSUER=http://localhost:3000 -AUDIENCE=http://localhost:3000 - - - - -NEXT_PUBLIC_BASE_URL=http://localhost:3000 -# get the email pass key from https://myaccount.google.com/apppasswords -EMAIL_USER= "sample@gmail.com" -EMAIL_PASS= "aaaa bbbb cccc dddd" \ No newline at end of file From 9a48bee5a9e14ed11e7266f1bd7c7175d688a554 Mon Sep 17 00:00:00 2001 From: arshvinw5 Date: Fri, 20 Mar 2026 10:32:56 +0530 Subject: [PATCH 09/23] Refactor reg fn with mutation --- docs/api/API_Registration.md | 148 ++++++++++++++++++ server/src/app/create-account/page.tsx | 38 +---- .../hooks/admin/useCompleteRegistration.ts | 47 ++++++ 3 files changed, 203 insertions(+), 30 deletions(-) create mode 100644 docs/api/API_Registration.md create mode 100644 server/src/hooks/admin/useCompleteRegistration.ts diff --git a/docs/api/API_Registration.md b/docs/api/API_Registration.md new file mode 100644 index 00000000..d42857f6 --- /dev/null +++ b/docs/api/API_Registration.md @@ -0,0 +1,148 @@ +# API Guide + +Welcome to the Digital Logbook API documentation. This guide provides an overview of available endpoints and how to use them. + +## Table of Contents + +- [Authentication](#authentication) +- [Endpoints](#endpoints) + - [Projects](#projects) + - [Invitations](#invitations) +- [Error Handling](#error-handling) +- [Best Practices](#best-practices) + +## Authentication + +All API endpoints require authentication via JWT tokens. Include the token in the `Authorization` header: + +``` +Authorization: Bearer +``` + +## Endpoints + +### Projects + +For detailed project endpoint documentation, see [API_PROJECTS.md](./API_PROJECTS.md) + +**Available Operations:** + +- Get all projects +- Get project by ID +- Create new project +- Update project +- Delete project +- Manage project allocations + +### Invitations + +For detailed invitation endpoint documentation, see [API_INVITATIONS.md](./API_INVITATIONS.md) + +**Available Operations:** + +- Send invitations +- Validate invitation tokens +- Accept invitations +- Resend invitations + +## Error Handling + +All endpoints return standard HTTP status codes: + +| Status | Meaning | +| ------ | --------------------- | +| 200 | Success | +| 400 | Bad Request | +| 401 | Unauthorized | +| 404 | Not Found | +| 500 | Internal Server Error | + +### Error Response Format + +```json +{ + "error": "Error description" +} +``` + +## Best Practices + +### 1. Request Validation + +- Always validate input data before sending requests +- Check that required fields are present +- Ensure data types match expected values + +### 2. Error Handling + +- Implement proper try-catch blocks +- Handle specific error messages from the API +- Log errors for debugging + +### 3. Performance + +- Use pagination for large datasets +- Cache responses when appropriate +- Implement request debouncing on the frontend + +### 4. Security + +- Never expose JWT tokens in logs +- Always use HTTPS in production +- Validate tokens on the backend +- Implement rate limiting on sensitive endpoints + +### 5. Testing + +- Test all endpoints with valid and invalid data +- Verify error handling +- Test edge cases and boundary conditions + +## Frontend Integration + +When using these APIs in the frontend, consider using: + +- **TanStack Query (React Query)** for caching and state management +- **Custom Hooks** for reusable API logic +- **Error Boundaries** for graceful error handling +- **Toast Notifications** for user feedback + +### Example Hook Pattern + +```typescript +import { useMutation } from "@tanstack/react-query"; + +export const useYourMutation = () => { + return useMutation({ + mutationFn: async (payload) => { + const response = await fetch("/api/endpoint", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error("Request failed"); + } + + return response.json(); + }, + onSuccess: (data) => { + // Handle success + }, + onError: (error) => { + // Handle error + }, + }); +}; +``` + +## Additional Resources + +- [Database Schema](../schema/README.md) +- [Project Endpoints](./API_PROJECTS.md) +- [Invitation Endpoints](./API_INVITATIONS.md) + +--- + +For questions or issues, please refer to the main [README.md](../../README.md) diff --git a/server/src/app/create-account/page.tsx b/server/src/app/create-account/page.tsx index b96e2bdd..bc609459 100644 --- a/server/src/app/create-account/page.tsx +++ b/server/src/app/create-account/page.tsx @@ -2,11 +2,12 @@ import { useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useSearchParams } from "next/navigation"; import { toast } from "react-hot-toast"; +import { useCompleteRegistration } from "@/hooks/admin/useCompleteRegistration"; + export default function CreateAccount() { - const router = useRouter(); const searchParams = useSearchParams(); const token = searchParams.get("token") || ""; @@ -16,7 +17,8 @@ export default function CreateAccount() { const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); - const [loading, setLoading] = useState(false); + + const { mutate: completeRegistration, isPending } = useCompleteRegistration(); const handleRegister = async () => { if (!tempPassword || !newPassword || !confirmPassword) { @@ -29,31 +31,7 @@ export default function CreateAccount() { return; } - setLoading(true); - - try { - const res = await fetch("/api/complete-registration", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, token, tempPassword, newPassword }), - }); - - const data = await res.json(); - - if (res.ok) { - toast.success( - "Account created successfully! Please login with your new password.", - ); - router.push(`/login?email=${encodeURIComponent(email)}`); - } else { - toast.error(data.error || "Failed to complete registration"); - } - } catch (err) { - console.error(err); - toast.error("Something went wrong"); - } finally { - setLoading(false); - } + completeRegistration({ email, token, tempPassword, newPassword }); }; return ( @@ -103,10 +81,10 @@ export default function CreateAccount() {
diff --git a/server/src/hooks/admin/useCompleteRegistration.ts b/server/src/hooks/admin/useCompleteRegistration.ts new file mode 100644 index 00000000..5091bc55 --- /dev/null +++ b/server/src/hooks/admin/useCompleteRegistration.ts @@ -0,0 +1,47 @@ +import { useMutation } from "@tanstack/react-query"; + +import { useRouter } from "next/navigation"; +import { toast } from "react-hot-toast"; + +interface CompleteRegistrationPayload { + email: string; + token: string; + tempPassword: string; + newPassword: string; +} + +interface CompleteRegistrationResponse { + success: boolean; + role: string; +} + +export const useCompleteRegistration = () => { + const router = useRouter(); + + return useMutation({ + mutationFn: async (payload: CompleteRegistrationPayload) => { + const res = await fetch("/api/complete-registration", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || "Failed to complete registration"); + } + + return data as CompleteRegistrationResponse; + }, + onSuccess: (_, variables) => { + toast.success( + "Account created successfully! Please login with your new password.", + ); + router.push(`/login?email=${encodeURIComponent(variables.email)}`); + }, + onError: (error: Error) => { + toast.error(error.message || "Something went wrong"); + }, + }); +}; From 73de91c71860b327a23a3a40d1b144ac57803918 Mon Sep 17 00:00:00 2001 From: ShasmaAfs Date: Fri, 20 Mar 2026 14:12:15 +0530 Subject: [PATCH 10/23] Finalize-SuperAdmin-Component --- server/.env.example | 23 -- server/package-lock.json | 256 +++++++++++++-- server/package.json | 4 +- .../admin-dashboard/DashboardHeader.tsx | 31 ++ .../admin-dashboard/RecentProjectsTable.tsx | 70 ++++ .../components/admin-dashboard/StatCard.tsx | 50 +++ .../components/admin-dashboard/StatsGrid.tsx | 24 ++ .../admin-dashboard/StatusBadge.tsx | 29 ++ .../admin-dashboard/SuperAdminDashboard.tsx | 298 ++++++++++++++++++ .../src/components/admin-dashboard/index.ts | 6 + server/src/components/adminDashboard.tsx | 165 +--------- server/src/hooks/admin/useAdminDashboard.ts | 134 ++++++++ 12 files changed, 883 insertions(+), 207 deletions(-) delete mode 100644 server/.env.example create mode 100644 server/src/components/admin-dashboard/DashboardHeader.tsx create mode 100644 server/src/components/admin-dashboard/RecentProjectsTable.tsx create mode 100644 server/src/components/admin-dashboard/StatCard.tsx create mode 100644 server/src/components/admin-dashboard/StatsGrid.tsx create mode 100644 server/src/components/admin-dashboard/StatusBadge.tsx create mode 100644 server/src/components/admin-dashboard/SuperAdminDashboard.tsx create mode 100644 server/src/components/admin-dashboard/index.ts create mode 100644 server/src/hooks/admin/useAdminDashboard.ts diff --git a/server/.env.example b/server/.env.example deleted file mode 100644 index a5fc34ab..00000000 --- a/server/.env.example +++ /dev/null @@ -1,23 +0,0 @@ -BASE_URL=http://localhost:3000 - -# This was inserted by `prisma init`: -# Environment variables declared in this file are automatically made available to Prisma. -# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema - -# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. -# See the documentation for all the connection string options: https://pris.ly/d/connection-strings - -JWT_SECRET="Your secret key" -DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres?schema=public" -JWT_SECRET="thisismysecretkey" -JWT_EXPIRY="60 min" -ISSUER=http://localhost:3000 -AUDIENCE=http://localhost:3000 - - - - -NEXT_PUBLIC_BASE_URL=http://localhost:3000 -# get the email pass key from https://myaccount.google.com/apppasswords -EMAIL_USER= "sample@gmail.com" -EMAIL_PASS= "aaaa bbbb cccc dddd" \ No newline at end of file diff --git a/server/package-lock.json b/server/package-lock.json index d8c23952..c91e8b31 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -19,7 +19,7 @@ "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-toast": "^1.2.1", "@shadcn/ui": "^0.0.4", - "@tanstack/react-query": "^5.91.0", + "@tanstack/react-query": "^5.91.2", "bcrypt": "^5.1.1", "bcrypt-ts": "^5.0.2", "bullmq": "^5.12.14", @@ -31,6 +31,7 @@ "ioredis": "^5.4.1", "jose": "^5.8.0", "json2csv": "^6.0.0-alpha.2", + "jspdf": "^4.2.1", "lucide-react": "^0.436.0", "next": "14.2.5", "next-themes": "^0.4.6", @@ -64,6 +65,7 @@ "@types/istanbul-lib-report": "^3.0.3", "@types/jest": "^29.5.13", "@types/json2csv": "^5.0.7", + "@types/jspdf": "^1.3.3", "@types/node": "^20", "@types/nodemailer": "^6.4.16", "@types/prop-types": "^15.7.13", @@ -3583,23 +3585,14 @@ } }, "node_modules/@babel/runtime": { - "version": "7.25.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.6.tgz", - "integrity": "sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/runtime/node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "license": "MIT" - }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -9515,9 +9508,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.91.0", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.91.0.tgz", - "integrity": "sha512-FYXN8Kk9Q5VKuV6AIVaNwMThSi0nvAtR4X7HQoigf6ePOtFcavJYVIzgFhOVdtbBQtCJE3KimDIMMJM2DR1hjw==", + "version": "5.91.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.91.2.tgz", + "integrity": "sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw==", "license": "MIT", "funding": { "type": "github", @@ -9525,12 +9518,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.91.0", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.91.0.tgz", - "integrity": "sha512-S8FODsDTNv0Ym+o/JVBvA6EWiWVhg6K2Q4qFehZyFKk6uW4H9OPbXl4kyiN9hAly0uHJ/1GEbR6kAI4MZWfjEA==", + "version": "5.91.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.91.2.tgz", + "integrity": "sha512-GClLPzbM57iFXv+FlvOUL56XVe00PxuTaVEyj1zAObhRiKF008J5vedmaq7O6ehs+VmPHe8+PUQhMuEyv8d9wQ==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.91.0" + "@tanstack/query-core": "5.91.2" }, "funding": { "type": "github", @@ -9685,6 +9678,13 @@ "@types/node": "*" } }, + "node_modules/@types/jspdf": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@types/jspdf/-/jspdf-1.3.3.tgz", + "integrity": "sha512-DqwyAKpVuv+7DniCp2Deq1xGvfdnKSNgl9Agun2w6dFvR5UKamiv4VfYUgcypd8S9ojUyARFIlZqBrYrBMQlew==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.16.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.5.tgz", @@ -9705,12 +9705,25 @@ "@types/node": "*" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.13", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", "license": "MIT" }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "18.3.8", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.8.tgz", @@ -9743,6 +9756,13 @@ "@types/react": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/yargs-parser": { "version": "21.0.3", "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", @@ -10866,6 +10886,16 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/bcrypt": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", @@ -10994,6 +11024,26 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -11182,6 +11232,18 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-js-compat": { "version": "3.38.1", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.38.1.tgz", @@ -11368,6 +11430,16 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -11460,6 +11532,16 @@ "csstype": "^3.0.2" } }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "16.4.5", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", @@ -17691,6 +17773,17 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -17709,6 +17802,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -17981,6 +18080,20 @@ "dev": true, "license": "MIT" }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -18154,6 +18267,12 @@ "loose-envify": "cli.js" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/ioredis": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.4.1.tgz", @@ -19983,6 +20102,23 @@ "node": ">=6" } }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -21023,6 +21159,19 @@ "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -21488,6 +21637,16 @@ } } }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -21743,6 +21902,13 @@ "loose-envify": "cli.js" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -21814,6 +21980,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -21853,6 +22029,16 @@ "node": ">=0.10.0" } }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -22415,6 +22601,16 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/tailwind-merge": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.2.tgz", @@ -22471,6 +22667,16 @@ "tailwindcss": ">=3.0.0 || insiders" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -22740,6 +22946,16 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", diff --git a/server/package.json b/server/package.json index ee929d23..90f9fb79 100644 --- a/server/package.json +++ b/server/package.json @@ -31,7 +31,7 @@ "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-toast": "^1.2.1", "@shadcn/ui": "^0.0.4", - "@tanstack/react-query": "^5.91.0", + "@tanstack/react-query": "^5.91.2", "bcrypt": "^5.1.1", "bcrypt-ts": "^5.0.2", "bullmq": "^5.12.14", @@ -43,6 +43,7 @@ "ioredis": "^5.4.1", "jose": "^5.8.0", "json2csv": "^6.0.0-alpha.2", + "jspdf": "^4.2.1", "lucide-react": "^0.436.0", "next": "14.2.5", "next-themes": "^0.4.6", @@ -76,6 +77,7 @@ "@types/istanbul-lib-report": "^3.0.3", "@types/jest": "^29.5.13", "@types/json2csv": "^5.0.7", + "@types/jspdf": "^1.3.3", "@types/node": "^20", "@types/nodemailer": "^6.4.16", "@types/prop-types": "^15.7.13", diff --git a/server/src/components/admin-dashboard/DashboardHeader.tsx b/server/src/components/admin-dashboard/DashboardHeader.tsx new file mode 100644 index 00000000..f76c4289 --- /dev/null +++ b/server/src/components/admin-dashboard/DashboardHeader.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Button } from "@/components/ui/button"; + +interface DashboardHeaderProps { + userName?: string; + onRequestExport?: () => void; +} + +export default function DashboardHeader({ + userName, + onRequestExport, +}: DashboardHeaderProps) { + return ( +
+
+

Dashboard Overview

+

Admin Super Dashboard

+

+ Welcome back{userName ? `, ${userName}` : ""}! Here’s your latest platform summary. +

+
+ +
+ ); +} diff --git a/server/src/components/admin-dashboard/RecentProjectsTable.tsx b/server/src/components/admin-dashboard/RecentProjectsTable.tsx new file mode 100644 index 00000000..c263601c --- /dev/null +++ b/server/src/components/admin-dashboard/RecentProjectsTable.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { Card } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import StatusBadge, { ProjectStatus } from "./StatusBadge"; + +export interface ProjectRow { + projectName: string; + domain: string; + dateCreated: string; + status: ProjectStatus; +} + +interface RecentProjectsTableProps { + projects: ProjectRow[]; +} + +export default function RecentProjectsTable({ projects }: RecentProjectsTableProps) { + return ( + +
+
+

Recently Created Projects

+

Track project onboarding and delivery status.

+
+ +
+ + {projects.length === 0 ? ( +
+ No recent projects available. +
+ ) : ( +
+ + + + Project Name + Domain + Date Created + Status + + + + {projects.map((project) => ( + + {project.projectName} + {project.domain} + {project.dateCreated} + + + + + ))} + +
+
+ )} +
+ ); +} diff --git a/server/src/components/admin-dashboard/StatCard.tsx b/server/src/components/admin-dashboard/StatCard.tsx new file mode 100644 index 00000000..05fc7581 --- /dev/null +++ b/server/src/components/admin-dashboard/StatCard.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { Card } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; + +export type TrendType = "positive" | "negative" | "neutral"; + +export interface StatCardProps { + label: string; + value?: string | number; + trendLabel?: string; + trendType?: TrendType; + icon?: React.ReactNode; +} + +const trendClasses: Record = { + positive: "bg-emerald-50 text-emerald-700 border-emerald-100", + negative: "bg-rose-50 text-rose-700 border-rose-100", + neutral: "bg-slate-100 text-slate-700 border-slate-200", +}; + +export default function StatCard({ + label, + value = "--", + trendLabel, + trendType = "neutral", + icon, +}: StatCardProps) { + return ( + +
+
+

+ {label} +

+

{value}

+
+ {icon &&
{icon}
} +
+ {trendLabel ? ( +
+ + {trendLabel} + +

since last month

+
+ ) : null} +
+ ); +} diff --git a/server/src/components/admin-dashboard/StatsGrid.tsx b/server/src/components/admin-dashboard/StatsGrid.tsx new file mode 100644 index 00000000..4eabd201 --- /dev/null +++ b/server/src/components/admin-dashboard/StatsGrid.tsx @@ -0,0 +1,24 @@ +"use client"; + +import StatCard, { StatCardProps } from "./StatCard"; + +interface StatsGridProps { + stats: StatCardProps[]; +} + +export default function StatsGrid({ stats }: StatsGridProps) { + return ( +
+ {stats.map((item) => ( + + ))} +
+ ); +} diff --git a/server/src/components/admin-dashboard/StatusBadge.tsx b/server/src/components/admin-dashboard/StatusBadge.tsx new file mode 100644 index 00000000..6f6911b6 --- /dev/null +++ b/server/src/components/admin-dashboard/StatusBadge.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; + +export type ProjectStatus = "active" | "pending" | "delayed"; + +interface StatusBadgeProps { + status: ProjectStatus; +} + +const styles: Record = { + active: "bg-emerald-50 text-emerald-700 border border-emerald-100", + pending: "bg-amber-50 text-amber-700 border border-amber-100", + delayed: "bg-rose-50 text-rose-700 border border-rose-100", +}; + +const labels: Record = { + active: "Active", + pending: "Pending", + delayed: "Delayed", +}; + +export default function StatusBadge({ status }: StatusBadgeProps) { + return ( + + {labels[status]} + + ); +} diff --git a/server/src/components/admin-dashboard/SuperAdminDashboard.tsx b/server/src/components/admin-dashboard/SuperAdminDashboard.tsx new file mode 100644 index 00000000..363bb769 --- /dev/null +++ b/server/src/components/admin-dashboard/SuperAdminDashboard.tsx @@ -0,0 +1,298 @@ +"use client"; + +import { useState } from "react"; +import AsideSidebar from "@/components/AsideSidebar"; +import DashboardHeader from "./DashboardHeader"; +import StatsGrid from "./StatsGrid"; +import RecentProjectsTable from "./RecentProjectsTable"; +import { useAdminDashboard } from "@/hooks/admin/useAdminDashboard"; +import { + Users, + GraduationCap, + UserCheck, + Briefcase, + Mail, + Activity, +} from "lucide-react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, + DialogClose, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; + +interface SuperAdminDashboardProps { + userName?: string; +} + +export default function SuperAdminDashboard({ userName }: SuperAdminDashboardProps) { + const { stats, recentProjects, isLoading, error } = useAdminDashboard(); + const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); + const [chosenFormat, setChosenFormat] = useState<"csv" | "pdf">("csv"); + const [isExporting, setIsExporting] = useState(false); + + const cards = [ + { + label: "Total Users", + value: stats.totalUsers || "--", + icon: , + }, + { + label: "Students", + value: stats.students || "--", + icon: , + }, + { + label: "Mentors", + value: stats.mentors || "--", + icon: , + }, + { + label: "Total Projects", + value: stats.totalProjects || "--", + icon: , + }, + { + label: "Pending Invites", + value: stats.pendingInvites || "--", + icon: , + }, + { + label: "Active Projects", + value: stats.activeProjects || "--", + icon: , + }, + ]; + + const createCSV = () => { + const lines: string[] = []; + lines.push("Dashboard Report"); + lines.push(`Generated at:,${new Date().toLocaleString()}`); + lines.push(" "); + lines.push("Section,Key,Value"); + + cards.forEach((card) => { + lines.push(`Metrics,${card.label},${card.value}`); + }); + + lines.push(" "); + lines.push("Section,Project Name,Domain,Date Created,Status"); + recentProjects.forEach((project) => { + lines.push(`Projects,${project.projectName},${project.domain},${project.dateCreated},${project.status}`); + }); + + const csv = lines.join("\n"); + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `dashboard-report-${new Date().toISOString()}.csv`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + + const createPDF = async () => { + try { + const jsPDFModule = await import("jspdf"); + const jsPDF = jsPDFModule.default; + const doc = new jsPDF({ unit: "pt", format: "a4" }); + const margin = 40; + let y = 50; + + // Title block with accent gradient-style background + doc.setFillColor(6, 78, 124); + doc.rect(0, 0, 595, 110, "F"); + doc.setFillColor(12, 112, 162); + doc.rect(0, 70, 595, 40, "F"); + doc.setTextColor(255, 255, 255); + doc.setFontSize(20); + doc.setFont("helvetica", "bold"); + doc.text("LOGBOOK MENTORSHIP OS", margin, 36); + doc.setFontSize(14); + doc.setFont("helvetica", "normal"); + doc.text("Dashboard Report", margin, 58); + doc.setFontSize(9); + doc.text(`Generated at: ${new Date().toLocaleString()}`, margin, 74); + doc.setTextColor(255, 255, 255); + doc.setFontSize(9); + doc.text("Summary metrics and recent project performance.", margin, 88); + doc.setTextColor(0, 0, 0); + + y = 120; + doc.setDrawColor(220); + doc.line(margin, y, 555, y); + y += 18; + + // Metrics card panel + doc.setFontSize(14); + doc.setFont("helvetica", "bold"); + doc.text("Summary Metrics", margin, y); + y += 14; + doc.setFont("helvetica", "normal"); + doc.setFontSize(10); + + const metricStartX = margin; + let boxX = metricStartX; + const boxY = y; + const boxW = 160; + const boxH = 58; + const metricColors = [ + [219, 234, 254], + [220, 252, 231], + [254, 226, 226], + ]; + cards.slice(0, 3).forEach((card, idx) => { + doc.setFillColor(...metricColors[idx]); + doc.roundedRect(boxX, boxY, boxW, boxH, 8, 8, "F"); + doc.setDrawColor(203); + doc.roundedRect(boxX, boxY, boxW, boxH, 8, 8, "S"); + doc.setFontSize(8); + doc.setTextColor(15, 23, 42); + doc.text(card.label, boxX + 11, boxY + 18); + doc.setFontSize(18); + doc.setFont("helvetica", "bold"); + doc.text(String(card.value), boxX + 11, boxY + 38); + doc.setFont("helvetica", "normal"); + boxX += boxW + 8; + }); + + y = boxY + boxH + 20; + doc.setFont("helvetica", "normal"); + doc.setFontSize(10); + doc.text("Detailed statistics", margin, y); + y += 14; + cards.slice(3).forEach((card) => { + doc.text(`• ${card.label}: ${card.value}`, margin, y); + y += 12; + }); + + y += 8; + doc.setFontSize(12); + doc.setFont("helvetica", "bold"); + doc.text("Recent Projects", margin, y); + y += 16; + + // Table heading + doc.setFontSize(9); + doc.setFont("helvetica", "bold"); + doc.setFillColor(247, 248, 250); + doc.rect(margin, y - 9, 515, 16, "F"); + doc.text("Project Name", margin + 3, y); + doc.text("Domain", margin + 180, y); + doc.text("Date Created", margin + 310, y); + doc.text("Status", margin + 430, y); + y += 10; + doc.setFont("helvetica", "normal"); + + recentProjects.forEach((project) => { + if (y > 760) { + doc.addPage(); + y = 50; + } + doc.text(project.projectName, margin + 3, y); + doc.text(project.domain, margin + 180, y); + doc.text(project.dateCreated, margin + 310, y); + doc.text(project.status, margin + 430, y); + y += 12; + }); + + doc.save(`dashboard-report-${new Date().toISOString()}.pdf`); + } catch (err) { + console.error("PDF generation error", err); + alert("Unable to generate PDF at this time. Please try again later."); + } + }; + + const handleExport = async () => { + setIsExporting(true); + try { + if (chosenFormat === "csv") { + createCSV(); + } else { + await createPDF(); + } + } finally { + setIsExporting(false); + setIsExportDialogOpen(false); + } + }; + + const openDialog = () => setIsExportDialogOpen(true); + + return ( +
+ +
+
+ + + + + + Export Dashboard Report + + Choose format and export the content including stats and recent projects. + + +
+ + +
+ + + + + + +
+
+ + {isLoading ? ( +
+ Loading Super Admin dashboard... +
+ ) : error ? ( +
+ {error} +
+ ) : ( + <> +
+ +
+ + + )} +
+
+
+ ); +} diff --git a/server/src/components/admin-dashboard/index.ts b/server/src/components/admin-dashboard/index.ts new file mode 100644 index 00000000..108bf14f --- /dev/null +++ b/server/src/components/admin-dashboard/index.ts @@ -0,0 +1,6 @@ +export { default as SuperAdminDashboard } from "./SuperAdminDashboard"; +export { default as DashboardHeader } from "./DashboardHeader"; +export { default as StatsGrid } from "./StatsGrid"; +export { default as StatCard } from "./StatCard"; +export { default as RecentProjectsTable } from "./RecentProjectsTable"; +export { default as StatusBadge } from "./StatusBadge"; diff --git a/server/src/components/adminDashboard.tsx b/server/src/components/adminDashboard.tsx index f89aadf1..422f8e3d 100644 --- a/server/src/components/adminDashboard.tsx +++ b/server/src/components/adminDashboard.tsx @@ -1,168 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; - -import getSession from "@/server_actions/getSession"; -import AsideSidebar from "@/components/AsideSidebar"; - -import { TestInvitation } from "./Test/invitation"; - -interface AdminUser { - id: string; - email: string; - firstName: string; - lastName: string; - role: string; -} +import { SuperAdminDashboard } from "@/components/admin-dashboard"; export default function AdminDashboard() { - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - const fetchUserData = async () => { - try { - const session = await getSession(); - if (session.isAuthenticated()) { - const userData = { - id: session.getId(), - email: session.getUsername(), - firstName: session.getName()?.split(" ")[0] || "", - lastName: session.getName()?.split(" ")[1] || "", - role: session.getRole(), - }; - setUser(userData as AdminUser); - } - } catch (error) { - console.error("Error fetching user data:", error); - } finally { - setLoading(false); - } - }; - - fetchUserData(); - }, []); - - if (loading) { - return ( -
-

Loading admin dashboard...

-
- ); - } - - return ( -
- -
-
- {/* Header */} -
-
-

- Admin Dashboard -

-

- Welcome back, {user?.firstName || "Admin"} -

-
-
- -
-
- - {/* Admin Info Card */} -
-

- Admin Information -

-
-
-

Name

-

- {user?.firstName} {user?.lastName} -

-
-
-

Email

-

- {user?.email} -

-
-
-

Role

-

- {user?.role} -

-
-
-
- - {/* Dashboard Sections */} -
- {/* Users Management */} -
-
-
-

Users

-

--

-
-
👥
-
- -
- - {/* Activities */} -
-
-
-

Activities

-

--

-
-
📊
-
- -
- - {/* Reports */} -
-
-
-

Reports

-

--

-
-
📄
-
- -
-
- - {/* Placeholder for More Content */} -
-

- Recent Activities -

-

- Admin activity logs and recent actions will appear here. -

-
- -
- -
-
-
-
- ); + return ; } diff --git a/server/src/hooks/admin/useAdminDashboard.ts b/server/src/hooks/admin/useAdminDashboard.ts new file mode 100644 index 00000000..25e88287 --- /dev/null +++ b/server/src/hooks/admin/useAdminDashboard.ts @@ -0,0 +1,134 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export type ProjectStatus = "active" | "pending" | "delayed"; + +export interface ProjectRow { + projectName: string; + domain: string; + dateCreated: string; + status: ProjectStatus; +} + +export interface AdminStats { + totalUsers: number; + students: number; + mentors: number; + totalProjects: number; + pendingInvites: number; + activeProjects: number; +} + +export interface UseAdminDashboardResult { + stats: AdminStats; + recentProjects: ProjectRow[]; + isLoading: boolean; + error: string | null; +} + +const initialStats: AdminStats = { + totalUsers: 0, + students: 0, + mentors: 0, + totalProjects: 0, + pendingInvites: 0, + activeProjects: 0, +}; + +const statusForProject = (project: any): ProjectStatus => { + const hasPeople = (project?.mentorCount ?? 0) > 0 || (project?.studentCount ?? 0) > 0; + const createdAt = project?.createdAt ? new Date(project.createdAt) : null; + const ageDays = createdAt ? Math.floor((Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24)) : 0; + + if (!hasPeople && ageDays > 45) return "delayed"; + if (!hasPeople) return "pending"; + return "active"; +}; + +export function useAdminDashboard() { + const [stats, setStats] = useState(initialStats); + const [recentProjects, setRecentProjects] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let mounted = true; + + const fetchData = async () => { + setIsLoading(true); + setError(null); + + try { + const [usersRes, projectsRes, invitesRes] = await Promise.all([ + fetch("/api/admin/users", { cache: "no-store" }), + fetch("/api/project", { cache: "no-store" }), + fetch("/api/invitations", { cache: "no-store" }), + ]); + + if (!usersRes.ok) throw new Error("Failed to load users"); + if (!projectsRes.ok) throw new Error("Failed to load projects"); + if (!invitesRes.ok) throw new Error("Failed to load invitations"); + + const usersData = (await usersRes.json()) as Array<{ role: string }>; + const projectsData = (await projectsRes.json()) as Array; + const invitationsData = (await invitesRes.json()) as Array<{ status: string }>; + + if (!mounted) return; + + const totalUsers = usersData.length; + const students = usersData.filter((user) => user.role === "student").length; + const mentors = usersData.filter((user) => user.role === "mentor").length; + + const totalProjects = projectsData.length; + + const pendingInvites = invitationsData.filter((inv) => inv.status === "Pending").length; + + const activeProjects = projectsData.filter((project) => statusForProject(project) === "active").length; + + const sortedProjects = [...projectsData].sort((a, b) => { + const aDate = new Date(a.createdAt).getTime(); + const bDate = new Date(b.createdAt).getTime(); + return bDate - aDate; + }); + + const recentProjectsMapped = sortedProjects.slice(0, 6).map((project) => ({ + projectName: project.name || "-", + domain: project.domain || "-", + dateCreated: project.createdAt + ? new Date(project.createdAt).toLocaleDateString("en-US", { + month: "short", + day: "2-digit", + year: "numeric", + }) + : "-", + status: statusForProject(project), + })); + + setStats({ + totalUsers, + students, + mentors, + totalProjects, + pendingInvites, + activeProjects, + }); + + setRecentProjects(recentProjectsMapped); + } catch (err) { + if (!mounted) return; + setError(err instanceof Error ? err.message : "Unexpected error"); + } finally { + if (mounted) setIsLoading(false); + } + }; + + fetchData(); + + return () => { + mounted = false; + }; + }, []); + + return { stats, recentProjects, isLoading, error }; +} From 6124426bfd23595f513af66a306971217f25f007 Mon Sep 17 00:00:00 2001 From: jaleelfathima Date: Fri, 20 Mar 2026 17:37:01 +0530 Subject: [PATCH 11/23] add project dashbaord --- server/src/app/porjects/page.tsx | 439 ++++++++++++++++++++++++ server/src/components/dropdown-menu.tsx | 201 +++++++++++ 2 files changed, 640 insertions(+) create mode 100644 server/src/app/porjects/page.tsx create mode 100644 server/src/components/dropdown-menu.tsx diff --git a/server/src/app/porjects/page.tsx b/server/src/app/porjects/page.tsx new file mode 100644 index 00000000..857fc1b7 --- /dev/null +++ b/server/src/app/porjects/page.tsx @@ -0,0 +1,439 @@ +"use client"; + +import { useState } from "react"; + +import { + Beaker, + Bot, + CheckCircle, + Cpu, + Database, + FolderOpen, + Globe, + Leaf, + MoreVertical, + Users, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import AsideSidebar from "@/components/AsideSidebar"; + +type ProjectStatus = "active" | "pending" | "completed" | "archived"; +type ProjectDomain = + | "Engineering" + | "Environment" + | "Healthcare" + | "Technology" + | "Science" + | "Business"; + +interface Project { + id: string; + name: string; + domain: ProjectDomain; + mentors: number; + students: number; + createdBy: string; + createdDate: string; + status: ProjectStatus; + icon: React.ReactNode; +} + +const DOMAIN_ICONS: Record = { + Engineering: , + Environment: , + Healthcare: , + Technology: , + Science: , + Business: , +}; + +const MOCK_PROJECTS: Project[] = [ + { + id: "1", + name: "Smart City Analytics", + domain: "Engineering", + mentors: 4, + students: 24, + createdBy: "Dr. Emily Chen", + createdDate: "Oct 12, 2023", + status: "active", + icon: DOMAIN_ICONS["Engineering"], + }, + { + id: "2", + name: "Carbon Footprint App", + domain: "Environment", + mentors: 2, + students: 10, + createdBy: "Marcus V.", + createdDate: "Oct 14, 2023", + status: "pending", + icon: DOMAIN_ICONS["Environment"], + }, + { + id: "3", + name: "Health Records DB", + domain: "Healthcare", + mentors: 3, + students: 15, + createdBy: "Sarah Jenkins", + createdDate: "Oct 15, 2023", + status: "active", + icon: DOMAIN_ICONS["Healthcare"], + }, + { + id: "4", + name: "AI Research Portal", + domain: "Technology", + mentors: 5, + students: 30, + createdBy: "Dr. Alan Turing", + createdDate: "Oct 18, 2023", + status: "completed", + icon: DOMAIN_ICONS["Technology"], + }, + { + id: "5", + name: "BioSynth Lab", + domain: "Science", + mentors: 2, + students: 8, + createdBy: "Elena Rossi", + createdDate: "Oct 20, 2023", + status: "archived", + icon: DOMAIN_ICONS["Science"], + }, + { + id: "6", + name: "FinTech Dashboard", + domain: "Business", + mentors: 3, + students: 12, + createdBy: "Marcus Vane", + createdDate: "Oct 22, 2023", + status: "active", + icon: DOMAIN_ICONS["Business"], + }, +]; + +const STATUS_CONFIG: Record< + ProjectStatus, + { label: string; dotColor: string; textColor: string } +> = { + active: { + label: "ACTIVE", + dotColor: "bg-green-500", + textColor: "text-green-600", + }, + pending: { + label: "PENDING", + dotColor: "bg-yellow-400", + textColor: "text-yellow-600", + }, + completed: { + label: "COMPLETED", + dotColor: "bg-blue-500", + textColor: "text-blue-600", + }, + archived: { + label: "ARCHIVED", + dotColor: "bg-gray-400", + textColor: "text-gray-500", + }, +}; + +const ITEMS_PER_PAGE = 3; +const TOTAL_RESULTS = 24; + +export default function ProjectsPage() { + const [page, setPage] = useState(1); + const [domainFilter, setDomainFilter] = useState("all"); + const [statusFilter, setStatusFilter] = useState("all"); + const [openMenuId, setOpenMenuId] = useState(null); + + const filtered = MOCK_PROJECTS.filter((p) => { + const domainMatch = domainFilter === "all" || p.domain === domainFilter; + const statusMatch = statusFilter === "all" || p.status === statusFilter; + return domainMatch && statusMatch; + }); + + const totalPages = Math.ceil(TOTAL_RESULTS / ITEMS_PER_PAGE); + const displayedProjects = filtered.slice(0, ITEMS_PER_PAGE); + + return ( +
+ + +
+ {/* Header */} +
+

Projects

+ +
+ + {/* Stat Cards */} +
+ +
+

Total Projects

+

24

+
+ +
+ +
+

Active Projects

+

18

+
+ +
+ +
+

Total Students

+

342

+
+ +
+
+ + {/* Filters */} +
+ + + + + {(domainFilter !== "all" || statusFilter !== "all") && ( + + )} +
+ + {/* Table */} + + + + + + Project Name + + + Domain + + + Mentors + + + Students + + + Created By + + + Created Date + + + Status + + + + + + {displayedProjects.map((project) => { + const statusConfig = STATUS_CONFIG[project.status]; + return ( + + +
+
+ {project.icon} +
+ + {project.name} + +
+
+ + + {project.domain} + + + + {project.mentors} + + + {project.students} + + + {project.createdBy} + + + {project.createdDate} + + +
+ + + {statusConfig.label} + +
+
+ +
+ + {openMenuId === project.id && ( +
+ + + +
+ )} +
+
+
+ ); + })} +
+
+ + {/* Pagination */} +
+

+ Showing{" "} + + {(page - 1) * ITEMS_PER_PAGE + 1} + {" "} + to{" "} + + {Math.min(page * ITEMS_PER_PAGE, TOTAL_RESULTS)} + {" "} + of{" "} + + {TOTAL_RESULTS} + {" "} + results +

+ + + + setPage((p) => Math.max(1, p - 1))} + /> + + {[1, 2, 3].map((p) => ( + + setPage(p)} + > + {p} + + + ))} + {totalPages > 4 && ( + + + + )} + + setPage((p) => Math.min(totalPages, p + 1))} + /> + + + +
+
+
+
+ ); +} diff --git a/server/src/components/dropdown-menu.tsx b/server/src/components/dropdown-menu.tsx new file mode 100644 index 00000000..31c16766 --- /dev/null +++ b/server/src/components/dropdown-menu.tsx @@ -0,0 +1,201 @@ +"use client"; + +import * as React from "react"; + +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; From 2e12c3cfe937ebc548bf4be25eb86446e9965856 Mon Sep 17 00:00:00 2001 From: ruchiralak Date: Sat, 21 Mar 2026 13:43:21 +0530 Subject: [PATCH 12/23] Update the invitation layout --- server/src/app/admin/invitation/page.tsx | 445 +++++++++++++++-------- server/src/app/admin/projects/page.tsx | 2 +- server/src/app/admin/reports/page.tsx | 2 +- server/src/app/admin/users/page.tsx | 2 +- server/src/app/api/invitations/route.ts | 29 +- server/src/hooks/admin/useInvitation.ts | 27 +- server/tailwind.config.ts | 9 + 7 files changed, 359 insertions(+), 157 deletions(-) diff --git a/server/src/app/admin/invitation/page.tsx b/server/src/app/admin/invitation/page.tsx index e2f647b2..abf80bc6 100644 --- a/server/src/app/admin/invitation/page.tsx +++ b/server/src/app/admin/invitation/page.tsx @@ -2,13 +2,18 @@ import React, { useEffect, useState } from 'react'; import AsideSidebar from "@/components/AsideSidebar"; -import { useInvitation, useRecentInvitations, useExpireInvitation, useDeleteInvitation } from "@/hooks/admin/useInvitation"; +import { useInvitation, useRecentInvitations, useChangeInvitationStatus, useDeleteInvitation } from "@/hooks/admin/useInvitation"; import { z } from 'zod'; import { UserPlus, Send, MoreVertical, - ChevronDown + ChevronDown, + Plus, + Users, + Clock, + CheckCircle2, + XCircle } from 'lucide-react'; import { Project } from '@prisma/client'; import { @@ -32,6 +37,7 @@ import { Dialog, DialogClose, DialogContent, + DialogDescription, DialogFooter, DialogHeader, DialogTitle, @@ -73,7 +79,7 @@ export default function InvitationsView() { const [projects, setProjects] = useState([]); const { mutate: sendInvitation, isPending } = useInvitation(); const { data: recentInvitations, isLoading: isInvLoading, isError: isInvError } = useRecentInvitations(); - const { mutate: expireInvitation, isPending: isExpiring } = useExpireInvitation(); + const { mutate: changeStatus, isPending: isChangingStatus } = useChangeInvitationStatus(); const { mutate: deleteInvitation, isPending: isDeleting } = useDeleteInvitation(); const [formData, setFormData] = useState({ @@ -89,8 +95,10 @@ export default function InvitationsView() { const [page, setPage] = useState(1); // Modals + const [createOpen, setCreateOpen] = useState(false); const [viewInv, setViewInv] = useState(null); - const [expireId, setExpireId] = useState(null); + const [changeStatusInv, setChangeStatusInv] = useState(null); + const [selectedStatus, setSelectedStatus] = useState<'Accepted' | 'Pending' | 'Expired'>('Pending'); const [deleteId, setDeleteId] = useState(null); const handleInputChange = (field: string, value: string) => { @@ -133,6 +141,7 @@ export default function InvitationsView() { email: "", project: "", }); + setCreateOpen(false); } }); }; @@ -154,140 +163,88 @@ useEffect(() => { const invList: InvitationRow[] = (recentInvitations as InvitationRow[] | undefined) ?? []; const totalInvitations = invList.length; + const pendingCount = invList.filter(i => i.status === 'Pending').length; + const acceptedCount = invList.filter(i => i.status === 'Accepted').length; + const expiredCount = invList.filter(i => i.status === 'Expired').length; const totalPages = Math.max(1, Math.ceil(totalInvitations / ITEMS_PER_PAGE)); const currentPage = Math.min(page, totalPages); const pagedInvitations = invList.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE); return ( <> -
+
-
- {/* Top Bar */} -
-

Invitations

+
+ + {/* Page Header */} +
+
+
+

Invitations

+

Manage organizational access and track member onboarding.

+
+ +
-
- {/* Page Title */} -
-

Invitations Management

-

Manage organizational access and track member onboarding status.

-
+
- {/* Invite User Card */} -
-
-
-
- + {/* Stat Cards */} +
+
+
+

Total

+
+
- Invite User
+

{isInvLoading ? '—' : totalInvitations}

- -
-
- {/* Row 1 / Col 1 */} -
- -
- - -
- {errors.role &&

{errors.role}

} -
- - {/* Row 1 / Col 2 */} -
- - handleInputChange('firstName', e.target.value)} - className={`w-full h-10 px-3 border ${errors.firstName ? 'border-red-500 focus:ring-red-500 placeholder:text-red-300' : 'border-slate-200 focus:ring-slate-900 placeholder:text-slate-400'} rounded-md bg-white text-sm focus:outline-none focus:ring-2 focus:border-transparent`} - /> - {errors.firstName &&

{errors.firstName}

} +
+
+

Pending

+
+
- - {/* Row 1 / Col 3 */} -
- - handleInputChange('lastName', e.target.value)} - className={`w-full h-10 px-3 border ${errors.lastName ? 'border-red-500 focus:ring-red-500 placeholder:text-red-300' : 'border-slate-200 focus:ring-slate-900 placeholder:text-slate-400'} rounded-md bg-white text-sm focus:outline-none focus:ring-2 focus:border-transparent`} - /> - {errors.lastName &&

{errors.lastName}

} -
- - {/* Row 2 / Col 1 */} -
- - handleInputChange('email', e.target.value)} - className={`w-full h-10 px-3 border ${errors.email ? 'border-red-500 focus:ring-red-500 placeholder:text-red-300' : 'border-slate-200 focus:ring-slate-900 placeholder:text-slate-400'} rounded-md bg-white text-sm focus:outline-none focus:ring-2 focus:border-transparent`} - /> - {errors.email &&

{errors.email}

} -
- - {/* Row 2 / Col 2 */} -
- -
- - -
- {errors.project &&

{errors.project}

} +
+

{isInvLoading ? '—' : pendingCount}

+
+
+
+

Accepted

+
+
- - {/* Row 2 / Col 3 - Button */} -
- +
+

{isInvLoading ? '—' : acceptedCount}

+
+
+
+

Expired

+
+
+

{isInvLoading ? '—' : expiredCount}

- {/* Recent Invitations Section */} -
-
-

Recent Invitations

+ {/* Invitations Table */} +
+ {/* Table Toolbar */} +
+
+

All Invitations

+ {!isInvLoading &&

{totalInvitations} total record{totalInvitations !== 1 ? 's' : ''}

} +
- -
-
+
@@ -331,11 +288,9 @@ useEffect(() => { setViewInv(inv)}> View Details - {inv.status !== 'Expired' && ( - setExpireId(inv.id)}> - Mark as Expired - - )} + { setChangeStatusInv(inv); setSelectedStatus(inv.status); }}> + Change Status + {
- {/* Pagination */} -
+ {/* Pagination */} +

Showing{" "} @@ -380,13 +335,167 @@ useEffect(() => { -

+
-
+ {/* ── Create Invitation Dialog ─────────────────────────────────────── */} + { setCreateOpen(open); if (!open) { setErrors({}); } }}> + + {/* Header */} +
+ + +
+ +
+ Create Invitation +
+ + Send an invitation email to onboard a new member to your organization. + +
+
+ + {/* Form Body */} +
+ + {/* Section: Access Details */} +
+

Access Details

+
+ {/* Role */} +
+ +
+ + +
+ {errors.role + ?

{errors.role}

+ :

Determines the user's access level

+ } +
+ + {/* Project */} +
+ +
+ + +
+ {errors.project + ?

{errors.project}

+ :

Assign to an existing project

+ } +
+
+
+ + {/* Divider */} +
+ + {/* Section: Recipient Details */} +
+

Recipient Details

+
+ {/* Name row */} +
+
+ + handleInputChange('firstName', e.target.value)} + className={`w-full h-10 px-3 border ${errors.firstName ? 'border-red-400 focus:ring-red-400 bg-red-50/40 placeholder:text-red-300' : 'border-slate-300 focus:ring-slate-900 bg-white placeholder:text-slate-400'} rounded-lg text-sm text-slate-800 focus:outline-none focus:ring-2 focus:border-transparent transition-colors`} + /> + {errors.firstName &&

{errors.firstName}

} +
+
+ + handleInputChange('lastName', e.target.value)} + className={`w-full h-10 px-3 border ${errors.lastName ? 'border-red-400 focus:ring-red-400 bg-red-50/40 placeholder:text-red-300' : 'border-slate-300 focus:ring-slate-900 bg-white placeholder:text-slate-400'} rounded-lg text-sm text-slate-800 focus:outline-none focus:ring-2 focus:border-transparent transition-colors`} + /> + {errors.lastName &&

{errors.lastName}

} +
+
+ + {/* Email */} +
+ + handleInputChange('email', e.target.value)} + className={`w-full h-10 px-3 border ${errors.email ? 'border-red-400 focus:ring-red-400 bg-red-50/40 placeholder:text-red-300' : 'border-slate-300 focus:ring-slate-900 bg-white placeholder:text-slate-400'} rounded-lg text-sm text-slate-800 focus:outline-none focus:ring-2 focus:border-transparent transition-colors`} + /> + {errors.email + ?

{errors.email}

+ :

The invitation link will be sent to this address

+ } +
+
+
+
+ + {/* Footer */} +
+

Fields marked * are required

+
+ + + + +
+
+ +
+ {/* ── View Details Dialog ──────────────────────────────────────────── */} !open && setViewInv(null)}> @@ -431,27 +540,63 @@ useEffect(() => { - {/* ── Expire Confirmation Dialog ───────────────────────────────────── */} - !open && setExpireId(null)}> - - - Mark Invitation as Expired - - This will immediately invalidate the invitation link. The invited user will no longer be able to use it to complete registration. - - - - Cancel - expireId && expireInvitation({ id: expireId }, { onSettled: () => setExpireId(null) })} + {/* ── Change Status Dialog ─────────────────────────────────────────── */} + !open && setChangeStatusInv(null)}> + + {/* Header */} +
+ + Change Invitation Status + + Update the status for {changeStatusInv?.email} + + +
+ + {/* Status Options */} +
+ {([ + { value: 'Accepted', label: 'Active', desc: 'Invitation is accepted and account is active', color: 'text-emerald-600', dot: 'bg-emerald-500', ring: 'border-emerald-500 bg-emerald-50' }, + { value: 'Pending', label: 'Pending', desc: 'Awaiting the invitee to complete registration', color: 'text-amber-600', dot: 'bg-amber-500', ring: 'border-amber-500 bg-amber-50' }, + { value: 'Expired', label: 'Expired', desc: 'Invalidates the invitation link immediately', color: 'text-rose-600', dot: 'bg-rose-500', ring: 'border-rose-500 bg-rose-50' }, + ] as const).map(opt => ( + + ))} +
+ + {/* Footer */} +
+ + + + +
+
+
{/* ── Delete Confirmation Dialog ────────────────────────────────────── */} !open && setDeleteId(null)}> @@ -493,10 +638,16 @@ function StatusBadge({ status }: { status: 'Pending' | 'Accepted' | 'Expired' }) Expired: "bg-rose-500", }; + const labels = { + Pending: "Pending", + Accepted: "Active", + Expired: "Expired", + }; + return ( - {status} + {labels[status]} ); } \ No newline at end of file diff --git a/server/src/app/admin/projects/page.tsx b/server/src/app/admin/projects/page.tsx index 439d12ea..00826a5f 100644 --- a/server/src/app/admin/projects/page.tsx +++ b/server/src/app/admin/projects/page.tsx @@ -187,7 +187,7 @@ export default function ProjectsPage() {
{/* Header */}
-

Projects

+

Projects

diff --git a/server/src/app/admin/users/page.tsx b/server/src/app/admin/users/page.tsx index c06bd208..13c0342a 100644 --- a/server/src/app/admin/users/page.tsx +++ b/server/src/app/admin/users/page.tsx @@ -317,7 +317,7 @@ export default function UsersPage() {
-

User Administration

+

User Administration

Manage system users, define their platform roles, and monitor account statuses. diff --git a/server/src/app/api/invitations/route.ts b/server/src/app/api/invitations/route.ts index e83f3c8e..abd300ed 100644 --- a/server/src/app/api/invitations/route.ts +++ b/server/src/app/api/invitations/route.ts @@ -106,6 +106,8 @@ export async function POST(req: NextRequest) { } catch (emailError: any) { // If email fails, delete the created user and invitation console.error("Email failed, rolling back user creation:", emailError); + // Remove project allocations first to avoid FK constraint violations + await prisma.projectAllocation.deleteMany({ where: { studentId: userId } }); await userRepository.delete(userId); await invitationRepository.delete(invitation.id); throw new Error( @@ -237,7 +239,7 @@ export async function GET(req: NextRequest) { } } -// PATCH: Expire an invitation so the link can no longer be used +// PATCH: Update the status of an invitation export async function PATCH(req: NextRequest) { try { const session = await getSession(); @@ -248,18 +250,33 @@ export async function PATCH(req: NextRequest) { return NextResponse.json({ message: "Forbidden" }, { status: 403 }); } - const { id } = await req.json(); + const { id, status } = await req.json(); if (!id) return NextResponse.json({ message: "id is required" }, { status: 400 }); - // Set expiresAt to epoch so the token is permanently invalidated + let updateData: { expiresAt?: Date; accepted?: boolean } = {}; + + switch (status) { + case "Accepted": + updateData = { accepted: true }; + break; + case "Pending": + updateData = { accepted: false, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) }; + break; + case "Expired": + default: + // Set expiresAt to epoch so the token is permanently invalidated + updateData = { accepted: false, expiresAt: new Date(0) }; + break; + } + await prisma.invitation.update({ where: { id }, - data: { expiresAt: new Date(0) }, + data: updateData, }); - return NextResponse.json({ message: "Invitation expired" }); + return NextResponse.json({ message: "Invitation status updated" }); } catch (err) { - console.error("Error expiring invitation:", err); + console.error("Error updating invitation status:", err); return NextResponse.json({ message: "Internal Server Error" }, { status: 500 }); } } diff --git a/server/src/hooks/admin/useInvitation.ts b/server/src/hooks/admin/useInvitation.ts index e90fc56f..d83a7d07 100644 --- a/server/src/hooks/admin/useInvitation.ts +++ b/server/src/hooks/admin/useInvitation.ts @@ -73,7 +73,7 @@ export const useExpireInvitation = () => { const res = await fetch("/api/invitations", { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ id }), + body: JSON.stringify({ id, status: "Expired" }), }); if (!res.ok) { const errorData = await res.json(); @@ -90,6 +90,31 @@ export const useExpireInvitation = () => { }); }; +export const useChangeInvitationStatus = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, status }) => { + const res = await fetch("/api/invitations", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, status }), + }); + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.message || "Failed to update invitation status"); + } + }, + onSuccess: () => { + toast.success("Invitation status updated."); + queryClient.invalidateQueries({ queryKey: ["invitations"] }); + }, + onError: (error) => { + toast.error(error.message || "Failed to update invitation status"); + }, + }); +}; + export const useDeleteInvitation = () => { const queryClient = useQueryClient(); diff --git a/server/tailwind.config.ts b/server/tailwind.config.ts index 3e088fc0..b28b9e23 100644 --- a/server/tailwind.config.ts +++ b/server/tailwind.config.ts @@ -86,6 +86,15 @@ const config = { animation: { 'accordion-down': 'accordion-down 0.2s ease-out', 'accordion-up': 'accordion-up 0.2s ease-out' + }, + fontSize: { + // Admin module heading scale + // page-title : top-level page heading (h1 in the page header bar) + // section-title: card / section headings (h2/h3 inside content areas) + // card-label : small all-caps labels inside cards / table headers + 'page-title': ['1.25rem', { lineHeight: '1.75rem', fontWeight: '600', letterSpacing: '-0.01em' }], + 'section-title': ['0.875rem', { lineHeight: '1.25rem', fontWeight: '600' }], + 'card-label': ['0.6875rem', { lineHeight: '1rem', fontWeight: '700', letterSpacing: '0.05em' }], } } }, From d3c508997b016d60830757498bdce11116f7d197 Mon Sep 17 00:00:00 2001 From: Muditha <89349527+muditha20@users.noreply.github.com> Date: Sat, 21 Mar 2026 13:47:43 +0530 Subject: [PATCH 13/23] update aside bar and cards --- server/.env.example | 2 + server/src/app/admin/projects/page.tsx | 317 ++-- server/src/app/admin/reports/page.tsx | 690 ++++++--- server/src/app/admin/users/page.tsx | 1370 +++++++++-------- server/src/app/api/admin/reports/route.ts | 66 + server/src/app/api/generateReport/route.ts | 38 +- server/src/components/AsideSidebar.tsx | 143 +- .../components/admin-dashboard/StatCard.tsx | 8 +- .../admin-dashboard/SuperAdminDashboard.tsx | 70 +- 9 files changed, 1720 insertions(+), 984 deletions(-) create mode 100644 server/src/app/api/admin/reports/route.ts diff --git a/server/.env.example b/server/.env.example index 85112e52..a7b8a727 100644 --- a/server/.env.example +++ b/server/.env.example @@ -18,6 +18,8 @@ AUDIENCE=http://localhost:3000 NEXT_PUBLIC_BASE_URL=http://localhost:3000 +REDIS_HOST=localhost +REDIS_PORT=6379 # get the email pass key from https://myaccount.google.com/apppasswords EMAIL_USER= "sample@gmail.com" EMAIL_PASS= "aaaa bbbb cccc dddd" \ No newline at end of file diff --git a/server/src/app/admin/projects/page.tsx b/server/src/app/admin/projects/page.tsx index 439d12ea..e9eacefa 100644 --- a/server/src/app/admin/projects/page.tsx +++ b/server/src/app/admin/projects/page.tsx @@ -2,6 +2,16 @@ import { useEffect, useState } from "react"; +import type { + AdminProject, + AdminProjectStats, +} from "@/server_actions/adminProjectActions"; +import { + createProject, + deleteProject, + getAdminProjectStats, + updateProject, +} from "@/server_actions/adminProjectActions"; import { Beaker, BookOpen, @@ -15,14 +25,6 @@ import { Users, } from "lucide-react"; -import type { AdminProject, AdminProjectStats } from "@/server_actions/adminProjectActions"; -import { - createProject, - deleteProject, - getAdminProjectStats, - updateProject, -} from "@/server_actions/adminProjectActions"; - import { AlertDialog, AlertDialogAction, @@ -70,6 +72,7 @@ import { TableRow, } from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; +import AsideSidebar from "@/components/AsideSidebar"; import { DropdownMenu, DropdownMenuContent, @@ -77,7 +80,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/dropdown-menu"; -import AsideSidebar from "@/components/AsideSidebar"; const DOMAIN_ICONS: Record = { software: , @@ -124,18 +126,29 @@ export default function ProjectsPage() { .finally(() => setLoading(false)); }; - useEffect(() => { reload(); }, []); + useEffect(() => { + reload(); + }, []); function openEdit(project: AdminProject) { setEditProject(project); - setEditForm({ name: project.name, description: project.description, domain: project.domain }); + setEditForm({ + name: project.name, + description: project.description, + domain: project.domain, + }); } async function handleEditSave() { if (!editProject) return; setEditSaving(true); try { - await updateProject(editProject.id, editForm.name, editForm.description, editForm.domain); + await updateProject( + editProject.id, + editForm.name, + editForm.description, + editForm.domain, + ); setEditProject(null); reload(); } finally { @@ -146,7 +159,11 @@ export default function ProjectsPage() { async function handleCreateSave() { setCreateSaving(true); try { - await createProject(createForm.name, createForm.description, createForm.domain); + await createProject( + createForm.name, + createForm.description, + createForm.domain, + ); setShowCreate(false); setCreateForm(EMPTY_FORM); reload(); @@ -185,51 +202,61 @@ export default function ProjectsPage() {

- {/* Header */} -
-

Projects

- -
+ {/* Header + Stat Cards */} + +
+

Projects

+ +
- {/* Stat Cards */} -
- -
-

Total Projects

-

- {loading ? "—" : stats?.totalProjects ?? 0} -

+
+
+
+

Total Projects

+

+ {loading ? "—" : (stats?.totalProjects ?? 0)} +

+
+
- - - -
-

Total Mentors

-

- {loading ? "—" : stats?.totalMentors ?? 0} -

+
+
+

Total Mentors

+

+ {loading ? "—" : (stats?.totalMentors ?? 0)} +

+
+
- - - -
-

Total Students

-

- {loading ? "—" : stats?.totalStudents ?? 0} -

+
+
+

Total Students

+

+ {loading ? "—" : (stats?.totalStudents ?? 0)} +

+
+
- - -
+
+
{/* Filters */}
- { + setDomainFilter(v); + setPage(1); + }} + > @@ -263,24 +290,46 @@ export default function ProjectsPage() { - Project Name - Domain - Mentors - Students - Created By - Created Date + + Project Name + + + Domain + + + Mentors + + + Students + + + Created By + + + Created Date + {loading && ( - Loading projects… + + Loading projects… + )} {!loading && displayedProjects.length === 0 && ( - No projects found. + + No projects found. + )} {displayedProjects.map((project) => ( @@ -288,9 +337,13 @@ export default function ProjectsPage() {
- {DOMAIN_ICONS[project.domain] ?? } + {DOMAIN_ICONS[project.domain] ?? ( + + )}
- {project.name} + + {project.name} +
@@ -298,20 +351,38 @@ export default function ProjectsPage() { {DOMAIN_LABELS[project.domain] ?? project.domain} - {project.mentors} - {project.students} - {project.createdBy} - {project.createdDate} + + {project.mentors} + + + {project.students} + + + {project.createdBy} + + + {project.createdDate} + - - setViewProject(project)}>View Details - openEdit(project)}>Edit Project + setViewProject(project)} + > + View Details + + openEdit(project)}> + Edit Project + Showing{" "} - {filtered.length === 0 ? 0 : (currentPage - 1) * ITEMS_PER_PAGE + 1} + {filtered.length === 0 + ? 0 + : (currentPage - 1) * ITEMS_PER_PAGE + 1} {" "} to{" "} @@ -353,7 +426,10 @@ export default function ProjectsPage() { onClick={() => setPage((p) => Math.max(1, p - 1))} /> - {Array.from({ length: Math.min(totalPages, 5) }, (_, i) => i + 1).map((p) => ( + {Array.from( + { length: Math.min(totalPages, 5) }, + (_, i) => i + 1, + ).map((p) => ( {/* ── View Details Dialog ──────────────────────────────────────────── */} - !open && setViewProject(null)}> + !open && setViewProject(null)} + > Project Details @@ -389,38 +468,58 @@ export default function ProjectsPage() {
- {DOMAIN_ICONS[viewProject.domain] ?? } + {DOMAIN_ICONS[viewProject.domain] ?? ( + + )}
-

{viewProject.name}

+

+ {viewProject.name} +

{viewProject.description && (
-

Description

+

+ Description +

{viewProject.description}

)}
-

Domain

+

+ Domain +

{DOMAIN_LABELS[viewProject.domain] ?? viewProject.domain}
-

Created Date

+

+ Created Date +

{viewProject.createdDate}

-

Mentors

-

{viewProject.mentors}

+

+ Mentors +

+

+ {viewProject.mentors} +

-

Students

-

{viewProject.students}

+

+ Students +

+

+ {viewProject.students} +

-

Created By

+

+ Created By +

{viewProject.createdBy}

@@ -434,7 +533,10 @@ export default function ProjectsPage() {
{/* ── Edit Project Dialog ───────────────────────────────────────────── */} - !open && setEditProject(null)}> + !open && setEditProject(null)} + > Edit Project @@ -445,7 +547,9 @@ export default function ProjectsPage() { setEditForm((f) => ({ ...f, name: e.target.value }))} + onChange={(e) => + setEditForm((f) => ({ ...f, name: e.target.value })) + } placeholder="Enter project name" /> @@ -454,14 +558,19 @@ export default function ProjectsPage() {