From 4608d547f9c1475d6eb94a74e0f241538723652e Mon Sep 17 00:00:00 2001 From: Lukeshan Thananchayan Date: Sun, 1 Jun 2025 12:22:23 +0100 Subject: [PATCH] Added requests page and backend --- package-lock.json | 7 + package.json | 1 + src/app/api/requests/route.ts | 41 ++++++ src/app/management/page.tsx | 122 +++++++++++++---- src/app/requests/page.tsx | 242 ++++++++++++++++++++++++++++++++++ src/components/Navbar.tsx | 9 ++ 6 files changed, 393 insertions(+), 29 deletions(-) create mode 100644 src/app/api/requests/route.ts create mode 100644 src/app/requests/page.tsx diff --git a/package-lock.json b/package-lock.json index 1ed5173..2574ef0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "csv-parse": "^5.6.0", "csv-parser": "^3.2.0", "date-fns": "^4.1.0", + "DatePicker": "^2.0.0", "dotenv": "^16.5.0", "easy-peasy": "^6.1.0", "express": "^5.1.0", @@ -2867,6 +2868,12 @@ "url": "https://github.com/sponsors/kossnocorp" } }, + "node_modules/DatePicker": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/DatePicker/-/DatePicker-2.0.0.tgz", + "integrity": "sha512-x5zuXdURsRHGtLJAufN+LaR7EaisJ8HUDrfoHmOHQ5xfqYQa35kIwaQQeAQgc14puau2t1A2slDTuKqJwfZAdA==", + "license": "ISC" + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", diff --git a/package.json b/package.json index 0c79f56..36742f0 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "csv-parse": "^5.6.0", "csv-parser": "^3.2.0", "date-fns": "^4.1.0", + "DatePicker": "^2.0.0", "dotenv": "^16.5.0", "easy-peasy": "^6.1.0", "express": "^5.1.0", diff --git a/src/app/api/requests/route.ts b/src/app/api/requests/route.ts new file mode 100644 index 0000000..0df7883 --- /dev/null +++ b/src/app/api/requests/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@utils/prisma"; + +// GET requests, just requestingUser only +export async function GET() { + try { + const requests = await prisma.request.findMany({ + orderBy: { createdAt: "desc" }, + include: { + requestingUser: true, + }, + }); + return NextResponse.json({ requests }, { status: 200 }); + } catch (err) { + console.error("Failed to get requests", err); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } +} + +export async function PUT(req: NextRequest) { + try { + const { id, outcome } = await req.json(); + if (!["FULFILLED", "REJECTED"].includes(outcome)) { + return NextResponse.json({ error: "Invalid outcome" }, { status: 400 }); + } + const existing = await prisma.request.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: "Request not found" }, { status: 404 }); + } + const updated = await prisma.request.update({ + where: { id }, + data: { + outcome, + }, + }); + return NextResponse.json({ request: updated }, { status: 200 }); + } catch (err) { + console.error("Update request failed", err); + return NextResponse.json({ error: "Update failed" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/management/page.tsx b/src/app/management/page.tsx index 667fda3..4de9c16 100644 --- a/src/app/management/page.tsx +++ b/src/app/management/page.tsx @@ -23,6 +23,37 @@ type Scientist = { subordinates: Scientist[]; }; // --- Helpers --- + +function normalizeScientist(input: any): Scientist { + return { + id: input.id, + createdAt: + typeof input.createdAt === "string" + ? input.createdAt + : input.createdAt instanceof Date + ? input.createdAt.toISOString() + : String(input.createdAt), + name: input.name, + level: + input.level === "JUNIOR" + ? "JUNIOR" + : input.level === "SENIOR" + ? "SENIOR" + : ("JUNIOR" as Level), // fallback/safe - but this should never happen + user: input.user, + userId: input.userId, + superior: input.superior ? normalizeScientist(input.superior) : null, + superiorId: + input.superiorId === undefined + ? null + : input.superiorId, + subordinates: + Array.isArray(input.subordinates) + ? input.subordinates.map(normalizeScientist) + : [], + }; + } + const initialScientists: Scientist[] = [ { id: 0, @@ -44,8 +75,7 @@ type SortField = (typeof sortFields)[number]["value"]; type SortDir = "asc" | "desc"; const dirLabels: Record = { asc: "ascending", desc: "descending" }; const fieldLabels: Record = { name: "Name", level: "Level" }; - -// --- Updated RequestModal (only level/removal, no comment) +// --- Updated RequestModal (clearer wording for "myself" & only level/removal) type RequestModalProps = { open: boolean; onClose: () => void; @@ -57,7 +87,6 @@ function RequestModal({ open, onClose, requestingUserId, scientist }: RequestMod const [loading, setLoading] = useState(false); const [success, setSuccess] = useState(null); const [error, setError] = useState(null); - async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(null); setSuccess(null); @@ -78,7 +107,7 @@ function RequestModal({ open, onClose, requestingUserId, scientist }: RequestMod const d = await res.json().catch(() => ({})); throw new Error(d?.error || "Request failed"); } - setSuccess("Request submitted for review."); + setSuccess("Your request has been submitted for review."); } catch (e: any) { setError(e?.message || "Unknown error"); } finally { @@ -89,18 +118,31 @@ function RequestModal({ open, onClose, requestingUserId, scientist }: RequestMod return open ? (
-

