Added requests page and backend
This commit is contained in:
parent
ec067773f4
commit
4608d547f9
7
package-lock.json
generated
7
package-lock.json
generated
@ -17,6 +17,7 @@
|
|||||||
"csv-parse": "^5.6.0",
|
"csv-parse": "^5.6.0",
|
||||||
"csv-parser": "^3.2.0",
|
"csv-parser": "^3.2.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"DatePicker": "^2.0.0",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
"easy-peasy": "^6.1.0",
|
"easy-peasy": "^6.1.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
@ -2867,6 +2868,12 @@
|
|||||||
"url": "https://github.com/sponsors/kossnocorp"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||||
|
|||||||
@ -20,6 +20,7 @@
|
|||||||
"csv-parse": "^5.6.0",
|
"csv-parse": "^5.6.0",
|
||||||
"csv-parser": "^3.2.0",
|
"csv-parser": "^3.2.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"DatePicker": "^2.0.0",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
"easy-peasy": "^6.1.0",
|
"easy-peasy": "^6.1.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
|||||||
41
src/app/api/requests/route.ts
Normal file
41
src/app/api/requests/route.ts
Normal file
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -23,6 +23,37 @@ type Scientist = {
|
|||||||
subordinates: Scientist[];
|
subordinates: Scientist[];
|
||||||
};
|
};
|
||||||
// --- Helpers ---
|
// --- 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[] = [
|
const initialScientists: Scientist[] = [
|
||||||
{
|
{
|
||||||
id: 0,
|
id: 0,
|
||||||
@ -44,8 +75,7 @@ type SortField = (typeof sortFields)[number]["value"];
|
|||||||
type SortDir = "asc" | "desc";
|
type SortDir = "asc" | "desc";
|
||||||
const dirLabels: Record<SortDir, string> = { asc: "ascending", desc: "descending" };
|
const dirLabels: Record<SortDir, string> = { asc: "ascending", desc: "descending" };
|
||||||
const fieldLabels: Record<SortField, string> = { name: "Name", level: "Level" };
|
const fieldLabels: Record<SortField, string> = { name: "Name", level: "Level" };
|
||||||
|
// --- Updated RequestModal (clearer wording for "myself" & only level/removal)
|
||||||
// --- Updated RequestModal (only level/removal, no comment)
|
|
||||||
type RequestModalProps = {
|
type RequestModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@ -57,7 +87,6 @@ function RequestModal({ open, onClose, requestingUserId, scientist }: RequestMod
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null); setSuccess(null);
|
setError(null); setSuccess(null);
|
||||||
@ -78,7 +107,7 @@ function RequestModal({ open, onClose, requestingUserId, scientist }: RequestMod
|
|||||||
const d = await res.json().catch(() => ({}));
|
const d = await res.json().catch(() => ({}));
|
||||||
throw new Error(d?.error || "Request failed");
|
throw new Error(d?.error || "Request failed");
|
||||||
}
|
}
|
||||||
setSuccess("Request submitted for review.");
|
setSuccess("Your request has been submitted for review.");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message || "Unknown error");
|
setError(e?.message || "Unknown error");
|
||||||
} finally {
|
} finally {
|
||||||
@ -89,18 +118,31 @@ function RequestModal({ open, onClose, requestingUserId, scientist }: RequestMod
|
|||||||
return open ? (
|
return open ? (
|
||||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black bg-opacity-30">
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black bg-opacity-30">
|
||||||
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full relative">
|
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full relative">
|
||||||
<h3 className="text-lg font-bold mb-4">Request Action</h3>
|
<h3 className="text-lg font-bold mb-2">
|
||||||
|
Request a Change to Your Profile
|
||||||
|
</h3>
|
||||||
|
<div className="mb-2 text-gray-600 text-sm">
|
||||||
|
{/* Explain exactly what the request is for */}
|
||||||
|
{scientist
|
||||||
|
? (
|
||||||
|
<>You are requesting a change for your own scientist profile: <b>{scientist.name}</b> ({scientist.user.email}).</>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<>You are requesting a change for your own scientist profile.</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
<form onSubmit={handleSubmit} className="space-y-3">
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Action Type</label>
|
<label className="block text-sm font-medium mb-1">Request Action Type</label>
|
||||||
<select
|
<select
|
||||||
required
|
required
|
||||||
className="w-full border px-2 py-1 rounded-lg"
|
className="w-full border px-2 py-1 rounded-lg"
|
||||||
value={requestType}
|
value={requestType}
|
||||||
onChange={e=>setRequestType(e.target.value)}
|
onChange={e=>setRequestType(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="CHANGE_LEVEL">Request Change Level</option>
|
<option value="CHANGE_LEVEL">Request level change</option>
|
||||||
<option value="DELETE">Request Removal</option>
|
<option value="DELETE">Remove yourself from scientists</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="text-red-600 text-xs">{error}</div>}
|
{error && <div className="text-red-600 text-xs">{error}</div>}
|
||||||
@ -160,6 +202,7 @@ export default function ScientistManagementPage() {
|
|||||||
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 readOnly = isSeniorScientist && !isAdmin;
|
const readOnly = isSeniorScientist && !isAdmin;
|
||||||
|
|
||||||
// Data loading effects
|
// Data loading effects
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchAllUsers() {
|
async function fetchAllUsers() {
|
||||||
@ -302,6 +345,18 @@ export default function ScientistManagementPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const usersWithNoScientist = allUsers.filter(u => !u.scientist);
|
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) {
|
if (!isAdmin && !isSeniorScientist) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[70vh] flex-col">
|
<div className="flex items-center justify-center min-h-[70vh] flex-col">
|
||||||
@ -416,18 +471,34 @@ export default function ScientistManagementPage() {
|
|||||||
{sortDir === "asc" ? "↑" : "↓"}
|
{sortDir === "asc" ? "↑" : "↓"}
|
||||||
</button>
|
</button>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<button
|
<button
|
||||||
className="ml-2 px-2 py-1 rounded-lg bg-green-600 hover:bg-green-700 text-white font-bold flex items-center shadow transition duration-150"
|
className="ml-2 px-2 py-1 rounded-lg bg-green-600 hover:bg-green-700 text-white font-bold flex items-center shadow transition duration-150"
|
||||||
type="button"
|
type="button"
|
||||||
style={{ minWidth: 36, minHeight: 36 }}
|
style={{ minWidth: 36, minHeight: 36 }}
|
||||||
onClick={() => setAddOpen(true)}
|
onClick={() => setAddOpen(true)}
|
||||||
disabled={addOpen}
|
disabled={addOpen}
|
||||||
title="Add scientist"
|
title="Add scientist"
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth={2.2} viewBox="0 0 24 24">
|
<svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth={2.2} viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m7-7H5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m7-7H5" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Senior scientist: Request Change button (for myself) */}
|
||||||
|
{!isAdmin && isSeniorScientist && !!myScientist && (
|
||||||
|
<button
|
||||||
|
className="ml-2 px-2 py-1 rounded-lg bg-yellow-500 hover:bg-yellow-600 text-white font-bold flex items-center shadow transition duration-150"
|
||||||
|
type="button"
|
||||||
|
style={{ minWidth: 36, minHeight: 36 }}
|
||||||
|
onClick={() => setRequestOpen(true)}
|
||||||
|
disabled={requestOpen}
|
||||||
|
title="Request changes to your own scientist profile"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth={2.2} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M20 12H4m8 8V4" />
|
||||||
|
</svg>
|
||||||
|
<span className="ml-2 text-xs font-normal text-center">🔁</span>
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<small className="text-xs text-gray-500 mb-2 px-1">
|
<small className="text-xs text-gray-500 mb-2 px-1">
|
||||||
@ -526,7 +597,7 @@ export default function ScientistManagementPage() {
|
|||||||
open={requestOpen}
|
open={requestOpen}
|
||||||
onClose={()=>setRequestOpen(false)}
|
onClose={()=>setRequestOpen(false)}
|
||||||
requestingUserId={user?.id}
|
requestingUserId={user?.id}
|
||||||
scientist={editScientist}
|
scientist={myScientist}
|
||||||
/>
|
/>
|
||||||
{/* MAIN PANEL */}
|
{/* MAIN PANEL */}
|
||||||
<div className="flex-1 p-24 bg-white overflow-y-auto">
|
<div className="flex-1 p-24 bg-white overflow-y-auto">
|
||||||
@ -617,14 +688,7 @@ export default function ScientistManagementPage() {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{readOnly && (
|
{/* No request change button here for readOnly/SCIENTIST users anymore */}
|
||||||
<button type="button"
|
|
||||||
onClick={()=>setRequestOpen(true)}
|
|
||||||
className="px-4 py-2 rounded-lg bg-yellow-500 hover:bg-yellow-600 text-white font-semibold shadow transition ml-3"
|
|
||||||
>
|
|
||||||
Request Change
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
242
src/app/requests/page.tsx
Normal file
242
src/app/requests/page.tsx
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useStoreState } from "@hooks/store";
|
||||||
|
|
||||||
|
// Helper dicts/types
|
||||||
|
const outcomeColors: Record<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<Request[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [actionLoading, setActionLoading] = useState<number | null>(null);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
const [actionSuccess, setActionSuccess] = useState<string | null>(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 (
|
||||||
|
<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>
|
||||||
|
<div className="text-gray-600">You do not have access to this page.</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl mx-auto py-10 px-4">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold">
|
||||||
|
{isAdmin ? "All Requests" : "My Requests"}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 mt-2">
|
||||||
|
View {isAdmin ? "and manage pending" : "your"} requests related to scientist management.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-gray-500 text-center py-20">Loading...</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-red-600 text-center py-10">{error}</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white rounded-lg shadow p-5 overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="px-4 py-2">Date</th>
|
||||||
|
<th className="px-4 py-2">Type</th>
|
||||||
|
<th className="px-4 py-2">Requested By</th>
|
||||||
|
<th className="px-4 py-2">Status</th>
|
||||||
|
{isAdmin && <th className="px-4 py-2">Actions</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredRequests.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
className="py-12 text-center text-gray-400 font-semibold"
|
||||||
|
colSpan={isAdmin ? 5 : 4}
|
||||||
|
>
|
||||||
|
No requests found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredRequests.map((req) => (
|
||||||
|
<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>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{requestTypeLabels[req.requestType] || req.requestType}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 whitespace-nowrap">
|
||||||
|
<span className="font-medium">{req.requestingUser.name}</span>
|
||||||
|
<span className="ml-1 text-xs text-gray-500">
|
||||||
|
({req.requestingUser.email})
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className={
|
||||||
|
"inline-block px-2 py-1 rounded-xl text-xs font-semibold " +
|
||||||
|
(outcomeColors[req.outcome] ||
|
||||||
|
"bg-gray-100 text-gray-600")
|
||||||
|
}>
|
||||||
|
{req.outcome.replace(/_/g, " ")}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{isAdmin && (
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{req.outcome === "IN_PROGRESS" ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction(req.id, "FULFILLED")}
|
||||||
|
className={
|
||||||
|
"px-3 py-1 rounded-lg bg-green-600 hover:bg-green-700 text-white" +
|
||||||
|
(actionLoading === req.id ? " opacity-60" : "")
|
||||||
|
}
|
||||||
|
disabled={actionLoading === req.id}
|
||||||
|
>
|
||||||
|
Fulfilled
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAction(req.id, "REJECTED")}
|
||||||
|
className={
|
||||||
|
"px-3 py-1 rounded-lg bg-red-500 hover:bg-red-600 text-white" +
|
||||||
|
(actionLoading === req.id ? " opacity-60" : "")
|
||||||
|
}
|
||||||
|
disabled={actionLoading === req.id}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">No actions</span>
|
||||||
|
)}
|
||||||
|
{(actionError && actionLoading === req.id) && (
|
||||||
|
<div className="text-xs text-red-600">{actionError}</div>
|
||||||
|
)}
|
||||||
|
{(actionSuccess && actionLoading === req.id) && (
|
||||||
|
<div className="text-xs text-green-600">{actionSuccess}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -142,6 +142,15 @@ export default function Navbar({}: // currencySelector,
|
|||||||
<ManagementNavbarButton name="Scientist Management" href="/management" />
|
<ManagementNavbarButton name="Scientist Management" href="/management" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
)}
|
||||||
|
{user && (
|
||||||
|
(user.role === "ADMIN" ||
|
||||||
|
(user.role === "SCIENTIST" && user.scientist?.level === "SENIOR")
|
||||||
|
) && (
|
||||||
|
<div className="flex h-full mr-5">
|
||||||
|
<ManagementNavbarButton name="Requests" href="/requests" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
{user && user.role === "ADMIN" && (
|
{user && user.role === "ADMIN" && (
|
||||||
<div className="flex h-full mr-5">
|
<div className="flex h-full mr-5">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user