From a81acedbfaabb863c020c75518c9eb8257afbc40 Mon Sep 17 00:00:00 2001 From: arshvinw5 Date: Thu, 19 Mar 2026 16:03:45 +0530 Subject: [PATCH 01/18] 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 02/18] 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 9a48bee5a9e14ed11e7266f1bd7c7175d688a554 Mon Sep 17 00:00:00 2001 From: arshvinw5 Date: Fri, 20 Mar 2026 10:32:56 +0530 Subject: [PATCH 03/18] 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 a0c7de8d1e94d14821e626fa2041b72aace0ba41 Mon Sep 17 00:00:00 2001 From: arshvinw5 Date: Sat, 21 Mar 2026 14:16:18 +0530 Subject: [PATCH 04/18] redesgin create account page and refactor code with custom hook for post api --- .../app/api/complete-registration/route.ts | 11 +- server/src/app/create-account/page.tsx | 382 +++++++++++++++--- server/src/middleware.ts | 12 +- server/src/middlewares/authMiddleware.ts | 68 ++-- 4 files changed, 376 insertions(+), 97 deletions(-) diff --git a/server/src/app/api/complete-registration/route.ts b/server/src/app/api/complete-registration/route.ts index 40268895..a5871361 100644 --- a/server/src/app/api/complete-registration/route.ts +++ b/server/src/app/api/complete-registration/route.ts @@ -1,7 +1,7 @@ -import { NextResponse } from "next/server"; -import {UserRepository} from "@/repositories/user_repository_impl"; import { InvitationRepository } from "@/repositories/invitation_repository_impl"; +import { UserRepository } from "@/repositories/user_repository_impl"; import bcrypt from "bcrypt"; +import { NextResponse } from "next/server"; export async function POST(req: Request) { try { @@ -21,10 +21,7 @@ export async function POST(req: Request) { // Verify temporary password against the user's current passwordHash const user = await userRepo.getByEmail(email); if (!user || !user.passwordHash) - return NextResponse.json( - { error: "User not found" }, - { status: 404 }, - ); + return NextResponse.json({ error: "User not found" }, { status: 404 }); const isValid = await bcrypt.compare(tempPassword, user.passwordHash); if (!isValid) @@ -53,4 +50,4 @@ export async function POST(req: Request) { { status: 500 }, ); } -} \ No newline at end of file +} diff --git a/server/src/app/create-account/page.tsx b/server/src/app/create-account/page.tsx index bc609459..6867b51f 100644 --- a/server/src/app/create-account/page.tsx +++ b/server/src/app/create-account/page.tsx @@ -1,14 +1,29 @@ "use client"; -import { useState } from "react"; +import React, { useState } from "react"; -import { useSearchParams } from "next/navigation"; -import { toast } from "react-hot-toast"; +import { Eye, EyeOff, Loader2, Lock, Mail } from "lucide-react"; +import Image from "next/image"; +import { useRouter, useSearchParams } from "next/navigation"; import { useCompleteRegistration } from "@/hooks/admin/useCompleteRegistration"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "@/components/ui/toast"; + +type ToastVariant = "default" | "destructive"; export default function CreateAccount() { const searchParams = useSearchParams(); + const router = useRouter(); const token = searchParams.get("token") || ""; const email = searchParams.get("email") || ""; @@ -17,76 +32,331 @@ export default function CreateAccount() { const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); + const [toastData, setToastData] = useState({ + open: false, + title: "", + description: "", + variant: "default" as ToastVariant, + }); const { mutate: completeRegistration, isPending } = useCompleteRegistration(); - const handleRegister = async () => { + const handleRegister = (e: React.FormEvent) => { + e.preventDefault(); + if (!tempPassword || !newPassword || !confirmPassword) { - toast.error("All fields are required"); + setToastData({ + open: true, + title: "Missing Fields", + description: "All fields are required to proceed.", + variant: "destructive", + }); return; } if (newPassword !== confirmPassword) { - toast.error("Passwords do not match"); + setToastData({ + open: true, + title: "Password Mismatch", + description: "Your new password and confirmation must match.", + variant: "destructive", + }); + return; + } + + if (newPassword.length < 8) { + setToastData({ + open: true, + title: "Weak Password", + description: "Password must be at least 8 characters long.", + variant: "destructive", + }); return; } - completeRegistration({ email, token, tempPassword, newPassword }); + completeRegistration( + { email, token, tempPassword, newPassword }, + { + onSuccess: () => { + setToastData({ + open: true, + title: "Account Created", + description: + "Your account has been successfully created. Redirecting...", + variant: "default", + }); + setTimeout(() => { + router.push("/login"); + }, 1000); + }, + onError: (error: any) => { + setToastData({ + open: true, + title: "Registration Failed", + description: + error?.message || "An error occurred during registration.", + variant: "destructive", + }); + }, + }, + ); }; return ( -
-
-

- Create Your Account -

- - - - setTempPassword(e.target.value)} - /> - - setNewPassword(e.target.value)} - /> - - setConfirmPassword(e.target.value)} - /> - -
- setShowPassword(!showPassword)} + +
+ {/* Visual Brand Panel (Desktop) */} +
+ Product Visual - Show Password +
+ +
+
+
+ Logo +
+ + Logbook Pro + +
+
+ +
+

+ Welcome to
+ + the Network + {" "} +
+ Initialize Now. +

+

+ Set up your secure credentials and join a community of precision + record keepers. +

+
+ +
+ © 2024 T2T ANALYTICS + + SECURE SETUP +
- + {/* Auth Interface */} +
+
+
+
+ Logo +
+

+ LOGBOOK PRO +

+
+ +
+

+ CREATE ACCOUNT +

+

+ Set up your credentials to access the logbook network. +

+
+ +
+
+ {/* Email Field (Disabled) */} +
+ +
+ + +
+
+ + {/* Temporary Password */} +
+ +
+ + setTempPassword(e.target.value)} + disabled={isPending} + required + className="h-14 pl-12 pr-12 bg-zinc-50 dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 focus:ring-4 focus:ring-blue-500/10 transition-all text-base font-semibold rounded-xl" + /> + +
+
+ + {/* New Password */} +
+ +
+ + setNewPassword(e.target.value)} + disabled={isPending} + required + className="h-14 pl-12 pr-12 bg-zinc-50 dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 focus:ring-4 focus:ring-blue-500/10 transition-all text-base font-semibold rounded-xl" + /> + +
+
+ + {/* Confirm Password */} +
+ +
+ + setConfirmPassword(e.target.value)} + disabled={isPending} + required + className="h-14 pl-12 pr-12 bg-zinc-50 dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 focus:ring-4 focus:ring-blue-500/10 transition-all text-base font-semibold rounded-xl" + /> + +
+
+
+ + +
+ +
+ Already have credentials?{" "} + +
+
+
-
+ + {toastData.open && ( + +
+ + {toastData.title} + + + {toastData.description} + +
+ +
+ )} + + +
); } diff --git a/server/src/middleware.ts b/server/src/middleware.ts index bc7f6d73..6a52626d 100644 --- a/server/src/middleware.ts +++ b/server/src/middleware.ts @@ -1,11 +1,9 @@ -import { chain } from "./middlewares/chain"; import { withAuthMiddleware } from "./middlewares/authMiddleware"; +import { chain } from "./middlewares/chain"; import { withRoleBasedRoutingMiddleware } from "./middlewares/routingMiddleware"; -export default chain([withAuthMiddleware, withRoleBasedRoutingMiddleware]) +export default chain([withAuthMiddleware, withRoleBasedRoutingMiddleware]); -export const config = { - matcher: [ - '/((?!login|api/|_next/static|_next/image|favicon.ico).*)', - ], -} \ No newline at end of file +export const config = { + matcher: ["/((?!login|api/|_next/static|_next/image|favicon.ico).*)"], +}; diff --git a/server/src/middlewares/authMiddleware.ts b/server/src/middlewares/authMiddleware.ts index 7b8aa707..c1d5c1bc 100644 --- a/server/src/middlewares/authMiddleware.ts +++ b/server/src/middlewares/authMiddleware.ts @@ -1,34 +1,48 @@ -import * as jose from 'jose'; -import { NextFetchEvent, NextMiddleware, NextRequest, NextResponse } from "next/server"; +import * as jose from "jose"; +import { + NextFetchEvent, + NextMiddleware, + NextRequest, + NextResponse, +} from "next/server"; export function withAuthMiddleware(middleware: NextMiddleware): NextMiddleware { - return async (request: NextRequest, event: NextFetchEvent) => { - - let token = request.cookies.get("token"); - - if (!token) { - return NextResponse.redirect(`${process.env.BASE_URL}/login`); - } - - try { - const secret = new TextEncoder().encode(process.env.JWT_SECRET!); - const {payload, protectedHeader} = await jose.jwtVerify(token.value, secret); + return async (request: NextRequest, event: NextFetchEvent) => { + let token = request.cookies.get("token"); - } catch (error) { - - console.error(error); - - const headers = new Headers(request.headers); + if (!token) { + return NextResponse.redirect(`${process.env.BASE_URL}/login`); + } + + try { + const secret = new TextEncoder().encode(process.env.JWT_SECRET!); + await jose.jwtVerify(token.value, secret); + } catch (error) { + console.error(error); - if ((error as any).code == "ERR_JWT_EXPIRED") { - headers.set("Set-Cookie", "redirect_error=Session expired. Please login again.; Path=/login;"); - } + const headers = new Headers(request.headers); - headers.set("Set-Cookie", "redirect_error=An error occurred while logging you in.; Path=/login;"); - headers.append("Set-Cookie", `token=${token}; expires=Thu, 01 Jan 1970 00:00:00 UTC; Path=/; HttpOnly`); - return NextResponse.redirect(`${process.env.BASE_URL}/login`, { status: 303, headers: headers }); - } + if ((error as any).code == "ERR_JWT_EXPIRED") { + headers.set( + "Set-Cookie", + "redirect_error=Session expired. Please login again.; Path=/login;", + ); + } - return middleware(request, event); + headers.set( + "Set-Cookie", + "redirect_error=An error occurred while logging you in.; Path=/login;", + ); + headers.append( + "Set-Cookie", + `token=${token}; expires=Thu, 01 Jan 1970 00:00:00 UTC; Path=/; HttpOnly`, + ); + return NextResponse.redirect(`${process.env.BASE_URL}/login`, { + status: 303, + headers: headers, + }); } -} \ No newline at end of file + + return middleware(request, event); + }; +} From a782760aee63c0db5a49d0ca1fe7cb7350d8ba7c Mon Sep 17 00:00:00 2001 From: ruchiralak Date: Sat, 21 Mar 2026 16:22:19 +0530 Subject: [PATCH 05/18] Refactor the Invitation code base --- server/src/app/admin/invitation/page.tsx | 701 +++--------------- .../Invitations/InvitationStats.tsx | 34 + .../Invitations/InvitationsPagination.tsx | 34 + .../Invitations/InvitationsTable.tsx | 76 ++ .../components/Invitations/StatusBadge.tsx | 24 + .../dialogs/ChangeStatusDialog.tsx | 67 ++ .../dialogs/CreateInvitationDialog.tsx | 149 ++++ .../Invitations/dialogs/DeleteDialog.tsx | 31 + .../dialogs/viewInvitationDialog.tsx | 57 ++ 9 files changed, 585 insertions(+), 588 deletions(-) create mode 100644 server/src/components/Invitations/InvitationStats.tsx create mode 100644 server/src/components/Invitations/InvitationsPagination.tsx create mode 100644 server/src/components/Invitations/InvitationsTable.tsx create mode 100644 server/src/components/Invitations/StatusBadge.tsx create mode 100644 server/src/components/Invitations/dialogs/ChangeStatusDialog.tsx create mode 100644 server/src/components/Invitations/dialogs/CreateInvitationDialog.tsx create mode 100644 server/src/components/Invitations/dialogs/DeleteDialog.tsx create mode 100644 server/src/components/Invitations/dialogs/viewInvitationDialog.tsx diff --git a/server/src/app/admin/invitation/page.tsx b/server/src/app/admin/invitation/page.tsx index abf80bc6..6a5ea494 100644 --- a/server/src/app/admin/invitation/page.tsx +++ b/server/src/app/admin/invitation/page.tsx @@ -4,58 +4,21 @@ import React, { useEffect, useState } from 'react'; import AsideSidebar from "@/components/AsideSidebar"; import { useInvitation, useRecentInvitations, useChangeInvitationStatus, useDeleteInvitation } from "@/hooks/admin/useInvitation"; import { z } from 'zod'; -import { - UserPlus, - Send, - MoreVertical, - ChevronDown, - Plus, - Users, - Clock, - CheckCircle2, - XCircle -} from 'lucide-react'; import { Project } from '@prisma/client'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/dropdown-menu"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { - Pagination, - PaginationContent, - PaginationEllipsis, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, -} from "@/components/ui/pagination"; +import { Plus } from 'lucide-react'; +import InvitationsStats from '@/components/Invitations/InvitationStats' +import InvitationsTable, { InvitationRow } from '@/components/Invitations/InvitationsTable' +import InvitationsPagination from '@/components/Invitations/InvitationsPagination' +import CreateInvitationDialog from '@/components/Invitations/dialogs/CreateInvitationDialog' +import ViewInvitationDialog from '@/components/Invitations/dialogs/viewInvitationDialog' +import ChangeStatusDialog from '@/components/Invitations/dialogs/ChangeStatusDialog' +import DeleteInvitationDialog from '@/components/Invitations/dialogs/DeleteDialog' + +// Constants const ITEMS_PER_PAGE = 10; -// Define our Zod schema for the invite form +// Zod Schema const inviteSchema = z.object({ role: z.string().min(1, { message: "Role is required" }), firstName: z.string().min(2, { message: "Must be at least 2 characters" }), @@ -64,103 +27,72 @@ const inviteSchema = z.object({ project: z.string().min(1, { message: "Project is required" }), }); -interface InvitationRow { - id: string; - email: string; - role: string; - project: string; - status: 'Pending' | 'Accepted' | 'Expired'; - createdAt: string; - expiresAt: string; -} - -//Invitation View export default function InvitationsView() { + // State const [projects, setProjects] = useState([]); - const { mutate: sendInvitation, isPending } = useInvitation(); - const { data: recentInvitations, isLoading: isInvLoading, isError: isInvError } = useRecentInvitations(); - const { mutate: changeStatus, isPending: isChangingStatus } = useChangeInvitationStatus(); - const { mutate: deleteInvitation, isPending: isDeleting } = useDeleteInvitation(); - - const [formData, setFormData] = useState({ - role: "", - firstName: "", - lastName: "", - email: "", - project: "" - }); + const [formData, setFormData] = useState({ role: "", firstName: "", lastName: "", email: "", project: "" }); const [errors, setErrors] = useState>({}); - - // Pagination const [page, setPage] = useState(1); - // Modals + // Dialogs const [createOpen, setCreateOpen] = useState(false); const [viewInv, setViewInv] = useState(null); const [changeStatusInv, setChangeStatusInv] = useState(null); const [selectedStatus, setSelectedStatus] = useState<'Accepted' | 'Pending' | 'Expired'>('Pending'); const [deleteId, setDeleteId] = useState(null); + // Hooks + const { mutate: sendInvitation, isPending } = useInvitation(); + const { data: recentInvitations, isLoading: isInvLoading, isError: isInvError } = useRecentInvitations(); + const { mutate: changeStatus, isPending: isChangingStatus } = useChangeInvitationStatus(); + const { mutate: deleteInvitation, isPending: isDeleting } = useDeleteInvitation(); + + // Fetch Projects + useEffect(() => { + const fetchProjects = async () => { + try { + const res = await fetch("/api/project", { cache: "no-store" }); + const data = await res.json(); + setProjects(data); + } catch (err) { + console.error(err); + } + }; + fetchProjects(); + }, []); + + // Form handling const handleInputChange = (field: string, value: string) => { setFormData(prev => ({ ...prev, [field]: value })); - if (errors[field]) { - setErrors(prev => ({ ...prev, [field]: "" })); - } + if (errors[field]) setErrors(prev => ({ ...prev, [field]: "" })); }; const handleSubmit = () => { - const result = inviteSchema.safeParse(formData); - - if (!result.success) { - const fieldErrors: Record = {}; - result.error.issues.forEach(issue => { - if (issue.path[0]) { - fieldErrors[issue.path[0].toString()] = issue.message; - } - }); - setErrors(fieldErrors); - return; - } - - setErrors({}); - - // Invitation Hook - sendInvitation({ - email: formData.email, - firstName: formData.firstName, - lastName: formData.lastName, - role: formData.role as "student" | "mentor" | "superAdmin", - projectId: formData.project, - }, { - onSuccess: () => { - // reset form after success - setFormData({ - role: "", - firstName: "", - lastName: "", - email: "", - project: "", + const result = inviteSchema.safeParse(formData); + if (!result.success) { + const fieldErrors: Record = {}; + result.error.issues.forEach(issue => { + if (issue.path[0]) fieldErrors[issue.path[0].toString()] = issue.message; }); - setCreateOpen(false); - } - }); -}; - -//Fetch projects -useEffect(() => { - const fetchProjects = async () => { - try { - const res = await fetch("/api/project", { cache: "no-store" }); - const data = await res.json(); - setProjects(data); - } catch (err) { - console.error(err); + setErrors(fieldErrors); + return; } + setErrors({}); + sendInvitation({ + email: formData.email, + firstName: formData.firstName, + lastName: formData.lastName, + role: formData.role as "student" | "mentor" | "superAdmin", + projectId: formData.project, + }, { + onSuccess: () => { + setFormData({ role: "", firstName: "", lastName: "", email: "", project: "" }); + setCreateOpen(false); + } + }); }; - fetchProjects(); -}, []); - + // Invitations list const invList: InvitationRow[] = (recentInvitations as InvitationRow[] | undefined) ?? []; const totalInvitations = invList.length; const pendingCount = invList.filter(i => i.status === 'Pending').length; @@ -172,482 +104,75 @@ useEffect(() => { return ( <> -
- -
+
+ +
- {/* Page Header */} -
-
+ {/* Header */} +

Invitations

Manage organizational access and track member onboarding.

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

Total

-
- -
-
-

{isInvLoading ? '—' : totalInvitations}

-
-
-
-

Pending

-
- -
-
-

{isInvLoading ? '—' : pendingCount}

-
-
-
-

Accepted

-
- -
-
-

{isInvLoading ? '—' : acceptedCount}

-
-
-
-

Expired

-
- -
-
-

{isInvLoading ? '—' : expiredCount}

-
-
- - {/* Invitations Table */} -
- {/* Table Toolbar */} -
-
-

All Invitations

- {!isInvLoading &&

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

} -
-
-
- - - - - - - - - - - - {isInvLoading && ( - - )} - {isInvError && ( - - )} - {!isInvLoading && !isInvError && pagedInvitations.length === 0 && ( - - )} - {pagedInvitations.map((inv: InvitationRow) => ( - - - - - - - - ))} - -
Email AddressRoleProjectStatusActions
Loading...
Failed to load invitations
No invitations found.
{inv.email} - - {inv.role} - - {inv.project} - - - - - - setViewInv(inv)}> - View Details - - { setChangeStatusInv(inv); setSelectedStatus(inv.status); }}> - Change Status - - - setDeleteId(inv.id)} - > - Delete - - - -
-
- {/* Pagination */} -
-

- Showing{" "} - - {totalInvitations === 0 ? 0 : (currentPage - 1) * ITEMS_PER_PAGE + 1} - {" "}to{" "} - - {Math.min(currentPage * ITEMS_PER_PAGE, totalInvitations)} - {" "}of{" "} - {totalInvitations} results -