Request Action

+

+ Request a Change to Your Profile +

+
+ {/* Explain exactly what the request is for */} + {scientist + ? ( + <>You are requesting a change for your own scientist profile: {scientist.name} ({scientist.user.email}). + ) + : ( + <>You are requesting a change for your own scientist profile. + ) + } +
- +
{error &&
{error}
} @@ -160,6 +202,7 @@ export default function ScientistManagementPage() { const isAdmin = userRole === "ADMIN"; const isSeniorScientist = userRole === "SCIENTIST" && user?.scientist?.level === "SENIOR"; const readOnly = isSeniorScientist && !isAdmin; + // Data loading effects useEffect(() => { async function fetchAllUsers() { @@ -302,6 +345,18 @@ export default function ScientistManagementPage() { } }; const usersWithNoScientist = allUsers.filter(u => !u.scientist); + + // Find "my" scientist for the modal as senior scientist + const rawMyScientist = + user?.scientist + ? scientists.find(s => s.id === user.scientist?.id) || user.scientist + : undefined; + + // Only pass to modal if available, and fully normalized: + const myScientist = rawMyScientist + ? normalizeScientist(rawMyScientist) + : undefined; + if (!isAdmin && !isSeniorScientist) { return (
@@ -416,18 +471,34 @@ export default function ScientistManagementPage() { {sortDir === "asc" ? "↑" : "↓"} {isAdmin && ( - + + )} + {/* Senior scientist: Request Change button (for myself) */} + {!isAdmin && isSeniorScientist && !!myScientist && ( + )}
@@ -526,7 +597,7 @@ export default function ScientistManagementPage() { open={requestOpen} onClose={()=>setRequestOpen(false)} requestingUserId={user?.id} - scientist={editScientist} + scientist={myScientist} /> {/* MAIN PANEL */}
@@ -617,14 +688,7 @@ export default function ScientistManagementPage() { )} - {readOnly && ( - - )} + {/* No request change button here for readOnly/SCIENTIST users anymore */}
) : ( diff --git a/src/app/requests/page.tsx b/src/app/requests/page.tsx new file mode 100644 index 0000000..9d0807f --- /dev/null +++ b/src/app/requests/page.tsx @@ -0,0 +1,242 @@ +"use client"; +import React, { useState, useEffect } from "react"; +import { useStoreState } from "@hooks/store"; + +// Helper dicts/types +const outcomeColors: Record = { + FULFILLED: "text-green-700 bg-green-100", + REJECTED: "text-red-700 bg-red-100", + IN_PROGRESS: "text-blue-700 bg-blue-100", + CANCELLED: "text-gray-600 bg-gray-100", + OTHER: "text-yellow-800 bg-yellow-100", +}; +const requestTypeLabels: Record = { + NEW_USER: "New User", + CHANGE_LEVEL: "Change Level", + DELETE: "Removal", +}; +function formatDate(val?: string) { + if (!val) return "--"; + return new Date(val).toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + +// Minimal types +type User = { + id: number; + name: string; + email: string; + role?: string; + scientist?: { level: string } | null; +}; + +type Request = { + id: number; + createdAt: string; + requestType: string; + requestingUser: User; + outcome: string; +}; + +export default function RequestManagementPage() { + const user = useStoreState((s) => s.user); + + // All hooks first! + const [requests, setRequests] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [actionLoading, setActionLoading] = useState(null); + const [actionError, setActionError] = useState(null); + const [actionSuccess, setActionSuccess] = useState(null); + + // User role logic must remain invariant per render + const userRole = user?.role as string | undefined; + const isAdmin = userRole === "ADMIN"; + const isSeniorScientist = userRole === "SCIENTIST" && user?.scientist?.level === "SENIOR"; + const userId = user?.id; + + // Requests fetch + useEffect(() => { + setLoading(true); + setError(null); + fetch("/api/requests") + .then((res) => { + if (!res.ok) throw new Error("Failed to fetch requests"); + return res.json(); + }) + .then((data) => { + setRequests(data.requests || []); + setLoading(false); + }) + .catch((err) => { + setError("Failed to load requests."); + setLoading(false); + }); + }, []); + + // Filtering for non-admins to only their requests + const filteredRequests = React.useMemo( + () => + isAdmin + ? requests + : requests.filter((r) => r.requestingUser.id === userId), + [isAdmin, requests, userId] + ); + + // Sorted: newest first + filteredRequests.sort((a, b) => + (b.createdAt ?? "").localeCompare(a.createdAt ?? "") + ); + + async function handleAction( + requestId: number, + action: "FULFILLED" | "REJECTED" + ) { + setActionLoading(requestId); + setActionError(null); + setActionSuccess(null); + try { + const res = await fetch("/api/requests", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: requestId, outcome: action }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data?.error || "Failed to update request"); + } + setRequests((prev) => + prev.map((r) => + r.id === requestId ? { ...r, outcome: action } : r + ) + ); + setActionSuccess("Request updated."); + } catch (err: any) { + setActionError(err?.message || "Failed to update request"); + } finally { + setActionLoading(null); + } + } + + // Unauthorized access should return early, but not before hooks! + if (!isAdmin && !isSeniorScientist) { + return ( +
+

Unauthorized Access

+
You do not have access to this page.
+
+ ); + } + + return ( +
+
+

+ {isAdmin ? "All Requests" : "My Requests"} +

+

+ View {isAdmin ? "and manage pending" : "your"} requests related to scientist management. +

+
+ + {loading ? ( +
Loading...
+ ) : error ? ( +
{error}
+ ) : ( +
+ + + + + + + + {isAdmin && } + + + + {filteredRequests.length === 0 ? ( + + + + ) : ( + filteredRequests.map((req) => ( + + + + + + {isAdmin && ( + + )} + + )) + )} + +
DateTypeRequested ByStatusActions
+ No requests found. +
+ {formatDate(req.createdAt)} + + {requestTypeLabels[req.requestType] || req.requestType} + + {req.requestingUser.name} + + ({req.requestingUser.email}) + + + + {req.outcome.replace(/_/g, " ")} + + + {req.outcome === "IN_PROGRESS" ? ( +
+ + +
+ ) : ( + No actions + )} + {(actionError && actionLoading === req.id) && ( +
{actionError}
+ )} + {(actionSuccess && actionLoading === req.id) && ( +
{actionSuccess}
+ )} +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 4d654b6..44d4bb1 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -142,6 +142,15 @@ export default function Navbar({}: // currencySelector,
) + )} + {user && ( + (user.role === "ADMIN" || + (user.role === "SCIENTIST" && user.scientist?.level === "SENIOR") + ) && ( +
+ +
+ ) )} {user && user.role === "ADMIN" && (