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/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/docs/shared-components-guide.md b/docs/shared-components-guide.md new file mode 100644 index 00000000..a1f1e17d --- /dev/null +++ b/docs/shared-components-guide.md @@ -0,0 +1,1501 @@ +# Shared Admin Components — Developer Guide + +> **Who is this for?** +> This guide is written for beginners who are working on the Digital Logbook super-admin +> pages. It explains what each shared component does, all the options (props) it accepts, +> and shows exactly where and how to drop it into the three files you will be editing most: +> +> - `server/src/app/admin/projects/page.tsx` +> - `server/src/app/admin/reports/page.tsx` +> - `server/src/app/admin/users/page.tsx` +> - `server/src/components/admin-dashboard/SuperAdminDashboard.tsx` + +--- + +## Table of Contents + +- [Shared Admin Components — Developer Guide](#shared-admin-components--developer-guide) + - [Table of Contents](#table-of-contents) + - [1. Where do the components live?](#1-where-do-the-components-live) + - [2. How to import them](#2-how-to-import-them) + - [3. Component Reference](#3-component-reference) + - [3.1 `AdminPageLayout`](#31-adminpagelayout) + - [3.2 `PageHeader`](#32-pageheader) + - [3.3 `FilterBar`](#33-filterbar) + - [`FilterBar` (root)](#filterbar-root) + - [`FilterBar.Field`](#filterbarfield) + - [`FilterBar.Search`](#filterbarsearch) + - [3.4 `TableStateRows`](#34-tablestaterows) + - [3.5 `TableActionMenu`](#35-tableactionmenu) + - [3.6 `AdminPagination`](#36-adminpagination) + - [3.7 `AdminStatusBadge`](#37-adminstatusbadge) + - [3.8 `RoleBadge`](#38-rolebadge) + - [3.9 `ConfirmDeleteDialog`](#39-confirmdeletedialog) + - [4. Page-by-Page Integration Guide](#4-page-by-page-integration-guide) + - [4.1 `projects/page.tsx`](#41-projectspagetsx) + - [4.2 `reports/page.tsx`](#42-reportspagetsx) + - [4.3 `SuperAdminDashboard.tsx`](#43-superadmindashboardtsx) + - [4.4 `users/page.tsx`](#44-userspagetsx) + - [5. Quick Cheat Sheet](#5-quick-cheat-sheet) + +--- + +## 1. Where do the components live? + +All shared components are stored in one folder: + +``` +server/ + src/ + components/ + admin/ ← shared folder + AdminPageLayout.tsx + AdminPagination.tsx + AdminStatusBadge.tsx + ConfirmDeleteDialog.tsx + FilterBar.tsx + PageHeader.tsx + RoleBadge.tsx + TableActionMenu.tsx + TableStateRows.tsx + index.ts ← barrel file (single import entry point) +``` + +> **What is the barrel file (`index.ts`)?** +> It re-exports every component so you can import all of them from one short path +> instead of writing a separate import line for every file. + +--- + +## 2. How to import them + +Add this one line at the top of any admin page file, then pick the components you need: + +```tsx +import { + AdminPageLayout, + PageHeader, + FilterBar, + TableStateRows, + TableActionMenu, + AdminPagination, + AdminStatusBadge, + RoleBadge, + ConfirmDeleteDialog, +} from "@/components/admin"; +``` + +You do **not** need to import all of them — only the ones you use on that page. + +--- + +## 3. Component Reference + +--- + +### 3.1 `AdminPageLayout` + +**File:** `components/admin/AdminPageLayout.tsx` + +**What it does:** +Wraps every admin page with the sidebar (`AsideSidebar`) on the left and a flex +content area on the right. Instead of repeating the same `
+` in every file, you just wrap your page content with this component. + +**Props:** + +| Prop | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `children` | `ReactNode` | ✅ Yes | — | Your page content | +| `className` | `string` | No | `"bg-[#f5f7fb]"` | Tailwind background color class | + +**Example:** + +```tsx +// BEFORE (what projects/page.tsx currently does): +return ( +
+ +
+ {/* page content */} +
+
+); + +// AFTER (using the shared component): +return ( + +
+ {/* page content */} +
+
+); +``` + +> **Tip:** Use `className="bg-[#f1f1f9]"` for projects and reports pages (slightly +> grey-purple). Use the default (no className) for the users page (light blue-grey). + +--- + +### 3.2 `PageHeader` + +**File:** `components/admin/PageHeader.tsx` + +**What it does:** +Renders a page title on the left and an optional action button (like "Create New +Project") on the right. Comes in two visual styles called `variant`. + +**Props:** + +| Prop | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `title` | `string` | ✅ Yes | — | The big heading text | +| `subtitle` | `string` | No | — | Smaller grey text below the title | +| `action` | `ReactNode` | No | — | Button or element on the right side | +| `variant` | `"inline"` \| `"banner"` | No | `"inline"` | Visual style (see below) | + +**`variant` explained:** + +- `"inline"` — plain title + action inside a Card. Used by Projects and Reports pages. +- `"banner"` — full-width white bar with a bottom border. Used by the Invitations page. + +**Example — Projects page (inline):** + +```tsx +// BEFORE: +
+

