Made some small requests changes

This commit is contained in:
Tim Howitz 2025-06-01 14:30:18 +01:00
parent 74ed7bd50a
commit 13d36012a8
2 changed files with 213 additions and 247 deletions

View File

@ -3,39 +3,39 @@ import { prisma } from "@utils/prisma";
// GET requests, just requestingUser only // GET requests, just requestingUser only
export async function GET() { export async function GET() {
try { try {
const requests = await prisma.request.findMany({ const requests = await prisma.request.findMany({
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
include: { include: {
requestingUser: true, requestingUser: true,
}, },
}); });
return NextResponse.json({ requests }, { status: 200 }); return NextResponse.json({ requests }, { status: 200 });
} catch (err) { } catch (err) {
console.error("Failed to get requests", err); console.error("Failed to get requests", err);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
} }
} }
export async function PUT(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const { id, outcome } = await req.json(); const { id, outcome } = await req.json();
if (!["FULFILLED", "REJECTED"].includes(outcome)) { if (!["FULFILLED", "REJECTED"].includes(outcome)) {
return NextResponse.json({ error: "Invalid outcome" }, { status: 400 }); return NextResponse.json({ error: "Invalid outcome" }, { status: 400 });
} }
const existing = await prisma.request.findUnique({ where: { id } }); const existing = await prisma.request.findUnique({ where: { id } });
if (!existing) { if (!existing) {
return NextResponse.json({ error: "Request not found" }, { status: 404 }); return NextResponse.json({ error: "Request not found" }, { status: 404 });
} }
const updated = await prisma.request.update({ const updated = await prisma.request.update({
where: { id }, where: { id },
data: { data: {
outcome, outcome,
}, },
}); });
return NextResponse.json({ request: updated }, { status: 200 }); return NextResponse.json({ request: updated }, { status: 200 });
} catch (err) { } catch (err) {
console.error("Update request failed", err); console.error("Update request failed", err);
return NextResponse.json({ error: "Update failed" }, { status: 500 }); return NextResponse.json({ error: "Update failed" }, { status: 500 });
} }
} }

View File