- - - - setPage(p => Math.max(1, p - 1))} /> - - {Array.from({ length: Math.min(totalPages, 5) }, (_, i) => i + 1).map(p => ( - - setPage(p)}>{p} - - ))} - {totalPages > 5 && } - - setPage(p => Math.min(totalPages, p + 1))} /> - - - -
+
+ {/* Stats */} + + + {/* Table */} + { setChangeStatusInv(inv); setSelectedStatus(inv.status); }} + onDelete={setDeleteId} + /> + + {/* Pagination */} +
-
- - {/* ── 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)}> - - - Invitation Details - - {viewInv != null && ( -
-
-
-

Email

-

{viewInv!.email}

-
-
-

Role

- {viewInv!.role} -
-
-

Project

-

{viewInv!.project}

-
-
-

Status

- -
-
-

Sent On

-

{new Date(viewInv!.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}

-
-
-

Expires At

-

{new Date(viewInv!.expiresAt) <= new Date(1000) ? 'Manually expired' : new Date(viewInv!.expiresAt).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' })}

-
-
-
+ {/* Dialogs */} + + setViewInv(null)} /> + changeStatusInv && changeStatus( + { id: changeStatusInv.id, status: selectedStatus }, + { onSettled: () => setChangeStatusInv(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)}> - - - Delete Invitation - - This will permanently delete the invitation and remove the associated user account along with all their data — including project allocations, activities, and feedback. This action cannot be undone. - - - - Cancel - deleteId && deleteInvitation({ id: deleteId }, { onSettled: () => setDeleteId(null) })} - > - {isDeleting ? 'Deleting…' : 'Delete'} - - - - + isPending={isChangingStatus} + onClose={() => setChangeStatusInv(null)} + /> + !open && setDeleteId(null)} + onDelete={() => deleteId && deleteInvitation({ id: deleteId }, { onSettled: () => setDeleteId(null) })} + isDeleting={isDeleting} + /> ); -} - -/* --- Subcomponents --- */ - -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", - }; - - const labels = { - Pending: "Pending", - Accepted: "Active", - Expired: "Expired", - }; - - return ( - - - {labels[status]} - - ); } \ No newline at end of file diff --git a/server/src/components/Invitations/InvitationStats.tsx b/server/src/components/Invitations/InvitationStats.tsx new file mode 100644 index 00000000..28bbd56f --- /dev/null +++ b/server/src/components/Invitations/InvitationStats.tsx @@ -0,0 +1,34 @@ +import { Users, Clock, CheckCircle2, XCircle } from 'lucide-react'; + +interface InvitationsStatsProps { + loading: boolean; + total: number; + pending: number; + accepted: number; + expired: number; +} + +export default function InvitationsStats({ loading, total, pending, accepted, expired }: InvitationsStatsProps) { + const stats = [ + { label: 'Total', value: total, icon: Users, color: 'text-slate-500', bg: 'bg-slate-100' }, + { label: 'Pending', value: pending, icon: Clock, color: 'text-amber-500', bg: 'bg-amber-50' }, + { label: 'Accepted', value: accepted, icon: CheckCircle2, color: 'text-emerald-500', bg: 'bg-emerald-50' }, + { label: 'Expired', value: expired, icon: XCircle, color: 'text-rose-500', bg: 'bg-rose-50' }, + ]; + + return ( +
+ {stats.map((stat) => ( +
+
+

{stat.label}

+
+ +
+
+

{loading ? '—' : stat.value}

+
+ ))} +
+ ); +} \ No newline at end of file diff --git a/server/src/components/Invitations/InvitationsPagination.tsx b/server/src/components/Invitations/InvitationsPagination.tsx new file mode 100644 index 00000000..e72e9751 --- /dev/null +++ b/server/src/components/Invitations/InvitationsPagination.tsx @@ -0,0 +1,34 @@ +import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, PaginationEllipsis } from '@/components/ui/pagination'; + +interface InvitationsPaginationProps { + page: number; + totalPages: number; + total: number; + onPageChange: (page: number) => void; +} + +export default function InvitationsPagination({ page, totalPages, total, onPageChange }: InvitationsPaginationProps) { + return ( +
+

+ Showing {total === 0 ? 0 : (page - 1) * 10 + 1} to {Math.min(page * 10, total)} of {total} results +

+ + + + onPageChange(Math.max(1, page - 1))} /> + + {Array.from({ length: Math.min(totalPages, 5) }, (_, i) => i + 1).map((p) => ( + + onPageChange(p)}>{p} + + ))} + {totalPages > 5 && } + + onPageChange(Math.min(totalPages, page + 1))} /> + + + +
+ ); +} \ No newline at end of file diff --git a/server/src/components/Invitations/InvitationsTable.tsx b/server/src/components/Invitations/InvitationsTable.tsx new file mode 100644 index 00000000..d754f7ac --- /dev/null +++ b/server/src/components/Invitations/InvitationsTable.tsx @@ -0,0 +1,76 @@ +import { MoreVertical } from 'lucide-react'; +import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from '@/components/dropdown-menu'; +import StatusBadge from './StatusBadge'; + +export interface InvitationRow { + id: string; + email: string; + role: string; + project: string; + status: 'Pending' | 'Accepted' | 'Expired'; + createdAt: string; + expiresAt: string; +} + +interface InvitationsTableProps { + data: InvitationRow[]; + loading: boolean; + error: boolean; + onView: (inv: InvitationRow) => void; + onChangeStatus: (inv: InvitationRow) => void; + onDelete: (id: string) => void; +} + +export default function InvitationsTable({ data, loading, error, onView, onChangeStatus, onDelete }: InvitationsTableProps) { + return ( +
+
+ + + + + + + + + + + + {loading && } + {error && } + {!loading && !error && data.length === 0 && ( + + )} + {!loading && data.map((inv) => ( + + + + + + + + ))} + +
EmailRoleProjectStatusActions
Loading...
Failed to load invitations
No invitations found.
{inv.email} + + {inv.role} + + {inv.project} + + + + + + onView(inv)}>View Details + onChangeStatus(inv)}>Change Status + + onDelete(inv.id)}>Delete + + +
+
+
+ ); +} \ No newline at end of file diff --git a/server/src/components/Invitations/StatusBadge.tsx b/server/src/components/Invitations/StatusBadge.tsx new file mode 100644 index 00000000..137ed8ad --- /dev/null +++ b/server/src/components/Invitations/StatusBadge.tsx @@ -0,0 +1,24 @@ +export default 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", + }; + const labels = { + Pending: "Pending", + Accepted: "Active", + Expired: "Expired", + }; + + return ( + + + {labels[status]} + + ); +} \ No newline at end of file diff --git a/server/src/components/Invitations/dialogs/ChangeStatusDialog.tsx b/server/src/components/Invitations/dialogs/ChangeStatusDialog.tsx new file mode 100644 index 00000000..19d1c7dc --- /dev/null +++ b/server/src/components/Invitations/dialogs/ChangeStatusDialog.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogClose } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import InvitationsTable,{InvitationRow} from '../InvitationsTable' + +interface ChangeStatusDialogProps { + invitation: InvitationRow | null; + selectedStatus: 'Pending' | 'Accepted' | 'Expired'; + onChangeStatus: (status: 'Pending' | 'Accepted' | 'Expired') => void; + onSave: () => void; + isPending: boolean; + onClose: () => void; +} + +export default function ChangeStatusDialog({ invitation, selectedStatus, onChangeStatus, onSave, isPending, onClose }: ChangeStatusDialogProps) { + if (!invitation) return null; + + const 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; + + return ( + onClose()}> + +
+ + Change Invitation Status + + Update the status for {invitation.email} + + +
+ +
+ {options.map(opt => ( + + ))} +
+ +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/server/src/components/Invitations/dialogs/CreateInvitationDialog.tsx b/server/src/components/Invitations/dialogs/CreateInvitationDialog.tsx new file mode 100644 index 00000000..d659c362 --- /dev/null +++ b/server/src/components/Invitations/dialogs/CreateInvitationDialog.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogClose } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { ChevronDown, UserPlus, Send } from 'lucide-react'; +import { Project } from '@prisma/client'; + +interface CreateInvitationDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + formData: any; + errors: Record; + projects: Project[]; + isPending: boolean; + onChange: (field: string, value: string) => void; + onSubmit: () => void; +} + +export default function CreateInvitationDialog({ + open, onOpenChange, formData, errors, projects, isPending, onChange, onSubmit +}: CreateInvitationDialogProps) { + + return ( + + + {/* Header */} +
+ + +
+ +
+ Create Invitation +
+ + Send an invitation email to onboard a new member to your organization. + +
+
+ + {/* Form Body */} +
+ {/* 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

} +
+
+
+ + {/* Recipient Details */} +
+

Recipient Details

+
+
+ + onChange('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`} + /> + {errors.firstName &&

{errors.firstName}

} +
+
+ + onChange('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`} + /> + {errors.lastName &&

{errors.lastName}

} +
+
+ + {/* Email */} +
+ + onChange('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`} + /> + {errors.email ?

{errors.email}

:

The invitation link will be sent to this address

} +
+
+
+ + {/* Footer */} +
+ + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/server/src/components/Invitations/dialogs/DeleteDialog.tsx b/server/src/components/Invitations/dialogs/DeleteDialog.tsx new file mode 100644 index 00000000..f514fd92 --- /dev/null +++ b/server/src/components/Invitations/dialogs/DeleteDialog.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction } from '@/components/ui/alert-dialog'; + +interface DeleteInvitationDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onDelete: () => void; + isDeleting: boolean; +} + +export default function DeleteInvitationDialog({ open, onOpenChange, onDelete, isDeleting }: DeleteInvitationDialogProps) { + return ( + + + + Delete Invitation + + This will permanently delete the invitation and remove the associated user account along with all their data — including project allocations, activities, and feedback. This action cannot be undone. + + + + Cancel + + {isDeleting ? 'Deleting…' : 'Delete'} + + + + + ); +} \ No newline at end of file diff --git a/server/src/components/Invitations/dialogs/viewInvitationDialog.tsx b/server/src/components/Invitations/dialogs/viewInvitationDialog.tsx new file mode 100644 index 00000000..6863389d --- /dev/null +++ b/server/src/components/Invitations/dialogs/viewInvitationDialog.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import StatusBadge, { InvitationRow } from '../StatusBadge'; + +interface ViewInvitationDialogProps { + invitation: InvitationRow | null; + onClose: () => void; +} + +export default function ViewInvitationDialog({ invitation, onClose }: ViewInvitationDialogProps) { + if (!invitation) return null; + + return ( + onClose()}> + + + Invitation Details + +
+
+
+

Email

+

{invitation.email}

+
+
+

Role

+ + {invitation.role} + +
+
+

Project

+

{invitation.project}

+
+
+

Status

+ +
+
+

Sent On

+

{new Date(invitation.createdAt).toLocaleDateString()}

+
+
+

Expires At

+

{new Date(invitation.expiresAt).toLocaleString()}

+
+
+
+ + + +
+
+ ); +} \ No newline at end of file From cfda14749833abdd04fd1d2bc014db82b9727fa1 Mon Sep 17 00:00:00 2001 From: ruchiralak Date: Sat, 21 Mar 2026 16:50:01 +0530 Subject: [PATCH 06/18] Fix lint error --- .../src/components/Invitations/dialogs/ChangeStatusDialog.tsx | 2 +- .../components/Invitations/dialogs/CreateInvitationDialog.tsx | 2 +- .../components/Invitations/dialogs/viewInvitationDialog.tsx | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/server/src/components/Invitations/dialogs/ChangeStatusDialog.tsx b/server/src/components/Invitations/dialogs/ChangeStatusDialog.tsx index 19d1c7dc..4b3593a6 100644 --- a/server/src/components/Invitations/dialogs/ChangeStatusDialog.tsx +++ b/server/src/components/Invitations/dialogs/ChangeStatusDialog.tsx @@ -2,7 +2,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogClose } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; -import InvitationsTable,{InvitationRow} from '../InvitationsTable' +import {InvitationRow} from '../InvitationsTable' interface ChangeStatusDialogProps { invitation: InvitationRow | null; diff --git a/server/src/components/Invitations/dialogs/CreateInvitationDialog.tsx b/server/src/components/Invitations/dialogs/CreateInvitationDialog.tsx index d659c362..75283fbb 100644 --- a/server/src/components/Invitations/dialogs/CreateInvitationDialog.tsx +++ b/server/src/components/Invitations/dialogs/CreateInvitationDialog.tsx @@ -62,7 +62,7 @@ export default function CreateInvitationDialog({
- {errors.role ?

{errors.role}

:

Determines the user's access level

} + {errors.role ?

{errors.role}

:

Determines the user`s access level

}
{/* Project */} diff --git a/server/src/components/Invitations/dialogs/viewInvitationDialog.tsx b/server/src/components/Invitations/dialogs/viewInvitationDialog.tsx index 6863389d..33b010d2 100644 --- a/server/src/components/Invitations/dialogs/viewInvitationDialog.tsx +++ b/server/src/components/Invitations/dialogs/viewInvitationDialog.tsx @@ -2,7 +2,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; -import StatusBadge, { InvitationRow } from '../StatusBadge'; +import StatusBadge from '../StatusBadge'; +import { InvitationRow } from '../InvitationsTable'; interface ViewInvitationDialogProps { invitation: InvitationRow | null; From 52fcd3c59daa9097cb53371bdb0dba36ec6ea919 Mon Sep 17 00:00:00 2001 From: Muditha <89349527+muditha20@users.noreply.github.com> Date: Sat, 21 Mar 2026 21:04:29 +0530 Subject: [PATCH 07/18] Admin-reports code refactor --- server/src/app/admin/reports/page.tsx | 647 +++--------------- .../admin-dashboard/SuperAdminDashboard.tsx | 3 +- .../src/components/reports/ReportsFilters.tsx | 111 +++ .../components/reports/ReportsPagination.tsx | 96 +++ .../components/reports/ReportsStatCards.tsx | 42 ++ .../src/components/reports/ReportsTable.tsx | 85 +++ server/src/components/reports/types.ts | 32 + .../src/components/reports/useGeneratePDF.ts | 120 ++++ .../src/components/reports/useReportsData.ts | 57 ++ .../components/reports/useReportsFilters.ts | 99 +++ 10 files changed, 728 insertions(+), 564 deletions(-) create mode 100644 server/src/components/reports/ReportsFilters.tsx create mode 100644 server/src/components/reports/ReportsPagination.tsx create mode 100644 server/src/components/reports/ReportsStatCards.tsx create mode 100644 server/src/components/reports/ReportsTable.tsx create mode 100644 server/src/components/reports/types.ts create mode 100644 server/src/components/reports/useGeneratePDF.ts create mode 100644 server/src/components/reports/useReportsData.ts create mode 100644 server/src/components/reports/useReportsFilters.ts diff --git a/server/src/app/admin/reports/page.tsx b/server/src/app/admin/reports/page.tsx index 02d0aa25..f158fa79 100644 --- a/server/src/app/admin/reports/page.tsx +++ b/server/src/app/admin/reports/page.tsx @@ -1,317 +1,55 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; - -import { CheckCircle, Clock, FileText } 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"; - -interface ReportRow { - id: string; - projectName: string; - mentor: string; - studentsCount: number; - date: string; - rawDate: string; -} - -const ITEMS_PER_PAGE = 5; - -function formatDisplayDate(value: string) { - const date = new Date(value); - - if (Number.isNaN(date.getTime())) { - return "-"; - } - - return date.toLocaleDateString("en-US", { - month: "short", - day: "2-digit", - year: "numeric", - }); -} - -function formatInputDate(value: string) { - const date = new Date(value); - - if (Number.isNaN(date.getTime())) { - return ""; - } - - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - - return `${year}-${month}-${day}`; -} +import { ReportsFilters } from "@/components/reports/ReportsFilters"; +import { ReportsPagination } from "@/components/reports/ReportsPagination"; +import { ReportsStatCards } from "@/components/reports/ReportsStatCards"; +import { ReportsTable } from "@/components/reports/ReportsTable"; +import { useGeneratePDF } from "@/components/reports/useGeneratePDF"; +import { useReportsData } from "@/components/reports/useReportsData"; +import { useReportsFilters } from "@/components/reports/useReportsFilters"; export default function ReportsPage() { - const [isLoading, setIsLoading] = useState(true); - const [fetchError, setFetchError] = useState(null); - const [page, setPage] = useState(1); - const [projectFilter, setProjectFilter] = useState("all"); - const [mentorFilter, setMentorFilter] = useState("all"); - const [dateFrom, setDateFrom] = useState(""); - const [dateTo, setDateTo] = useState(""); - const [tableRows, setTableRows] = useState([]); - - useEffect(() => { - let mounted = true; - - const loadData = async () => { - setIsLoading(true); - setFetchError(null); - - try { - const tableResponse = await fetch("/api/admin/reports", { - cache: "no-store", - }); - - if (!tableResponse.ok) { - const payload = await tableResponse - .json() - .catch(() => ({ message: "Failed to fetch project data" })); - throw new Error(payload.message ?? "Failed to fetch project data"); - } - - const tableData = (await tableResponse.json()) as ReportRow[]; - - if (!mounted) { - return; - } - - setTableRows( - Array.isArray(tableData) - ? tableData.map((row) => ({ - ...row, - date: formatDisplayDate(row.rawDate), - })) - : [], - ); - } catch (error) { - if (!mounted) { - return; - } - - setTableRows([]); - setFetchError( - error instanceof Error - ? error.message - : "Failed to fetch project data", - ); - } finally { - if (mounted) { - setIsLoading(false); - } - } - }; - - loadData(); - - return () => { - mounted = false; - }; - }, []); - - const projectOptions = useMemo(() => { - return Array.from(new Set(tableRows.map((row) => row.projectName))).sort( - (left, right) => left.localeCompare(right), - ); - }, [tableRows]); - - const mentorOptions = useMemo(() => { - return Array.from(new Set(tableRows.map((row) => row.mentor))).sort( - (left, right) => left.localeCompare(right), - ); - }, [tableRows]); - - const filteredReports = useMemo(() => { - return tableRows.filter((row) => { - const matchesProject = - projectFilter === "all" || row.projectName === projectFilter; - const matchesMentor = - mentorFilter === "all" || row.mentor === mentorFilter; - - const rowDate = formatInputDate(row.rawDate); - const matchesFrom = dateFrom.length === 0 || rowDate >= dateFrom; - const matchesTo = dateTo.length === 0 || rowDate <= dateTo; - - return matchesProject && matchesMentor && matchesFrom && matchesTo; - }); - }, [dateFrom, dateTo, mentorFilter, projectFilter, tableRows]); - - const totalGenerated = tableRows.length; - const pendingReports = 0; - const completedReports = tableRows.length; - - const totalPages = Math.max( - 1, - Math.ceil(filteredReports.length / ITEMS_PER_PAGE), - ); - const safePage = Math.min(page, totalPages); - const pageNumbers = Array.from( - { length: totalPages }, - (_, index) => index + 1, - ).filter((pageNumber) => Math.abs(pageNumber - safePage) <= 1); - const startIndex = (safePage - 1) * ITEMS_PER_PAGE; - const visibleReports = filteredReports.slice( - startIndex, - startIndex + ITEMS_PER_PAGE, + // Fetch raw report rows from the API + const { tableRows, isLoading, fetchError } = useReportsData(); + + // Manage filter state and derive paginated/filtered data + const { + projectFilter, + mentorFilter, + dateFrom, + dateTo, + setProjectFilter, + setMentorFilter, + setDateFrom, + setDateTo, + projectOptions, + mentorOptions, + resetFilters, + filteredReports, + visibleReports, + safePage, + totalPages, + pageNumbers, + startCount, + endCount, + setPage, + } = useReportsFilters(tableRows); + + // PDF export using the currently filtered rows + const { isExporting, generatePDF } = useGeneratePDF( + filteredReports, + dateFrom, + dateTo, ); - const startCount = filteredReports.length === 0 ? 0 : startIndex + 1; - const endCount = Math.min( - startIndex + ITEMS_PER_PAGE, - filteredReports.length, - ); - - const [isExporting, setIsExporting] = useState(false); - - const resetFilters = () => { - setProjectFilter("all"); - setMentorFilter("all"); - setDateFrom(""); - setDateTo(""); - setPage(1); - }; - - const generatePDF = async () => { - setIsExporting(true); - try { - const jsPDFModule = await import("jspdf"); - const jsPDF = jsPDFModule.default; - const doc = new jsPDF({ unit: "pt", format: "a4" }); - const margin = 40; - let y = 50; - - // Header banner - 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("Project Reports", margin, 58); - doc.setFontSize(9); - doc.text(`Generated at: ${new Date().toLocaleString()}`, margin, 74); - doc.text("Project Name · Mentor · Students Count · Date", margin, 88); - doc.setTextColor(0, 0, 0); - - y = 128; - doc.setDrawColor(220); - doc.line(margin, y, 555, y); - y += 16; - - // Summary row - doc.setFontSize(10); - doc.setFont("helvetica", "normal"); - doc.text(`Total rows exported: ${filteredReports.length}`, margin, y); - if (dateFrom || dateTo) { - const range = [ - dateFrom && `From: ${dateFrom}`, - dateTo && `To: ${dateTo}`, - ] - .filter(Boolean) - .join(" "); - doc.text(range, margin + 200, y); - } - y += 20; - - // Table header - const colX = { - project: margin, - mentor: margin + 180, - students: margin + 360, - date: margin + 440, - }; - doc.setFillColor(247, 248, 250); - doc.rect(margin, y - 10, 515, 18, "F"); - doc.setFontSize(9); - doc.setFont("helvetica", "bold"); - doc.setTextColor(55, 65, 81); - doc.text("PROJECT NAME", colX.project + 3, y); - doc.text("MENTOR", colX.mentor, y); - doc.text("STUDENTS", colX.students, y); - doc.text("DATE", colX.date, y); - y += 14; - - doc.setDrawColor(220); - doc.line(margin, y, 555, y); - y += 6; - - // Table rows - doc.setFont("helvetica", "normal"); - doc.setFontSize(9); - let rowIndex = 0; - for (const row of filteredReports) { - if (y > 760) { - doc.addPage(); - y = 50; - } - if (rowIndex % 2 === 0) { - doc.setFillColor(249, 250, 251); - doc.rect(margin, y - 9, 515, 14, "F"); - } - doc.setTextColor(15, 23, 42); - // Truncate long project/mentor names to avoid overflow - const projectText = - row.projectName.length > 28 - ? row.projectName.slice(0, 25) + "..." - : row.projectName; - const mentorText = - row.mentor.length > 20 ? row.mentor.slice(0, 17) + "..." : row.mentor; - doc.text(projectText, colX.project + 3, y); - doc.text(mentorText, colX.mentor, y); - doc.text(String(row.studentsCount), colX.students + 20, y); - doc.text(row.date, colX.date, y); - y += 14; - rowIndex++; - } - - doc.save(`project-reports-${new Date().toISOString().slice(0, 10)}.pdf`); - } catch (err) { - console.error("PDF generation error", err); - alert("Unable to generate PDF. Please try again."); - } finally { - setIsExporting(false); - } - }; return (
-
- {/* Header + Stat Cards */} + {/* Header + stat summary */}

Reports

@@ -323,271 +61,54 @@ export default function ReportsPage() { {isExporting ? "Generating..." : "Generate Report"}
- -
-
-
-

Total Generated

-

- {totalGenerated} -

-
- -
-
-
-

Pending Reports

-

- {pendingReports} -

-
- -
-
-
-

Completed Reports

-

- {completedReports} -

-
- -
-
+
- {/* Filters + Table */} + {/* Filters, table, and pagination */} - {/* Filters */} -
-
-
- - -
-
- - -
-
- - { - setDateFrom(event.target.value); - setPage(1); - }} - className="border border-[#E5E5E5] rounded-md px-3 py-2 text-sm text-[#0A0A0A] bg-white focus:outline-none focus:ring-2 focus:ring-[#3B82F6]" - /> -
-
- - { - setDateTo(event.target.value); - setPage(1); - }} - className="border border-[#E5E5E5] rounded-md px-3 py-2 text-sm text-[#0A0A0A] bg-white focus:outline-none focus:ring-2 focus:ring-[#3B82F6]" - /> -
- -
-
- - {/* Table */} - - - - - Project Name - - - Mentor - - - Students Count - - - Date - - - - - {isLoading ? ( - - - Loading reports... - - - ) : fetchError ? ( - - - {fetchError} - - - ) : visibleReports.length === 0 ? ( - - - No reports found for the selected filters. - - - ) : ( - visibleReports.map((report) => ( - - -
- {report.projectName} -
-
- - {report.mentor} - - - {report.studentsCount} - - - {report.date} - -
- )) - )} -
-
- - {/* Pagination */} -
-

- Showing {startCount} to {endCount} of {filteredReports.length}{" "} - entries -

- - - - - setPage((currentPage) => Math.max(1, currentPage - 1)) - } - /> - - {safePage > 2 ? ( - <> - - setPage(1)} - > - 1 - - - {safePage > 3 ? ( - - - - ) : null} - - ) : null} - {pageNumbers.map((pageNumber) => ( - - setPage(pageNumber)} - > - {pageNumber} - - - ))} - {safePage < totalPages - 1 ? ( - <> - {safePage < totalPages - 2 ? ( - - - - ) : null} - - setPage(totalPages)} - > - {totalPages} - - - - ) : null} - - - setPage((currentPage) => - Math.min(totalPages, currentPage + 1), - ) - } - /> - - - -
+ { + setProjectFilter(v); + setPage(1); + }} + onMentorChange={(v) => { + setMentorFilter(v); + setPage(1); + }} + onDateFromChange={(v) => { + setDateFrom(v); + setPage(1); + }} + onDateToChange={(v) => { + setDateTo(v); + setPage(1); + }} + onReset={resetFilters} + /> + +
diff --git a/server/src/components/admin-dashboard/SuperAdminDashboard.tsx b/server/src/components/admin-dashboard/SuperAdminDashboard.tsx index ca9e5e70..23153a0c 100644 --- a/server/src/components/admin-dashboard/SuperAdminDashboard.tsx +++ b/server/src/components/admin-dashboard/SuperAdminDashboard.tsx @@ -155,7 +155,8 @@ export default function SuperAdminDashboard({ [254, 226, 226], ]; cards.slice(0, 3).forEach((card, idx) => { - doc.setFillColor(...metricColors[idx]); + const [r, g, b] = metricColors[idx]; + doc.setFillColor(r, g, b); doc.roundedRect(boxX, boxY, boxW, boxH, 8, 8, "F"); doc.setDrawColor(203); doc.roundedRect(boxX, boxY, boxW, boxH, 8, 8, "S"); diff --git a/server/src/components/reports/ReportsFilters.tsx b/server/src/components/reports/ReportsFilters.tsx new file mode 100644 index 00000000..293fd23b --- /dev/null +++ b/server/src/components/reports/ReportsFilters.tsx @@ -0,0 +1,111 @@ +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface Props { + projectFilter: string; + mentorFilter: string; + dateFrom: string; + dateTo: string; + projectOptions: string[]; + mentorOptions: string[]; + onProjectChange: (value: string) => void; + onMentorChange: (value: string) => void; + onDateFromChange: (value: string) => void; + onDateToChange: (value: string) => void; + onReset: () => void; +} + +// Filter bar for reports +export function ReportsFilters({ + projectFilter, + mentorFilter, + dateFrom, + dateTo, + projectOptions, + mentorOptions, + onProjectChange, + onMentorChange, + onDateFromChange, + onDateToChange, + onReset, +}: Props) { + return ( +
+
+
+ + +
+
+ + +
+
+ + onDateFromChange(e.target.value)} + className="border border-[#E5E5E5] rounded-md px-3 py-2 text-sm text-[#0A0A0A] bg-white focus:outline-none focus:ring-2 focus:ring-[#3B82F6]" + /> +
+
+ + onDateToChange(e.target.value)} + className="border border-[#E5E5E5] rounded-md px-3 py-2 text-sm text-[#0A0A0A] bg-white focus:outline-none focus:ring-2 focus:ring-[#3B82F6]" + /> +
+ +
+
+ ); +} diff --git a/server/src/components/reports/ReportsPagination.tsx b/server/src/components/reports/ReportsPagination.tsx new file mode 100644 index 00000000..fcb47bf8 --- /dev/null +++ b/server/src/components/reports/ReportsPagination.tsx @@ -0,0 +1,96 @@ +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; + +interface Props { + startCount: number; + endCount: number; + totalCount: number; + safePage: number; + totalPages: number; + pageNumbers: number[]; + onPageChange: (page: number) => void; +} + +// Footer pagination +export function ReportsPagination({ + startCount, + endCount, + totalCount, + safePage, + totalPages, + pageNumbers, + onPageChange, +}: Props) { + return ( +
+

+ Showing {startCount} to {endCount} of {totalCount} entries +

+ + + + onPageChange(Math.max(1, safePage - 1))} + /> + + {safePage > 2 ? ( + <> + + onPageChange(1)} + > + 1 + + + {safePage > 3 ? ( + + + + ) : null} + + ) : null} + {pageNumbers.map((pageNumber) => ( + + onPageChange(pageNumber)} + > + {pageNumber} + + + ))} + {safePage < totalPages - 1 ? ( + <> + {safePage < totalPages - 2 ? ( + + + + ) : null} + + onPageChange(totalPages)} + > + {totalPages} + + + + ) : null} + + onPageChange(Math.min(totalPages, safePage + 1))} + /> + + + +
+ ); +} diff --git a/server/src/components/reports/ReportsStatCards.tsx b/server/src/components/reports/ReportsStatCards.tsx new file mode 100644 index 00000000..fe0c28d4 --- /dev/null +++ b/server/src/components/reports/ReportsStatCards.tsx @@ -0,0 +1,42 @@ +import { CheckCircle, Clock, FileText } from "lucide-react"; + +interface Props { + totalGenerated: number; + pendingReports: number; + completedReports: number; +} + +// Three summary stat cards- total, pending, and completed reports +export function ReportsStatCards({ + totalGenerated, + pendingReports, + completedReports, +}: Props) { + return ( +
+
+
+

Total Generated

+

{totalGenerated}

+
+ +
+
+
+

Pending Reports

+

{pendingReports}

+
+ +
+
+
+

Completed Reports

+

+ {completedReports} +

+
+ +
+
+ ); +} diff --git a/server/src/components/reports/ReportsTable.tsx b/server/src/components/reports/ReportsTable.tsx new file mode 100644 index 00000000..9ab48df0 --- /dev/null +++ b/server/src/components/reports/ReportsTable.tsx @@ -0,0 +1,85 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +import { ReportRow } from "./types"; + +interface Props { + isLoading: boolean; + fetchError: string | null; + visibleReports: ReportRow[]; +} + +// Renders the report rows for the current page +export function ReportsTable({ isLoading, fetchError, visibleReports }: Props) { + return ( + + + + + Project Name + + + Mentor + + + Students Count + + + Date + + + + + {isLoading ? ( + + + Loading reports... + + + ) : fetchError ? ( + + + {fetchError} + + + ) : visibleReports.length === 0 ? ( + + + No reports found for the selected filters. + + + ) : ( + visibleReports.map((report) => ( + + +
+ {report.projectName} +
+
+ {report.mentor} + + {report.studentsCount} + + {report.date} +
+ )) + )} +
+
+ ); +} diff --git a/server/src/components/reports/types.ts b/server/src/components/reports/types.ts new file mode 100644 index 00000000..25143efb --- /dev/null +++ b/server/src/components/reports/types.ts @@ -0,0 +1,32 @@ +// Shape of a single report row returned from the API +export interface ReportRow { + id: string; + projectName: string; + mentor: string; + studentsCount: number; + date: string; + rawDate: string; +} + +export const ITEMS_PER_PAGE = 5; + +// Formats an date string for display +export function formatDisplayDate(value: string) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "-"; + return date.toLocaleDateString("en-US", { + month: "short", + day: "2-digit", + year: "numeric", + }); +} + +// Formats an date string to input comparison +export function formatInputDate(value: string) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} diff --git a/server/src/components/reports/useGeneratePDF.ts b/server/src/components/reports/useGeneratePDF.ts new file mode 100644 index 00000000..b2ee25e7 --- /dev/null +++ b/server/src/components/reports/useGeneratePDF.ts @@ -0,0 +1,120 @@ +"use client"; + +import { useState } from "react"; + +import { ReportRow } from "./types"; + +// Hook that builds and downloads a PDF +export function useGeneratePDF( + filteredReports: ReportRow[], + dateFrom: string, + dateTo: string, +) { + const [isExporting, setIsExporting] = useState(false); + + const generatePDF = async () => { + setIsExporting(true); + try { + const jsPDFModule = await import("jspdf"); + const jsPDF = jsPDFModule.default; + const doc = new jsPDF({ unit: "pt", format: "a4" }); + const margin = 40; + let y = 50; + + // Header banner + 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("Project Reports", margin, 58); + doc.setFontSize(9); + doc.text(`Generated at: ${new Date().toLocaleString()}`, margin, 74); + doc.text("Project Name · Mentor · Students Count · Date", margin, 88); + doc.setTextColor(0, 0, 0); + + y = 128; + doc.setDrawColor(220); + doc.line(margin, y, 555, y); + y += 16; + + // Summary row + doc.setFontSize(10); + doc.setFont("helvetica", "normal"); + doc.text(`Total rows exported: ${filteredReports.length}`, margin, y); + if (dateFrom || dateTo) { + const range = [ + dateFrom && `From: ${dateFrom}`, + dateTo && `To: ${dateTo}`, + ] + .filter(Boolean) + .join(" "); + doc.text(range, margin + 200, y); + } + y += 20; + + // Table header + const colX = { + project: margin, + mentor: margin + 180, + students: margin + 360, + date: margin + 440, + }; + doc.setFillColor(247, 248, 250); + doc.rect(margin, y - 10, 515, 18, "F"); + doc.setFontSize(9); + doc.setFont("helvetica", "bold"); + doc.setTextColor(55, 65, 81); + doc.text("PROJECT NAME", colX.project + 3, y); + doc.text("MENTOR", colX.mentor, y); + doc.text("STUDENTS", colX.students, y); + doc.text("DATE", colX.date, y); + y += 14; + doc.setDrawColor(220); + doc.line(margin, y, 555, y); + y += 6; + + // Table rows + doc.setFont("helvetica", "normal"); + doc.setFontSize(9); + let rowIndex = 0; + for (const row of filteredReports) { + if (y > 760) { + doc.addPage(); + y = 50; + } + if (rowIndex % 2 === 0) { + doc.setFillColor(249, 250, 251); + doc.rect(margin, y - 9, 515, 14, "F"); + } + doc.setTextColor(15, 23, 42); + const projectText = + row.projectName.length > 28 + ? row.projectName.slice(0, 25) + "..." + : row.projectName; + const mentorText = + row.mentor.length > 20 ? row.mentor.slice(0, 17) + "..." : row.mentor; + doc.text(projectText, colX.project + 3, y); + doc.text(mentorText, colX.mentor, y); + doc.text(String(row.studentsCount), colX.students + 20, y); + doc.text(row.date, colX.date, y); + y += 14; + rowIndex++; + } + + doc.save(`project-reports-${new Date().toISOString().slice(0, 10)}.pdf`); + } catch (err) { + console.error("PDF generation error", err); + alert("Unable to generate PDF. Please try again."); + } finally { + setIsExporting(false); + } + }; + + return { isExporting, generatePDF }; +} diff --git a/server/src/components/reports/useReportsData.ts b/server/src/components/reports/useReportsData.ts new file mode 100644 index 00000000..e25832d4 --- /dev/null +++ b/server/src/components/reports/useReportsData.ts @@ -0,0 +1,57 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { formatDisplayDate, type ReportRow } from "./types"; + +// Fetches report rows from the API and formats dates for display +export function useReportsData() { + const [tableRows, setTableRows] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [fetchError, setFetchError] = useState(null); + + useEffect(() => { + let mounted = true; + + const loadData = async () => { + setIsLoading(true); + setFetchError(null); + try { + const res = await fetch("/api/admin/reports", { cache: "no-store" }); + if (!res.ok) { + const payload = await res + .json() + .catch(() => ({ message: "Failed to fetch project data" })); + throw new Error(payload.message ?? "Failed to fetch project data"); + } + const data = (await res.json()) as ReportRow[]; + if (!mounted) return; + setTableRows( + Array.isArray(data) + ? data.map((row) => ({ + ...row, + date: formatDisplayDate(row.rawDate), + })) + : [], + ); + } catch (error) { + if (!mounted) return; + setTableRows([]); + setFetchError( + error instanceof Error + ? error.message + : "Failed to fetch project data", + ); + } finally { + if (mounted) setIsLoading(false); + } + }; + + loadData(); + return () => { + mounted = false; + }; + }, []); + + return { tableRows, isLoading, fetchError }; +} diff --git a/server/src/components/reports/useReportsFilters.ts b/server/src/components/reports/useReportsFilters.ts new file mode 100644 index 00000000..d17c8e16 --- /dev/null +++ b/server/src/components/reports/useReportsFilters.ts @@ -0,0 +1,99 @@ +"use client"; + +import { useMemo, useState } from "react"; + +import { formatInputDate, ITEMS_PER_PAGE, type ReportRow } from "./types"; + +// Manages all filter state +export function useReportsFilters(tableRows: ReportRow[]) { + const [page, setPage] = useState(1); + const [projectFilter, setProjectFilter] = useState("all"); + const [mentorFilter, setMentorFilter] = useState("all"); + const [dateFrom, setDateFrom] = useState(""); + const [dateTo, setDateTo] = useState(""); + + // Unique, sorted options for the project + const projectOptions = useMemo( + () => + Array.from(new Set(tableRows.map((r) => r.projectName))).sort((a, b) => + a.localeCompare(b), + ), + [tableRows], + ); + const mentorOptions = useMemo( + () => + Array.from(new Set(tableRows.map((r) => r.mentor))).sort((a, b) => + a.localeCompare(b), + ), + [tableRows], + ); + + // Apply all active filters to produce the visible subset + const filteredReports = useMemo( + () => + tableRows.filter((row) => { + const rowDate = formatInputDate(row.rawDate); + return ( + (projectFilter === "all" || row.projectName === projectFilter) && + (mentorFilter === "all" || row.mentor === mentorFilter) && + (dateFrom.length === 0 || rowDate >= dateFrom) && + (dateTo.length === 0 || rowDate <= dateTo) + ); + }), + [dateFrom, dateTo, mentorFilter, projectFilter, tableRows], + ); + + // Pagination derived values + const totalPages = Math.max( + 1, + Math.ceil(filteredReports.length / ITEMS_PER_PAGE), + ); + const safePage = Math.min(page, totalPages); + const pageNumbers = Array.from( + { length: totalPages }, + (_, i) => i + 1, + ).filter((n) => Math.abs(n - safePage) <= 1); + const startIndex = (safePage - 1) * ITEMS_PER_PAGE; + const visibleReports = filteredReports.slice( + startIndex, + startIndex + ITEMS_PER_PAGE, + ); + const startCount = filteredReports.length === 0 ? 0 : startIndex + 1; + const endCount = Math.min( + startIndex + ITEMS_PER_PAGE, + filteredReports.length, + ); + + const resetFilters = () => { + setProjectFilter("all"); + setMentorFilter("all"); + setDateFrom(""); + setDateTo(""); + setPage(1); + }; + + return { + // Filter state & setters + projectFilter, + mentorFilter, + dateFrom, + dateTo, + setProjectFilter, + setMentorFilter, + setDateFrom, + setDateTo, + resetFilters, + // Dropdown options + projectOptions, + mentorOptions, + // Paginated data + filteredReports, + visibleReports, + safePage, + totalPages, + pageNumbers, + startCount, + endCount, + setPage, + }; +} From 9dddce748f57871ef5fc5cb5fe1c9635c005b7db Mon Sep 17 00:00:00 2001 From: arshvinw5 Date: Sat, 21 Mar 2026 22:30:32 +0530 Subject: [PATCH 08/18] refacoting code --- .../migration.sql | 5 + server/prisma/schema.prisma | 5 +- server/src/app/admin/invitation/page.tsx | 1246 ++++++++++------- server/src/app/api/invitations/route.ts | 122 +- server/src/hooks/admin/useInvitation.ts | 23 +- .../invitation_repository_impl.ts | 14 +- 6 files changed, 858 insertions(+), 557 deletions(-) create mode 100644 server/prisma/migrations/20260321103623_add_projectid_to_invitations/migration.sql diff --git a/server/prisma/migrations/20260321103623_add_projectid_to_invitations/migration.sql b/server/prisma/migrations/20260321103623_add_projectid_to_invitations/migration.sql new file mode 100644 index 00000000..4c7d8ea2 --- /dev/null +++ b/server/prisma/migrations/20260321103623_add_projectid_to_invitations/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "invitations" ADD COLUMN "projectId" TEXT; + +-- AddForeignKey +ALTER TABLE "invitations" ADD CONSTRAINT "invitations_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 7d8c58f3..8521cfbb 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -105,12 +105,14 @@ model Invitation { role Role token String @unique invitedBy String? + projectId String? expiresAt DateTime accepted Boolean @default(false) createdAt DateTime @default(now()) - inviter User? @relation("InvitationSender", fields: [invitedBy], references: [id], onDelete: SetNull) + inviter User? @relation("InvitationSender", fields: [invitedBy], references: [id], onDelete: SetNull) + project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) @@map("invitations") } @@ -128,6 +130,7 @@ model Project { mentors ProjectMentor[] assignments ProjectAllocation[] + invitations Invitation[] @@map("projects") } diff --git a/server/src/app/admin/invitation/page.tsx b/server/src/app/admin/invitation/page.tsx index abf80bc6..2f351d3b 100644 --- a/server/src/app/admin/invitation/page.tsx +++ b/server/src/app/admin/invitation/page.tsx @@ -1,28 +1,27 @@ "use client"; -import React, { useEffect, useState } from 'react'; -import AsideSidebar from "@/components/AsideSidebar"; -import { useInvitation, useRecentInvitations, useChangeInvitationStatus, useDeleteInvitation } from "@/hooks/admin/useInvitation"; -import { z } from 'zod'; +import React, { useState } from "react"; + import { - UserPlus, - Send, - MoreVertical, + CheckCircle2, ChevronDown, + Clock, + MoreVertical, Plus, + Send, + UserPlus, Users, - Clock, - CheckCircle2, - XCircle -} from 'lucide-react'; -import { Project } from '@prisma/client'; + XCircle, +} from "lucide-react"; +import { z } from "zod"; + import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/dropdown-menu"; + useChangeInvitationStatus, + useDeleteInvitation, + useInvitation, + useRecentInvitations, +} from "@/hooks/admin/useInvitation"; +import { useGetProjects } from "@/hooks/projects"; import { AlertDialog, AlertDialogAction, @@ -33,6 +32,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; import { Dialog, DialogClose, @@ -42,7 +42,6 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; import { Pagination, PaginationContent, @@ -52,6 +51,14 @@ import { PaginationNext, PaginationPrevious, } from "@/components/ui/pagination"; +import AsideSidebar from "@/components/AsideSidebar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/dropdown-menu"; const ITEMS_PER_PAGE = 10; @@ -69,25 +76,31 @@ interface InvitationRow { email: string; role: string; project: string; - status: 'Pending' | 'Accepted' | 'Expired'; + status: "Pending" | "Accepted" | "Expired"; createdAt: string; expiresAt: string; } //Invitation View export default function InvitationsView() { - const [projects, setProjects] = useState([]); const { mutate: sendInvitation, isPending } = useInvitation(); - const { data: recentInvitations, isLoading: isInvLoading, isError: isInvError } = useRecentInvitations(); - const { mutate: changeStatus, isPending: isChangingStatus } = useChangeInvitationStatus(); - const { mutate: deleteInvitation, isPending: isDeleting } = useDeleteInvitation(); + const { data: projectsData } = useGetProjects(); + const { + data: recentInvitations, + isLoading: isInvLoading, + isError: isInvError, + } = useRecentInvitations(); + const { mutate: changeStatus, isPending: isChangingStatus } = + useChangeInvitationStatus(); + const { mutate: deleteInvitation, isPending: isDeleting } = + useDeleteInvitation(); const [formData, setFormData] = useState({ role: "", firstName: "", lastName: "", email: "", - project: "" + project: "", }); const [errors, setErrors] = useState>({}); @@ -97,535 +110,802 @@ export default function InvitationsView() { // Modals const [createOpen, setCreateOpen] = useState(false); const [viewInv, setViewInv] = useState(null); - const [changeStatusInv, setChangeStatusInv] = useState(null); - const [selectedStatus, setSelectedStatus] = useState<'Accepted' | 'Pending' | 'Expired'>('Pending'); + const [changeStatusInv, setChangeStatusInv] = useState( + null, + ); + const [selectedStatus, setSelectedStatus] = useState< + "Accepted" | "Pending" | "Expired" + >("Pending"); const [deleteId, setDeleteId] = useState(null); const handleInputChange = (field: string, value: string) => { - setFormData(prev => ({ ...prev, [field]: value })); + setFormData((prev) => ({ ...prev, [field]: value })); if (errors[field]) { - setErrors(prev => ({ ...prev, [field]: "" })); + setErrors((prev) => ({ ...prev, [field]: "" })); } }; const handleSubmit = () => { - const result = inviteSchema.safeParse(formData); - - if (!result.success) { - const fieldErrors: Record = {}; - result.error.issues.forEach(issue => { - if (issue.path[0]) { - fieldErrors[issue.path[0].toString()] = issue.message; - } - }); - setErrors(fieldErrors); - return; - } - - setErrors({}); - - // Invitation Hook - sendInvitation({ - email: formData.email, - firstName: formData.firstName, - lastName: formData.lastName, - role: formData.role as "student" | "mentor" | "superAdmin", - projectId: formData.project, - }, { - onSuccess: () => { - // reset form after success - setFormData({ - role: "", - firstName: "", - lastName: "", - email: "", - project: "", + const result = inviteSchema.safeParse(formData); + + if (!result.success) { + const fieldErrors: Record = {}; + result.error.issues.forEach((issue) => { + if (issue.path[0]) { + fieldErrors[issue.path[0].toString()] = issue.message; + } }); - setCreateOpen(false); - } - }); -}; - -//Fetch projects -useEffect(() => { - const fetchProjects = async () => { - try { - const res = await fetch("/api/project", { cache: "no-store" }); - const data = await res.json(); - setProjects(data); - } catch (err) { - console.error(err); + setErrors(fieldErrors); + return; } - }; - fetchProjects(); -}, []); + setErrors({}); + + // Invitation Hook + sendInvitation( + { + email: formData.email, + firstName: formData.firstName, + lastName: formData.lastName, + role: formData.role as "student" | "mentor" | "superAdmin", + projectId: formData.project, + }, + { + onSuccess: () => { + // reset form after success + setFormData({ + role: "", + firstName: "", + lastName: "", + email: "", + project: "", + }); + setCreateOpen(false); + }, + }, + ); + }; - const invList: InvitationRow[] = (recentInvitations as InvitationRow[] | undefined) ?? []; + 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 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); + const pagedInvitations = invList.slice( + (currentPage - 1) * ITEMS_PER_PAGE, + currentPage * ITEMS_PER_PAGE, + ); return ( <> -
- -
- - {/* Page Header */} -
-
-
-

Invitations

-

Manage organizational access and track member onboarding.

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

Invitations

+

+ Manage organizational access and track member onboarding. +

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

Total

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

+ Total +

+
+ +
+
+

+ {isInvLoading ? "—" : totalInvitations} +

-
-

{isInvLoading ? '—' : totalInvitations}

-
-
-
-

Pending

-
- +
+
+

+ Pending +

+
+ +
+
+

+ {isInvLoading ? "—" : pendingCount} +

-
-

{isInvLoading ? '—' : pendingCount}

-
-
-
-

Accepted

-
- +
+
+

+ Accepted +

+
+ +
+
+

+ {isInvLoading ? "—" : acceptedCount} +

-
-

{isInvLoading ? '—' : acceptedCount}

-
-
-
-

Expired

-
- +
+
+

+ Expired +

+
+ +
+
+

+ {isInvLoading ? "—" : expiredCount} +

-

{isInvLoading ? '—' : expiredCount}

-
-
- {/* Invitations Table */} -
- {/* Table Toolbar */} -
-
-

All Invitations

- {!isInvLoading &&

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

} -
-
-
- - - - - - - - - - - - {isInvLoading && ( - - )} - {isInvError && ( - + {/* Invitations Table */} +
+ {/* Table Toolbar */} +
+
+

+ All Invitations +

+ {!isInvLoading && ( +

+ {totalInvitations} total record + {totalInvitations !== 1 ? "s" : ""} +

)} - {!isInvLoading && !isInvError && pagedInvitations.length === 0 && ( -
- )} - {pagedInvitations.map((inv: InvitationRow) => ( - - - - - - + + +
+
Email AddressRoleProjectStatusActions
Loading...
Failed to load invitations
No invitations found.
{inv.email} - - {inv.role} - - {inv.project} - - - - - - setViewInv(inv)}> - View Details - - { setChangeStatusInv(inv); setSelectedStatus(inv.status); }}> - Change Status - - - setDeleteId(inv.id)} - > - Delete - - - -
+ + + + + + + - ))} - -
+ Email Address + + Role + + Project + + Status + + Actions +
-
+ + + {isInvLoading && ( + + + Loading... + + + )} + {isInvError && ( + + + Failed to load invitations + + + )} + {!isInvLoading && + !isInvError && + pagedInvitations.length === 0 && ( + + + No invitations found. + + + )} + {pagedInvitations.map((inv: InvitationRow) => ( + + + {inv.email} + + + + {inv.role} + + + + {inv.project || "—"} + + + + + + + + + + + setViewInv(inv)} + > + View Details + + { + setChangeStatusInv(inv); + setSelectedStatus(inv.status); + }} + > + Change Status + + + setDeleteId(inv.id)} + > + Delete + + + + + + ))} + + +
{/* Pagination */}
-

- Showing{" "} - - {totalInvitations === 0 ? 0 : (currentPage - 1) * ITEMS_PER_PAGE + 1} - {" "}to{" "} - - {Math.min(currentPage * ITEMS_PER_PAGE, totalInvitations)} - {" "}of{" "} - {totalInvitations} results -

- - - - setPage(p => Math.max(1, p - 1))} /> - - {Array.from({ length: Math.min(totalPages, 5) }, (_, i) => i + 1).map(p => ( - - setPage(p)}>{p} +

+ Showing{" "} + + {totalInvitations === 0 + ? 0 + : (currentPage - 1) * ITEMS_PER_PAGE + 1} + {" "} + to{" "} + + {Math.min(currentPage * ITEMS_PER_PAGE, totalInvitations)} + {" "} + of{" "} + + {totalInvitations} + {" "} + results +

+ + + + setPage((p) => Math.max(1, p - 1))} + /> - ))} - {totalPages > 5 && } - - setPage(p => Math.min(totalPages, p + 1))} /> - - - + {Array.from( + { length: Math.min(totalPages, 5) }, + (_, i) => i + 1, + ).map((p) => ( + + setPage(p)} + > + {p} + + + ))} + {totalPages > 5 && ( + + + + )} + + + setPage((p) => Math.min(totalPages, p + 1)) + } + /> + +
+
+
-
- {/* ── Create Invitation Dialog ─────────────────────────────────────── */} - { setCreateOpen(open); if (!open) { setErrors({}); } }}> - - {/* Header */} -
- - -
- -
- Create Invitation -
- - Send an invitation email to onboard a new member to your organization. - -
-
+ {/* ── 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 */} -
- -
- - + {/* Form Body */} +
+ {/* Section: Access Details */} +
+

+ Access Details +

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

+ {errors.role} +

+ ) : ( +

+ Determines the user's access level +

+ )}
- {errors.role - ?

{errors.role}

- :

Determines the user's access level

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

+ {errors.project} +

+ ) : ( +

+ Assign to an existing project +

+ )}
- {errors.project - ?

{errors.project}

- :

Assign to an existing project

- }
-
- {/* Divider */} -
+ {/* 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}

} + {/* 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('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`} + type="email" + placeholder="e.g. jane.doe@company.com" + value={formData.email} + onChange={(e) => 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.lastName &&

{errors.lastName}

} + {errors.email ? ( +

+ {errors.email} +

+ ) : ( +

+ The invitation link will be sent to this address +

+ )}
+
+
- {/* 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)} + > + + + Invitation Details + + {viewInv != null && ( +
+
+
+

+ Email +

+

+ {viewInv!.email} +

+
+
+

+ Role +

+ + {viewInv!.role} + +
+
+

+ Project +

+

+ {viewInv!.project || "—"} +

+
+
+

+ Status +

+ +
+
+

+ Sent On +

+

+ {new Date(viewInv!.createdAt).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} +

+
+
+

+ Expires At +

+

+ {new Date(viewInv!.expiresAt) <= new Date(1000) + ? "Manually expired" + : new Date(viewInv!.expiresAt).toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + })} +

+
+ )} + + + + + +
+
+ + {/* ── Change Status Dialog ─────────────────────────────────────────── */} + !open && setChangeStatusInv(null)} + > + + {/* Header */} +
+ + + Change Invitation Status + + + Update the status for{" "} + + {changeStatusInv?.email} + + +
-
- {/* Footer */} -
-

Fields marked * are required

-
+ {/* 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 */} +
-
-
- - - - {/* ── View Details Dialog ──────────────────────────────────────────── */} - !open && setViewInv(null)}> - - - Invitation Details - - {viewInv != null && ( -
-
-
-

Email

-

{viewInv!.email}

-
-
-

Role

- {viewInv!.role} -
-
-

Project

-

{viewInv!.project}

-
-
-

Status

- -
-
-

Sent On

-

{new Date(viewInv!.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}

-
-
-

Expires At

-

{new Date(viewInv!.expiresAt) <= new Date(1000) ? 'Manually expired' : new Date(viewInv!.expiresAt).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' })}

-
-
-
- )} - - - -
-
- - {/* ── 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 => ( -
+ + {/* ── Delete Confirmation Dialog ────────────────────────────────────── */} + !open && setDeleteId(null)} + > + + + Delete Invitation + + This will permanently delete the invitation and remove the + associated user account along with all their data — including + project allocations, activities, and feedback. This action cannot + be undone. + + + + Cancel + + deleteId && + deleteInvitation( + { id: deleteId }, + { onSettled: () => setDeleteId(null) }, + ) + } > - -
-

{opt.label}

-

{opt.desc}

-
- - ))} -
- - {/* Footer */} -
- - - - -
- - - - {/* ── Delete Confirmation Dialog ────────────────────────────────────── */} - !open && setDeleteId(null)}> - - - Delete Invitation - - This will permanently delete the invitation and remove the associated user account along with all their data — including project allocations, activities, and feedback. This action cannot be undone. - - - - Cancel - deleteId && deleteInvitation({ id: deleteId }, { onSettled: () => setDeleteId(null) })} - > - {isDeleting ? 'Deleting…' : 'Delete'} - - - - + {isDeleting ? "Deleting…" : "Delete"} + + + + ); } /* --- Subcomponents --- */ -function StatusBadge({ status }: { status: 'Pending' | 'Accepted' | '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", @@ -645,9 +925,11 @@ function StatusBadge({ status }: { status: 'Pending' | 'Accepted' | 'Expired' }) }; return ( - + {labels[status]} ); -} \ No newline at end of file +} diff --git a/server/src/app/api/invitations/route.ts b/server/src/app/api/invitations/route.ts index abd300ed..5c927f13 100644 --- a/server/src/app/api/invitations/route.ts +++ b/server/src/app/api/invitations/route.ts @@ -23,11 +23,10 @@ export async function POST(req: NextRequest) { // 2. Check for permissions (super_admin) const userRole = session.getRole(); - if (userRole !== Role.superAdmin ) { + if (userRole !== Role.superAdmin) { return NextResponse.json( { - message: - "Forbidden: Only Super Admins can create invitations", + message: "Forbidden: Only Super Admins can create invitations", }, { status: 403 }, ); @@ -87,6 +86,7 @@ export async function POST(req: NextRequest) { email, role, invitedBy, + projectId, }); console.log("Invite Created", invitation); @@ -107,7 +107,9 @@ export async function POST(req: NextRequest) { // 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 prisma.projectAllocation.deleteMany({ + where: { studentId: userId }, + }); await userRepository.delete(userId); await invitationRepository.delete(invitation.id); throw new Error( @@ -152,7 +154,7 @@ export async function GET(req: NextRequest) { if (invitations.length === 0) { return NextResponse.json( { valid: false, message: "Invalid token" }, - { status: 404 } + { status: 404 }, ); } @@ -161,14 +163,14 @@ export async function GET(req: NextRequest) { if (invitation.accepted) { return NextResponse.json( { valid: false, message: "Token has already been used" }, - { status: 400 } + { status: 400 }, ); } if (new Date() > new Date(invitation.expiresAt)) { return NextResponse.json( { valid: false, message: "Token has expired" }, - { status: 400 } + { status: 400 }, ); } @@ -182,50 +184,26 @@ export async function GET(req: NextRequest) { // If no token, return **all invitations for the table** const invitations = await invitationRepository.getAll({ 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 }, - }); - - // Map email → userId - const emailToUserId: Record = {}; - users.forEach(user => { - emailToUserId[user.email] = user.id; - }); - - // 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 }, + include: { + inviter: true, + project: true, + }, }); - // Map userId → project name - const projectMap: Record = {}; - allocations.forEach(allocation => { - projectMap[allocation.studentId] = allocation.project.name; - }); - - const mapped = invitations.map(inv => { - const status: "Pending" | "Accepted" | "Expired" = - inv.accepted ? "Accepted" : (now > new Date(inv.expiresAt) ? "Expired" : "Pending"); + const now = new Date(); - const userId = emailToUserId[inv.email]; - const projectName = userId ? projectMap[userId] || "-" : "-"; + const mapped = invitations.map((inv) => { + const status: "Pending" | "Accepted" | "Expired" = inv.accepted + ? "Accepted" + : now > new Date(inv.expiresAt) + ? "Expired" + : "Pending"; return { id: inv.id, email: inv.email, role: inv.role, - project: projectName, + project: inv.project?.name || "—", status, createdAt: inv.createdAt, expiresAt: inv.expiresAt, @@ -235,7 +213,10 @@ export async function GET(req: NextRequest) { return NextResponse.json(mapped, { status: 200 }); } catch (err) { console.error("Error fetching invitations:", err); - return NextResponse.json({ message: "Internal Server Error" }, { status: 500 }); + return NextResponse.json( + { message: "Internal Server Error" }, + { status: 500 }, + ); } } @@ -251,7 +232,8 @@ export async function PATCH(req: NextRequest) { } const { id, status } = await req.json(); - if (!id) return NextResponse.json({ message: "id is required" }, { status: 400 }); + if (!id) + return NextResponse.json({ message: "id is required" }, { status: 400 }); let updateData: { expiresAt?: Date; accepted?: boolean } = {}; @@ -260,7 +242,10 @@ export async function PATCH(req: NextRequest) { updateData = { accepted: true }; break; case "Pending": - updateData = { accepted: false, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) }; + updateData = { + accepted: false, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }; break; case "Expired": default: @@ -277,7 +262,10 @@ export async function PATCH(req: NextRequest) { return NextResponse.json({ message: "Invitation status updated" }); } catch (err) { console.error("Error updating invitation status:", err); - return NextResponse.json({ message: "Internal Server Error" }, { status: 500 }); + return NextResponse.json( + { message: "Internal Server Error" }, + { status: 500 }, + ); } } @@ -294,28 +282,48 @@ export async function DELETE(req: NextRequest) { const { searchParams } = new URL(req.url); const id = searchParams.get("id"); - if (!id) return NextResponse.json({ message: "id is required" }, { status: 400 }); + if (!id) + return NextResponse.json({ message: "id is required" }, { status: 400 }); const invitation = await prisma.invitation.findUnique({ where: { id } }); - if (!invitation) return NextResponse.json({ message: "Invitation not found" }, { status: 404 }); + if (!invitation) + return NextResponse.json( + { message: "Invitation not found" }, + { status: 404 }, + ); - const user = await prisma.user.findUnique({ where: { email: invitation.email } }); + const user = await prisma.user.findUnique({ + where: { email: invitation.email }, + }); if (user) { await prisma.$transaction(async (tx) => { // Delete mentor feedback on user's activities - const activityIds = (await tx.activity.findMany({ where: { studentId: user.id }, select: { id: true } })).map(a => a.id); - if (activityIds.length) await tx.mentorFeedback.deleteMany({ where: { activityId: { in: activityIds } } }); + const activityIds = ( + await tx.activity.findMany({ + where: { studentId: user.id }, + select: { id: true }, + }) + ).map((a) => a.id); + if (activityIds.length) + await tx.mentorFeedback.deleteMany({ + where: { activityId: { in: activityIds } }, + }); await tx.activity.deleteMany({ where: { studentId: user.id } }); await tx.mentorFeedback.deleteMany({ where: { mentorId: user.id } }); await tx.mentorActivity.deleteMany({ where: { mentorId: user.id } }); await tx.report.deleteMany({ where: { mentorId: user.id } }); await tx.userBadge.deleteMany({ where: { userId: user.id } }); - await tx.projectAllocation.deleteMany({ where: { studentId: user.id } }); + await tx.projectAllocation.deleteMany({ + where: { studentId: user.id }, + }); await tx.projectMentor.deleteMany({ where: { mentorId: user.id } }); // Null-out invitations sent BY this user (onDelete: SetNull handles FK, but explicit is safer) - await tx.invitation.updateMany({ where: { invitedBy: user.id }, data: { invitedBy: null } }); + await tx.invitation.updateMany({ + where: { invitedBy: user.id }, + data: { invitedBy: null }, + }); // Delete the invitation record itself await tx.invitation.delete({ where: { id } }); await tx.user.delete({ where: { id: user.id } }); @@ -328,7 +336,9 @@ export async function DELETE(req: NextRequest) { return NextResponse.json({ message: "Deleted successfully" }); } catch (err) { console.error("Error deleting invitation:", err); - return NextResponse.json({ message: "Internal Server Error" }, { status: 500 }); + 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 6725575e..5b3be758 100644 --- a/server/src/hooks/admin/useInvitation.ts +++ b/server/src/hooks/admin/useInvitation.ts @@ -24,13 +24,6 @@ interface InvitationResponse { }; } -interface GetInvitationResponse { - valid: boolean; - email?: string; - role?: string; - message?: string; -} - export const useInvitation = () => { const queryClient = useQueryClient(); @@ -61,7 +54,6 @@ export const useInvitation = () => { return mutation; }; - export const useRecentInvitations = () => { return useQuery({ queryKey: ["invitations"], @@ -101,7 +93,11 @@ export const useExpireInvitation = () => { export const useChangeInvitationStatus = () => { const queryClient = useQueryClient(); - return useMutation({ + return useMutation< + void, + Error, + { id: string; status: "Accepted" | "Pending" | "Expired" } + >({ mutationFn: async ({ id, status }) => { const res = await fetch("/api/invitations", { method: "PATCH", @@ -110,7 +106,9 @@ export const useChangeInvitationStatus = () => { }); if (!res.ok) { const errorData = await res.json(); - throw new Error(errorData.message || "Failed to update invitation status"); + throw new Error( + errorData.message || "Failed to update invitation status", + ); } }, onSuccess: () => { @@ -128,7 +126,9 @@ export const useDeleteInvitation = () => { return useMutation({ mutationFn: async ({ id }) => { - const res = await fetch(`/api/invitations?id=${id}`, { method: "DELETE" }); + const res = await fetch(`/api/invitations?id=${id}`, { + method: "DELETE", + }); if (!res.ok) { const errorData = await res.json(); throw new Error(errorData.message || "Failed to delete invitation"); @@ -155,4 +155,3 @@ export const useValidateInvitation = (token: string) => { enabled: !!token, }); }; ->>>>>>> 86ewz6naj-Invitation diff --git a/server/src/repositories/invitation_repository_impl.ts b/server/src/repositories/invitation_repository_impl.ts index c341a31a..0787ee9f 100644 --- a/server/src/repositories/invitation_repository_impl.ts +++ b/server/src/repositories/invitation_repository_impl.ts @@ -1,10 +1,10 @@ +import crypto from "crypto"; + +import { Invitation, Role } from "@prisma/client"; + import prisma from "@/lib/prisma"; -import { - Invitation, - Role, -} from "@prisma/client"; + import BaseRepository from "./baseRepository"; -import crypto from "crypto" export class InvitationRepository extends BaseRepository { constructor() { @@ -15,6 +15,7 @@ export class InvitationRepository extends BaseRepository { email: string; role: Role; invitedBy: string; // ID of the super-admin + projectId: string; // Project ID }) { const token = crypto.randomBytes(32).toString("hex"); // secure token const expiresAt = new Date(Date.now() + 3 * 60 * 60 * 1000); // 3 hours @@ -31,6 +32,7 @@ export class InvitationRepository extends BaseRepository { expiresAt, accepted: false, invitedBy: data.invitedBy, + projectId: data.projectId, }, }); } @@ -52,4 +54,4 @@ export class InvitationRepository extends BaseRepository { data: { accepted: true }, }); } -} \ No newline at end of file +} From 73a9f45318a3756dd86a7a6c426768e71daa210f Mon Sep 17 00:00:00 2001 From: arshvinw5 Date: Sun, 22 Mar 2026 01:04:06 +0530 Subject: [PATCH 09/18] fix this ui of invitation page --- server/package-lock.json | 273 ++++++- server/package.json | 9 +- server/src/app/admin/invitation/page.tsx | 218 +++-- server/src/app/index.css | 20 +- .../Invitations/InvitationsTable.tsx | 208 ++--- .../dialogs/CreateInvitationDialog.tsx | 159 +++- server/src/components/ui/button.tsx | 47 +- server/src/components/ui/dropdown-menu.tsx | 201 +++++ server/src/components/ui/input.tsx | 41 +- server/src/components/ui/separator.tsx | 31 + server/src/components/ui/sheet.tsx | 140 ++++ server/src/components/ui/sidebar.tsx | 773 ++++++++++++++++++ server/src/components/ui/skeleton.tsx | 15 + server/src/components/ui/tooltip.tsx | 32 + server/src/hooks/projects/useProject.ts | 4 +- server/src/hooks/use-mobile.tsx | 19 + server/tailwind.config.ts | 40 +- 17 files changed, 1930 insertions(+), 300 deletions(-) create mode 100644 server/src/components/ui/dropdown-menu.tsx create mode 100644 server/src/components/ui/separator.tsx create mode 100644 server/src/components/ui/sheet.tsx create mode 100644 server/src/components/ui/sidebar.tsx create mode 100644 server/src/components/ui/skeleton.tsx create mode 100644 server/src/components/ui/tooltip.tsx create mode 100644 server/src/hooks/use-mobile.tsx diff --git a/server/package-lock.json b/server/package-lock.json index c91e8b31..7ced3fa6 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -12,18 +12,21 @@ "@prisma/client": "^5.19.1", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-avatar": "^1.1.0", - "@radix-ui/react-dialog": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-scroll-area": "^1.1.0", - "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-toast": "^1.2.1", + "@radix-ui/react-tooltip": "^1.2.8", "@shadcn/ui": "^0.0.4", "@tanstack/react-query": "^5.91.2", "bcrypt": "^5.1.1", "bcrypt-ts": "^5.0.2", "bullmq": "^5.12.14", - "class-variance-authority": "^0.7.0", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.13", "dotenv": "^16.4.5", @@ -8011,6 +8014,24 @@ } } }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", @@ -8170,6 +8191,24 @@ } } }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -8264,6 +8303,24 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", @@ -8524,6 +8581,24 @@ } } }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-menubar": { "version": "1.1.16", "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", @@ -8693,6 +8768,24 @@ } } }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", @@ -8796,6 +8889,24 @@ } } }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-progress": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", @@ -8957,13 +9068,54 @@ } } }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-separator": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", - "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", @@ -9014,9 +9166,9 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" @@ -9207,6 +9359,29 @@ } } }, + "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", @@ -9241,6 +9416,24 @@ } } }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -11187,24 +11380,15 @@ } }, "node_modules/class-variance-authority": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.0.tgz", - "integrity": "sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", "license": "Apache-2.0", "dependencies": { - "clsx": "2.0.0" + "clsx": "^2.1.1" }, "funding": { - "url": "https://joebell.co.uk" - } - }, - "node_modules/class-variance-authority/node_modules/clsx": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz", - "integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==", - "license": "MIT", - "engines": { - "node": ">=6" + "url": "https://polar.sh/cva" } }, "node_modules/clsx": { @@ -21637,6 +21821,47 @@ } } }, + "node_modules/radix-ui/node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/raf": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", diff --git a/server/package.json b/server/package.json index 90f9fb79..7a0222b8 100644 --- a/server/package.json +++ b/server/package.json @@ -24,18 +24,21 @@ "@prisma/client": "^5.19.1", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-avatar": "^1.1.0", - "@radix-ui/react-dialog": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-scroll-area": "^1.1.0", - "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-toast": "^1.2.1", + "@radix-ui/react-tooltip": "^1.2.8", "@shadcn/ui": "^0.0.4", "@tanstack/react-query": "^5.91.2", "bcrypt": "^5.1.1", "bcrypt-ts": "^5.0.2", "bullmq": "^5.12.14", - "class-variance-authority": "^0.7.0", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.13", "dotenv": "^16.4.5", diff --git a/server/src/app/admin/invitation/page.tsx b/server/src/app/admin/invitation/page.tsx index 52012a18..899e6aa9 100644 --- a/server/src/app/admin/invitation/page.tsx +++ b/server/src/app/admin/invitation/page.tsx @@ -1,9 +1,8 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useMemo, useState } from "react"; -import { Project } from "@prisma/client"; -import { Plus } from "lucide-react"; +import { Plus, Search } from "lucide-react"; import { z } from "zod"; import { @@ -12,12 +11,13 @@ import { useInvitation, useRecentInvitations, } from "@/hooks/admin/useInvitation"; +import { useGetProjects } from "@/hooks/projects"; +import { Card } from "@/components/ui/card"; import AsideSidebar from "@/components/AsideSidebar"; import ChangeStatusDialog from "@/components/Invitations/dialogs/ChangeStatusDialog"; import CreateInvitationDialog from "@/components/Invitations/dialogs/CreateInvitationDialog"; import DeleteInvitationDialog from "@/components/Invitations/dialogs/DeleteDialog"; import ViewInvitationDialog from "@/components/Invitations/dialogs/viewInvitationDialog"; -import InvitationsPagination from "@/components/Invitations/InvitationsPagination"; import InvitationsTable, { InvitationRow, } from "@/components/Invitations/InvitationsTable"; @@ -37,7 +37,6 @@ const inviteSchema = z.object({ export default function InvitationsView() { // State - const [projects, setProjects] = useState([]); const [formData, setFormData] = useState({ role: "", firstName: "", @@ -47,6 +46,7 @@ export default function InvitationsView() { }); const [errors, setErrors] = useState>({}); const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); // Dialogs const [createOpen, setCreateOpen] = useState(false); @@ -71,19 +71,7 @@ export default function InvitationsView() { const { mutate: deleteInvitation, isPending: isDeleting } = useDeleteInvitation(); - // Fetch Projects - useEffect(() => { - const fetchProjects = async () => { - try { - const res = await fetch("/api/project", { cache: "no-store" }); - const data = await res.json(); - setProjects(data); - } catch (err) { - console.error(err); - } - }; - fetchProjects(); - }, []); + const { data: projectsData } = useGetProjects(); // Form handling const handleInputChange = (field: string, value: string) => { @@ -126,72 +114,162 @@ export default function InvitationsView() { ); }; - // Invitations list + // Invitations list with filtering const invList: InvitationRow[] = (recentInvitations as InvitationRow[] | undefined) ?? []; - const totalInvitations = invList.length; + + const filteredInvitations = useMemo(() => { + const query = search.trim().toLowerCase(); + return query.length === 0 + ? invList + : invList.filter( + (inv) => + inv.email.toLowerCase().includes(query) || + inv.role.toLowerCase().includes(query) || + inv.project.toLowerCase().includes(query), + ); + }, [invList, search]); + + const totalInvitations = filteredInvitations.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( + const pagedInvitations = filteredInvitations.slice( (currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE, ); + const startCount = + filteredInvitations.length === 0 + ? 0 + : (currentPage - 1) * ITEMS_PER_PAGE + 1; + const endCount = Math.min( + currentPage * ITEMS_PER_PAGE, + filteredInvitations.length, + ); + + const goToPage = (nextPage: number) => { + if (nextPage < 1 || nextPage > totalPages) { + return; + } + setPage(nextPage); + }; + return ( <> -
+
-
- {/* Header */} -
-
-

Invitations

-

- Manage organizational access and track member onboarding. -

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

Invitations

+

+ Manage organizational access and track member onboarding. +

+
+ + {/* Stats Cards */} + + + {/* Search and Create Button Bar */} +
+
+

+ Search +

+
+ + { + setSearch(event.target.value); + setPage(1); + }} + placeholder="Search by email, role, or project..." + /> +
+
+ + {/* Create Button */} + +
+ + {/* Table */} +
+ { + setChangeStatusInv(inv); + setSelectedStatus(inv.status); + }} + onDelete={setDeleteId} + /> + + {/* Pagination Footer */} +
+