Projects

+ +
+ +// 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/.env b/server/.env new file mode 100644 index 00000000..f5001981 --- /dev/null +++ b/server/.env @@ -0,0 +1,23 @@ +BASE_URL=http://localhost:3000 + +# This was inserted by `prisma init`: +# Environment variables declared in this file are automatically made available to Prisma. +# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema + +# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. +# See the documentation for all the connection string options: https://pris.ly/d/connection-strings + +JWT_SECRET=hftgry5486 +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres?schema=public" +JWT_SECRET="thisismysecretkey" +JWT_EXPIRY="60 min" +ISSUER=http://localhost:3000 +AUDIENCE=http://localhost:3000 + + + + +NEXT_PUBLIC_BASE_URL=http://localhost:3000 +# get the email pass key from https://myaccount.google.com/apppasswords +EMAIL_USER= "" +EMAIL_PASS= "" \ No newline at end of file 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/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/badges/page.tsx b/server/src/app/admin/badges/page.tsx new file mode 100644 index 00000000..30589e6b --- /dev/null +++ b/server/src/app/admin/badges/page.tsx @@ -0,0 +1,37 @@ +import React from "react"; + +import { Construction } from "lucide-react"; + +import { Card } from "@/components/ui/card"; +import PageHeader from "@/components/admin/PageHeader"; +import AsideSidebar from "@/components/AsideSidebar"; + +export default function BadgesPage() { + return ( +
+ + +
+ +
+ + +
+ +

+ Page Under Construction +

+

+ The Badges management page is currently under development. + Please check back soon for this feature. +