@ -1,242 +1,208 @@
"use client"; "use client";
import axios from "axios";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { useStoreState } from "@hooks/store"; import { useStoreState } from "@hooks/store";
// Helper dicts/types // Helper dicts/types
const outcomeColors: Record<string, string> = { const outcomeColors: Record<string, string> = {
FULFILLED: "text-green-700 bg-green-100", FULFILLED: "text-green-700 bg-green-100",
REJECTED: "text-red-700 bg-red-100", REJECTED: "text-red-700 bg-red-100",
IN_PROGRESS: "text-blue-700 bg-blue-100", IN_PROGRESS: "text-blue-700 bg-blue-100",
CANCELLED: "text-gray-600 bg-gray-100", CANCELLED: "text-gray-600 bg-gray-100",
OTHER: "text-yellow-800 bg-yellow-100", OTHER: "text-yellow-800 bg-yellow-100",
}; };
const requestTypeLabels: Record<string, string> = { const requestTypeLabels: Record<string, string> = {
NEW_USER: "New User", NEW_USER: "New User",
CHANGE_LEVEL: "Change Level", CHANGE_LEVEL: "Change Level",
DELETE: "Removal", DELETE: "Removal",
}; };
function formatDate(val?: string) { function formatDate(val?: string) {
if (!val) return "--"; if (!val) return "--";
return new Date(val).toLocaleString(undefined, { return new Date(val).toLocaleString(undefined, {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "2-digit", day: "2-digit",
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
}); });
} }
// Minimal types // Minimal types
type User = { type User = {
id: number; id: number;
name: string; name: string;
email: string; email: string;
role?: string; role?: string;
scientist?: { level: string } | null; scientist?: { level: string } | null;
}; };
type Request = { type Request = {
id: number; id: number;
createdAt: string; createdAt: string;
requestType: string; requestType: string;
requestingUser: User; requestingUser: User;
outcome: string; outcome: string;
}; };
export default function RequestManagementPage() { export default function RequestManagementPage() {
const user = useStoreState((s) => s.user); const user = useStoreState((s) => s.user);
// All hooks first! // All hooks first!
const [requests, setRequests] = useState<Request[]>([]); const [requests, setRequests] = useState<Request[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [actionLoading, setActionLoading] = useState<number | null>(null); const [actionLoading, setActionLoading] = useState<number | null>(null);
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const [actionSuccess, setActionSuccess] = useState<string | null>(null); const [actionSuccess, setActionSuccess] = useState<string | null>(null);
// User role logic must remain invariant per render // User role logic must remain invariant per render
const userRole = user?.role as string | undefined; const userRole = user?.role as string | undefined;
const isAdmin = userRole === "ADMIN"; const isAdmin = userRole === "ADMIN";
const isSeniorScientist = userRole === "SCIENTIST" && user?.scientist?.level === "SENIOR"; const isSeniorScientist = userRole === "SCIENTIST" && user?.scientist?.level === "SENIOR";
const userId = user?.id; const userId = user?.id;
// Requests fetch // Requests fetch
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
setError(null); setError(null);
fetch("/api/requests") axios
.then((res) => { .get("/api/requests")
if (!res.ok) throw new Error("Failed to fetch requests"); .then((res) => {
return res.json(); setRequests(res.data.requests || []);
}) setLoading(false);
.then((data) => { })
setRequests(data.requests || []); .catch(() => {
setLoading(false); setError("Failed to load requests.");
}) setLoading(false);
.catch((err) => { });
setError("Failed to load requests."); }, []);
setLoading(false);
});
}, []);
// Filtering for non-admins to only their requests // Filtering for non-admins to only their requests
const filteredRequests = React.useMemo( const filteredRequests = React.useMemo(
() => () => (isAdmin ? requests : requests.filter((r) => r.requestingUser.id === userId)),
isAdmin [isAdmin, requests, userId]
? requests );
: requests.filter((r) => r.requestingUser.id === userId),
[isAdmin, requests, userId]
);
// Sorted: newest first // Sorted: newest first
filteredRequests.sort((a, b) => filteredRequests.sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
(b.createdAt ?? "").localeCompare(a.createdAt ?? "")
);
async function handleAction( async function handleAction(requestId: number, action: "FULFILLED" | "REJECTED") {
requestId: number, setActionLoading(requestId);
action: "FULFILLED" | "REJECTED" setActionError(null);
) { setActionSuccess(null);
setActionLoading(requestId); try {
setActionError(null); const res = await axios.post("/api/requests", { id: requestId, outcome: action });
setActionSuccess(null); setRequests((prev) => prev.map((r) => (r.id === requestId ? { ...r, outcome: action } : r)));
try { setActionSuccess("Request updated.");
const res = await fetch("/api/requests", { } catch (err: any) {
method: "PUT", setActionError(err.response?.data?.error || "Failed to update request");
headers: { "Content-Type": "application/json" }, } finally {
body: JSON.stringify({ id: requestId, outcome: action }), setActionLoading(null);
}); }
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! // Unauthorized access should return early, but not before hooks!
if (!isAdmin && !isSeniorScientist) { if (!isAdmin && !isSeniorScientist) {
return ( return (
<div className="min-h-[60vh] flex flex-col items-center justify-center"> <div className="min-h-[60vh] flex flex-col items-center justify-center">
<h1 className="text-2xl font-bold text-red-600 mb-4">Unauthorized Access</h1> <h1 className="text-2xl font-bold text-red-600 mb-4">Unauthorized Access</h1>
<div className="text-gray-600">You do not have access to this page.</div> <div className="text-gray-600">You do not have access to this page.</div>
</div> </div>
); );
} }
return ( return (
<div className="max-w-5xl mx-auto py-10 px-4"> <div className="max-w-5xl mx-auto py-10 px-4">
<div className="mb-8"> <div className="mb-8">
<h1 className="text-2xl font-bold"> <h1 className="text-2xl font-bold">{isAdmin ? "All Requests" : "My Requests"}</h1>
{isAdmin ? "All Requests" : "My Requests"} <p className="text-gray-600 mt-2">
</h1> View {isAdmin ? "and manage pending" : "your"} requests related to scientist management.
<p className="text-gray-600 mt-2"> </p>
View {isAdmin ? "and manage pending" : "your"} requests related to scientist management. </div>
</p>
</div>
{loading ? ( {loading ? (
<div className="text-gray-500 text-center py-20">Loading...</div> <div className="text-gray-500 text-center py-20">Loading...</div>
) : error ? ( ) : error ? (
<div className="text-red-600 text-center py-10">{error}</div> <div className="text-red-600 text-center py-10">{error}</div>
) : ( ) : (
<div className="bg-white rounded-lg shadow p-5 overflow-x-auto"> <div className="bg-white border-neutral-200 border rounded-lg shadow p-5 overflow-x-auto">
<table className="min-w-full text-sm"> <table className="min-w-full text-sm">
<thead> <thead>
<tr className="border-b"> <tr className="border-b">
<th className="px-4 py-2">Date</th> <th className="px-4 py-2">Date</th>
<th className="px-4 py-2">Type</th> <th className="px-4 py-2">Type</th>
<th className="px-4 py-2">Requested By</th> <th className="px-4 py-2">Requested By</th>
<th className="px-4 py-2">Status</th> <th className="px-4 py-2">Status</th>
{isAdmin && <th className="px-4 py-2">Actions</th>} {isAdmin && <th className="px-4 py-2">Actions</th>}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredRequests.length === 0 ? ( {filteredRequests.length === 0 ? (
<tr> <tr>
<td <td className="py-12 text-center text-gray-400 font-semibold" colSpan={isAdmin ? 5 : 4}>
className="py-12 text-center text-gray-400 font-semibold" No requests found.
colSpan={isAdmin ? 5 : 4} </td>
> </tr>
No requests found. ) : (
</td> filteredRequests.map((req) => (
</tr> <tr key={req.id} className="border-b last:border-0 hover:bg-neutral-50">
) : ( <td className="px-4 py-2 whitespace-nowrap">{formatDate(req.createdAt)}</td>
filteredRequests.map((req) => ( <td className="px-4 py-2">{requestTypeLabels[req.requestType] || req.requestType}</td>
<tr key={req.id} className="border-b last:border-0 hover:bg-neutral-50"> <td className="px-4 py-2 whitespace-nowrap">
<td className="px-4 py-2 whitespace-nowrap"> <span className="font-medium">{req.requestingUser.name}</span>
{formatDate(req.createdAt)} <span className="ml-1 text-xs text-gray-500">({req.requestingUser.email})</span>
</td> </td>
<td className="px-4 py-2"> <td className="px-4 py-2">
{requestTypeLabels[req.requestType] || req.requestType} <span
</td> className={
<td className="px-4 py-2 whitespace-nowrap"> "inline-block px-2 py-1 rounded-xl text-xs font-semibold " +
<span className="font-medium">{req.requestingUser.name}</span> (outcomeColors[req.outcome] || "bg-gray-100 text-gray-600")
<span className="ml-1 text-xs text-gray-500"> }
({req.requestingUser.email}) >
</span> {req.outcome.replace(/_/g, " ")}
</td> </span>
<td className="px-4 py-2"> </td>
<span className={ {isAdmin && (
"inline-block px-2 py-1 rounded-xl text-xs font-semibold " + <td className="px-4 py-2">
(outcomeColors[req.outcome] || {req.outcome === "IN_PROGRESS" ? (
"bg-gray-100 text-gray-600") <div className="flex gap-2">
}> <button
{req.outcome.replace(/_/g, " ")} onClick={() => handleAction(req.id, "FULFILLED")}
</span> className={
</td> "px-3 py-1 rounded-lg bg-green-600 hover:bg-green-700 text-white" +
{isAdmin && ( (actionLoading === req.id ? " opacity-60" : "")
<td className="px-4 py-2"> }
{req.outcome === "IN_PROGRESS" ? ( disabled={actionLoading === req.id}
<div className="flex gap-2"> >
<button Fulfilled
onClick={() => handleAction(req.id, "FULFILLED")} </button>
className={ <button
"px-3 py-1 rounded-lg bg-green-600 hover:bg-green-700 text-white" + onClick={() => handleAction(req.id, "REJECTED")}
(actionLoading === req.id ? " opacity-60" : "") className={
} "px-3 py-1 rounded-lg bg-red-500 hover:bg-red-600 text-white" +
disabled={actionLoading === req.id} (actionLoading === req.id ? " opacity-60" : "")
> }
Fulfilled disabled={actionLoading === req.id}
</button> >
<button Reject
onClick={() => handleAction(req.id, "REJECTED")} </button>
className={ </div>
"px-3 py-1 rounded-lg bg-red-500 hover:bg-red-600 text-white" + ) : (
(actionLoading === req.id ? " opacity-60" : "") <span className="text-xs text-gray-400">No actions</span>
} )}
disabled={actionLoading === req.id} {actionError && actionLoading === req.id && <div className="text-xs text-red-600">{actionError}</div>}
> {actionSuccess && actionLoading === req.id && (
Reject <div className="text-xs text-green-600">{actionSuccess}</div>
</button> )}
</div> </td>
) : ( )}
<span className="text-xs text-gray-400">No actions</span> </tr>
)} ))
{(actionError && actionLoading === req.id) && ( )}
<div className="text-xs text-red-600">{actionError}</div> </tbody>
)} </table>
{(actionSuccess && actionLoading === req.id) && ( </div>
<div className="text-xs text-green-600">{actionSuccess}</div> )}
)} </div>
</td> );
)} }
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div>
);
}