+ Showing {startCount} to {endCount} of {totalInvitations}{" "} + invitations +

+ +
+ +
+ {Array.from({ length: totalPages }, (_, i) => i + 1).map( + (pageNum) => ( + + ), + )} +
+ +
+
+
- -
- -
- {/* Stats */} - - - {/* Table */} - { - setChangeStatusInv(inv); - setSelectedStatus(inv.status); - }} - onDelete={setDeleteId} - /> - - {/* Pagination */} - -
+
@@ -201,7 +279,7 @@ export default function InvitationsView() { onOpenChange={setCreateOpen} formData={formData} errors={errors} - projects={projects} + projects={projectsData ?? []} isPending={isPending} onChange={handleInputChange} onSubmit={handleSubmit} diff --git a/server/src/app/index.css b/server/src/app/index.css index cefb791f..19a91197 100644 --- a/server/src/app/index.css +++ b/server/src/app/index.css @@ -30,7 +30,15 @@ --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; --radius: 0.5rem - } + ; + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%} .dark { --background: 0 0% 3.9%; --foreground: 0 0% 98%; @@ -56,7 +64,15 @@ --chart-3: 30 80% 55%; --chart-4: 280 65% 60%; --chart-5: 340 75% 55% - } + ; + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%} } @layer base { * { diff --git a/server/src/components/Invitations/InvitationsTable.tsx b/server/src/components/Invitations/InvitationsTable.tsx index 4cef9394..81771218 100644 --- a/server/src/components/Invitations/InvitationsTable.tsx +++ b/server/src/components/Invitations/InvitationsTable.tsx @@ -1,5 +1,13 @@ import { MoreVertical } from "lucide-react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { DropdownMenu, DropdownMenuContent, @@ -38,105 +46,107 @@ export default function InvitationsTable({ onDelete, }: InvitationsTableProps) { return ( -
-
- - - - - - - - - - - - {loading && ( - - - - )} - {error && ( - - - - )} - {!loading && !error && data.length === 0 && ( - - - - )} - {!loading && - data.map((inv) => ( - - - - - - - - ))} - -
- Email - - Role - - Project - - Status - - Actions -
- Loading... -
- Failed to load invitations -
- No invitations found. -
- {inv.email} - - - {inv.role} - - {inv.project} - - - - - - - - onView(inv)}> - View Details - - onChangeStatus(inv)}> - Change Status - - - onDelete(inv.id)} - > - Delete - - - -
-
+
+ + + + + Email + + + Role + + + Project + + + Status + + + Actions + + + + + {loading && ( + + + Loading... + + + )} + {error && ( + + + Failed to load invitations + + + )} + {!loading && !error && data.length === 0 && ( + + + No invitations found. + + + )} + {!loading && + data.map((inv) => ( + + + {inv.email} + + + + {inv.role} + + + {inv.project} + + + + + + + + + + onView(inv)}> + View Details + + onChangeStatus(inv)}> + Change Status + + + onDelete(inv.id)} + > + Delete + + + + + + ))} + +
); } diff --git a/server/src/components/Invitations/dialogs/CreateInvitationDialog.tsx b/server/src/components/Invitations/dialogs/CreateInvitationDialog.tsx index 75283fbb..84cddf57 100644 --- a/server/src/components/Invitations/dialogs/CreateInvitationDialog.tsx +++ b/server/src/components/Invitations/dialogs/CreateInvitationDialog.tsx @@ -1,9 +1,29 @@ "use client"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogClose } from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { ChevronDown, UserPlus, Send } from 'lucide-react'; -import { Project } from '@prisma/client'; +import { ChevronDown, Send, UserPlus } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +// Type matching useGetProjects return type +interface Project { + id: string; + name: string; + description: string | null; + domain: string; + createdBy: string; + createdAt: string; + updatedAt: string; + studentCount?: number; + mentorCount?: number; +} interface CreateInvitationDialogProps { open: boolean; @@ -17,9 +37,15 @@ interface CreateInvitationDialogProps { } export default function CreateInvitationDialog({ - open, onOpenChange, formData, errors, projects, isPending, onChange, onSubmit + open, + onOpenChange, + formData, + errors, + projects, + isPending, + onChange, + onSubmit, }: CreateInvitationDialogProps) { - return ( @@ -33,7 +59,8 @@ export default function CreateInvitationDialog({ Create Invitation - Send an invitation email to onboard a new member to your organization. + Send an invitation email to onboard a new member to your + organization.
@@ -42,7 +69,9 @@ export default function CreateInvitationDialog({
{/* Access Details */}
-

Access Details

+

+ Access Details +

{/* Role */}
@@ -52,17 +81,30 @@ export default function CreateInvitationDialog({
- +
- {errors.role ?

{errors.role}

:

Determines the user`s access level

} + {errors.role ? ( +

+ {errors.role} +

+ ) : ( +

+ Determines the user`s access level +

+ )}
{/* Project */} @@ -73,58 +115,99 @@ export default function CreateInvitationDialog({
- +
- {errors.project ?

{errors.project}

:

Assign to an existing project

} + {errors.project ? ( +

+ {errors.project} +

+ ) : ( +

+ Assign to an existing project +

+ )}
{/* Recipient Details */}
-

Recipient Details

+

+ Recipient Details +

- + onChange('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`} + onChange={(e) => onChange("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`} /> - {errors.firstName &&

{errors.firstName}

} + {errors.firstName && ( +

+ {errors.firstName} +

+ )}
- + onChange('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`} + onChange={(e) => onChange("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`} /> - {errors.lastName &&

{errors.lastName}

} + {errors.lastName && ( +

+ {errors.lastName} +

+ )}
{/* Email */}
- + onChange('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`} + onChange={(e) => onChange("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`} /> - {errors.email ?

{errors.email}

:

The invitation link will be sent to this address

} + {errors.email ? ( +

+ {errors.email} +

+ ) : ( +

+ The invitation link will be sent to this address +

+ )}
@@ -132,7 +215,13 @@ export default function CreateInvitationDialog({ {/* Footer */}
- +
); -} \ No newline at end of file +} diff --git a/server/src/components/ui/button.tsx b/server/src/components/ui/button.tsx index a0b90b8d..65d4fcd9 100644 --- a/server/src/components/ui/button.tsx +++ b/server/src/components/ui/button.tsx @@ -1,33 +1,30 @@ -import * as React from "react"; -import { Slot } from "@radix-ui/react-slot"; -import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/lib/utils"; +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" const buttonVariants = cva( - "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", { variants: { variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", destructive: - "bg-destructive text-destructive-foreground hover:bg-destructive/90", + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", outline: - "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80", + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", - blue: "bg-[#0000FF] text-white hover:bg-[#0000CC]", - blueContrast: "bg-[#0a1262] text-white hover:bg-[#206699]", // Darker blue - tealContrast: "bg-[#2A9D8F] text-white hover:bg-[#1B6F65]", // Teal - navyContrast: "bg-[#1B3A5B] text-white hover:bg-[#142B44]", // Soft Navy - }, size: { - default: "h-10 px-4 py-2", - sm: "h-9 rounded-md px-3", - lg: "h-11 rounded-md px-8", - icon: "h-10 w-10", + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", }, }, defaultVariants: { @@ -35,26 +32,26 @@ const buttonVariants = cva( size: "default", }, } -); +) export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { - asChild?: boolean; + asChild?: boolean } const Button = React.forwardRef( ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button"; + const Comp = asChild ? Slot : "button" return ( - ); + ) } -); -Button.displayName = "Button"; +) +Button.displayName = "Button" -export { Button, buttonVariants }; +export { Button, buttonVariants } diff --git a/server/src/components/ui/dropdown-menu.tsx b/server/src/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000..5a20503b --- /dev/null +++ b/server/src/components/ui/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) => ( + svg]:size-4 [&>svg]:shrink-0", + inset && "pl-8", + className + )} + {...props} + /> +)) +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, +} diff --git a/server/src/components/ui/input.tsx b/server/src/components/ui/input.tsx index 4a8b39c4..69b64fb2 100644 --- a/server/src/components/ui/input.tsx +++ b/server/src/components/ui/input.tsx @@ -1,41 +1,16 @@ import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" -const inputVariants = cva( - "block w-full rounded-md border shadow-sm transition focus:ring focus:ring-opacity-50 disabled:opacity-50 disabled:cursor-not-allowed", - { - variants: { - variant: { - default: "border-blue-300 bg-white text-gray-900", - outline: "border border-input bg-transparent hover:border-accent", - ghost: "border-none bg-transparent focus:ring-0", - }, - inputSize: { // Renamed 'size' to 'inputSize' to avoid collision - sm: "px-2 py-1 text-sm", - default: "px-3 py-2 text-base", - lg: "px-4 py-3 text-lg", - }, - }, - defaultVariants: { - variant: "default", - inputSize: "default", - }, - } -) - -export interface InputProps - extends React.InputHTMLAttributes, - VariantProps { - // No additional properties needed -} - -const Input = React.forwardRef( - ({ className, variant, inputSize, ...props }, ref) => { // Use 'inputSize' instead of 'size' +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { return ( @@ -44,4 +19,4 @@ const Input = React.forwardRef( ) Input.displayName = "Input" -export { Input, inputVariants } +export { Input } diff --git a/server/src/components/ui/separator.tsx b/server/src/components/ui/separator.tsx new file mode 100644 index 00000000..12d81c4a --- /dev/null +++ b/server/src/components/ui/separator.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + + ) +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/server/src/components/ui/sheet.tsx b/server/src/components/ui/sheet.tsx new file mode 100644 index 00000000..272cb721 --- /dev/null +++ b/server/src/components/ui/sheet.tsx @@ -0,0 +1,140 @@ +"use client" + +import * as React from "react" +import * as SheetPrimitive from "@radix-ui/react-dialog" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Sheet = SheetPrimitive.Root + +const SheetTrigger = SheetPrimitive.Trigger + +const SheetClose = SheetPrimitive.Close + +const SheetPortal = SheetPrimitive.Portal + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName + +const sheetVariants = cva( + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", + }, + }, + defaultVariants: { + side: "right", + }, + } +) + +interface SheetContentProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = "right", className, children, ...props }, ref) => ( + + + + + + Close + + {children} + + +)) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetHeader.displayName = "SheetHeader" + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetFooter.displayName = "SheetFooter" + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/server/src/components/ui/sidebar.tsx b/server/src/components/ui/sidebar.tsx new file mode 100644 index 00000000..5158ed50 --- /dev/null +++ b/server/src/components/ui/sidebar.tsx @@ -0,0 +1,773 @@ +"use client" + +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" +import { PanelLeft } from "lucide-react" + +import { useIsMobile } from "@/hooks/use-mobile" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Skeleton } from "@/components/ui/skeleton" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" + +const SIDEBAR_COOKIE_NAME = "sidebar_state" +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = "16rem" +const SIDEBAR_WIDTH_MOBILE = "18rem" +const SIDEBAR_WIDTH_ICON = "3rem" +const SIDEBAR_KEYBOARD_SHORTCUT = "b" + +type SidebarContextProps = { + state: "expanded" | "collapsed" + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext(null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider.") + } + + return context +} + +const SidebarProvider = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void + } +>( + ( + { + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props + }, + ref + ) => { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] + ) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile + ? setOpenMobile((open) => !open) + : setOpen((open) => !open) + }, [isMobile, setOpen, setOpenMobile]) + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed" + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + + +
+ {children} +
+
+
+ ) + } +) +SidebarProvider.displayName = "SidebarProvider" + +const Sidebar = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + side?: "left" | "right" + variant?: "sidebar" | "floating" | "inset" + collapsible?: "offcanvas" | "icon" | "none" + } +>( + ( + { + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props + }, + ref + ) => { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === "none") { + return ( +
+ {children} +
+ ) + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ) + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ) + } +) +Sidebar.displayName = "Sidebar" + +const SidebarTrigger = React.forwardRef< + React.ElementRef, + React.ComponentProps +>(({ className, onClick, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( + + ) +}) +SidebarTrigger.displayName = "SidebarTrigger" + +const SidebarRail = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> +>(({ className, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( + +
+ +// AFTER: + { setCreateForm(EMPTY_FORM); setShowCreate(true); }} + > + + Create New Project + + } +/> +``` + +**Example — Reports page (inline with disabled button):** + +```tsx + + {isExporting ? "Generating..." : "Generate Report"} + + } +/> +``` + +--- + +### 3.3 `FilterBar` + +**File:** `components/admin/FilterBar.tsx` + +**What it does:** +Renders a styled horizontal bar containing filter dropdowns and/or a search box. +It is a **compound component**, meaning it has two built-in sub-components you use +as child tags: `FilterBar.Field` and `FilterBar.Search`. + +#### `FilterBar` (root) +Just a styled wrapper. Put `FilterBar.Field` and `FilterBar.Search` inside it. + +#### `FilterBar.Field` + +Wraps one filter control (a ` { setDomainFilter(v); setPage(1); }}> + + + + + All Domains + Software + {/* ... */} + + +
+ +// AFTER: + + + + + +``` + +**Example — Reports page (project + mentor + date filters):** + +```tsx + + + + + + + + + + + { setDateFrom(e.target.value); setPage(1); }} + className="border border-[#E5E5E5] rounded-md px-3 py-2 text-sm bg-white" + /> + + + + { setDateTo(e.target.value); setPage(1); }} + className="border border-[#E5E5E5] rounded-md px-3 py-2 text-sm bg-white" + /> + + +``` + +--- + +### 3.4 `TableStateRows` + +**File:** `components/admin/TableStateRows.tsx` + +**What it does:** +Renders a single full-width row inside `` to show one of three states: +**loading**, **error**, or **empty**. Without this component, every page manually +writes three separate `if` blocks inside the table body. Now you write it once. + +> ⚠️ **Important:** Place this component as the **first child** inside ``. +> The regular data rows come after it. + +**Props:** + +| Prop | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `colSpan` | `number` | ✅ Yes | — | How many columns the table has (to span the full width) | +| `loading` | `boolean` | ✅ Yes | — | `true` while data is being fetched | +| `error` | `string \| boolean \| null` | No | — | Pass your error message or `true`; pass `null`/`false` when there is no error | +| `empty` | `boolean` | No | — | `true` when the data array has zero items | +| `loadingMessage` | `string` | No | `"Loading..."` | Text shown while loading | +| `emptyMessage` | `string` | No | `"No records found."` | Text shown when empty | + +**Example — Projects page (7 columns):** + +```tsx +// BEFORE: + + {loading && ( + + + Loading projects… + + + )} + {!loading && displayedProjects.length === 0 && ( + + + No projects found. + + + )} + {displayedProjects.map((project) => ( + ... + ))} + + +// AFTER: + + + {!loading && displayedProjects.map((project) => ( + ... + ))} + +``` + +**Example — Reports page (4 columns, includes error state):** + +```tsx + + + {!isLoading && !fetchError && visibleReports.map((report) => ( + ... + ))} + +``` + +--- + +### 3.5 `TableActionMenu` + +**File:** `components/admin/TableActionMenu.tsx` + +**What it does:** +Renders the three-dot (**⋮**) button at the end of each table row that opens a +dropdown menu with actions like "View Details", "Edit", or "Delete". + +**Props:** + +| Prop | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `items` | `ActionItem[]` | ✅ Yes | — | Array of menu items (see below) | +| `ariaLabel` | `string` | No | `"Row actions"` | Screen reader label for the button | + +**`ActionItem` object shape:** + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `label` | `string` | ✅ Yes | — | Text shown in the menu | +| `onSelect` | `() => void` | ✅ Yes | — | Function called when user clicks this item | +| `variant` | `"default"` \| `"danger"` | No | `"default"` | `"danger"` makes the text red | +| `icon` | `ReactNode` | No | — | Icon shown before the label | +| `separator` | `boolean` | No | `false` | Draws a divider line **above** this item | + +**Example — Projects page:** + +```tsx +// BEFORE: + + + + + + setViewProject(project)}>View Details + openEdit(project)}>Edit Project + + setDeleteId(project.id)} + > + Delete Project + + + + +// AFTER: + setViewProject(project) }, + { label: "Edit Project", onSelect: () => openEdit(project) }, + { + label: "Delete Project", + onSelect: () => setDeleteId(project.id), + variant: "danger", + separator: true, + icon: , + }, + ]} +/> +``` + +--- + +### 3.6 `AdminPagination` + +**File:** `components/admin/AdminPagination.tsx` + +**What it does:** +Renders the pagination footer at the bottom of a table. Shows "Showing X to Y of Z +results" on the left and page number buttons (Previous / 1 / 2 / 3 / Next) on the right. + +**Props:** + +| Prop | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `page` | `number` | ✅ Yes | — | Current active page number (starts at 1) | +| `totalPages` | `number` | ✅ Yes | — | Total number of pages | +| `total` | `number` | ✅ Yes | — | Total number of records (after filtering) | +| `itemsPerPage` | `number` | No | `10` | Used to calculate the "Showing X to Y" label | +| `onPageChange` | `(page: number) => void` | ✅ Yes | — | Called when user clicks a page button | + +**Example — Projects page (`ITEMS_PER_PAGE = 10`):** + +```tsx +// BEFORE (inline pagination block): +
+

+ Showing {...} to {...} of {filtered.length} results +

+ + {/* ... many lines of PaginationItem code ... */} + +
+ +// AFTER: + +``` + +**Example — Reports page (`ITEMS_PER_PAGE = 5`):** + +```tsx + +``` + +> **Where to place it:** Put it directly after the closing `` tag, still inside +> the same ``. + +--- + +### 3.7 `AdminStatusBadge` + +**File:** `components/admin/AdminStatusBadge.tsx` + +**What it does:** +Shows a small coloured pill with a status label. Replaces the two separate +`StatusBadge` files that existed before. Works for all three status types: + +| Usage Context | Pass this value | Shows | +|---------------|----------------|-------| +| Invitation | `"Pending"` | 🟡 Pending | +| Invitation | `"Accepted"` | 🟢 Active | +| Invitation | `"Expired"` | 🔴 Expired | +| User | `"Active"` | 🔵 Active | +| User | `"Inactive"` | ⚪ Inactive | +| Project | `"active"` | 🟢 Active | +| Project | `"pending"` | 🟡 Pending | +| Project | `"delayed"` | 🔴 Delayed | + +**Props:** + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| `status` | `string` | ✅ Yes | One of the status values listed above | + +**Example — inside a table row:** + +```tsx +// Project status in RecentProjectsTable: + + + + +// Invitation status: + + + +``` + +--- + +### 3.8 `RoleBadge` + +**File:** `components/admin/RoleBadge.tsx` + +**What it does:** +Shows a small coloured pill for user roles. Accepts both the API format +(`"student"`, `"superAdmin"`) and the display format (`"Student"`, `"SuperAdmin"`). + +| Role value | Colour | +|------------|--------| +| `"student"` / `"Student"` | Grey | +| `"mentor"` / `"Mentor"` | Green | +| `"superAdmin"` / `"SuperAdmin"` | Indigo/Purple | + +**Props:** + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| `role` | `string` | ✅ Yes | Role value (see table above) | + +**Example:** + +```tsx +// Inside a table row: + + + + +// Hardcoded value: + +``` + +--- + +### 3.9 `ConfirmDeleteDialog` + +**File:** `components/admin/ConfirmDeleteDialog.tsx` + +**What it does:** +Shows a confirmation popup (modal) when the user wants to delete something. Has a +red "Delete" button and a "Cancel" button. Disables both buttons and shows "Deleting…" +while the delete operation is in progress. + +**Props:** + +| Prop | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `open` | `boolean` | ✅ Yes | — | `true` = dialog is visible | +| `onOpenChange` | `(open: boolean) => void` | ✅ Yes | — | Called when user closes the dialog | +| `onConfirm` | `() => void` | ✅ Yes | — | Called when user clicks the Delete button | +| `isPending` | `boolean` | ✅ Yes | — | `true` while the delete is in progress | +| `title` | `string` | No | `"Confirm Delete"` | Heading text inside the dialog | +| `description` | `string` | No | `"This action cannot be undone."` | Warning text | +| `confirmLabel` | `string` | No | `"Delete"` | Text on the red button | + +**Example — Projects page:** + +```tsx +// You need these two state variables somewhere above your return(): +const [deleteId, setDeleteId] = useState(null); +const [deleting, setDeleting] = useState(false); + +// The dialog tag goes OUTSIDE the main return div, at the bottom of the component: + !open && setDeleteId(null)} + onConfirm={handleDelete} + isPending={deleting} + title="Delete Project" + description="This will permanently delete the project and all associated data. This action cannot be undone." +/> +``` + +**Example — Reports page (reports don't have delete but if added):** + +```tsx + !open && setDeleteId(null)} + onConfirm={handleDelete} + isPending={isDeleting} + title="Delete Report" + description="This will permanently remove this report entry." + confirmLabel="Yes, Delete" +/> +``` + +> **How to trigger it:** When the user clicks "Delete" in the `TableActionMenu`, +> set the delete ID: `onSelect: () => setDeleteId(project.id)`. +> The dialog becomes visible because `open={!!deleteId}` becomes `true`. + +--- + +## 4. Page-by-Page Integration Guide + +--- + +### 4.1 `projects/page.tsx` + +**File:** `server/src/app/admin/projects/page.tsx` + +**Components to use and where:** + +``` +projects/page.tsx +│ +├── 1. AdminPageLayout ← wraps the entire return() +├── 2. PageHeader ← replaces the title + "Create New Project" button div +├── 3. FilterBar ← replaces the domain filter div +│ └── FilterBar.Field ← wraps the Select dropdown +├── 4. TableStateRows ← first child inside +├── 5. TableActionMenu ← inside each , last +├── 6. AdminPagination ← replaces the pagination div below +└── 7. ConfirmDeleteDialog ← replaces the AlertDialog for project deletion +``` + +**Step-by-step:** + +**Step 1** — Add the import at the top of the file: +```tsx +import { + AdminPageLayout, + PageHeader, + FilterBar, + TableStateRows, + TableActionMenu, + AdminPagination, + ConfirmDeleteDialog, +} from "@/components/admin"; +``` + +**Step 2** — Wrap the return with `AdminPageLayout`: +```tsx +// Find this at the start of return(): +
+ +
+ +// Replace with: + +
+``` +Also remove `import AsideSidebar from "@/components/AsideSidebar"` (no longer needed directly). + +**Step 3** — Replace the page title block: +```tsx +// Find: +
+

Projects

+ +
+ +// Replace with: + { setCreateForm(EMPTY_FORM); setShowCreate(true); }} + > + + Create New Project + + } +/> +``` + +**Step 4** — Replace the filter div: +```tsx +// Find: +
+ + {domainFilter !== "all" && } +
+ +// Replace with: + + + + + {domainFilter !== "all" && ( + + )} + +``` + +**Step 5** — Replace loading/empty rows inside ``: +```tsx +// Find these two if-blocks inside : +{loading && ( ... )} +{!loading && displayedProjects.length === 0 && ( ... )} + +// Replace with ONE line: + +``` + +**Step 6** — Replace the action dropdown in each row: +```tsx +// Find the ... inside the last : + +// Replace with: + setViewProject(project) }, + { label: "Edit Project", onSelect: () => openEdit(project) }, + { + label: "Delete Project", + onSelect: () => setDeleteId(project.id), + variant: "danger", + separator: true, + icon: , + }, + ]} +/> +``` + +**Step 7** — Replace the pagination div: +```tsx +// Find the div with "Showing ... to ... of ... results" and the block + +// Replace the entire div with: + +``` + +**Step 8** — Replace the delete AlertDialog: +```tsx +// Find: + !open && setDeleteId(null)}> + + ... + + + +// Replace with: + !open && setDeleteId(null)} + onConfirm={handleDelete} + isPending={deleting} + title="Delete Project" + description="This will permanently delete the project and all associated data. This action cannot be undone." +/> +``` + +**Imports you can now REMOVE** from the file after this refactor: +- `import AsideSidebar from "@/components/AsideSidebar"` +- `AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle` from `@/components/ui/alert-dialog` +- `MoreVertical` from `lucide-react` +- `Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious` from `@/components/ui/pagination` +- `DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger` from `@/components/dropdown-menu` + +--- + +### 4.2 `reports/page.tsx` + +**File:** `server/src/app/admin/reports/page.tsx` + +**Components to use and where:** + +``` +reports/page.tsx +│ +├── 1. AdminPageLayout ← wraps the entire return() +├── 2. PageHeader ← replaces the title + "Generate Report" button div +├── 3. FilterBar ← replaces the filter div above the table +│ ├── FilterBar.Field ← wraps each Select and date input +├── 4. TableStateRows ← first child inside +└── 5. AdminPagination ← replaces the pagination div below +``` + +> Reports page has **no delete** and **no action dropdown**, so `ConfirmDeleteDialog`, +> `TableActionMenu`, `RoleBadge` and `AdminStatusBadge` are not needed here. + +**Step 1** — Add the import: +```tsx +import { + AdminPageLayout, + PageHeader, + FilterBar, + TableStateRows, + AdminPagination, +} from "@/components/admin"; +``` + +**Step 2** — Wrap with `AdminPageLayout`: +```tsx +// Find: +
+ +
+ +// Replace with: + +
+``` + +**Step 3** — Replace the title block: +```tsx +// Find: +
+

Reports

+ +
+ +// Replace with: + + {isExporting ? "Generating..." : "Generate Report"} + + } +/> +``` + +**Step 4** — Replace the filter section: +```tsx +// Find the div that contains the Project, Mentor, From, To selects and the Reset button + +// Replace with: + + + + + + + + + + + { setDateFrom(e.target.value); setPage(1); }} + className="border border-[#E5E5E5] rounded-md px-3 py-2 text-sm bg-white" /> + + + + { setDateTo(e.target.value); setPage(1); }} + className="border border-[#E5E5E5] rounded-md px-3 py-2 text-sm bg-white" /> + + + + +``` + +**Step 5** — Replace loading/error/empty rows inside ``: +```tsx + +``` + +**Step 6** — Replace the pagination block: +```tsx + +``` + +**Imports you can now REMOVE:** +- `import AsideSidebar from "@/components/AsideSidebar"` +- `Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious` from `@/components/ui/pagination` + +--- + +### 4.3 `SuperAdminDashboard.tsx` + +**File:** `server/src/components/admin-dashboard/SuperAdminDashboard.tsx` + +**Components to use and where:** + +``` +SuperAdminDashboard.tsx +│ +├── 1. AdminPageLayout ← wraps the entire return() +└── 2. AdminStatusBadge ← inside RecentProjectsTable for project status +``` + +> The dashboard already has its own `DashboardHeader`, `StatsGrid`, and +> `RecentProjectsTable` sub-components, so most of the heavy lifting is done. +> The main gain here is replacing `AsideSidebar` with `AdminPageLayout` and +> adopting `AdminStatusBadge` in `RecentProjectsTable.tsx`. + +**Step 1** — Add the import in `SuperAdminDashboard.tsx`: +```tsx +import { AdminPageLayout } from "@/components/admin"; +``` + +**Step 2** — Wrap the return: +```tsx +// Find: +return ( +
+ +
+ +// Replace with: +return ( + +
+``` + +**Step 3** — In `RecentProjectsTable.tsx`, adopt `AdminStatusBadge`: +```tsx +// Add import at the top of RecentProjectsTable.tsx: +import { AdminStatusBadge } from "@/components/admin"; + +// Find the existing StatusBadge usage: +import StatusBadge, { ProjectStatus } from "./StatusBadge"; +// ... + + +// Replace with: + +``` + +This means you no longer need `components/admin-dashboard/StatusBadge.tsx` for new +work — the unified `AdminStatusBadge` handles it. + +--- + +### 4.4 `users/page.tsx` + +**File:** `server/src/app/admin/users/page.tsx` + +**What this page does:** +Lists every user registered in the system. Admins can filter by role and status, +search by name/email/ID, view a user's full details, change their active status, +and delete accounts. + +**Components used from `@/components/admin`:** + +| Shared Component | Replaces | +|-----------------|----------| +| `AdminPageLayout` | `
` | +| `PageHeader` | Inline title + subtitle `
` | +| `FilterBar` + `.Field` + `.Search` | Hand-rolled filter bar with custom label `

` tags | +| `TableStateRows` | Three separate loading / error / empty `` blocks | +| `TableActionMenu` | `` in each row | +| `RoleBadge` | `roleClass()` helper + inline `` | +| `AdminStatusBadge` | `statusClass()` helper + inline `` | +| `AdminPagination` | Showing-count `

` + `` block | +| `ConfirmDeleteDialog` | The delete `` | + +> **Note — Change Status dialog:** The users page has a **second** dialog +> (`statusUser` / `handleChangeStatus`) that toggles a user between Active and +> Inactive. There is no shared component for this yet, so keep that `` +> block as-is for now. + +--- + +#### Step 1 — Add the import + +At the top of `users/page.tsx`, add one import line and remove the imports that +are no longer needed: + +```tsx +// ADD this line: +import { + AdminPageLayout, + PageHeader, + FilterBar, + TableStateRows, + TableActionMenu, + RoleBadge, + AdminStatusBadge, + AdminPagination, + ConfirmDeleteDialog, +} from "@/components/admin"; + +// REMOVE these (they are now covered by the shared components above): +// import AsideSidebar from "@/components/AsideSidebar"; +// import { EllipsisVertical, Search } from "lucide-react"; // ← Search only; keep EllipsisVertical if used elsewhere +// import { AlertDialog, AlertDialogAction, AlertDialogCancel, +// AlertDialogContent, AlertDialogDescription, AlertDialogFooter, +// AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; // Keep ONE block for the Change Status dialog +// import { Pagination, PaginationContent, PaginationEllipsis, +// PaginationItem, PaginationLink } from "@/components/ui/pagination"; +// import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, +// DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/dropdown-menu"; +``` + +> **Tip:** Keep the `AlertDialog` import — you still need it for the **Change Status** +> dialog. Remove everything else that the shared components replace. + +--- + +#### Step 2 — Wrap with `AdminPageLayout` + +```tsx +// BEFORE: +return ( + <> +

+ +
+ {/* ... */} +
+
+ {/* dialogs below */} + +); + +// AFTER: +return ( + <> + +
+ {/* ... */} +
+
+ {/* dialogs below */} + +); +``` + +> `AdminPageLayout` uses `bg-[#f5f7fb]` as its default background — exactly what +> the users page already used, so no `className` override is needed. + +--- + +#### Step 3 — Replace the title block with `PageHeader` + +```tsx +// BEFORE: +
+

User Administration

+

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

+
+ +// AFTER: + +``` + +--- + +#### Step 4 — Replace the filter bar with `FilterBar` + +```tsx +// BEFORE: +
+
+

Role

+ +
+
+

Status

+ +
+
+

Search

+
+ + +
+
+
+ +// AFTER: + + + + + + + + + + { setSearch(value); setPage(1); }} + placeholder="Search by name, email, or ID..." + /> + +``` + +--- + +#### Step 5 — Replace loading / error / empty rows with `TableStateRows` + +The table has **5 columns** (`colSpan={5}`). + +```tsx +// BEFORE: + + {isLoading ? ( + + + Loading users... + + + ) : fetchError ? ( + + + {fetchError} + + + ) : visibleUsers.length === 0 ? ( + + + No users found for the selected filters. + + + ) : ( + visibleUsers.map((user) => ( /* rows */ )) + )} + + +// AFTER: + + + {!isLoading && !fetchError && visibleUsers.map((user) => ( + + {/* ...cells unchanged... */} + + ))} + +``` + +--- + +#### Step 6 — Replace role badge with `RoleBadge` + +```tsx +// BEFORE: + + + {user.role} + + + +// AFTER: + + + +``` + +You can now also **delete** the `roleClass()` helper function — it is no longer needed. + +--- + +#### Step 7 — Replace status badge with `AdminStatusBadge` + +```tsx +// BEFORE: + + + + {user.status} + + + +// AFTER: + + + +``` + +You can now also **delete** the `statusClass()` helper function. + +--- + +#### Step 8 — Replace the Actions dropdown with `TableActionMenu` + +```tsx +// BEFORE: + + + + + + + setViewUser(user)}>View Details + { setStatusUser(user); setPendingStatus(user.status === "Active" ? "Inactive" : "Active"); }}> + Change Status + + + setDeleteUserId(user.id)}> + Delete User + + + + + +// AFTER: + + setViewUser(user), + }, + { + label: "Change Status", + onSelect: () => { + setStatusUser(user); + setPendingStatus(user.status === "Active" ? "Inactive" : "Active"); + }, + }, + { separator: true, label: "", onSelect: () => {} }, + { + label: "Delete User", + variant: "danger", + onSelect: () => setDeleteUserId(user.id), + }, + ]} + /> + +``` + +--- + +#### Step 9 — Replace pagination with `AdminPagination` + +```tsx +// BEFORE: +
+

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

+ + + {/* Previous / page-number / Next buttons */} + + +
+ +// AFTER: + +``` + +You can now delete the `startCount` / `endCount` variables and the `goToPage` helper +(or keep `goToPage` if `AdminPagination`'s `onPageChange` calls it directly — either +way is fine). + +--- + +#### Step 10 — Replace the Delete `AlertDialog` with `ConfirmDeleteDialog` + +```tsx +// BEFORE (somewhere after the return's main div): + { if (!open) setDeleteUserId(null); }}> + + + Delete User + + This action cannot be undone. The user account and all associated data will + be permanently removed. + + + + Cancel + + {isMutating ? "Deleting..." : "Delete"} + + + + + +// AFTER: + { if (!open) setDeleteUserId(null); }} + onConfirm={handleDeleteUser} + isPending={isMutating} + title="Delete User" + description="This action cannot be undone. The user account and all associated data will be permanently removed." +/> +``` + +> **Keep the Change Status `` as-is** — there is no shared component +> for it yet. The `statusUser` / `handleChangeStatus` dialog stays unchanged. + +--- + +## 5. Quick Cheat Sheet + +| Component | File | Replaces | +|-----------|------|---------| +| `` | `admin/AdminPageLayout.tsx` | `
` | +| `` | `admin/PageHeader.tsx` | Title + action button divs | +| `` + `` + `` | `admin/FilterBar.tsx` | Filter row divs with labels | +| `` | `admin/TableStateRows.tsx` | Loading / error / empty `` blocks | +| `` | `admin/TableActionMenu.tsx` | `` in table rows | +| `` | `admin/AdminPagination.tsx` | Pagination div with count + page buttons | +| `` | `admin/AdminStatusBadge.tsx` | Both old `StatusBadge` files | +| `` | `admin/RoleBadge.tsx` | Inline `roleClass()` spans | +| `` | `admin/ConfirmDeleteDialog.tsx` | Delete `` | + +**Single import line covers all of the above:** +```tsx +import { + AdminPageLayout, PageHeader, FilterBar, + TableStateRows, TableActionMenu, AdminPagination, + AdminStatusBadge, RoleBadge, ConfirmDeleteDialog, +} from "@/components/admin"; +``` + +--- + +> **To convert this file to PDF:** +> - **VS Code:** Install the "Markdown PDF" extension → right-click this file → *Export (pdf)* +> - **Browser:** Open the preview panel → right-click → *Print* → *Save as PDF* +> - **Online:** Paste into [markdowntopdf.com](https://www.markdowntopdf.com) diff --git a/server/src/components/admin/AdminPageLayout.tsx b/server/src/components/admin/AdminPageLayout.tsx new file mode 100644 index 00000000..1e87ff6f --- /dev/null +++ b/server/src/components/admin/AdminPageLayout.tsx @@ -0,0 +1,32 @@ +"use client"; + +import AsideSidebar from "@/components/AsideSidebar"; + +interface AdminPageLayoutProps { + children: React.ReactNode; + /** Tailwind bg class for the page background. Defaults to bg-[#f5f7fb] */ + className?: string; +} + +/** + * Shared layout wrapper for all admin pages. + * Renders the sidebar alongside page content. + * + * Usage: + * + *
+ * ...page content... + *
+ *
+ */ +export default function AdminPageLayout({ + children, + className = "bg-[#f5f7fb]", +}: AdminPageLayoutProps) { + return ( +
+ + {children} +
+ ); +} diff --git a/server/src/components/admin/AdminPagination.tsx b/server/src/components/admin/AdminPagination.tsx new file mode 100644 index 00000000..dcf62797 --- /dev/null +++ b/server/src/components/admin/AdminPagination.tsx @@ -0,0 +1,84 @@ +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; + +interface AdminPaginationProps { + page: number; + totalPages: number; + total: number; + /** Items per page – used to compute the "Showing X to Y" label. Default 10 */ + itemsPerPage?: number; + onPageChange: (page: number) => void; +} + +/** + * Shared pagination footer for all admin tables. + * Shows "Showing X to Y of Z results" + prev/next/page links. + * + * Usage: + * + */ +export default function AdminPagination({ + page, + totalPages, + total, + itemsPerPage = 10, + onPageChange, +}: AdminPaginationProps) { + const from = total === 0 ? 0 : (page - 1) * itemsPerPage + 1; + const to = Math.min(page * itemsPerPage, total); + + return ( +
+

+ Showing{" "} + {from} to{" "} + {to} of{" "} + {total} results +

+ + + + onPageChange(Math.max(1, page - 1))} + /> + + {Array.from({ length: Math.min(totalPages, 5) }, (_, i) => i + 1).map( + (p) => ( + + onPageChange(p)} + > + {p} + + + ), + )} + {totalPages > 5 && ( + + + + )} + + onPageChange(Math.min(totalPages, page + 1))} + /> + + + +
+ ); +} diff --git a/server/src/components/admin/AdminStatusBadge.tsx b/server/src/components/admin/AdminStatusBadge.tsx new file mode 100644 index 00000000..4d90c179 --- /dev/null +++ b/server/src/components/admin/AdminStatusBadge.tsx @@ -0,0 +1,106 @@ +/** + * Unified status badge that covers all admin status types in one component, + * replacing the two separate StatusBadge files that existed in + * admin-dashboard/ and Invitations/. + * + * Supported status values: + * Invitation: "Pending" | "Accepted" | "Expired" + * User: "Active" | "Inactive" + * Project: "active" | "pending" | "delayed" + * + * Usage: + * + * + * + */ + +export type StatusValue = + | "Pending" + | "Accepted" + | "Expired" + | "Active" + | "Inactive" + | "active" + | "pending" + | "delayed"; + +interface AdminStatusBadgeProps { + status: StatusValue | string; +} + +const config: Record< + string, + { bg: string; text: string; border: string; dot?: string; label: string } +> = { + Pending: { + bg: "bg-amber-50", + text: "text-amber-600", + border: "border-amber-200/60", + dot: "bg-amber-500", + label: "Pending", + }, + pending: { + bg: "bg-amber-50", + text: "text-amber-700", + border: "border-amber-100", + label: "Pending", + }, + Accepted: { + bg: "bg-emerald-50", + text: "text-emerald-600", + border: "border-emerald-200/60", + dot: "bg-emerald-500", + label: "Active", + }, + active: { + bg: "bg-emerald-50", + text: "text-emerald-700", + border: "border-emerald-100", + label: "Active", + }, + Active: { + bg: "bg-blue-50", + text: "text-blue-600", + border: "border-blue-200/60", + dot: "bg-blue-500", + label: "Active", + }, + Inactive: { + bg: "bg-slate-50", + text: "text-slate-500", + border: "border-slate-200", + dot: "bg-slate-400", + label: "Inactive", + }, + Expired: { + bg: "bg-rose-50", + text: "text-rose-600", + border: "border-rose-200/60", + dot: "bg-rose-500", + label: "Expired", + }, + delayed: { + bg: "bg-rose-50", + text: "text-rose-700", + border: "border-rose-100", + label: "Delayed", + }, +}; + +export default function AdminStatusBadge({ status }: AdminStatusBadgeProps) { + const c = config[status] ?? { + bg: "bg-slate-50", + text: "text-slate-600", + border: "border-slate-200", + label: status, + }; + + return ( + + {c.dot && } + {c.label} + + ); +} diff --git a/server/src/components/admin/ConfirmDeleteDialog.tsx b/server/src/components/admin/ConfirmDeleteDialog.tsx new file mode 100644 index 00000000..e8da93c0 --- /dev/null +++ b/server/src/components/admin/ConfirmDeleteDialog.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +interface ConfirmDeleteDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + isPending: boolean; + /** Dialog title. Default: "Confirm Delete" */ + title?: string; + /** Description / warning text shown under the title */ + description?: string; + /** Label for the destructive button. Default: "Delete" */ + confirmLabel?: string; +} + +/** + * Generic delete-confirmation dialog (AlertDialog) reusable across all + * admin pages (users, projects, invitations, etc.). + * + * Usage: + * !open && setDeleteId(null)} + * onConfirm={handleDelete} + * isPending={isDeleting} + * title="Delete User" + * description="This will permanently delete the user account and all associated data. This action cannot be undone." + * /> + */ +export default function ConfirmDeleteDialog({ + open, + onOpenChange, + onConfirm, + isPending, + title = "Confirm Delete", + description = "This action cannot be undone.", + confirmLabel = "Delete", +}: ConfirmDeleteDialogProps) { + return ( + + + + {title} + {description} + + + Cancel + + {isPending ? "Deleting…" : confirmLabel} + + + + + ); +} diff --git a/server/src/components/admin/FilterBar.tsx b/server/src/components/admin/FilterBar.tsx new file mode 100644 index 00000000..9e39af23 --- /dev/null +++ b/server/src/components/admin/FilterBar.tsx @@ -0,0 +1,119 @@ +"use client"; + +import React from "react"; + +import { Search } from "lucide-react"; + +// ─── Sub-component: FilterField ────────────────────────────────────────────── + +interface FilterFieldProps { + /** Short uppercase label shown above the control */ + label: string; + children: React.ReactNode; + className?: string; +} + +/** + * A labeled wrapper for a single filter control (Select, date input, etc.). + * + * Usage: + * + * + * + */ +function FilterField({ label, children, className }: FilterFieldProps) { + return ( +
+

+ {label} +

+ {children} +
+ ); +} + +// ─── Sub-component: FilterSearch ───────────────────────────────────────────── + +interface FilterSearchProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; +} + +/** + * Search input with a magnifier icon, auto-positioned to the far right of the + * filter bar via ml-auto. + * + * Usage: + * { setSearch(v); setPage(1); }} + * placeholder="Search by name, email, or ID..." + * /> + */ +function FilterSearch({ + value, + onChange, + placeholder = "Search...", +}: FilterSearchProps) { + return ( + +
+ + onChange(e.target.value)} + placeholder={placeholder} + className="h-8 w-full sm:w-[360px] rounded-lg border border-[#dbe0e8] bg-white pl-9 pr-3 text-sm text-slate-700 outline-none transition focus:border-slate-400" + /> +
+
+ ); +} + +// ─── Root component: FilterBar ─────────────────────────────────────────────── + +interface FilterBarProps { + children: React.ReactNode; +} + +/** + * Horizontal filter bar used at the top of admin data tables. + * Provides consistent spacing, border and background styling. + * + * Compound sub-components: + * FilterBar.Field – labeled wrapper for any filter control + * FilterBar.Search – search input auto-aligned to the right + * + * Usage: + * + * + * + * + * + * { setSearch(v); setPage(1); }} + * placeholder="Search by name, email, or ID..." + * /> + * + */ +function FilterBar({ children }: FilterBarProps) { + return ( +
+ {children} +
+ ); +} + +FilterBar.Field = FilterField; +FilterBar.Search = FilterSearch; + +export default FilterBar; diff --git a/server/src/components/admin/PageHeader.tsx b/server/src/components/admin/PageHeader.tsx new file mode 100644 index 00000000..e48c0025 --- /dev/null +++ b/server/src/components/admin/PageHeader.tsx @@ -0,0 +1,63 @@ +"use client"; + +interface PageHeaderProps { + title: string; + subtitle?: string; + /** Optional button/element rendered on the right side */ + action?: React.ReactNode; + /** + * "inline" – plain title+action row inside a Card (default) + * "banner" – white top-bar with border-b (used by invitation page) + */ + variant?: "inline" | "banner"; +} + +/** + * Shared page header for all admin pages. + * + * Inline usage (inside a Card): + * + Create New Project} + * /> + * + * Banner usage (full-width top bar): + * Create Invitation} + * /> + */ +export default function PageHeader({ + title, + subtitle, + action, + variant = "inline", +}: PageHeaderProps) { + if (variant === "banner") { + return ( +
+
+

{title}

+ {subtitle && ( +

{subtitle}

+ )} +
+ {action &&
{action}
} +
+ ); + } + + return ( +
+
+

{title}

+ {subtitle && ( +

{subtitle}

+ )} +
+ {action &&
{action}
} +
+ ); +} diff --git a/server/src/components/admin/RoleBadge.tsx b/server/src/components/admin/RoleBadge.tsx new file mode 100644 index 00000000..c5d28a8c --- /dev/null +++ b/server/src/components/admin/RoleBadge.tsx @@ -0,0 +1,50 @@ +export type RoleValue = + | "student" + | "mentor" + | "superAdmin" + | "Student" + | "Mentor" + | "SuperAdmin"; + +interface RoleBadgeProps { + role: RoleValue | string; +} + +const styles: Record = { + student: "bg-slate-100 text-slate-700 border-slate-200", + Student: "bg-slate-100 text-slate-700 border-slate-200", + mentor: "bg-emerald-50 text-emerald-700 border-emerald-200", + Mentor: "bg-emerald-50 text-emerald-700 border-emerald-200", + superAdmin: "bg-indigo-50 text-indigo-700 border-indigo-200", + SuperAdmin: "bg-indigo-50 text-indigo-700 border-indigo-200", +}; + +const labels: Record = { + student: "Student", + Student: "Student", + mentor: "Mentor", + Mentor: "Mentor", + superAdmin: "Super Admin", + SuperAdmin: "Super Admin", +}; + +/** + * Colored pill badge for user roles (Student / Mentor / SuperAdmin). + * Accepts both camelCase API values ("student", "superAdmin") and + * display-case values ("Student", "SuperAdmin"). + * + * Usage: + * + * + */ +export default function RoleBadge({ role }: RoleBadgeProps) { + return ( + + {labels[role] ?? role} + + ); +} diff --git a/server/src/components/admin/TableActionMenu.tsx b/server/src/components/admin/TableActionMenu.tsx new file mode 100644 index 00000000..88e7a270 --- /dev/null +++ b/server/src/components/admin/TableActionMenu.tsx @@ -0,0 +1,81 @@ +"use client"; + +import React from "react"; + +import { MoreVertical } from "lucide-react"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/dropdown-menu"; + +export interface ActionItem { + label: string; + onSelect: () => void; + /** "danger" renders text and focus state in red. Default: "default" */ + variant?: "default" | "danger"; + /** Optional icon rendered before the label */ + icon?: React.ReactNode; + /** Renders a separator line ABOVE this item */ + separator?: boolean; +} + +interface TableActionMenuProps { + items: ActionItem[]; + /** Accessible label for the trigger button. Default: "Row actions" */ + ariaLabel?: string; +} + +/** + * Reusable three-dot (⋮) action dropdown used in table rows across all admin + * pages (users, projects, invitations). + * + * Usage: + * setViewUser(user) }, + * { label: "Change Status", onSelect: () => setStatusUser(user) }, + * { label: "Delete User", onSelect: () => setDeleteId(user.id), variant: "danger", separator: true }, + * ]} + * /> + */ +export default function TableActionMenu({ + items, + ariaLabel = "Row actions", +}: TableActionMenuProps) { + return ( + + + + + + {items.map((item, index) => ( + + {item.separator && } + + {item.icon && {item.icon}} + {item.label} + + + ))} + + + ); +} diff --git a/server/src/components/admin/TableStateRows.tsx b/server/src/components/admin/TableStateRows.tsx new file mode 100644 index 00000000..9989bc86 --- /dev/null +++ b/server/src/components/admin/TableStateRows.tsx @@ -0,0 +1,80 @@ +import { TableCell, TableRow } from "@/components/ui/table"; + +interface TableStateRowsProps { + /** Number of columns – used for colSpan so the row spans the full table */ + colSpan: number; + loading: boolean; + /** Pass the error message string, any truthy value, or null/false to hide */ + error?: string | null | boolean; + /** Pass true when the data array is empty (and not loading/erroring) */ + empty?: boolean; + emptyMessage?: string; + loadingMessage?: string; +} + +/** + * Renders a single-row placeholder inside for the three common + * async states: loading, error, and empty results. + * + * Usage: + * + * + * {!isLoading && visibleRows.map((row) => ...)} + * + */ +export default function TableStateRows({ + colSpan, + loading, + error, + empty, + emptyMessage = "No records found.", + loadingMessage = "Loading...", +}: TableStateRowsProps) { + if (loading) { + return ( + + + {loadingMessage} + + + ); + } + + if (error) { + return ( + + + {typeof error === "string" ? error : "Failed to load data."} + + + ); + } + + if (empty) { + return ( + + + {emptyMessage} + + + ); + } + + return null; +} diff --git a/server/src/components/admin/index.ts b/server/src/components/admin/index.ts new file mode 100644 index 00000000..8a83e74a --- /dev/null +++ b/server/src/components/admin/index.ts @@ -0,0 +1,10 @@ +export { default as AdminPageLayout } from "./AdminPageLayout"; +export { default as PageHeader } from "./PageHeader"; +export { default as AdminPagination } from "./AdminPagination"; +export { default as ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +export { default as RoleBadge } from "./RoleBadge"; +export { default as AdminStatusBadge } from "./AdminStatusBadge"; +export { default as TableActionMenu } from "./TableActionMenu"; +export type { ActionItem } from "./TableActionMenu"; +export { default as TableStateRows } from "./TableStateRows"; +export { default as FilterBar } from "./FilterBar"; From 13ea33752e7627bc68a5afef043b864f919db16c Mon Sep 17 00:00:00 2001 From: Muditha <89349527+muditha20@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:55:34 +0530 Subject: [PATCH 12/18] changed table view of report page --- server/src/app/admin/reports/page.tsx | 126 ++++++++-------- .../src/components/reports/ReportsFilters.tsx | 136 +++++++++--------- .../components/reports/ReportsPagination.tsx | 89 +----------- .../src/components/reports/ReportsTable.tsx | 87 +++++++++-- .../components/reports/useReportsFilters.ts | 5 - server/src/components/ui/table-pagination.tsx | 125 ++++++++++++++++ 6 files changed, 333 insertions(+), 235 deletions(-) create mode 100644 server/src/components/ui/table-pagination.tsx diff --git a/server/src/app/admin/reports/page.tsx b/server/src/app/admin/reports/page.tsx index f158fa79..18247a39 100644 --- a/server/src/app/admin/reports/page.tsx +++ b/server/src/app/admin/reports/page.tsx @@ -32,7 +32,6 @@ export default function ReportsPage() { visibleReports, safePage, totalPages, - pageNumbers, startCount, endCount, setPage, @@ -48,67 +47,72 @@ export default function ReportsPage() { return (
-
- {/* Header + stat summary */} - -
-

Reports

- -
- -
+
+ + {/* Header + stat summary */} + +
+

Reports

+ +
+ +
+ + {/* Filters */} + + { + setProjectFilter(v); + setPage(1); + }} + onMentorChange={(v) => { + setMentorFilter(v); + setPage(1); + }} + onDateFromChange={(v) => { + setDateFrom(v); + setPage(1); + }} + onDateToChange={(v) => { + setDateTo(v); + setPage(1); + }} + onReset={resetFilters} + /> + - {/* Filters, table, and pagination */} - - { - setProjectFilter(v); - setPage(1); - }} - onMentorChange={(v) => { - setMentorFilter(v); - setPage(1); - }} - onDateFromChange={(v) => { - setDateFrom(v); - setPage(1); - }} - onDateToChange={(v) => { - setDateTo(v); - setPage(1); - }} - onReset={resetFilters} - /> - - + {/* Table and pagination */} + + + +
diff --git a/server/src/components/reports/ReportsFilters.tsx b/server/src/components/reports/ReportsFilters.tsx index 293fd23b..e81e87b6 100644 --- a/server/src/components/reports/ReportsFilters.tsx +++ b/server/src/components/reports/ReportsFilters.tsx @@ -36,76 +36,74 @@ export function ReportsFilters({ onReset, }: Props) { return ( -
-
-
- - -
-
- - -
-
- - onDateFromChange(e.target.value)} - className="border border-[#E5E5E5] rounded-md px-3 py-2 text-sm text-[#0A0A0A] bg-white focus:outline-none focus:ring-2 focus:ring-[#3B82F6]" - /> -
-
- - onDateToChange(e.target.value)} - className="border border-[#E5E5E5] rounded-md px-3 py-2 text-sm text-[#0A0A0A] bg-white focus:outline-none focus:ring-2 focus:ring-[#3B82F6]" - /> -
- +
+
+ +
+
+ + +
+
+ + onDateFromChange(e.target.value)} + className="border border-[#E5E5E5] rounded-md px-3 py-2 text-sm text-[#0A0A0A] bg-white focus:outline-none focus:ring-2 focus:ring-[#3B82F6]" + /> +
+
+ + onDateToChange(e.target.value)} + className="border border-[#E5E5E5] rounded-md px-3 py-2 text-sm text-[#0A0A0A] bg-white focus:outline-none focus:ring-2 focus:ring-[#3B82F6]" + /> +
+
); } diff --git a/server/src/components/reports/ReportsPagination.tsx b/server/src/components/reports/ReportsPagination.tsx index fcb47bf8..4e740108 100644 --- a/server/src/components/reports/ReportsPagination.tsx +++ b/server/src/components/reports/ReportsPagination.tsx @@ -1,12 +1,4 @@ -import { - Pagination, - PaginationContent, - PaginationEllipsis, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, -} from "@/components/ui/pagination"; +import { TablePagination } from "@/components/ui/table-pagination"; interface Props { startCount: number; @@ -14,83 +6,10 @@ interface Props { totalCount: number; safePage: number; totalPages: number; - pageNumbers: number[]; onPageChange: (page: number) => void; } -// Footer pagination -export function ReportsPagination({ - startCount, - endCount, - totalCount, - safePage, - totalPages, - pageNumbers, - onPageChange, -}: Props) { - return ( -
-

- Showing {startCount} to {endCount} of {totalCount} entries -

- - - - onPageChange(Math.max(1, safePage - 1))} - /> - - {safePage > 2 ? ( - <> - - onPageChange(1)} - > - 1 - - - {safePage > 3 ? ( - - - - ) : null} - - ) : null} - {pageNumbers.map((pageNumber) => ( - - onPageChange(pageNumber)} - > - {pageNumber} - - - ))} - {safePage < totalPages - 1 ? ( - <> - {safePage < totalPages - 2 ? ( - - - - ) : null} - - onPageChange(totalPages)} - > - {totalPages} - - - - ) : null} - - onPageChange(Math.min(totalPages, safePage + 1))} - /> - - - -
- ); +// Footer pagination with entry count and windowed page links +export function ReportsPagination(props: Props) { + return ; } diff --git a/server/src/components/reports/ReportsTable.tsx b/server/src/components/reports/ReportsTable.tsx index 9ab48df0..634eb5b1 100644 --- a/server/src/components/reports/ReportsTable.tsx +++ b/server/src/components/reports/ReportsTable.tsx @@ -1,3 +1,17 @@ +import { + BarChart2, + BookOpen, + Briefcase, + Cpu, + FolderOpen, + GraduationCap, + Layers, + Monitor, + Palette, + Users, + type LucideIcon, +} from "lucide-react"; + import { Table, TableBody, @@ -9,6 +23,37 @@ import { import { ReportRow } from "./types"; +interface IconConfig { + Icon: LucideIcon; + bg: string; + color: string; +} + +// Maps keywords in the project name to an appropriate icon (all use grey styling) +function getProjectIcon(name: string): IconConfig { + const lower = name.toLowerCase(); + if (/ui|ux|design|figma|visual/.test(lower)) + return { Icon: Palette, bg: "bg-[#f0f0f0]", color: "text-[#737373]" }; + if (/train|learn|educat|course|portal/.test(lower)) + return { Icon: BookOpen, bg: "bg-[#f0f0f0]", color: "text-[#737373]" }; + if (/logbook|log|digital|mvp|app/.test(lower)) + return { Icon: Cpu, bg: "bg-[#f0f0f0]", color: "text-[#737373]" }; + if (/data|analytic|report|metric|stat/.test(lower)) + return { Icon: BarChart2, bg: "bg-[#f0f0f0]", color: "text-[#737373]" }; + if (/mentor|coach|guide/.test(lower)) + return { Icon: Users, bg: "bg-[#f0f0f0]", color: "text-[#737373]" }; + if (/student|grad|school|academ/.test(lower)) + return { Icon: GraduationCap, bg: "bg-[#f0f0f0]", color: "text-[#737373]" }; + if (/web|site|platform/.test(lower)) + return { Icon: Monitor, bg: "bg-[#f0f0f0]", color: "text-[#737373]" }; + if (/system|infra|layer|stack/.test(lower)) + return { Icon: Layers, bg: "bg-[#f0f0f0]", color: "text-[#737373]" }; + if (/business|enterprise|client|work/.test(lower)) + return { Icon: Briefcase, bg: "bg-[#f0f0f0]", color: "text-[#737373]" }; + // Default fallback + return { Icon: FolderOpen, bg: "bg-[#f0f0f0]", color: "text-[#737373]" }; +} + interface Props { isLoading: boolean; fetchError: string | null; @@ -20,7 +65,7 @@ export function ReportsTable({ isLoading, fetchError, visibleReports }: Props) { return ( - + Project Name @@ -64,20 +109,32 @@ export function ReportsTable({ isLoading, fetchError, visibleReports }: Props) { ) : ( - visibleReports.map((report) => ( - - -
- {report.projectName} -
-
- {report.mentor} - - {report.studentsCount} - - {report.date} -
- )) + visibleReports.map((report) => { + const { Icon, bg, color } = getProjectIcon(report.projectName); + return ( + + +
+
+ +
+ + {report.projectName} + +
+
+ + {report.mentor} + + + {report.studentsCount} + + {report.date} +
+ ); + }) )}
diff --git a/server/src/components/reports/useReportsFilters.ts b/server/src/components/reports/useReportsFilters.ts index d17c8e16..341ec128 100644 --- a/server/src/components/reports/useReportsFilters.ts +++ b/server/src/components/reports/useReportsFilters.ts @@ -49,10 +49,6 @@ export function useReportsFilters(tableRows: ReportRow[]) { Math.ceil(filteredReports.length / ITEMS_PER_PAGE), ); const safePage = Math.min(page, totalPages); - const pageNumbers = Array.from( - { length: totalPages }, - (_, i) => i + 1, - ).filter((n) => Math.abs(n - safePage) <= 1); const startIndex = (safePage - 1) * ITEMS_PER_PAGE; const visibleReports = filteredReports.slice( startIndex, @@ -91,7 +87,6 @@ export function useReportsFilters(tableRows: ReportRow[]) { visibleReports, safePage, totalPages, - pageNumbers, startCount, endCount, setPage, diff --git a/server/src/components/ui/table-pagination.tsx b/server/src/components/ui/table-pagination.tsx new file mode 100644 index 00000000..8f51ece4 --- /dev/null +++ b/server/src/components/ui/table-pagination.tsx @@ -0,0 +1,125 @@ +// Generic paginated-table footer: entry count label + windowed page links. +// Mirrors the pagination style used in the Users page. +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, +} from "@/components/ui/pagination"; + +export interface TablePaginationProps { + startCount: number; + endCount: number; + totalCount: number; + safePage: number; + totalPages: number; + onPageChange: (page: number) => void; + /** Label shown after the count, e.g. "entries" or "users". Defaults to "entries". */ + itemLabel?: string; +} + +export function TablePagination({ + startCount, + endCount, + totalCount, + safePage, + totalPages, + onPageChange, + itemLabel = "entries", +}: TablePaginationProps) { + // Windowed page numbers: current ± 1 + const pageNumbers = Array.from( + { length: totalPages }, + (_, i) => i + 1, + ).filter((n) => Math.abs(n - safePage) <= 1); + + return ( +
+

+ Showing {startCount} to {endCount} of {totalCount} {itemLabel} +

+ + + + {/* Previous */} + + onPageChange(Math.max(1, safePage - 1))} + disabled={safePage <= 1} + aria-label="Previous page" + > + {"<"} + + + + {/* First page + ellipsis when far from start */} + {safePage > 2 ? ( + <> + + onPageChange(1)} + > + 1 + + + {safePage > 3 ? ( + + + + ) : null} + + ) : null} + + {/* Windowed page numbers */} + {pageNumbers.map((pageNumber) => ( + + onPageChange(pageNumber)} + > + {pageNumber} + + + ))} + + {/* Last page + ellipsis when far from end */} + {safePage < totalPages - 1 ? ( + <> + {safePage < totalPages - 2 ? ( + + + + ) : null} + + onPageChange(totalPages)} + > + {totalPages} + + + + ) : null} + + {/* Next */} + + onPageChange(Math.min(totalPages, safePage + 1))} + disabled={safePage >= totalPages} + aria-label="Next page" + > + {">"} + + + + +
+ ); +} From affac5cadd5047029c6275d5da6ca085d82b8e11 Mon Sep 17 00:00:00 2001 From: Muditha <89349527+muditha20@users.noreply.github.com> Date: Sun, 22 Mar 2026 15:30:03 +0530 Subject: [PATCH 13/18] Apply shared admin components to reports page --- server/src/app/admin/reports/page.tsx | 98 +++++++++---------- .../src/components/reports/ReportsFilters.tsx | 41 +++----- 2 files changed, 64 insertions(+), 75 deletions(-) diff --git a/server/src/app/admin/reports/page.tsx b/server/src/app/admin/reports/page.tsx index 18247a39..4fd47374 100644 --- a/server/src/app/admin/reports/page.tsx +++ b/server/src/app/admin/reports/page.tsx @@ -2,11 +2,15 @@ import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; -import AsideSidebar from "@/components/AsideSidebar"; +import { + AdminPageLayout, + AdminPagination, + PageHeader, +} from "@/components/admin"; import { ReportsFilters } from "@/components/reports/ReportsFilters"; -import { ReportsPagination } from "@/components/reports/ReportsPagination"; import { ReportsStatCards } from "@/components/reports/ReportsStatCards"; import { ReportsTable } from "@/components/reports/ReportsTable"; +import { ITEMS_PER_PAGE } from "@/components/reports/types"; import { useGeneratePDF } from "@/components/reports/useGeneratePDF"; import { useReportsData } from "@/components/reports/useReportsData"; import { useReportsFilters } from "@/components/reports/useReportsFilters"; @@ -32,8 +36,6 @@ export default function ReportsPage() { visibleReports, safePage, totalPages, - startCount, - endCount, setPage, } = useReportsFilters(tableRows); @@ -45,22 +47,23 @@ export default function ReportsPage() { ); return ( -
- +
{/* Header + stat summary */} -
-

Reports

- -
+ + {isExporting ? "Generating..." : "Generate Report"} + + } + /> {/* Filters */} - - { - setProjectFilter(v); - setPage(1); - }} - onMentorChange={(v) => { - setMentorFilter(v); - setPage(1); - }} - onDateFromChange={(v) => { - setDateFrom(v); - setPage(1); - }} - onDateToChange={(v) => { - setDateTo(v); - setPage(1); - }} - onReset={resetFilters} - /> - + { + setProjectFilter(v); + setPage(1); + }} + onMentorChange={(v) => { + setMentorFilter(v); + setPage(1); + }} + onDateFromChange={(v) => { + setDateFrom(v); + setPage(1); + }} + onDateToChange={(v) => { + setDateTo(v); + setPage(1); + }} + onReset={resetFilters} + /> {/* Table and pagination */} @@ -104,17 +105,16 @@ export default function ReportsPage() { fetchError={fetchError} visibleReports={visibleReports} /> -
-
+ ); } diff --git a/server/src/components/reports/ReportsFilters.tsx b/server/src/components/reports/ReportsFilters.tsx index e81e87b6..07799f08 100644 --- a/server/src/components/reports/ReportsFilters.tsx +++ b/server/src/components/reports/ReportsFilters.tsx @@ -6,6 +6,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import FilterBar from "@/components/admin/FilterBar"; interface Props { projectFilter: string; @@ -21,7 +22,7 @@ interface Props { onReset: () => void; } -// Filter bar for reports +// Filter bar for reports — uses shared FilterBar compound component export function ReportsFilters({ projectFilter, mentorFilter, @@ -36,11 +37,8 @@ export function ReportsFilters({ onReset, }: Props) { return ( -
-
- + + -
-
- + + -
-
- + + onDateFromChange(e.target.value)} - className="border border-[#E5E5E5] rounded-md px-3 py-2 text-sm text-[#0A0A0A] bg-white focus:outline-none focus:ring-2 focus:ring-[#3B82F6]" + className="h-8 rounded-lg border border-[#dbe0e8] bg-white px-3 text-sm text-slate-700 outline-none focus:border-slate-400" /> -
-
- + + onDateToChange(e.target.value)} - className="border border-[#E5E5E5] rounded-md px-3 py-2 text-sm text-[#0A0A0A] bg-white focus:outline-none focus:ring-2 focus:ring-[#3B82F6]" + className="h-8 rounded-lg border border-[#dbe0e8] bg-white px-3 text-sm text-slate-700 outline-none focus:border-slate-400" /> -
+ -
+ ); } From b27e9e8314ffa5227b9b6c85f75eed12dc142df9 Mon Sep 17 00:00:00 2001 From: Muditha <89349527+muditha20@users.noreply.github.com> Date: Sun, 22 Mar 2026 16:14:36 +0530 Subject: [PATCH 14/18] refactor report page --- server/src/app/admin/invitation/page.tsx | 240 +++++++++++------- server/src/app/admin/reports/page.tsx | 1 + .../Invitations/InvitationsPagination.tsx | 34 --- .../Invitations/InvitationsTable.tsx | 146 +++++++---- .../src/components/admin/AdminPagination.tsx | 120 ++++++--- .../components/reports/ReportsPagination.tsx | 15 -- .../src/components/reports/ReportsTable.tsx | 42 +-- server/src/components/ui/table-pagination.tsx | 125 --------- 8 files changed, 341 insertions(+), 382 deletions(-) delete mode 100644 server/src/components/Invitations/InvitationsPagination.tsx delete mode 100644 server/src/components/reports/ReportsPagination.tsx delete mode 100644 server/src/components/ui/table-pagination.tsx diff --git a/server/src/app/admin/invitation/page.tsx b/server/src/app/admin/invitation/page.tsx index 6a5ea494..2a14086e 100644 --- a/server/src/app/admin/invitation/page.tsx +++ b/server/src/app/admin/invitation/page.tsx @@ -1,19 +1,30 @@ "use client"; -import React, { useEffect, useState } from 'react'; -import AsideSidebar from "@/components/AsideSidebar"; -import { useInvitation, useRecentInvitations, useChangeInvitationStatus, useDeleteInvitation } from "@/hooks/admin/useInvitation"; -import { z } from 'zod'; -import { Project } from '@prisma/client'; -import { Plus } from 'lucide-react'; - -import InvitationsStats from '@/components/Invitations/InvitationStats' -import InvitationsTable, { InvitationRow } from '@/components/Invitations/InvitationsTable' -import InvitationsPagination from '@/components/Invitations/InvitationsPagination' -import CreateInvitationDialog from '@/components/Invitations/dialogs/CreateInvitationDialog' -import ViewInvitationDialog from '@/components/Invitations/dialogs/viewInvitationDialog' -import ChangeStatusDialog from '@/components/Invitations/dialogs/ChangeStatusDialog' -import DeleteInvitationDialog from '@/components/Invitations/dialogs/DeleteDialog' +import React, { useEffect, useState } from "react"; + +import { Project } from "@prisma/client"; +import { Plus } from "lucide-react"; +import { z } from "zod"; + +import { + useChangeInvitationStatus, + useDeleteInvitation, + useInvitation, + useRecentInvitations, +} from "@/hooks/admin/useInvitation"; +import { + AdminPageLayout, + AdminPagination, + ConfirmDeleteDialog, + PageHeader, +} from "@/components/admin"; +import ChangeStatusDialog from "@/components/Invitations/dialogs/ChangeStatusDialog"; +import CreateInvitationDialog from "@/components/Invitations/dialogs/CreateInvitationDialog"; +import ViewInvitationDialog from "@/components/Invitations/dialogs/viewInvitationDialog"; +import InvitationsTable, { + InvitationRow, +} from "@/components/Invitations/InvitationsTable"; +import InvitationsStats from "@/components/Invitations/InvitationStats"; // Constants const ITEMS_PER_PAGE = 10; @@ -30,22 +41,38 @@ const inviteSchema = z.object({ export default function InvitationsView() { // State const [projects, setProjects] = useState([]); - const [formData, setFormData] = useState({ role: "", firstName: "", lastName: "", email: "", project: "" }); + const [formData, setFormData] = useState({ + role: "", + firstName: "", + lastName: "", + email: "", + project: "", + }); const [errors, setErrors] = useState>({}); const [page, setPage] = useState(1); // Dialogs const [createOpen, setCreateOpen] = useState(false); const [viewInv, setViewInv] = useState(null); - const [changeStatusInv, setChangeStatusInv] = useState(null); - const [selectedStatus, setSelectedStatus] = useState<'Accepted' | 'Pending' | 'Expired'>('Pending'); + const [changeStatusInv, setChangeStatusInv] = useState( + null, + ); + const [selectedStatus, setSelectedStatus] = useState< + "Accepted" | "Pending" | "Expired" + >("Pending"); const [deleteId, setDeleteId] = useState(null); // Hooks const { mutate: sendInvitation, isPending } = useInvitation(); - const { data: recentInvitations, isLoading: isInvLoading, isError: isInvError } = useRecentInvitations(); - const { mutate: changeStatus, isPending: isChangingStatus } = useChangeInvitationStatus(); - const { mutate: deleteInvitation, isPending: isDeleting } = useDeleteInvitation(); + const { + data: recentInvitations, + isLoading: isInvLoading, + isError: isInvError, + } = useRecentInvitations(); + const { mutate: changeStatus, isPending: isChangingStatus } = + useChangeInvitationStatus(); + const { mutate: deleteInvitation, isPending: isDeleting } = + useDeleteInvitation(); // Fetch Projects useEffect(() => { @@ -63,86 +90,113 @@ export default function InvitationsView() { // Form handling const handleInputChange = (field: string, value: string) => { - setFormData(prev => ({ ...prev, [field]: value })); - if (errors[field]) setErrors(prev => ({ ...prev, [field]: "" })); + setFormData((prev) => ({ ...prev, [field]: value })); + if (errors[field]) setErrors((prev) => ({ ...prev, [field]: "" })); }; const handleSubmit = () => { const result = inviteSchema.safeParse(formData); if (!result.success) { const fieldErrors: Record = {}; - result.error.issues.forEach(issue => { - if (issue.path[0]) fieldErrors[issue.path[0].toString()] = issue.message; + result.error.issues.forEach((issue) => { + if (issue.path[0]) + fieldErrors[issue.path[0].toString()] = issue.message; }); setErrors(fieldErrors); return; } setErrors({}); - sendInvitation({ - email: formData.email, - firstName: formData.firstName, - lastName: formData.lastName, - role: formData.role as "student" | "mentor" | "superAdmin", - projectId: formData.project, - }, { - onSuccess: () => { - setFormData({ role: "", firstName: "", lastName: "", email: "", project: "" }); - setCreateOpen(false); - } - }); + sendInvitation( + { + email: formData.email, + firstName: formData.firstName, + lastName: formData.lastName, + role: formData.role as "student" | "mentor" | "superAdmin", + projectId: formData.project, + }, + { + onSuccess: () => { + setFormData({ + role: "", + firstName: "", + lastName: "", + email: "", + project: "", + }); + setCreateOpen(false); + }, + }, + ); }; // Invitations list - const invList: InvitationRow[] = (recentInvitations as InvitationRow[] | undefined) ?? []; + 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 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); + const pagedInvitations = invList.slice( + (currentPage - 1) * ITEMS_PER_PAGE, + currentPage * ITEMS_PER_PAGE, + ); return ( <> -
- -
- - {/* Header */} -
-
-

Invitations

-

Manage organizational access and track member onboarding.

+ +
+ {/* Parent card */} +
+ {/* Sub-parent card: header + stats */} +
+ setCreateOpen(true)} + className="inline-flex items-center gap-2 h-9 px-4 bg-[#18181B] hover:bg-[#27272A] text-white text-sm font-medium rounded-lg transition-colors shadow-sm" + > + Create Invitation + + } + /> +
- -
- -
- {/* Stats */} - - - {/* Table */} - { setChangeStatusInv(inv); setSelectedStatus(inv.status); }} - onDelete={setDeleteId} - /> - {/* Pagination */} - + {/* Table + Pagination */} +
+ { + setChangeStatusInv(inv); + setSelectedStatus(inv.status); + }} + onDelete={setDeleteId} + /> + +
-
+ {/* Dialogs */} - setViewInv(null)} /> + setViewInv(null)} + /> changeStatusInv && changeStatus( - { id: changeStatusInv.id, status: selectedStatus }, - { onSettled: () => setChangeStatusInv(null) } - )} + onSave={() => + changeStatusInv && + changeStatus( + { id: changeStatusInv.id, status: selectedStatus }, + { onSettled: () => setChangeStatusInv(null) }, + ) + } isPending={isChangingStatus} onClose={() => setChangeStatusInv(null)} /> - !open && setDeleteId(null)} - onDelete={() => deleteId && deleteInvitation({ id: deleteId }, { onSettled: () => setDeleteId(null) })} - isDeleting={isDeleting} + onOpenChange={(open) => !open && setDeleteId(null)} + onConfirm={() => + deleteId && + deleteInvitation( + { id: deleteId }, + { onSettled: () => setDeleteId(null) }, + ) + } + isPending={isDeleting} + title="Delete Invitation" + description="This will permanently delete the invitation and remove the associated user account along with all their data — including project allocations, activities, and feedback. This action cannot be undone." /> ); -} \ 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 4fd47374..0088f90d 100644 --- a/server/src/app/admin/reports/page.tsx +++ b/server/src/app/admin/reports/page.tsx @@ -110,6 +110,7 @@ export default function ReportsPage() { totalPages={totalPages} total={filteredReports.length} itemsPerPage={ITEMS_PER_PAGE} + itemLabel="entries" onPageChange={setPage} /> diff --git a/server/src/components/Invitations/InvitationsPagination.tsx b/server/src/components/Invitations/InvitationsPagination.tsx deleted file mode 100644 index e72e9751..00000000 --- a/server/src/components/Invitations/InvitationsPagination.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, PaginationEllipsis } from '@/components/ui/pagination'; - -interface InvitationsPaginationProps { - page: number; - totalPages: number; - total: number; - onPageChange: (page: number) => void; -} - -export default function InvitationsPagination({ page, totalPages, total, onPageChange }: InvitationsPaginationProps) { - return ( -
-

- Showing {total === 0 ? 0 : (page - 1) * 10 + 1} to {Math.min(page * 10, total)} of {total} results -

- - - - onPageChange(Math.max(1, page - 1))} /> - - {Array.from({ length: Math.min(totalPages, 5) }, (_, i) => i + 1).map((p) => ( - - onPageChange(p)}>{p} - - ))} - {totalPages > 5 && } - - onPageChange(Math.min(totalPages, page + 1))} /> - - - -
- ); -} \ No newline at end of file diff --git a/server/src/components/Invitations/InvitationsTable.tsx b/server/src/components/Invitations/InvitationsTable.tsx index d754f7ac..4183410b 100644 --- a/server/src/components/Invitations/InvitationsTable.tsx +++ b/server/src/components/Invitations/InvitationsTable.tsx @@ -1,13 +1,24 @@ -import { MoreVertical } from 'lucide-react'; -import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from '@/components/dropdown-menu'; -import StatusBadge from './StatusBadge'; +"use client"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import AdminStatusBadge from "@/components/admin/AdminStatusBadge"; +import RoleBadge from "@/components/admin/RoleBadge"; +import TableActionMenu from "@/components/admin/TableActionMenu"; +import TableStateRows from "@/components/admin/TableStateRows"; export interface InvitationRow { id: string; email: string; role: string; project: string; - status: 'Pending' | 'Accepted' | 'Expired'; + status: "Pending" | "Accepted" | "Expired"; createdAt: string; expiresAt: string; } @@ -21,56 +32,81 @@ interface InvitationsTableProps { onDelete: (id: string) => void; } -export default function InvitationsTable({ data, loading, error, onView, onChangeStatus, onDelete }: InvitationsTableProps) { +export default function InvitationsTable({ + data, + loading, + error, + onView, + onChangeStatus, + onDelete, +}: InvitationsTableProps) { return ( -
-
- - - - - - - - - - - - {loading && } - {error && } - {!loading && !error && data.length === 0 && ( - - )} - {!loading && data.map((inv) => ( - - - - - - - - ))} - -
EmailRoleProjectStatusActions
Loading...
Failed to load invitations
No invitations found.
{inv.email} - - {inv.role} - - {inv.project} - - - - - - onView(inv)}>View Details - onChangeStatus(inv)}>Change Status - - onDelete(inv.id)}>Delete - - -
-
-
+ + + + + Email + + + Role + + + Project + + + Status + + + Actions + + + + + + {!loading && + !error && + data.map((inv) => ( + + + {inv.email} + + + + + + {inv.project} + + + + + + onView(inv) }, + { + label: "Change Status", + onSelect: () => onChangeStatus(inv), + }, + { + label: "Delete", + onSelect: () => onDelete(inv.id), + variant: "danger", + separator: true, + }, + ]} + /> + + + ))} + +
); -} \ No newline at end of file +} diff --git a/server/src/components/admin/AdminPagination.tsx b/server/src/components/admin/AdminPagination.tsx index dcf62797..d6bf4860 100644 --- a/server/src/components/admin/AdminPagination.tsx +++ b/server/src/components/admin/AdminPagination.tsx @@ -4,8 +4,6 @@ import { PaginationEllipsis, PaginationItem, PaginationLink, - PaginationNext, - PaginationPrevious, } from "@/components/ui/pagination"; interface AdminPaginationProps { @@ -14,68 +12,116 @@ interface AdminPaginationProps { total: number; /** Items per page – used to compute the "Showing X to Y" label. Default 10 */ itemsPerPage?: number; + /** Label shown after the count, e.g. "users" or "entries". Default "results" */ + itemLabel?: string; onPageChange: (page: number) => void; } /** * Shared pagination footer for all admin tables. - * Shows "Showing X to Y of Z results" + prev/next/page links. - * - * Usage: - * + * Matches the users page style: "Showing X to Y of Z" + windowed < 1 2 3 > links. */ export default function AdminPagination({ page, totalPages, total, itemsPerPage = 10, + itemLabel = "results", onPageChange, }: AdminPaginationProps) { const from = total === 0 ? 0 : (page - 1) * itemsPerPage + 1; const to = Math.min(page * itemsPerPage, total); + const goToPage = (next: number) => { + if (next < 1 || next > totalPages) return; + onPageChange(next); + }; + return ( -
+

- Showing{" "} - {from} to{" "} - {to} of{" "} - {total} results + Showing {from} to {to} of {total} {itemLabel}

- + + + {/* Previous */} - onPageChange(Math.max(1, page - 1))} - /> + goToPage(page - 1)} + disabled={page <= 1} + aria-label="Previous page" + > + {"<"} + - {Array.from({ length: Math.min(totalPages, 5) }, (_, i) => i + 1).map( - (p) => ( - + + {/* First page + ellipsis when far from start */} + {page > 2 ? ( + <> + + goToPage(1)} + > + 1 + + + {page > 3 ? ( + + + + ) : null} + + ) : null} + + {/* Windowed pages: current ±1 */} + {Array.from({ length: totalPages }, (_, idx) => idx + 1) + .filter((n) => Math.abs(n - page) <= 1) + .map((n) => ( + onPageChange(p)} + size="icon" + isActive={n === page} + onClick={() => goToPage(n)} > - {p} + {n} - ), - )} - {totalPages > 5 && ( - - - - )} + ))} + + {/* Last page + ellipsis when far from end */} + {page < totalPages - 1 ? ( + <> + {page < totalPages - 2 ? ( + + + + ) : null} + + goToPage(totalPages)} + > + {totalPages} + + + + ) : null} + + {/* Next */} - onPageChange(Math.min(totalPages, page + 1))} - /> + goToPage(page + 1)} + disabled={page >= totalPages} + aria-label="Next page" + > + {">"} + diff --git a/server/src/components/reports/ReportsPagination.tsx b/server/src/components/reports/ReportsPagination.tsx deleted file mode 100644 index 4e740108..00000000 --- a/server/src/components/reports/ReportsPagination.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { TablePagination } from "@/components/ui/table-pagination"; - -interface Props { - startCount: number; - endCount: number; - totalCount: number; - safePage: number; - totalPages: number; - onPageChange: (page: number) => void; -} - -// Footer pagination with entry count and windowed page links -export function ReportsPagination(props: Props) { - return ; -} diff --git a/server/src/components/reports/ReportsTable.tsx b/server/src/components/reports/ReportsTable.tsx index 634eb5b1..f8e64e7e 100644 --- a/server/src/components/reports/ReportsTable.tsx +++ b/server/src/components/reports/ReportsTable.tsx @@ -20,6 +20,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import TableStateRows from "@/components/admin/TableStateRows"; import { ReportRow } from "./types"; @@ -81,34 +82,16 @@ export function ReportsTable({ isLoading, fetchError, visibleReports }: Props) { - {isLoading ? ( - - - Loading reports... - - - ) : fetchError ? ( - - - {fetchError} - - - ) : visibleReports.length === 0 ? ( - - - No reports found for the selected filters. - - - ) : ( + + {!isLoading && + !fetchError && visibleReports.map((report) => { const { Icon, bg, color } = getProjectIcon(report.projectName); return ( @@ -134,8 +117,7 @@ export function ReportsTable({ isLoading, fetchError, visibleReports }: Props) { {report.date} ); - }) - )} + })} ); diff --git a/server/src/components/ui/table-pagination.tsx b/server/src/components/ui/table-pagination.tsx deleted file mode 100644 index 8f51ece4..00000000 --- a/server/src/components/ui/table-pagination.tsx +++ /dev/null @@ -1,125 +0,0 @@ -// Generic paginated-table footer: entry count label + windowed page links. -// Mirrors the pagination style used in the Users page. -import { - Pagination, - PaginationContent, - PaginationEllipsis, - PaginationItem, - PaginationLink, -} from "@/components/ui/pagination"; - -export interface TablePaginationProps { - startCount: number; - endCount: number; - totalCount: number; - safePage: number; - totalPages: number; - onPageChange: (page: number) => void; - /** Label shown after the count, e.g. "entries" or "users". Defaults to "entries". */ - itemLabel?: string; -} - -export function TablePagination({ - startCount, - endCount, - totalCount, - safePage, - totalPages, - onPageChange, - itemLabel = "entries", -}: TablePaginationProps) { - // Windowed page numbers: current ± 1 - const pageNumbers = Array.from( - { length: totalPages }, - (_, i) => i + 1, - ).filter((n) => Math.abs(n - safePage) <= 1); - - return ( -
-

- Showing {startCount} to {endCount} of {totalCount} {itemLabel} -

- - - - {/* Previous */} - - onPageChange(Math.max(1, safePage - 1))} - disabled={safePage <= 1} - aria-label="Previous page" - > - {"<"} - - - - {/* First page + ellipsis when far from start */} - {safePage > 2 ? ( - <> - - onPageChange(1)} - > - 1 - - - {safePage > 3 ? ( - - - - ) : null} - - ) : null} - - {/* Windowed page numbers */} - {pageNumbers.map((pageNumber) => ( - - onPageChange(pageNumber)} - > - {pageNumber} - - - ))} - - {/* Last page + ellipsis when far from end */} - {safePage < totalPages - 1 ? ( - <> - {safePage < totalPages - 2 ? ( - - - - ) : null} - - onPageChange(totalPages)} - > - {totalPages} - - - - ) : null} - - {/* Next */} - - onPageChange(Math.min(totalPages, safePage + 1))} - disabled={safePage >= totalPages} - aria-label="Next page" - > - {">"} - - - - -
- ); -} From 78c691a775a0a5753ad42f00ba2e0af42d05145c Mon Sep 17 00:00:00 2001 From: Azeez1999 Date: Sun, 22 Mar 2026 17:40:45 +0530 Subject: [PATCH 15/18] changes --- server/src/components/Invitations/InvitationsTable.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/components/Invitations/InvitationsTable.tsx b/server/src/components/Invitations/InvitationsTable.tsx index 4183410b..50223aa3 100644 --- a/server/src/components/Invitations/InvitationsTable.tsx +++ b/server/src/components/Invitations/InvitationsTable.tsx @@ -1,3 +1,4 @@ +import { MoreVertical } from "lucide-react"; "use client"; import { From 858faa3e11be08fd14c9092782cceee9eef96a2d Mon Sep 17 00:00:00 2001 From: Azeez1999 Date: Sun, 22 Mar 2026 18:18:33 +0530 Subject: [PATCH 16/18] Refactor user managment section --- server/src/app/admin/users/page.tsx | 831 ++---------------- .../Invitations/InvitationsTable.tsx | 1 - .../admin/users/ChangeStatusDialog.tsx | 78 ++ .../components/admin/users/UsersFilters.tsx | 71 ++ .../src/components/admin/users/UsersTable.tsx | 151 ++++ .../components/admin/users/ViewUserDialog.tsx | 83 ++ server/src/components/admin/users/index.ts | 8 + server/src/components/admin/users/types.ts | 24 + .../components/admin/users/useUsersAdmin.ts | 200 +++++ server/src/components/admin/users/utils.ts | 44 + 10 files changed, 731 insertions(+), 760 deletions(-) create mode 100644 server/src/components/admin/users/ChangeStatusDialog.tsx create mode 100644 server/src/components/admin/users/UsersFilters.tsx create mode 100644 server/src/components/admin/users/UsersTable.tsx create mode 100644 server/src/components/admin/users/ViewUserDialog.tsx create mode 100644 server/src/components/admin/users/index.ts create mode 100644 server/src/components/admin/users/types.ts create mode 100644 server/src/components/admin/users/useUsersAdmin.ts create mode 100644 server/src/components/admin/users/utils.ts diff --git a/server/src/app/admin/users/page.tsx b/server/src/app/admin/users/page.tsx index 75d71097..120be9c5 100644 --- a/server/src/app/admin/users/page.tsx +++ b/server/src/app/admin/users/page.tsx @@ -1,778 +1,91 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; - -import { EllipsisVertical, Search } from "lucide-react"; - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { - Dialog, - DialogClose, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { - Pagination, - PaginationContent, - PaginationEllipsis, - PaginationItem, - PaginationLink, -} from "@/components/ui/pagination"; + AdminPageLayout, + ConfirmDeleteDialog, + PageHeader, +} from "@/components/admin"; 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"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/dropdown-menu"; - -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"; -} + ChangeStatusDialog, + UsersFilters, + UsersTable, + ViewUserDialog, + useUsersAdmin, +} from "@/components/admin/users"; 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); - - // Modal state - const [viewUser, setViewUser] = useState(null); - const [statusUser, setStatusUser] = useState(null); - const [pendingStatus, setPendingStatus] = useState("Active"); - const [deleteUserId, setDeleteUserId] = useState(null); - const [isMutating, setIsMutating] = useState(false); - - const loadUsers = 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[]; - setUsers(data.map(mapApiUserToRecord)); - } catch (error) { - setUsers([]); - setFetchError( - error instanceof Error ? error.message : "Failed to fetch users", - ); - } finally { - setIsLoading(false); - } - }; - - 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 handleChangeStatus = async () => { - if (!statusUser) return; - setIsMutating(true); - try { - const res = await fetch("/api/admin/users", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - id: statusUser.id, - isActive: pendingStatus === "Active", - }), - }); - if (!res.ok) throw new Error("Failed to update status"); - setStatusUser(null); - await loadUsers(); - } catch { - // silently ignore — keep dialog open on error - } finally { - setIsMutating(false); - } - }; - - const handleDeleteUser = async () => { - if (!deleteUserId) return; - setIsMutating(true); - try { - const res = await fetch(`/api/admin/users?id=${deleteUserId}`, { - method: "DELETE", - }); - if (!res.ok) throw new Error("Failed to delete user"); - setDeleteUserId(null); - await loadUsers(); - } catch { - // silently ignore - } finally { - setIsMutating(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 { constants, filters, table, dialogs, actions } = useUsersAdmin(); return ( <> -
- - +
-
-

- User Administration -

-

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

-
- -
-
-

- Role -

- -
- -
-

- Status -

- -
- -
-

- Search -

-
- - { - setSearch(event.target.value); - setPage(1); - }} - placeholder="Search by name, email, or ID..." - /> -
-
-
- -
- - - - - 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} - - - - - - - - - setViewUser(user)} - > - View Details - - { - setStatusUser(user); - setPendingStatus( - user.status === "Active" - ? "Inactive" - : "Active", - ); - }} - > - Change Status - - - setDeleteUserId(user.id)} - > - Delete User - - - - -
- )) - )} -
-
- -
-

- 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" - > - {">"} - - - - -
-
+ + + { + filters.setRoleFilter(value); + filters.setPage(1); + }} + onStatusChange={(value) => { + filters.setStatusFilter(value); + filters.setPage(1); + }} + onSearchChange={(value) => { + filters.setSearch(value); + filters.setPage(1); + }} + /> + +
-
- - {/* ── View Details Dialog ─────────────────────────────────────────────── */} - !open && setViewUser(null)} - > - - - User Details - - {viewUser != null && ( -
-
- - {viewUser!.avatar ? ( - - ) : null} - - {getInitials(viewUser!.name)} - - -
-

- {viewUser!.name} -

-

{viewUser!.email}

-
-
-
-
-

- Role -

- - {viewUser!.role} - -
-
-

- Status -

- - - {viewUser!.status} - -
-
-

- Created Date -

-

{viewUser!.createdAt}

-
-
-

- User ID -

-

- {viewUser!.id} -

-
-
-
- )} - - - - - -
-
- - {/* ── Change Status Dialog ────────────────────────────────────────────── */} - !open && setStatusUser(null)} - > - - - Change User Status - - Select the new status for{" "} - - {statusUser?.name} - - . Deactivating a user will prevent them from logging in. - - -
-

- New Status -

- -
- - Cancel - - {isMutating ? "Saving…" : "Save"} - - -
-
- - {/* ── Delete Confirmation Dialog ──────────────────────────────────────── */} - !open && setDeleteUserId(null)} - > - - - Delete User - - This will permanently delete the user account and all associated - data — including activities, feedback, reports, badges, and - project allocations. This action cannot be undone. - - - - Cancel - - {isMutating ? "Deleting…" : "Delete"} - - - - + + + !open && dialogs.setViewUser(null)} + /> + + !open && dialogs.setStatusUser(null)} + onSave={actions.handleChangeStatus} + /> + + !open && dialogs.setDeleteUserId(null)} + onConfirm={actions.handleDeleteUser} + isPending={dialogs.isMutating} + title="Delete User" + description="This will permanently delete the user account and all associated data — including activities, feedback, reports, badges, and project allocations. This action cannot be undone." + /> ); } diff --git a/server/src/components/Invitations/InvitationsTable.tsx b/server/src/components/Invitations/InvitationsTable.tsx index 50223aa3..4183410b 100644 --- a/server/src/components/Invitations/InvitationsTable.tsx +++ b/server/src/components/Invitations/InvitationsTable.tsx @@ -1,4 +1,3 @@ -import { MoreVertical } from "lucide-react"; "use client"; import { diff --git a/server/src/components/admin/users/ChangeStatusDialog.tsx b/server/src/components/admin/users/ChangeStatusDialog.tsx new file mode 100644 index 00000000..08dbdbd7 --- /dev/null +++ b/server/src/components/admin/users/ChangeStatusDialog.tsx @@ -0,0 +1,78 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +import { UserRecord, UserStatus } from "./types"; + +interface ChangeStatusDialogProps { + user: UserRecord | null; + pendingStatus: UserStatus; + isMutating: boolean; + onPendingStatusChange: (status: UserStatus) => void; + onOpenChange: (open: boolean) => void; + onSave: () => void; +} + +export default function ChangeStatusDialog({ + user, + pendingStatus, + isMutating, + onPendingStatusChange, + onOpenChange, + onSave, +}: ChangeStatusDialogProps) { + return ( + + + + Change User Status + + Select the new status for{" "} + {user?.name} + . Deactivating a user will prevent them from logging in. + + +
+

+ New Status +

+ +
+ + Cancel + + {isMutating ? "Saving…" : "Save"} + + +
+
+ ); +} diff --git a/server/src/components/admin/users/UsersFilters.tsx b/server/src/components/admin/users/UsersFilters.tsx new file mode 100644 index 00000000..140160f7 --- /dev/null +++ b/server/src/components/admin/users/UsersFilters.tsx @@ -0,0 +1,71 @@ +import { FilterBar } from "@/components/admin"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +import { UserRole, UserStatus } from "./types"; + +interface UsersFiltersProps { + roleFilter: "all" | UserRole; + statusFilter: "all" | UserStatus; + search: string; + onRoleChange: (value: "all" | UserRole) => void; + onStatusChange: (value: "all" | UserStatus) => void; + onSearchChange: (value: string) => void; +} + +export default function UsersFilters({ + roleFilter, + statusFilter, + search, + onRoleChange, + onStatusChange, + onSearchChange, +}: UsersFiltersProps) { + return ( + + + + + + + + + + + + ); +} diff --git a/server/src/components/admin/users/UsersTable.tsx b/server/src/components/admin/users/UsersTable.tsx new file mode 100644 index 00000000..c777fa04 --- /dev/null +++ b/server/src/components/admin/users/UsersTable.tsx @@ -0,0 +1,151 @@ +import { + AdminPagination, + AdminStatusBadge, + RoleBadge, + TableActionMenu, + TableStateRows, +} from "@/components/admin"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +import { UserRecord } from "./types"; +import { getInitials } from "./utils"; + +interface UsersTableProps { + users: UserRecord[]; + isLoading: boolean; + fetchError: string | null; + page: number; + totalPages: number; + totalUsers: number; + itemsPerPage: number; + onPageChange: (page: number) => void; + onViewUser: (user: UserRecord) => void; + onChangeStatus: (user: UserRecord) => void; + onDeleteUser: (id: string) => void; +} + +export default function UsersTable({ + users, + isLoading, + fetchError, + page, + totalPages, + totalUsers, + itemsPerPage, + onPageChange, + onViewUser, + onChangeStatus, + onDeleteUser, +}: UsersTableProps) { + return ( +
+ + + + + User Identity + + + Role + + + Status + + + Created Date + + + Actions + + + + + + + {!isLoading && + !fetchError && + users.map((user) => ( + + +
+ + {user.avatar ? ( + + ) : null} + + {getInitials(user.name)} + + +
+

+ {user.name} +

+

{user.email}

+
+
+
+ + + + + + + + + + + {user.createdAt} + + + + onViewUser(user), + }, + { + label: "Change Status", + onSelect: () => onChangeStatus(user), + }, + { + label: "Delete User", + variant: "danger", + separator: true, + onSelect: () => onDeleteUser(user.id), + }, + ]} + /> + +
+ ))} +
+
+ + +
+ ); +} diff --git a/server/src/components/admin/users/ViewUserDialog.tsx b/server/src/components/admin/users/ViewUserDialog.tsx new file mode 100644 index 00000000..77624868 --- /dev/null +++ b/server/src/components/admin/users/ViewUserDialog.tsx @@ -0,0 +1,83 @@ +import { AdminStatusBadge, RoleBadge } from "@/components/admin"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +import { UserRecord } from "./types"; +import { getInitials } from "./utils"; + +interface ViewUserDialogProps { + user: UserRecord | null; + onOpenChange: (open: boolean) => void; +} + +export default function ViewUserDialog({ + user, + onOpenChange, +}: ViewUserDialogProps) { + return ( + + + + User Details + + {user != null && ( +
+
+ + {user.avatar ? ( + + ) : null} + + {getInitials(user.name)} + + +
+

{user.name}

+

{user.email}

+
+
+
+
+

+ Role +

+ +
+
+

+ Status +

+ +
+
+

+ Created Date +

+

{user.createdAt}

+
+
+

+ User ID +

+

{user.id}

+
+
+
+ )} + + + + + +
+
+ ); +} diff --git a/server/src/components/admin/users/index.ts b/server/src/components/admin/users/index.ts new file mode 100644 index 00000000..9b6b4e6c --- /dev/null +++ b/server/src/components/admin/users/index.ts @@ -0,0 +1,8 @@ +export { default as UsersFilters } from "./UsersFilters"; +export { default as UsersTable } from "./UsersTable"; +export { default as ViewUserDialog } from "./ViewUserDialog"; +export { default as ChangeStatusDialog } from "./ChangeStatusDialog"; + +export { useUsersAdmin } from "./useUsersAdmin"; + +export type { UserRecord, UserRole, UserStatus, ApiUserRecord } from "./types"; diff --git a/server/src/components/admin/users/types.ts b/server/src/components/admin/users/types.ts new file mode 100644 index 00000000..c973d439 --- /dev/null +++ b/server/src/components/admin/users/types.ts @@ -0,0 +1,24 @@ +export type UserRole = "Student" | "Mentor" | "SuperAdmin"; +export type UserStatus = "Active" | "Inactive"; + +export type ApiUserRole = "student" | "mentor" | "superAdmin"; + +export interface ApiUserRecord { + id: string; + email: string; + firstName: string; + lastName: string; + role: ApiUserRole; + isActive: boolean; + createdAt: string; +} + +export interface UserRecord { + id: string; + name: string; + email: string; + role: UserRole; + status: UserStatus; + createdAt: string; + avatar?: string; +} diff --git a/server/src/components/admin/users/useUsersAdmin.ts b/server/src/components/admin/users/useUsersAdmin.ts new file mode 100644 index 00000000..9a496ef8 --- /dev/null +++ b/server/src/components/admin/users/useUsersAdmin.ts @@ -0,0 +1,200 @@ +import { useEffect, useMemo, useState } from "react"; + +import { ApiUserRecord, UserRecord, UserRole, UserStatus } from "./types"; +import { mapApiUserToRecord } from "./utils"; + +const ITEMS_PER_PAGE = 5; + +export function useUsersAdmin() { + 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); + + const [viewUser, setViewUser] = useState(null); + const [statusUser, setStatusUser] = useState(null); + const [pendingStatus, setPendingStatus] = useState("Active"); + const [deleteUserId, setDeleteUserId] = useState(null); + const [isMutating, setIsMutating] = useState(false); + + const loadUsers = 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[]; + setUsers(data.map(mapApiUserToRecord)); + } catch (error) { + setUsers([]); + setFetchError( + error instanceof Error ? error.message : "Failed to fetch users", + ); + } finally { + setIsLoading(false); + } + }; + + 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 handleChangeStatus = async () => { + if (!statusUser) return; + + setIsMutating(true); + try { + const res = await fetch("/api/admin/users", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: statusUser.id, + isActive: pendingStatus === "Active", + }), + }); + if (!res.ok) throw new Error("Failed to update status"); + + setStatusUser(null); + await loadUsers(); + } catch { + // Keep dialog open so the user can retry. + } finally { + setIsMutating(false); + } + }; + + const handleDeleteUser = async () => { + if (!deleteUserId) return; + + setIsMutating(true); + try { + const res = await fetch(`/api/admin/users?id=${deleteUserId}`, { + method: "DELETE", + }); + if (!res.ok) throw new Error("Failed to delete user"); + + setDeleteUserId(null); + await loadUsers(); + } catch { + // Keep dialog state unchanged on failure. + } finally { + setIsMutating(false); + } + }; + + const openStatusDialog = (user: UserRecord) => { + setStatusUser(user); + setPendingStatus(user.status === "Active" ? "Inactive" : "Active"); + }; + + 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); + + return { + constants: { ITEMS_PER_PAGE }, + filters: { + search, + roleFilter, + statusFilter, + page, + setSearch, + setRoleFilter, + setStatusFilter, + setPage, + }, + table: { + isLoading, + fetchError, + visibleUsers, + totalUsers: filteredUsers.length, + totalPages, + safePage, + }, + dialogs: { + viewUser, + setViewUser, + statusUser, + setStatusUser, + pendingStatus, + setPendingStatus, + deleteUserId, + setDeleteUserId, + isMutating, + }, + actions: { + openStatusDialog, + handleChangeStatus, + handleDeleteUser, + }, + }; +} diff --git a/server/src/components/admin/users/utils.ts b/server/src/components/admin/users/utils.ts new file mode 100644 index 00000000..4e2e7952 --- /dev/null +++ b/server/src/components/admin/users/utils.ts @@ -0,0 +1,44 @@ +import { ApiUserRecord, ApiUserRole, UserRecord, UserRole } from "./types"; + +export function toUserRole(role: ApiUserRole): UserRole { + if (role === "student") { + return "Student"; + } + if (role === "mentor") { + return "Mentor"; + } + return "SuperAdmin"; +} + +export 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", + }); +} + +export 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), + }; +} + +export function getInitials(name: string) { + return name + .split(" ") + .map((part) => part[0]) + .join("") + .slice(0, 2) + .toUpperCase(); +} From 75bf72edd21ba25d58c02070d8aad7243a60a586 Mon Sep 17 00:00:00 2001 From: jaleelfathima Date: Sun, 22 Mar 2026 22:00:41 +0530 Subject: [PATCH 17/18] Code refactor - project --- server/src/app/admin/projects/page.tsx | 750 +++--------------------- server/src/components/CustomToolbar.tsx | 47 ++ server/src/components/calendar.tsx | 342 ++--------- server/src/hooks/useCalendarEvents.ts | 66 +++ server/src/hooks/useEventForDate.ts | 53 ++ server/src/hooks/useFormData.ts | 91 +++ server/src/hooks/useSubmission.ts | 142 +++++ 7 files changed, 533 insertions(+), 958 deletions(-) create mode 100644 server/src/components/CustomToolbar.tsx create mode 100644 server/src/hooks/useCalendarEvents.ts create mode 100644 server/src/hooks/useEventForDate.ts create mode 100644 server/src/hooks/useFormData.ts create mode 100644 server/src/hooks/useSubmission.ts diff --git a/server/src/app/admin/projects/page.tsx b/server/src/app/admin/projects/page.tsx index f8c251bf..d0fd95b2 100644 --- a/server/src/app/admin/projects/page.tsx +++ b/server/src/app/admin/projects/page.tsx @@ -1,703 +1,123 @@ "use client"; - 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, - CheckCircle, - Cpu, - Film, - FolderOpen, - Globe, - MoreVertical, - Trash2, - Users, -} from "lucide-react"; - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; +import type { AdminProject, AdminProjectStats } from "@/server_actions/adminProjectActions"; +import { createProject, deleteProject, getAdminProjectStats, updateProject } from "@/server_actions/adminProjectActions"; +import { Beaker, BookOpen, CheckCircle, Cpu, Film, FolderOpen, Globe, Trash2, Users } from "lucide-react"; +import { AdminPageLayout, PageHeader, FilterBar, TableStateRows, TableActionMenu, AdminPagination, ConfirmDeleteDialog } from "@/components/admin"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; -import { - Dialog, - DialogClose, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; +import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; -import AsideSidebar from "@/components/AsideSidebar"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/dropdown-menu"; - -const DOMAIN_ICONS: Record = { - software: , - film: , - training: , - research: , - other: , -}; - -const DOMAIN_LABELS: Record = { - software: "Software", - film: "Film", - training: "Training", - research: "Research", - other: "Other", -}; - -const ITEMS_PER_PAGE = 10; - -const EMPTY_FORM = { name: "", description: "", domain: "software" }; - -export default function ProjectsPage() { - const [page, setPage] = useState(1); - const [domainFilter, setDomainFilter] = useState("all"); - const [stats, setStats] = useState(null); - const [loading, setLoading] = useState(true); - - const [viewProject, setViewProject] = useState(null); - const [editProject, setEditProject] = useState(null); - const [editForm, setEditForm] = useState(EMPTY_FORM); - const [editSaving, setEditSaving] = useState(false); - - const [showCreate, setShowCreate] = useState(false); - const [createForm, setCreateForm] = useState(EMPTY_FORM); - const [createSaving, setCreateSaving] = useState(false); - - const [deleteId, setDeleteId] = useState(null); - const [deleting, setDeleting] = useState(false); - - const reload = () => { - setLoading(true); - getAdminProjectStats() - .then((data) => setStats(data)) - .finally(() => setLoading(false)); - }; - - useEffect(() => { - reload(); - }, []); - - function openEdit(project: AdminProject) { - setEditProject(project); - 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, - ); - setEditProject(null); - reload(); - } finally { - setEditSaving(false); - } - } - - async function handleCreateSave() { - setCreateSaving(true); - try { - await createProject( - createForm.name, - createForm.description, - createForm.domain, - ); - setShowCreate(false); - setCreateForm(EMPTY_FORM); - reload(); - } finally { - setCreateSaving(false); - } - } - - async function handleDelete() { - if (!deleteId) return; - setDeleting(true); - try { - await deleteProject(deleteId); - setDeleteId(null); - reload(); - } finally { - setDeleting(false); - } - } - - const allProjects: AdminProject[] = stats?.projects ?? []; - - const filtered = allProjects.filter((p) => - domainFilter === "all" ? true : p.domain === domainFilter, - ); - - const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE)); - const currentPage = Math.min(page, totalPages); - const displayedProjects = filtered.slice( - (currentPage - 1) * ITEMS_PER_PAGE, - currentPage * ITEMS_PER_PAGE, - ); - - return ( -
- +const DOMAIN_ICONS: Record = { software: , film: , training: , research: , other: }; +const DOMAIN_LABELS: Record = { software:"Software", film:"Film", training:"Training", research:"Research", other:"Other" }; +const DOMAIN_OPTIONS = ["software","film","training","research","other"]; +const ITEMS_PER_PAGE=10, EMPTY_FORM={name:"",description:"",domain:"software"}; + +export default function ProjectsPage(){ + const [page,setPage]=useState(1), [domainFilter,setDomainFilter]=useState("all"), [stats,setStats]=useState(null), [loading,setLoading]=useState(true); + const [viewProject,setViewProject]=useState(null), [editProject,setEditProject]=useState(null), [editForm,setEditForm]=useState(EMPTY_FORM), [editSaving,setEditSaving]=useState(false); + const [showCreate,setShowCreate]=useState(false), [createForm,setCreateForm]=useState(EMPTY_FORM), [createSaving,setCreateSaving]=useState(false); + const [deleteId,setDeleteId]=useState(null), [deleting,setDeleting]=useState(false); + + const reload=()=>{ setLoading(true); getAdminProjectStats().then(setStats).finally(()=>setLoading(false)); }; + useEffect(()=>reload(),[]); + const openEdit=(p:AdminProject)=>{setEditProject(p);setEditForm({name:p.name,description:p.description,domain:p.domain});}; + const handleEditSave=async()=>{if(!editProject)return;setEditSaving(true);try{await updateProject(editProject.id,editForm.name,editForm.description,editForm.domain);setEditProject(null);reload();}finally{setEditSaving(false);}}; + const handleCreateSave=async()=>{setCreateSaving(true);try{await createProject(createForm.name,createForm.description,createForm.domain);setShowCreate(false);setCreateForm(EMPTY_FORM);reload();}finally{setCreateSaving(false);}}; + const handleDelete=async()=>{if(!deleteId)return;setDeleting(true);try{await deleteProject(deleteId);setDeleteId(null);reload();}finally{setDeleting(false);}}; + + const allProjects=stats?.projects??[], filtered=allProjects.filter(p=>domainFilter==="all"?true:p.domain===domainFilter); + const totalPages=Math.max(1,Math.ceil(filtered.length/ITEMS_PER_PAGE)), currentPage=Math.min(page,totalPages); + const displayedProjects=filtered.slice((currentPage-1)*ITEMS_PER_PAGE,currentPage*ITEMS_PER_PAGE); + + const renderSelectItems=()=>DOMAIN_OPTIONS.map(d=>{DOMAIN_LABELS[d]}); + + return( +
- {/* Header + Stat Cards */} -
-

Projects

- -
- + {setCreateForm(EMPTY_FORM);setShowCreate(true);}}>+ Create New Project}/>
-
-
-

Total Projects

-

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

-
- -
-
-
-

Total Mentors

-

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

-
- -
-
-
-

Total Students

-

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

-
- -
+ {[{label:"Total Projects",value:stats?.totalProjects,icon:},{label:"Total Mentors",value:stats?.totalMentors,icon:},{label:"Total Students",value:stats?.totalStudents,icon:}].map(c=>( +

{c.label}

{loading?"—":c.value}

{c.icon}
+ ))}
- {/* Filters */} -
- + + + + + {domainFilter!=="all"&&} + - {domainFilter !== "all" && ( - - )} -
- - {/* Table */}
- - - Project Name - - - Domain - - - Mentors - - - Students - - - Created By - - - Created Date - - - + {["Project Name","Domain","Mentors","Students","Created By","Created Date",""].map(h=>{h})} - {loading && ( - - - Loading projects… - - - )} - {!loading && displayedProjects.length === 0 && ( - - - No projects found. - - - )} - {displayedProjects.map((project) => ( - - -
-
- {DOMAIN_ICONS[project.domain] ?? ( - - )} -
- - {project.name} - -
-
- - - {DOMAIN_LABELS[project.domain] ?? project.domain} - - - - {project.mentors} - - - {project.students} - - - {project.createdBy} - - - {project.createdDate} - + + {!loading && displayedProjects.map(p=>( + +
{DOMAIN_ICONS[p.domain]??}
{p.name}
+ {DOMAIN_LABELS[p.domain]??p.domain} + {p.mentors} + {p.students} + {p.createdBy} + {p.createdDate} - - - - - - setViewProject(project)} - > - View Details - - openEdit(project)}> - Edit Project - - - setDeleteId(project.id)} - > - - Delete Project - - - + setViewProject(p)},{label:"Edit Project",onSelect:()=>openEdit(p)},{label:"Delete Project",onSelect:()=>setDeleteId(p.id),variant:"danger",separator:true,icon:}]}/>
))}
- - {/* Pagination */} -
-

- Showing{" "} - - {filtered.length === 0 - ? 0 - : (currentPage - 1) * ITEMS_PER_PAGE + 1} - {" "} - to{" "} - - {Math.min(currentPage * ITEMS_PER_PAGE, filtered.length)} - {" "} - of{" "} - - {filtered.length} - {" "} - results -

- - - - setPage((p) => Math.max(1, p - 1))} - /> - - {Array.from( - { length: Math.min(totalPages, 5) }, - (_, i) => i + 1, - ).map((p) => ( - - setPage(p)} - > - {p} - - - ))} - {totalPages > 5 && ( - - - - )} - - setPage((p) => Math.min(totalPages, p + 1))} - /> - - - -
+
- {/* ── View Details Dialog ──────────────────────────────────────────── */} - !open && setViewProject(null)} - > + {/** Reusable Dialogs */} + {viewProject&&!open&&setViewProject(null)}> - - Project Details - - {viewProject && ( -
-
-
- {DOMAIN_ICONS[viewProject.domain] ?? ( - - )} -
-

- {viewProject.name} -

-
- {viewProject.description && ( -
-

- Description -

-

{viewProject.description}

-
- )} -
-
-

- Domain -

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

- Created Date -

-

{viewProject.createdDate}

-
-
-

- Mentors -

-

- {viewProject.mentors} -

-
-
-

- Students -

-

- {viewProject.students} -

-
-
-
-

- Created By -

-

{viewProject.createdBy}

-
-
- )} - - - - - -
-
- - {/* ── Edit Project Dialog ───────────────────────────────────────────── */} - !open && setEditProject(null)} - > - - - Edit Project - -
-
- - - setEditForm((f) => ({ ...f, name: e.target.value })) - } - placeholder="Enter project name" - /> -
-
- -