+
+
+
+
+
+ ); +} diff --git a/server/src/app/admin/invitation/page.tsx b/server/src/app/admin/invitation/page.tsx index abf80bc6..b41af696 100644 --- a/server/src/app/admin/invitation/page.tsx +++ b/server/src/app/admin/invitation/page.tsx @@ -1,61 +1,33 @@ "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 { - 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 React, { useMemo, useState } from "react"; + +import { Plus, Search } from "lucide-react"; +import { z } from "zod"; +import { + useChangeInvitationStatus, + useDeleteInvitation, + useInvitation, + useRecentInvitations, +} from "@/hooks/admin/useInvitation"; +import { useGetProjects } from "@/hooks/projects"; +import { Card } from "@/components/ui/card"; +import PageHeader from "@/components/admin/PageHeader"; +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 InvitationsTable, { + InvitationRow, +} from "@/components/Invitations/InvitationsTable"; +import InvitationsStats from "@/components/Invitations/InvitationStats"; + +// 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,590 +36,287 @@ 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() { - 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(); - + // State const [formData, setFormData] = useState({ role: "", firstName: "", lastName: "", email: "", - project: "" + project: "", }); const [errors, setErrors] = useState>({}); - - // Pagination const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); - // 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 [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: projectsData } = useGetProjects(); + + // 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; - } - }); - 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 with filtering + const invList: InvitationRow[] = useMemo( + () => (recentInvitations as InvitationRow[] | undefined) ?? [], + [recentInvitations], + ); - 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 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((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE); - - return ( - <> -
- -
- - {/* Page 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 - - - -
-
+ const pagedInvitations = filteredInvitations.slice( + (currentPage - 1) * ITEMS_PER_PAGE, + currentPage * ITEMS_PER_PAGE, + ); - {/* 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))} /> - - - -
-
-
-
-
+ const startCount = + filteredInvitations.length === 0 + ? 0 + : (currentPage - 1) * ITEMS_PER_PAGE + 1; + const endCount = Math.min( + currentPage * ITEMS_PER_PAGE, + filteredInvitations.length, + ); - {/* ── Create Invitation Dialog ─────────────────────────────────────── */} - { setCreateOpen(open); if (!open) { setErrors({}); } }}> - - {/* Header */} -
- - -
- -
- Create Invitation -
- - Send an invitation email to onboard a new member to your organization. - -
-
+ const goToPage = (nextPage: number) => { + if (nextPage < 1 || nextPage > totalPages) { + return; + } + setPage(nextPage); + }; - {/* Form Body */} -
+ return ( + <> +
+ - {/* Section: Access Details */} -
-

Access Details

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

{errors.role}

- :

Determines the user's access level

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

+ Search +

+
+ + { + setSearch(event.target.value); + setPage(1); + }} + placeholder="Search by email, role, or 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}

} -
+ {/* Create Button */} +
- {/* 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`} + {/* Table */} +
+ { + setChangeStatusInv(inv); + setSelectedStatus(inv.status); + }} + onDelete={setDeleteId} /> - {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' })}

+ {/* Pagination Footer */} +
+

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

+ +
+ +
+ {Array.from({ length: totalPages }, (_, i) => i + 1).map( + (pageNum) => ( + + ), + )} +
+ +
+
-
- )} - - - -
-
- - {/* ── 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'} - - - - + {/* Dialogs */} + + setViewInv(null)} + /> + + 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} + /> ); } - -/* --- 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/app/admin/projects/page.tsx b/server/src/app/admin/projects/page.tsx index f8c251bf..abab70a2 100644 --- a/server/src/app/admin/projects/page.tsx +++ b/server/src/app/admin/projects/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import type { AdminProject, @@ -12,29 +12,8 @@ import { getAdminProjectStats, updateProject, } from "@/server_actions/adminProjectActions"; -import { - Beaker, - BookOpen, - CheckCircle, - Cpu, - Film, - FolderOpen, - Globe, - MoreVertical, - Trash2, - Users, -} from "lucide-react"; +import { Beaker, BookOpen, Cpu, Film, Globe, Plus, Search } from "lucide-react"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { @@ -47,15 +26,6 @@ import { } 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, @@ -63,32 +33,20 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; +import { ConfirmDeleteDialog } from "@/components/admin"; +import ProjectsStats from "@/components/admin-dashboard/ProjectsStats"; +import ProjectsTable from "@/components/admin-dashboard/ProjectsTable"; +import PageHeader from "@/components/admin/PageHeader"; import AsideSidebar from "@/components/AsideSidebar"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/dropdown-menu"; const DOMAIN_ICONS: Record = { - software: , - film: , - training: , - research: , - other: , + software: , + film: , + training: , + research: , + other: , }; - const DOMAIN_LABELS: Record = { software: "Software", film: "Film", @@ -96,50 +54,34 @@ const DOMAIN_LABELS: Record = { research: "Research", other: "Other", }; - -const ITEMS_PER_PAGE = 10; - -const EMPTY_FORM = { name: "", description: "", domain: "software" }; +const DOMAIN_OPTIONS = ["software", "film", "training", "research", "other"]; +const EMPTY_FORM = { name: "", description: "", domain: "software" as const }; export default function ProjectsPage() { const [page, setPage] = useState(1); - const [domainFilter, setDomainFilter] = useState("all"); + const [search, setSearch] = useState(""); 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)) + .then(setStats) .finally(() => setLoading(false)); }; - useEffect(() => { - reload(); - }, []); - - function openEdit(project: AdminProject) { - setEditProject(project); - setEditForm({ - name: project.name, - description: project.description, - domain: project.domain, - }); - } + useEffect(() => reload(), []); - async function handleEditSave() { + const handleEditSave = async () => { if (!editProject) return; setEditSaving(true); try { @@ -154,9 +96,9 @@ export default function ProjectsPage() { } finally { setEditSaving(false); } - } + }; - async function handleCreateSave() { + const handleCreateSave = async () => { setCreateSaving(true); try { await createProject( @@ -170,9 +112,9 @@ export default function ProjectsPage() { } finally { setCreateSaving(false); } - } + }; - async function handleDelete() { + const handleDelete = async () => { if (!deleteId) return; setDeleting(true); try { @@ -182,522 +124,334 @@ export default function ProjectsPage() { } finally { setDeleting(false); } - } - - const allProjects: AdminProject[] = stats?.projects ?? []; + }; - const filtered = allProjects.filter((p) => - domainFilter === "all" ? true : p.domain === domainFilter, - ); + // Filtered projects based on search + const filteredProjects = useMemo(() => { + const allProjects = stats?.projects ?? []; + const query = search.trim().toLowerCase(); + return query.length === 0 + ? allProjects + : allProjects.filter( + (p) => + p.name.toLowerCase().includes(query) || + p.domain.toLowerCase().includes(query) || + p.createdBy.toLowerCase().includes(query), + ); + }, [stats?.projects, search]); - const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE)); + const ITEMS_PER_PAGE = 10; + const totalProjects = filteredProjects.length; + const totalPages = Math.max(1, Math.ceil(totalProjects / ITEMS_PER_PAGE)); const currentPage = Math.min(page, totalPages); - const displayedProjects = filtered.slice( + const pagedProjects = filteredProjects.slice( (currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE, ); - return ( -
- + const startCount = + filteredProjects.length === 0 ? 0 : (currentPage - 1) * ITEMS_PER_PAGE + 1; + const endCount = Math.min( + currentPage * ITEMS_PER_PAGE, + filteredProjects.length, + ); -
- {/* Header + Stat Cards */} - -
-

Projects

- -
+ const goToPage = (nextPage: number) => { + if (nextPage < 1 || nextPage > totalPages) { + return; + } + setPage(nextPage); + }; -
-
-
-

Total Projects

-

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

-
- -
-
+ const renderSelectItems = () => + DOMAIN_OPTIONS.map((d) => ( + + {DOMAIN_LABELS[d]} + + )); + + return ( + <> +
+ + +
+ +
+ {/* Header */}
-

Total Mentors

-

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

+
- -
-
-
-

Total Students

-

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

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

+ Search +

+
+ + { + setSearch(event.target.value); + setPage(1); + }} + placeholder="Search by name, domain, or creator..." + /> +
+
+ + {/* Create Button */} +
- -
-
-
- {/* Filters */} -
- + {/* Table */} +
+ ({ + id: p.id, + name: p.name, + domain: p.domain, + mentors: p.mentors, + students: p.students, + createdBy: p.createdBy, + createdDate: p.createdDate, + description: p.description, + }))} + loading={loading} + domainIcons={DOMAIN_ICONS} + domainLabels={DOMAIN_LABELS} + onView={(project) => setViewProject(project as any)} + onEdit={(project) => { + setEditProject(project as any); + setEditForm({ + name: project.name, + description: project.description || "", + domain: (project.domain as any) || "software", + }); + }} + onDelete={setDeleteId} + /> - {domainFilter !== "all" && ( - - )} -
+ {/* Pagination Footer */} +
+

+ Showing {startCount} to {endCount} of {totalProjects}{" "} + projects +

- {/* Table */} - -
- - - - - Project Name - - - Domain - - - Mentors - - - Students - - - Created By - - - Created Date - - - - - - {loading && ( - - + - - - setViewProject(project)} - > - View Details - - openEdit(project)}> - Edit Project - - - setDeleteId(project.id)} - > - - Delete Project - - - - - - ))} - -
-
- - {/* 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)} + {pageNum} + + ), + )} +
+
- + Next + +
+
+
+
+ +
- {/* ── View Details Dialog ──────────────────────────────────────────── */} - !open && setViewProject(null)} - > - - - Project Details - - {viewProject && ( + {/** Reusable Dialogs */} + {viewProject && ( + !open && setViewProject(null)}> + + + Project Details +
- {DOMAIN_ICONS[viewProject.domain] ?? ( - - )} + {DOMAIN_ICONS[viewProject.domain] ?? }
-

- {viewProject.name} -

+

{viewProject.name}

- {viewProject.description && ( -
-

- Description -

-

{viewProject.description}

-
- )} + {viewProject.description &&

{viewProject.description}

}
-
-

- Domain -

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

- Created Date -

-

{viewProject.createdDate}

-
-
-

- Mentors -

-

- {viewProject.mentors} -

-
-
-

- Students -

-

- {viewProject.students} -

-
+ {["Domain", "Created Date", "Mentors", "Students"].map( + (l, i) => ( +
+

+ {l} +

+

+ {l === "Domain" + ? DOMAIN_LABELS[viewProject.domain] + : l === "Created Date" + ? viewProject.createdDate + : l === "Mentors" + ? viewProject.mentors + : viewProject.students} +

+
+ ), + )}
-

+

Created By

-

{viewProject.createdBy}

+

{viewProject.createdBy}

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