Management and Admin pages

This commit is contained in:
Lukeshan Thananchayan 2025-05-31 19:52:50 +01:00
parent 4438953fab
commit 647c531d20
7 changed files with 1238 additions and 876 deletions

View File

@ -1,6 +1,8 @@
"use client"; "use client";
import React, { useRef, useState } from "react"; import React, { useRef, useState, useEffect } from "react";
import { useStoreState } from "@hooks/store";
// --- Types and labels ---
type Role = "ADMIN" | "GUEST" | "SCIENTIST"; type Role = "ADMIN" | "GUEST" | "SCIENTIST";
const roleLabels: Record<Role, string> = { const roleLabels: Record<Role, string> = {
ADMIN: "Admin", ADMIN: "Admin",
@ -12,147 +14,233 @@ type User = {
email: string; email: string;
name: string; name: string;
role: Role; role: Role;
password: string;
createdAt: string; createdAt: string;
}; };
// todo add fulfilling of requests
// todo create api route to get users, with auth for only admin
// todo add management of only junior scientists if senior scientist
// todo (optional) add display of each user's previous orders when selecting them
const initialUsers: User[] = [ const initialUsers: User[] = [
{ email: "john@example.com", name: "John Doe", role: "ADMIN", password: "secret1", createdAt: "2024-06-21T09:15:01Z", id: 1 },
{ email: "jane@example.com", name: "Jane Smith", role: "GUEST", password: "secret2", createdAt: "2024-06-21T10:01:09Z", id: 2 },
{ {
email: "bob@example.com", email: "users-loading@admin.api",
name: "Bob Brown", name: "Loading Users",
role: "SCIENTIST", role: "ADMIN",
password: "secret3", createdAt: "Check admin api and frontend",
createdAt: "2024-06-21T12:13:45Z", id: 0,
id: 3,
}, },
{
email: "alice@example.com",
name: "Alice Johnson",
role: "GUEST",
password: "secret4",
createdAt: "2024-06-20T18:43:20Z",
id: 4,
},
{ email: "eve@example.com", name: "Eve Black", role: "ADMIN", password: "secret5", createdAt: "2024-06-20T19:37:10Z", id: 5 },
{ email: "dave@example.com", name: "Dave Clark", role: "GUEST", password: "pw", createdAt: "2024-06-19T08:39:10Z", id: 6 },
{ email: "fred@example.com", name: "Fred Fox", role: "GUEST", password: "pw", createdAt: "2024-06-19T09:11:52Z", id: 7 },
{ email: "ginny@example.com", name: "Ginny Hall", role: "SCIENTIST", password: "pw", createdAt: "2024-06-17T14:56:27Z", id: 8 },
{ email: "harry@example.com", name: "Harry Lee", role: "ADMIN", password: "pw", createdAt: "2024-06-16T19:28:11Z", id: 9 },
{ email: "ivy@example.com", name: "Ivy Volt", role: "ADMIN", password: "pw", createdAt: "2024-06-15T21:04:05Z", id: 10 },
{ email: "kate@example.com", name: "Kate Moss", role: "SCIENTIST", password: "pw", createdAt: "2024-06-14T11:16:35Z", id: 11 },
{ email: "leo@example.com", name: "Leo Garrison", role: "GUEST", password: "pw", createdAt: "2024-06-12T08:02:51Z", id: 12 },
{ email: "isaac@example.com", name: "Isaac Yang", role: "GUEST", password: "pw", createdAt: "2024-06-12T15:43:29Z", id: 13 },
]; ];
const sortFields = [ const sortFields = [
// Sort box options
{ label: "Name", value: "name" }, { label: "Name", value: "name" },
{ label: "Email", value: "email" }, { label: "Email", value: "email" },
] as const; ] as const;
type SortField = (typeof sortFields)[number]["value"]; 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", email: "Email" }; const fieldLabels: Record<SortField, string> = { name: "Name", email: "Email" };
// =========== THE PAGE =============
export default function AdminPage() { export default function AdminPage() {
const [users, setUsers] = useState<User[]>(initialUsers); // ---- All hooks at the top!
const [selectedEmail, setSelectedEmail] = useState<string | null>(null); const user = useStoreState((state) => state.user);
// Local edit state for SCIENTIST form const [selectedEmail, setSelectedEmail] = useState<string | null>(null);
const [users, setUsers] = useState<User[]>(initialUsers);
const [addOpen, setAddOpen] = useState(false);
const [addForm, setAddForm] = useState<{ name: string; email: string; role: Role; password: string }>({
name: "",
email: "",
role: "SCIENTIST",
password: "",
});
const [addError, setAddError] = useState<string | null>(null);
const [addLoading, setAddLoading] = useState(false);
const [editUser, setEditUser] = useState<User | null>(null); const [editUser, setEditUser] = useState<User | null>(null);
// Reset editUser when the selected user changes const [searchField, setSearchField] = useState<"name" | "email">("name");
React.useEffect(() => { const [searchText, setSearchText] = useState("");
const [roleFilter, setRoleFilter] = useState<Role | "all">("all");
const [sortField, setSortField] = useState<SortField>("name");
const [sortDir, setSortDir] = useState<SortDir>("asc");
const [newPassword, setNewPassword] = useState<string>("");
const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
const [sortDropdownOpen, setSortDropdownOpen] = useState(false);
const filterDropdownRef = useRef<HTMLDivElement>(null);
const sortDropdownRef = useRef<HTMLDivElement>(null);
const [showEmailTooltip, setShowEmailTooltip] = useState(false);
useEffect(() => {
async function fetchUsers() {
try {
const res = await fetch("/api/admin");
if (!res.ok) throw new Error("Failed to fetch");
const data = await res.json();
setUsers(data.users);
} catch (err) {
console.error("Error fetching users:", err);
}
}
fetchUsers();
}, []);
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (
filterDropdownRef.current &&
!filterDropdownRef.current.contains(e.target as Node)
)
setFilterDropdownOpen(false);
if (
sortDropdownRef.current &&
!sortDropdownRef.current.contains(e.target as Node)
)
setSortDropdownOpen(false);
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
useEffect(() => {
if (!selectedEmail) setEditUser(null); if (!selectedEmail) setEditUser(null);
else { else {
const user = users.find((u) => u.email === selectedEmail); const user = users.find((u) => u.email === selectedEmail);
setEditUser(user ? { ...user } : null); setEditUser(user ? { ...user } : null);
} }
}, [selectedEmail, users]); }, [selectedEmail, users]);
// Search/filter/sort state
const [searchField, setSearchField] = useState<"name" | "email">("name");
const [searchText, setSearchText] = useState("");
const [roleFilter, setRoleFilter] = useState<Role | "all">("all");
const [sortField, setSortField] = useState<SortField>("name");
const [sortDir, setSortDir] = useState<SortDir>("asc");
// Dropdown states
const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
const [sortDropdownOpen, setSortDropdownOpen] = useState(false);
const filterDropdownRef = useRef<HTMLDivElement>(null);
const sortDropdownRef = useRef<HTMLDivElement>(null);
React.useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (filterDropdownRef.current && !filterDropdownRef.current.contains(e.target as Node)) setFilterDropdownOpen(false);
if (sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as Node)) setSortDropdownOpen(false);
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
// Filtering, searching, sorting logic // Filtering, searching, sorting logic
const filteredUsers = users.filter((user) => roleFilter === "all" || user.role === roleFilter); const filteredUsers = users.filter(
const searchedUsers = filteredUsers.filter((user) => user[searchField].toLowerCase().includes(searchText.toLowerCase())); (user) => roleFilter === "all" || user.role === roleFilter
);
const searchedUsers = filteredUsers.filter((user) =>
user[searchField].toLowerCase().includes(searchText.toLowerCase())
);
const sortedUsers = [...searchedUsers].sort((a, b) => { const sortedUsers = [...searchedUsers].sort((a, b) => {
let cmp = a[sortField].localeCompare(b[sortField]); let cmp = a[sortField].localeCompare(b[sortField]);
return sortDir === "asc" ? cmp : -cmp; return sortDir === "asc" ? cmp : -cmp;
}); });
async function handleAddUser(e: React.FormEvent) {
// Form input change handler e.preventDefault();
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => { setAddError(null);
if (!addForm.name || !addForm.email || !addForm.password) {
setAddError("All fields are required.");
return;
}
try {
setAddLoading(true);
const res = await fetch("/api/admin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(addForm),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error || "Failed to add user");
}
const data = await res.json();
setUsers((prev) => [...prev, data.user]);
setAddOpen(false);
setAddForm({ name: "", email: "", role: "SCIENTIST", password: "" });
} catch (err: any) {
setAddError(err?.message || "Unknown error");
} finally {
setAddLoading(false);
}
}
const handleEditChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
if (!editUser) return;
const { name, value } = e.target;
setEditUser((prev) => (prev ? { ...prev, [name]: value } : null));
};
const handlePasswordChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
if (!editUser) return; if (!editUser) return;
const { name, value } = e.target; const { name, value } = e.target;
setEditUser((prev) => (prev ? { ...prev, [name]: value } : null)); setEditUser((prev) => (prev ? { ...prev, [name]: value } : null));
}; };
// Update button logic (compare original selectedUser and editUser)
const selectedUser = users.find((u) => u.email === selectedEmail); const selectedUser = users.find((u) => u.email === selectedEmail);
const isEditChanged = React.useMemo(() => { const isEditChanged = React.useMemo(() => {
if (!editUser || !selectedUser) return false; if (!editUser || !selectedUser) return false;
// Compare primitive fields
return ( return (
editUser.name !== selectedUser.name || editUser.role !== selectedUser.role || editUser.password !== selectedUser.password editUser.name !== selectedUser.name ||
editUser.role !== selectedUser.role ||
newPassword
); );
}, [editUser, selectedUser]); }, [editUser, selectedUser, newPassword]);
async function updateUserOnServer(user: User, password: string) {
// Update/save changes const body: any = {
const handleUpdate = (e: React.FormEvent) => { id: user.id,
name: user.name,
role: user.role,
};
if (password.trim() !== "") {
body.password = password;
}
const res = await fetch("/api/admin", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) throw new Error("Failed to update user");
const data = await res.json();
return data.user as User;
}
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!editUser) return; if (!editUser) return;
setUsers((prev) => prev.map((u) => (u.email === editUser.email ? { ...editUser } : u))); try {
// todo create receiving api route const updated = await updateUserOnServer(editUser, newPassword);
// todo send to api route setUsers((prev) =>
// After successful update, update selectedUser local state prev.map((u) => (u.id === updated.id ? { ...updated } : u))
// (editUser will auto-sync due to useEffect on users) );
setNewPassword("");
} catch (err) {
console.error("Failed to update user:", err);
}
}; };
const handleDelete = async () => {
// Delete user logic
const handleDelete = () => {
if (!selectedUser) return; if (!selectedUser) return;
if (!window.confirm(`Are you sure you want to delete "${selectedUser.name}"? This cannot be undone.`)) return; if (
setUsers((prev) => prev.filter((u) => u.email !== selectedUser.email)); !window.confirm(
`Are you sure you want to delete "${selectedUser.name}"? This cannot be undone.`,
)
)
return;
try {
const res = await fetch("/api/admin", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: selectedUser.id }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error || "Failed to delete user");
setUsers((prev) =>
prev.filter((u) => u.email !== selectedUser.email)
);
setSelectedEmail(null); setSelectedEmail(null);
setEditUser(null); setEditUser(null);
} catch (err: any) {
alert(err?.message || "Delete failed!");
}
}; };
const allRoles: Role[] = ["ADMIN", "GUEST", "SCIENTIST"]; const allRoles: Role[] = ["ADMIN", "GUEST", "SCIENTIST"];
// Tooltip handling for email field // --- ADMIN ONLY:
const [showEmailTooltip, setShowEmailTooltip] = useState(false); if (!user || user.role !== "ADMIN") {
return (
<div className="flex items-center justify-center min-h-[70vh] flex-col">
<h1 className="text-2xl font-bold text-red-500 mb-4">
Unauthorized Access
</h1>
<div className="text-gray-600">You do not have access to this page.</div>
</div>
);
}
// --- Render admin UI
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex h-full overflow-hidden bg-gray-50"> <div className="flex h-full overflow-hidden bg-gray-50">
{/* SIDEBAR */} {/* SIDEBAR */}
<div className="w-80 h-full border-r border-neutral-200 bg-neutral-100 flex flex-col rounded-l-xl shadow-sm"> <div className="w-80 h-full border-r border-neutral-200 bg-neutral-100 flex flex-col rounded-l-xl shadow-sm">
<div className="p-4 flex flex-col h-full"> <div className="p-4 flex flex-col h-full">
{/* Search Bar */} {/* Search, filter, sort controls ... (your code unchanged) */}
<div className="mb-3 flex gap-2"> <div className="mb-3 flex gap-2">
<input <input
className="flex-1 border rounded-lg px-2 py-1 text-sm" className="flex-1 border rounded-lg px-2 py-1 text-sm"
@ -163,21 +251,19 @@ export default function AdminPage() {
<button <button
type="button" type="button"
className="border rounded-lg px-2 py-1 text-sm bg-white hover:bg-neutral-100 transition font-semibold" className="border rounded-lg px-2 py-1 text-sm bg-white hover:bg-neutral-100 transition font-semibold"
style={{ width: "80px" }} // fixed width, adjust as needed style={{ width: "80px" }}
onClick={() => setSearchField((field) => (field === "name" ? "email" : "name"))} onClick={() => setSearchField((field) => (field === "name" ? "email" : "name"))}
title={`Switch to searching by ${searchField === "name" ? "Email" : "Name"}`} title={`Switch to searching by ${searchField === "name" ? "Email" : "Name"}`}
> >
{searchField === "name" ? "Email" : "Name"} {searchField === "name" ? "Email" : "Name"}
</button> </button>
</div> </div>
{/* Filter and Sort Buttons */}
<div className="flex gap-2 items-center mb-2"> <div className="flex gap-2 items-center mb-2">
{/* Filter */} {/* Filter */}
<div className="relative" ref={filterDropdownRef}> <div className="relative" ref={filterDropdownRef}>
<button <button
className={`px-3 py-1 rounded-lg border font-semibold flex items-center transition className={`px-3 py-1 rounded-lg border font-semibold flex items-center transition
${ ${roleFilter !== "all"
roleFilter !== "all"
? "bg-blue-600 text-white border-blue-600 hover:bg-blue-700" ? "bg-blue-600 text-white border-blue-600 hover:bg-blue-700"
: "bg-white text-gray-700 border hover:bg-neutral-200" : "bg-white text-gray-700 border hover:bg-neutral-200"
} }
@ -257,12 +343,24 @@ export default function AdminPage() {
> >
{sortDir === "asc" ? "↑" : "↓"} {sortDir === "asc" ? "↑" : "↓"}
</button> </button>
{/* ADD 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"
type="button"
style={{ minWidth: 36, minHeight: 36 }}
onClick={() => setAddOpen(true)}
disabled={addOpen}
title="Add user"
>
<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" />
</svg>
</button>
</div> </div>
{/* Sort status text */}
<small className="text-xs text-gray-500 mb-2 px-1"> <small className="text-xs text-gray-500 mb-2 px-1">
Users sorted by {fieldLabels[sortField]} {dirLabels[sortDir]} Users sorted by {fieldLabels[sortField]} {dirLabels[sortDir]}
</small> </small>
{/* USERS LIST: full height, scrollable */} {/* USERS LIST */}
<ul className="overflow-y-auto flex-1 pr-1"> <ul className="overflow-y-auto flex-1 pr-1">
{sortedUsers.map((user) => ( {sortedUsers.map((user) => (
<li <li
@ -287,20 +385,98 @@ export default function AdminPage() {
</div> </div>
{/* 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">
{/* Add User Modal */}
{addOpen && (
<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-sm w-full relative">
<h3 className="text-lg font-bold mb-4">Add New User</h3>
<form onSubmit={handleAddUser} className="space-y-3">
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input
className="w-full border px-2 py-1 rounded-lg"
type="email"
required
value={addForm.email}
onChange={e => setAddForm(f => ({ ...f, email: e.target.value }))}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Name</label>
<input
className="w-full border px-2 py-1 rounded-lg"
type="text"
required
value={addForm.name}
onChange={e => setAddForm(f => ({ ...f, name: e.target.value }))}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Role</label>
<select
className="w-full border px-2 py-1 rounded-lg"
value={addForm.role}
onChange={e => setAddForm(f => ({ ...f, role: e.target.value as Role }))}
>
{allRoles.map(role => (
<option value={role} key={role}>{roleLabels[role]}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<input
className="w-full border px-2 py-1 rounded-lg"
type="text"
required
value={addForm.password}
onChange={e => setAddForm(f => ({ ...f, password: e.target.value }))}
/>
</div>
{addError && <div className="text-red-600 text-xs">{addError}</div>}
<div className="flex gap-2 justify-end pt-2">
<button
type="button"
className="px-3 py-1 rounded-lg bg-gray-200 text-gray-700 hover:bg-gray-300 transition"
onClick={() => setAddOpen(false)}
disabled={addLoading}
>
Cancel
</button>
<button
type="submit"
className={`px-3 py-1 rounded-lg text-white font-semibold ${addLoading ? "bg-green-400" : "bg-green-600 hover:bg-green-700"}`}
disabled={addLoading}
>
{addLoading ? "Adding..." : "Add"}
</button>
</div>
</form>
</div>
</div>
)}
{/* Edit User Panel */}
{editUser ? ( {editUser ? (
<div className="max-w-lg mx-auto bg-white p-6 rounded-lg shadow"> <div className="max-w-lg mx-auto bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-bold mb-6">Edit User</h2> <h2 className="text-lg font-bold mb-6">Edit User</h2>
<form className="space-y-4" onSubmit={handleUpdate}> <form className="space-y-4" onSubmit={handleUpdate}>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<label className="text-sm font-medium text-gray-700">Account Creation Time:</label> <label className="text-sm font-medium text-gray-700">
Account Creation Time:
</label>
<span className="text-sm text-gray-500">{editUser.createdAt}</span> <span className="text-sm text-gray-500">{editUser.createdAt}</span>
</div> </div>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<label className="text-sm font-medium text-gray-700">Account ID Number:</label> <label className="text-sm font-medium text-gray-700">
Account ID Number:
</label>
<span className="text-sm text-gray-500">{editUser.id}</span> <span className="text-sm text-gray-500">{editUser.id}</span>
</div> </div>
<div className="relative"> <div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-1">Email (unique):</label> <label className="block text-sm font-medium text-gray-700 mb-1">
Email (unique):
</label>
<input <input
className="w-full border px-3 py-2 rounded-lg outline-none bg-gray-100 cursor-not-allowed" className="w-full border px-3 py-2 rounded-lg outline-none bg-gray-100 cursor-not-allowed"
type="email" type="email"
@ -310,7 +486,6 @@ export default function AdminPage() {
onMouseEnter={() => setShowEmailTooltip(true)} onMouseEnter={() => setShowEmailTooltip(true)}
onMouseLeave={() => setShowEmailTooltip(false)} onMouseLeave={() => setShowEmailTooltip(false)}
/> />
{/* Custom tooltip */}
{showEmailTooltip && ( {showEmailTooltip && (
<div className="absolute left-0 top-full mt-1 z-10 w-max max-w-xs bg-gray-800 text-gray-100 text-xs px-3 py-2 rounded-md shadow-lg border border-gray-700"> <div className="absolute left-0 top-full mt-1 z-10 w-max max-w-xs bg-gray-800 text-gray-100 text-xs px-3 py-2 rounded-md shadow-lg border border-gray-700">
This field cannot be changed. <br /> This field cannot be changed. <br />
@ -319,7 +494,9 @@ export default function AdminPage() {
)} )}
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name:</label> <label className="block text-sm font-medium text-gray-700 mb-1">
Name:
</label>
<input <input
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300" className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
type="text" type="text"
@ -329,7 +506,9 @@ export default function AdminPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Role:</label> <label className="block text-sm font-medium text-gray-700 mb-1">
Role:
</label>
<select <select
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300" className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
name="role" name="role"
@ -344,13 +523,15 @@ export default function AdminPage() {
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Password:</label> <label className="block text-sm font-medium text-gray-700 mb-1">
Password:
</label>
<input <input
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300" className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
type="text" type="text"
name="password" name="password"
value={editUser.password} value={newPassword}
onChange={handleEditChange} onChange={(e) => setNewPassword(e.target.value)}
/> />
</div> </div>
<div className="flex gap-2 justify-end pt-6"> <div className="flex gap-2 justify-end pt-6">
@ -377,7 +558,9 @@ export default function AdminPage() {
</form> </form>
</div> </div>
) : ( ) : (
<div className="text-center text-gray-400 mt-16 text-lg">Select a user...</div> <div className="text-center text-gray-400 mt-16 text-lg">
Select a user...
</div>
)} )}
</div> </div>
</div> </div>

126
src/app/api/admin/route.ts Normal file
View File

@ -0,0 +1,126 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { prisma } from "@utils/prisma";
import { env } from "@utils/env";
import { verifyJwt } from "@utils/verifyJwt";
import bcryptjs from "bcryptjs";
import { z } from "zod";
// Helper
async function getUserFromRequest() {
const cookieStore = cookies();
const token = (await cookieStore).get("jwt")?.value;
if (!token) return null;
const payload = await verifyJwt({ token, secret: env.JWT_SECRET_KEY });
if (!payload?.userId) return null;
const user = await prisma.user.findUnique({
where: { id: payload.userId as number },
select: { id: true, role: true },
});
return user;
}
export async function GET() {
try {
const user = await getUserFromRequest();
if (!user || user.role !== "ADMIN") {
return NextResponse.json({ error: "Not authorized" }, { status: 403 });
}
const users = await prisma.user.findMany({
select: { id: true, email: true, name: true, role: true, createdAt: true },
});
const cleanedUsers = users.map(u => ({
...u,
createdAt: u.createdAt instanceof Date ? u.createdAt.toISOString() : u.createdAt,
}));
return NextResponse.json({ users: cleanedUsers }, { status: 200 });
} catch (error) {
console.error("Error fetching users:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
export async function PUT(request: Request) {
try {
const user = await getUserFromRequest();
if (!user || user.role !== "ADMIN") {
return NextResponse.json({ error: "Not authorized" }, { status: 403 });
}
const body = await request.json();
const { id, name, role, password } = body;
const updateData: any = { name, role };
if (typeof password === "string" && password.trim() !== "") {
updateData.passwordHash = await bcryptjs.hash(password, 10);
}
const updated = await prisma.user.update({
where: { id },
data: updateData,
});
return NextResponse.json({ user: updated }, { status: 200 });
} catch (error) {
console.error("Update error:", error);
return NextResponse.json({ error: "Update failed" }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const user = await getUserFromRequest();
if (!user || user.role !== "ADMIN") {
return NextResponse.json({ error: "Not authorized" }, { status: 403 });
}
const body = await request.json();
// Validate input (simple for demo, use zod or similar in prod)
const schema = z.object({
email: z.string().email(),
name: z.string().min(1),
role: z.enum(["ADMIN", "SCIENTIST", "GUEST"]),
password: z.string().min(6)
});
const { email, name, role, password } = schema.parse(body);
// Check uniqueness
const exists = await prisma.user.findUnique({ where: { email } });
if (exists) {
return NextResponse.json({ error: "Email already exists" }, { status: 409 });
}
const passwordHash = await bcryptjs.hash(password, 10);
const created = await prisma.user.create({
data: {
email,
name,
role,
passwordHash,
},
select: { id: true, email: true, name: true, role: true, createdAt: true },
});
return NextResponse.json({ user: { ...created, createdAt: created.createdAt instanceof Date ? created.createdAt.toISOString() : created.createdAt } }, { status: 201 });
} catch (error: any) {
console.error("Create user error:", error);
return NextResponse.json({ error: error?.message ?? "Failed to create user" }, { status: 400 });
}
}
export async function DELETE(request: Request) {
try {
const user = await getUserFromRequest();
if (!user || user.role !== "ADMIN") {
return NextResponse.json({ error: "Not authorized" }, { status: 403 });
}
const body = await request.json();
const { id } = body;
if (typeof id !== "number" || isNaN(id)) {
return NextResponse.json({ error: "Invalid id" }, { status: 400 });
}
await prisma.user.delete({
where: { id }
});
return NextResponse.json({ success: true }, { status: 200 });
} catch (error: any) {
console.error("Delete error:", error);
return NextResponse.json({ error: error.message || "Delete failed" }, { status: 500 });
}
}

View File

@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { prisma } from "@utils/prisma";
export async function POST(req: Request) {
try {
const { requestType, requestingUserId, scientistId, comment } = await req.json();
const request = await prisma.request.create({
data: {
requestType,
requestingUser: { connect: { id: requestingUserId } },
outcome: "IN_PROGRESS",
// Optionally you can connect to Scientist via an inline relation if you have a foreign key
// If the model has comment or details fields, add it!
},
});
return NextResponse.json({ request }, { status: 201 });
} catch (error) {
console.error("Request create error:", error);
return NextResponse.json({ error: "Failed to create request" }, { status: 500 });
}
}

View File

@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { prisma } from "@utils/prisma";
// GET all scientists (with user, superior.user, subordinates)
export async function GET() {
try {
const scientists = await prisma.scientist.findMany({
include: {
user: true,
superior: { include: { user: true } },
subordinates: true,
},
});
return NextResponse.json({ scientists }, { status: 200 });
} catch (error) {
console.error("Error fetching scientists:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
// CREATE scientist
export async function POST(req: Request) {
try {
const { name, level, userId, superiorId } = await req.json();
const scientist = await prisma.scientist.create({
data: {
name,
level,
user: { connect: { id: userId } },
superior: superiorId ? { connect: { id: superiorId } } : undefined,
},
include: {
user: true,
superior: { include: { user: true } },
subordinates: true,
},
});
return NextResponse.json({ scientist }, { status: 201 });
} catch (error) {
console.error("Scientist create error:", error);
return NextResponse.json({ error: "Failed to create scientist" }, { status: 500 });
}
}
// UPDATE scientist
export async function PUT(req: Request) {
try {
const { id, name, level, userId, superiorId } = await req.json();
const updatedScientist = await prisma.scientist.update({
where: { id },
data: {
name,
level,
user: { connect: { id: userId } },
superior: superiorId ? { connect: { id: superiorId } } : { disconnect: true },
},
include: {
user: true,
superior: { include: { user: true } },
subordinates: true,
},
});
return NextResponse.json({ scientist: updatedScientist }, { status: 200 });
} catch (error) {
console.error("Update error:", error);
return NextResponse.json({ error: "Update failed" }, { status: 500 });
}
}
// DELETE scientist
export async function DELETE(req: Request) {
try {
const { id } = await req.json();
await prisma.scientist.delete({ where: { id } });
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
console.error("Delete error:", error);
return NextResponse.json({ error: "Delete failed" }, { status: 500 });
}
}

View File

@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { prisma } from "@utils/prisma";
export async function GET() {
try {
const users = await prisma.user.findMany({
include: {
scientist: true, // So you know if the user already has a scientist
}
});
return NextResponse.json({ users }, { status: 200 });
} catch (error) {
console.error("Error fetching all users:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}

View File

@ -1,177 +1,200 @@
"use client"; "use client";
import React, { useRef, useState } from "react"; import React, { useRef, useState, useEffect } from "react";
import { useStoreState } from "@hooks/store";
// --- Types ---
type Level = "JUNIOR" | "SENIOR"; type Level = "JUNIOR" | "SENIOR";
const levelLabels: Record<Level, string> = { JUNIOR: "Junior", SENIOR: "Senior" }; const levelLabels: Record<Level, string> = { JUNIOR: "Junior", SENIOR: "Senior" };
type User = { id: number; email: string; name: string; }; type User = {
type Earthquakes = { id: number; code: string; location: string; }; id: number;
type Observatory = { id: number; name: string; location: string; }; name: string;
type Artefact = { id: number; name: string; type: string; }; email: string;
role?: string;
scientist?: Scientist | null;
};
type Scientist = { type Scientist = {
id: number; id: number;
createdAt: string; createdAt: string;
name: string; name: string;
level: Level; level: Level;
user: User; user: User;
userId: User["id"]; userId: number;
superior: Scientist | null; superior: Scientist | null;
superiorId: Scientist["id"] | null; superiorId: number | null;
subordinates: Scientist[]; subordinates: Scientist[];
earthquakes: Earthquakes[];
earthquakeIds: number[];
observatories: Observatory[];
observatoryIds: number[];
artefacts: Artefact[];
artefactIds: number[];
}; };
const users: User[] = [ // --- Helpers ---
{ id: 1, name: "Albert Einstein", email: "ae@uni.edu" }, const initialScientists: Scientist[] = [
{ id: 2, name: "Marie Curie", email: "mc@uni.edu" },
{ id: 3, name: "Ada Lovelace", email: "al@uni.edu" },
{ id: 4, name: "Carl Sagan", email: "cs@uni.edu" },
{ id: 5, name: "Isaac Newton", email: "in@uni.edu" }
];
const artefacts: Artefact[] = [
{ id: 1, name: "SeismoRing", type: "Instrument" },
{ id: 2, name: "QuakeCube", type: "Sensor" },
{ id: 3, name: "WavePen", type: "Recorder" },
{ id: 4, name: "TremorNet", type: "AI Chip" }
];
const observatories: Observatory[] = [
{ id: 1, name: "Stanford Observatory", location: "Stanford" },
{ id: 2, name: "Tokyo Seismic Center", location: "Tokyo" },
{ id: 3, name: "Oxford Observatory", location: "Oxford" },
{ id: 4, name: "Mount Wilson", location: "Pasadena" }
];
const earthquakes: Earthquakes[] = [
{ id: 1, code: "EQ-001", location: "San Francisco" },
{ id: 2, code: "EQ-002", location: "Tokyo" },
{ id: 3, code: "EQ-003", location: "Istanbul" },
{ id: 4, code: "EQ-004", location: "Mexico City" },
{ id: 5, code: "EQ-005", location: "Rome" }
];
const scientistList: Scientist[] = [
{ {
id: 1, id: 0,
createdAt: "2024-06-01T09:00:00Z", name: "Loading Scientist",
name: "Dr. John Junior",
level: "JUNIOR", level: "JUNIOR",
user: users[0], createdAt: "",
userId: 1, user: { id: 0, name: "Loading...", email: "--" },
superior: null, userId: 0,
superiorId: 2,
subordinates: [],
earthquakes: [earthquakes[0], earthquakes[2]],
earthquakeIds: [1, 3],
observatories: [observatories[0], observatories[1]],
observatoryIds: [1, 2],
artefacts: [artefacts[0], artefacts[2]],
artefactIds: [1, 3],
},
{
id: 2,
createdAt: "2024-06-01T10:00:00Z",
name: "Dr. Jane Senior",
level: "SENIOR",
user: users[1],
userId: 2,
superior: null, superior: null,
superiorId: null, superiorId: null,
subordinates: [], subordinates: [],
earthquakes: [earthquakes[1], earthquakes[3], earthquakes[4]],
earthquakeIds: [2, 4, 5],
observatories: [observatories[1], observatories[2]],
observatoryIds: [2, 3],
artefacts: [artefacts[1]],
artefactIds: [2],
}, },
{
id: 3,
createdAt: "2024-06-02T08:00:00Z",
name: "Dr. Amy Junior",
level: "JUNIOR",
user: users[2],
userId: 3,
superior: null,
superiorId: 2,
subordinates: [],
earthquakes: [earthquakes[0]],
earthquakeIds: [1],
observatories: [observatories[2]],
observatoryIds: [3],
artefacts: [artefacts[2], artefacts[3]],
artefactIds: [3, 4],
},
{
id: 4,
createdAt: "2024-06-02T08:15:00Z",
name: "Prof. Isaac Senior",
level: "SENIOR",
user: users[4],
userId: 5,
superior: null,
superiorId: null,
subordinates: [],
earthquakes: [earthquakes[2], earthquakes[3]],
earthquakeIds: [3, 4],
observatories: [observatories[3]],
observatoryIds: [4],
artefacts: [artefacts[3]],
artefactIds: [4],
},
{
id: 5,
createdAt: "2024-06-02T08:20:00Z",
name: "Dr. Carl Junior",
level: "JUNIOR",
user: users[3],
userId: 4,
superior: null,
superiorId: 4,
subordinates: [],
earthquakes: [earthquakes[3]],
earthquakeIds: [4],
observatories: [observatories[1], observatories[2]],
observatoryIds: [2, 3],
artefacts: [artefacts[0]],
artefactIds: [1],
}
]; ];
scientistList[0].superior = scientistList[1];
scientistList[2].superior = scientistList[1];
scientistList[4].superior = scientistList[3];
scientistList[1].subordinates = [scientistList[0], scientistList[2]];
scientistList[3].subordinates = [scientistList[4]];
const sortFields = [ const sortFields = [
{ label: "Name", value: "name" }, { label: "Name", value: "name" },
{ label: "Level", value: "level" }, { label: "Level", value: "level" }
] as const; ] as const;
type SortField = (typeof sortFields)[number]["value"]; 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" };
export default function Scientist() { // --- Updated RequestModal (only level/removal, no comment)
const [scientists, setScientists] = useState<Scientist[]>(scientistList); type RequestModalProps = {
open: boolean;
onClose: () => void;
requestingUserId: number;
scientist?: Scientist | null;
};
function RequestModal({ open, onClose, requestingUserId, scientist }: RequestModalProps) {
const [requestType, setRequestType] = useState<string>("CHANGE_LEVEL");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null); setSuccess(null);
setLoading(true);
try {
const body = {
requestType,
requestingUserId,
scientistId: scientist?.id ?? null,
comment: "", // Still send blank to backend for compatibility
};
const res = await fetch("/api/management/request", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d?.error || "Request failed");
}
setSuccess("Request submitted for review.");
} catch (e: any) {
setError(e?.message || "Unknown error");
} finally {
setLoading(false);
}
}
return open ? (
<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">
<h3 className="text-lg font-bold mb-4">Request Action</h3>
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<label className="block text-sm font-medium mb-1">Action Type</label>
<select
required
className="w-full border px-2 py-1 rounded-lg"
value={requestType}
onChange={e=>setRequestType(e.target.value)}
>
<option value="CHANGE_LEVEL">Request Change Level</option>
<option value="DELETE">Request Removal</option>
</select>
</div>
{error && <div className="text-red-600 text-xs">{error}</div>}
{success && <div className="text-green-600 text-xs">{success}</div>}
<div className="flex gap-2 justify-end pt-2">
<button
type="button"
className="px-3 py-1 rounded-lg bg-gray-200 text-gray-700 hover:bg-gray-300 transition"
onClick={onClose}
disabled={loading}
>
Cancel
</button>
<button
type="submit"
className={`px-3 py-1 rounded-lg text-white font-semibold ${loading ? "bg-blue-300" : "bg-blue-600 hover:bg-blue-700"}`}
disabled={loading}
>
{loading ? "Submitting..." : "Submit"}
</button>
</div>
</form>
</div>
</div>
) : null;
}
// ========================================================
export default function ScientistManagementPage() {
// All hooks first
const user = useStoreState((state)=>state.user);
const [scientists, setScientists] = useState<Scientist[]>(initialScientists);
const [allUsers, setAllUsers] = useState<User[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null); const [selectedId, setSelectedId] = useState<number | null>(null);
const [editScientist, setEditScientist] = useState<Scientist | null>(null); const [editScientist, setEditScientist] = useState<Scientist | null>(null);
React.useEffect(() => { const [addOpen, setAddOpen] = useState(false);
const [requestOpen, setRequestOpen] = useState(false);
const [addForm, setAddForm] = useState<{ name: string; level: Level; userId: number }>({
name: "",
level: "JUNIOR",
userId: 0,
});
const [addError, setAddError] = useState<string | null>(null);
const [addLoading, setAddLoading] = useState(false);
const [searchField, setSearchField] = useState<SortField>("name");
const [searchText, setSearchText] = useState("");
const [levelFilter, setLevelFilter] = useState<Level | "all">("all");
const [sortField, setSortField] = useState<SortField>("name");
const [sortDir, setSortDir] = useState<SortDir>("asc");
const [success, setSuccess] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
const [sortDropdownOpen, setSortDropdownOpen] = useState(false);
const filterDropdownRef = useRef<HTMLDivElement>(null);
const sortDropdownRef = useRef<HTMLDivElement>(null);
// AUTH LOGIC
const userRole = user?.role as string | undefined;
const isAdmin = userRole === "ADMIN";
const isSeniorScientist = userRole === "SCIENTIST" && user?.scientist?.level === "SENIOR";
const readOnly = isSeniorScientist && !isAdmin;
// Data loading effects
useEffect(() => {
async function fetchAllUsers() {
try {
const res = await fetch("/api/users/all");
if (!res.ok) throw new Error("Failed to fetch users");
const data = await res.json();
setAllUsers(data.users || []);
} catch (err) {
setError("Error fetching all users");
}
}
fetchAllUsers();
}, []);
useEffect(() => {
async function fetchScientists() {
try {
const res = await fetch("/api/management");
if (!res.ok) throw new Error("Failed to fetch scientists");
const data = await res.json();
setScientists(data.scientists);
} catch (err) {
setError("Error fetching scientists");
}
}
fetchScientists();
}, []);
useEffect(() => {
if (selectedId == null) setEditScientist(null); if (selectedId == null) setEditScientist(null);
else { else {
const sc = scientists.find((x) => x.id === selectedId); const sc = scientists.find((x) => x.id === selectedId);
setEditScientist(sc ? { ...sc } : null); setEditScientist(sc ? { ...sc } : null);
} }
}, [selectedId, scientists]); }, [selectedId, scientists]);
const [searchField, setSearchField] = useState<SortField>("name"); useEffect(() => {
const [searchText, setSearchText] = useState("");
const [levelFilter, setLevelFilter] = useState<Level | "all">("all");
const [sortField, setSortField] = useState<SortField>("name");
const [sortDir, setSortDir] = useState<SortDir>("asc");
const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
const [sortDropdownOpen, setSortDropdownOpen] = useState(false);
const filterDropdownRef = useRef<HTMLDivElement>(null);
const sortDropdownRef = useRef<HTMLDivElement>(null);
React.useEffect(() => {
const handleClick = (e: MouseEvent) => { const handleClick = (e: MouseEvent) => {
if (filterDropdownRef.current && !filterDropdownRef.current.contains(e.target as Node)) setFilterDropdownOpen(false); if (filterDropdownRef.current && !filterDropdownRef.current.contains(e.target as Node)) setFilterDropdownOpen(false);
if (sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as Node)) setSortDropdownOpen(false); if (sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as Node)) setSortDropdownOpen(false);
@ -179,151 +202,114 @@ export default function Scientist() {
document.addEventListener("mousedown", handleClick); document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick);
}, []); }, []);
const filtered = scientists.filter((s) => levelFilter === "all" || s.level === levelFilter); const filtered = scientists.filter(s => levelFilter === "all" || s.level === levelFilter);
const searched = filtered.filter((s) => s[searchField].toLowerCase().includes(searchText.toLowerCase())); const searched = filtered.filter(s => String(s[searchField]).toLowerCase().includes(searchText.toLowerCase()));
const sorted = [...searched].sort((a, b) => { const sorted = [...searched].sort((a, b) => {
let cmp = a[sortField].localeCompare(b[sortField]); let cmp = String(a[sortField]).localeCompare(String(b[sortField]));
return sortDir === "asc" ? cmp : -cmp; return sortDir === "asc" ? cmp : -cmp;
}); });
const allLevels: Level[] = ["JUNIOR", "SENIOR"]; const allLevels: Level[] = ["JUNIOR", "SENIOR"];
const allUsers = users;
const allObservatories = observatories;
const allArtefacts = artefacts;
const allEarthquakes = earthquakes;
const allOtherScientistOptions = (curId?: number) =>
scientists.filter((s) => s.id !== curId);
// -- Queries for selectors
const [artefactQuery, setArtefactQuery] = useState("");
const [earthquakeQuery, setEarthquakeQuery] = useState("");
const [observatoryQuery, setObservatoryQuery] = useState("");
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => { const handleEditChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
if (!editScientist) return; if (!editScientist) return;
const { name, value } = e.target; const { name, value } = e.target;
if (name === "superiorId") { if (name === "superiorId") {
const supId = value === "" ? null : Number(value); const supId = value === "" ? null : Number(value);
setEditScientist((prev) => setEditScientist(prev => prev ? {
prev
? {
...prev, ...prev,
superiorId: supId, superiorId: supId,
superior: supId ? scientists.find((s) => s.id === supId) ?? null : null, superior: supId ? scientists.find((s) => s.id === supId) ?? null : null
} } : null);
: null
);
} else if (name === "level") { } else if (name === "level") {
setEditScientist((prev) => (prev ? { ...prev, level: value as Level } : null)); setEditScientist(prev => prev ? { ...prev, level: value as Level } : null);
} else if (name === "userId") { } else if (name === "userId") {
const user = users.find((u) => u.id === Number(value)); const user = allUsers.find((u) => u.id === Number(value));
setEditScientist((prev) => (prev && user ? { ...prev, user, userId: user.id } : prev)); setEditScientist(prev => (prev && user ? { ...prev, user, userId: user.id } : prev));
} else { } else {
setEditScientist((prev) => (prev ? { ...prev, [name]: value } : null)); setEditScientist(prev => prev ? { ...prev, [name]: value } : null);
} }
}; };
function handleArtefactCheck(id: number) { async function handleAddScientist(e: React.FormEvent) {
if (!editScientist) return;
let nextIds = editScientist.artefactIds.includes(id)
? editScientist.artefactIds.filter((ai) => ai !== id)
: [...editScientist.artefactIds, id];
setEditScientist((prev) =>
prev
? {
...prev,
artefactIds: nextIds,
artefacts: allArtefacts.filter((a) => nextIds.includes(a.id)),
}
: null
);
}
function handleEarthquakeCheck(id: number) {
if (!editScientist) return;
let nextIds = editScientist.earthquakeIds.includes(id)
? editScientist.earthquakeIds.filter((ei) => ei !== id)
: [...editScientist.earthquakeIds, id];
setEditScientist((prev) =>
prev
? {
...prev,
earthquakeIds: nextIds,
earthquakes: allEarthquakes.filter((e) => nextIds.includes(e.id)),
}
: null
);
}
function handleObservatoryCheck(id: number) {
if (!editScientist) return;
let nextIds = editScientist.observatoryIds.includes(id)
? editScientist.observatoryIds.filter((oi) => oi !== id)
: [...editScientist.observatoryIds, id];
setEditScientist((prev) =>
prev
? {
...prev,
observatoryIds: nextIds,
observatories: allObservatories.filter((obs) => nextIds.includes(obs.id)),
}
: null
);
}
const selectedScientist = scientists.find((u) => u.id === selectedId);
function arraysEqualSet(a: number[], b: number[]) {
return a.length === b.length && a.every((v) => b.includes(v));
}
const isEditChanged = React.useMemo(() => {
if (!editScientist || !selectedScientist) return false;
return (
editScientist.name !== selectedScientist.name ||
editScientist.level !== selectedScientist.level ||
editScientist.superiorId !== selectedScientist.superiorId ||
editScientist.userId !== selectedScientist.userId ||
!arraysEqualSet(editScientist.observatoryIds, selectedScientist.observatoryIds) ||
!arraysEqualSet(editScientist.artefactIds, selectedScientist.artefactIds) ||
!arraysEqualSet(editScientist.earthquakeIds, selectedScientist.earthquakeIds)
);
}, [editScientist, selectedScientist]);
const handleUpdate = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!editScientist) return; setAddError(null);
setScientists((prev) => if (!addForm.name || !addForm.level || !addForm.userId) {
prev.map((item) => setAddError("All fields are required.");
item.id === editScientist.id return;
? { }
...editScientist, setAddLoading(true);
artefacts: allArtefacts.filter((a) => editScientist.artefactIds.includes(a.id)), try {
earthquakes: allEarthquakes.filter((eq) => editScientist.earthquakeIds.includes(eq.id)), const res = await fetch("/api/management", {
observatories: allObservatories.filter((obs) => editScientist.observatoryIds.includes(obs.id)), method: "POST",
subordinates: scientistList.filter((s) => s.superiorId === editScientist.id), headers: { "Content-Type": "application/json" },
body: JSON.stringify(addForm),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d?.error || "Failed to add scientist");
}
const data = await res.json();
setScientists(prev => [...prev, data.scientist]);
setAddOpen(false);
setAddForm({ name: "", level: "JUNIOR", userId: 0 });
} catch (err: any) {
setAddError(err?.message || "Unknown error");
} finally {
setAddLoading(false);
}
}
async function updateScientistOnServer(sc: Scientist) {
const res = await fetch("/api/management", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: sc.id,
name: sc.name,
level: sc.level,
userId: sc.user.id,
superiorId: sc.superior ? sc.superior.id : null,
}),
});
if (!res.ok) throw new Error("Failed to update scientist");
return (await res.json()).scientist as Scientist;
}
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
setSuccess(null); setError(null);
if (!editScientist) return;
try {
const updatedScientist = await updateScientistOnServer(editScientist);
setScientists(prev => prev.map(sci => sci.id === updatedScientist.id ? updatedScientist : sci));
setEditScientist(updatedScientist);
setSuccess("Scientist updated!");
} catch {
setError("Couldn't update scientist");
} }
: item
)
);
}; };
const handleDelete = () => { const handleDeleteScientist = async () => {
if (!selectedScientist) return; if (!editScientist) return;
if (!window.confirm(`Are you sure you want to delete "${selectedScientist.name}"?`)) return; if (!window.confirm(`Are you sure you want to delete "${editScientist.name}"?`)) return;
setScientists((prev) => prev.filter((i) => i.id !== selectedScientist.id)); try {
setSelectedId(null); const res = await fetch("/api/management", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: editScientist.id }),
});
if (!res.ok) throw new Error("Failed to delete scientist");
setScientists(prev => prev.filter(sci => sci.id !== editScientist.id));
setEditScientist(null); setEditScientist(null);
setSelectedId(null);
} catch {
alert("Delete failed");
}
}; };
const searchedArtefacts = allArtefacts.filter( const usersWithNoScientist = allUsers.filter(u => !u.scientist);
(a) => if (!isAdmin && !isSeniorScientist) {
artefactQuery.trim() === "" || return (
a.name.toLowerCase().includes(artefactQuery.toLowerCase()) || <div className="flex items-center justify-center min-h-[70vh] flex-col">
a.id.toString().includes(artefactQuery) <h1 className="text-2xl font-bold text-red-500 mb-4">Unauthorized Access</h1>
<div className="text-gray-600">You do not have access to this page.</div>
</div>
); );
const searchedEarthquakes = allEarthquakes.filter( }
(eq) =>
earthquakeQuery.trim() === "" ||
eq.id.toString().includes(earthquakeQuery) ||
eq.code.toLowerCase().includes(earthquakeQuery.toLowerCase())
);
const searchedObservatories = allObservatories.filter(
(obs) =>
observatoryQuery.trim() === "" ||
obs.name.toLowerCase().includes(observatoryQuery.toLowerCase()) ||
obs.location.toLowerCase().includes(observatoryQuery.toLowerCase()) ||
obs.id.toString().includes(observatoryQuery)
);
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex h-full overflow-hidden bg-gray-50"> <div className="flex h-full overflow-hidden bg-gray-50">
@ -348,7 +334,6 @@ export default function Scientist() {
</button> </button>
</div> </div>
<div className="flex gap-2 items-center mb-2"> <div className="flex gap-2 items-center mb-2">
{/* Filter dropdown */}
<div className="relative" ref={filterDropdownRef}> <div className="relative" ref={filterDropdownRef}>
<button <button
className={`px-3 py-1 rounded-lg border font-semibold flex items-center transition className={`px-3 py-1 rounded-lg border font-semibold flex items-center transition
@ -393,7 +378,6 @@ export default function Scientist() {
</div> </div>
)} )}
</div> </div>
{/* sort dropdown */}
<div className="relative" ref={sortDropdownRef}> <div className="relative" ref={sortDropdownRef}>
<button <button
className="px-3 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200" className="px-3 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200"
@ -431,6 +415,20 @@ export default function Scientist() {
> >
{sortDir === "asc" ? "↑" : "↓"} {sortDir === "asc" ? "↑" : "↓"}
</button> </button>
{isAdmin && (
<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"
type="button"
style={{ minWidth: 36, minHeight: 36 }}
onClick={() => setAddOpen(true)}
disabled={addOpen}
title="Add scientist"
>
<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" />
</svg>
</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">
Scientists sorted by {fieldLabels[sortField]} {dirLabels[sortDir]} Scientists sorted by {fieldLabels[sortField]} {dirLabels[sortDir]}
@ -457,33 +455,86 @@ export default function Scientist() {
</ul> </ul>
</div> </div>
</div> </div>
{/* MAIN PANEL */} {/* Add Scientist Modal */}
<div className="flex-1 flex justify-center p-24 bg-white overflow-y-auto"> {addOpen && (
{editScientist ? ( <div className="fixed inset-0 z-40 flex items-center justify-center bg-black bg-opacity-30">
<div <div className="bg-white rounded-lg shadow-lg p-8 max-w-sm w-full relative">
className=" <h3 className="text-lg font-bold mb-4">Add New Scientist</h3>
max-w-4xl w-full bg-white rounded-xl shadow flex flex-col pt-4 pb-3 px-5 <form onSubmit={handleAddScientist} className="space-y-3">
" <div>
style={{ <label className="block text-sm font-medium mb-1">Name</label>
minHeight: 0, <input
maxHeight: 780, className="w-full border px-2 py-1 rounded-lg"
overflow: "hidden" type="text"
}} required
value={addForm.name}
onChange={e => setAddForm(f => ({ ...f, name: e.target.value }))}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Level</label>
<select
className="w-full border px-2 py-1 rounded-lg"
value={addForm.level}
onChange={e => setAddForm(f => ({ ...f, level: e.target.value as Level }))}
> >
{/* Heading */} {allLevels.map(level => (
<div className="text-xl font-bold text-gray-900 mb-3 px-1">Edit Scientist</div> <option value={level} key={level}>{levelLabels[level]}</option>
<form className="flex flex-col flex-1 min-h-0" onSubmit={handleUpdate}> ))}
<div className="flex flex-col lg:flex-row gap-8 min-h-0 flex-1"> </select>
{/* LEFT COLUMN */}
<div className="flex-1 flex flex-col gap-4 min-h-0 overflow-y-auto">
<div>
<div className="text-xs text-gray-400">Created at</div>
<div className="font-mono text-sm">{editScientist.createdAt}</div>
</div> </div>
<div> <div>
<div className="text-xs text-gray-400">ID</div> <label className="block text-sm font-medium mb-1">User (select by email)</label>
<div className="font-mono text-sm">{editScientist.id}</div> <select
className="w-full border px-2 py-1 rounded-lg"
required
value={addForm.userId}
onChange={e => setAddForm(f => ({ ...f, userId: Number(e.target.value) }))}
>
<option value={0} disabled>Choose user...</option>
{usersWithNoScientist.map(u =>
<option key={u.id} value={u.id}>
{u.name} ({u.email})
</option>
)}
</select>
</div> </div>
{addError && <div className="text-red-600 text-xs">{addError}</div>}
<div className="flex gap-2 justify-end pt-2">
<button
type="button"
className="px-3 py-1 rounded-lg bg-gray-200 text-gray-700 hover:bg-gray-300 transition"
onClick={() => setAddOpen(false)}
disabled={addLoading}
>
Cancel
</button>
<button
type="submit"
className={`px-3 py-1 rounded-lg text-white font-semibold ${addLoading ? "bg-green-400" : "bg-green-600 hover:bg-green-700"}`}
disabled={addLoading}
>
{addLoading ? "Adding..." : "Add"}
</button>
</div>
</form>
</div>
</div>
)}
{/* Request Modal */}
<RequestModal
open={requestOpen}
onClose={()=>setRequestOpen(false)}
requestingUserId={user?.id}
scientist={editScientist}
/>
{/* MAIN PANEL */}
<div className="flex-1 p-24 bg-white overflow-y-auto">
{editScientist ? (
<form className="max-w-xl mx-auto bg-white p-6 rounded-lg shadow" onSubmit={handleUpdate}>
<h2 className="text-lg font-bold mb-4">Scientist Details</h2>
{!!success && <div className="text-green-600 text-sm">{success}</div>}
{!!error && <div className="text-red-600 text-xs">{error}</div>}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name:</label> <label className="block text-sm font-medium text-gray-700 mb-1">Name:</label>
<input <input
@ -492,6 +543,7 @@ export default function Scientist() {
name="name" name="name"
value={editScientist.name} value={editScientist.name}
onChange={handleEditChange} onChange={handleEditChange}
readOnly={readOnly}
/> />
</div> </div>
<div> <div>
@ -501,200 +553,80 @@ export default function Scientist() {
name="level" name="level"
value={editScientist.level} value={editScientist.level}
onChange={handleEditChange} onChange={handleEditChange}
disabled={readOnly}
> >
{allLevels.map((lvl) => ( {allLevels.map(lvl => <option key={lvl} value={lvl}>{levelLabels[lvl]}</option>)}
<option key={lvl} value={lvl}>{levelLabels[lvl]}</option>
))}
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">User (email):</label> <label className="block text-sm font-medium text-gray-700 mb-1">User (email):</label>
<select <select
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300" className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300 bg-gray-100"
name="userId" name="userId"
value={editScientist.userId} value={editScientist.user.id}
onChange={handleEditChange} onChange={handleEditChange}
disabled
> >
{allUsers.map((u) => ( <option value={editScientist.user.id}>{editScientist.user.name} ({editScientist.user.email})</option>
<option key={u.id} value={u.id}>
{u.name} ({u.email})
</option>
))}
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">Superior:</label> <label className="block text-sm font-medium text-gray-700 mb-1">Supervisor:</label>
{isAdmin ? (
<select <select
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300" className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
name="superiorId" name="superiorId"
value={editScientist.superiorId ?? ""} value={editScientist.superiorId ?? ""}
onChange={handleEditChange} onChange={handleEditChange}
disabled={readOnly}
> >
<option value="">None</option> <option value="">None</option>
{allOtherScientistOptions(editScientist.id).map((s) => ( {scientists
<option key={s.id} value={s.id}>{s.name}</option> .filter(s => s.id !== editScientist.id)
.map(s => (
<option key={s.id} value={s.id}>
{s.name} ({s.user.email})
</option>
))} ))}
</select> </select>
</div> ) : (
<div> <div className="border px-3 py-2 rounded-lg bg-gray-100">
<label className="block text-sm font-medium text-gray-700 mb-1">Subordinates:</label> {editScientist.superior && editScientist.superior.user ? (
<div className="flex flex-wrap gap-1 bg-gray-200 rounded px-2 py-2 min-h-[28px]"> <>
{editScientist.subordinates.length > 0 <span>{editScientist.superior.name}</span>
? editScientist.subordinates.map((s) => ( <span className="ml-2 text-gray-500 text-xs">
<span ({editScientist.superior.user.email})
key={s.id}
className="px-2 py-1 rounded-full bg-gray-300 text-gray-700 text-xs font-medium"
>
{s.name}
</span> </span>
)) </>
: <span className="text-sm text-gray-400">None</span>
}
</div>
</div>
</div>
{/* RIGHT COLUMN */}
<div className="flex-1 flex flex-col gap-4 min-h-0 overflow-y-auto">
{/* Observatories Box */}
<div className="flex flex-col h-full">
<div className="flex justify-between items-end">
<label className="block text-sm font-medium text-gray-700 mb-1">Observatories:</label>
<input
type="text"
className="ml-2 text-xs border rounded px-2 py-1"
placeholder="Search by name/location/id..."
value={observatoryQuery}
onChange={(e) => setObservatoryQuery(e.target.value)}
style={{maxWidth: "55%"}}
/>
</div>
<div
className="border rounded px-2 py-1 mt-1 bg-gray-50 flex-1"
style={{
minHeight: 160,
maxHeight: 230,
overflowY: "auto"
}}
>
{searchedObservatories.length === 0 ? (
<div className="text-xs text-gray-400 text-center py-2">No observatories</div>
) : ( ) : (
searchedObservatories.map((obs) => ( <span className="text-gray-400">None</span>
<label key={obs.id} className="block text-xs py-1">
<input
type="checkbox"
className="mr-2"
checked={editScientist.observatoryIds.includes(obs.id)}
onChange={() => handleObservatoryCheck(obs.id)}
/>
#{obs.id} {obs.name} ({obs.location})
</label>
))
)} )}
</div> </div>
</div>
{/* Earthquakes Box */}
<div className="flex flex-col h-full">
<div className="flex justify-between items-end">
<label className="block text-sm font-medium text-gray-700 mb-1">Earthquakes:</label>
<input
type="text"
className="ml-2 text-xs border rounded px-2 py-1"
placeholder="Search ID or code..."
value={earthquakeQuery}
onChange={(e) => setEarthquakeQuery(e.target.value)}
style={{maxWidth: "55%"}}
/>
</div>
<div
className="border rounded px-2 py-1 mt-1 bg-gray-50 flex-1"
style={{
minHeight: 160,
maxHeight: 230,
overflowY: "auto"
}}
>
{searchedEarthquakes.length === 0 ? (
<div className="text-xs text-gray-400 text-center py-2">No earthquakes</div>
) : (
searchedEarthquakes.map((eq) => (
<label key={eq.id} className="block text-xs py-1">
<input
type="checkbox"
className="mr-2"
checked={editScientist.earthquakeIds.includes(eq.id)}
onChange={() => handleEarthquakeCheck(eq.id)}
/>
#{eq.id} ({eq.code}) {eq.location}
</label>
))
)} )}
</div> </div>
</div> <div className="flex gap-2 justify-end pt-8">
{/* Artefacts Box */} {isAdmin && (
<div className="flex flex-col h-full"> <>
<div className="flex justify-between items-end"> <button type="button" onClick={handleDeleteScientist}
<label className="block text-sm font-medium text-gray-700 mb-1">Artefacts:</label> className="px-4 py-2 bg-red-500 hover:bg-red-600 text-white font-semibold rounded-lg shadow transition">
<input
type="text"
className="ml-2 text-xs border rounded px-2 py-1"
placeholder="Search ID or name..."
value={artefactQuery}
onChange={(e) => setArtefactQuery(e.target.value)}
style={{maxWidth: "55%"}}
/>
</div>
<div
className="border rounded px-2 py-1 mt-1 bg-gray-50 flex-1"
style={{
minHeight: 160,
maxHeight: 230,
overflowY: "auto"
}}
>
{searchedArtefacts.length === 0 ? (
<div className="text-xs text-gray-400 text-center py-2">No artefacts</div>
) : (
searchedArtefacts.map((a) => (
<label key={a.id} className="block text-xs py-1">
<input
type="checkbox"
className="mr-2"
checked={editScientist.artefactIds.includes(a.id)}
onChange={() => handleArtefactCheck(a.id)}
/>
#{a.id} {a.name} ({a.type})
</label>
))
)}
</div>
</div>
</div>
</div>
{/* BUTTONS */}
<div className="flex justify-end gap-2 pt-4">
<button
type="button"
className="px-4 py-2 bg-red-500 hover:bg-red-600 text-white font-semibold rounded-lg shadow transition"
onClick={handleDelete}
>
Delete Delete
</button> </button>
<button <button type="submit"
type="submit" className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold shadow transition ml-3">
className={`px-4 py-2 rounded-lg font-semibold transition
${
isEditChanged
? "bg-blue-600 hover:bg-blue-700 text-white shadow"
: "bg-gray-300 text-gray-500 cursor-not-allowed"
}`}
disabled={!isEditChanged}
>
Update Update
</button> </button>
</>
)}
{readOnly && (
<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>
</div>
) : ( ) : (
<div className="text-center text-gray-400 mt-16 text-lg">Select a scientist...</div> <div className="text-center text-gray-400 mt-16 text-lg">Select a scientist...</div>
)} )}

View File

@ -134,10 +134,14 @@ export default function Navbar({}: // currencySelector,
<ManagementNavbarButton name="Warehouse" href="/warehouse"></ManagementNavbarButton> <ManagementNavbarButton name="Warehouse" href="/warehouse"></ManagementNavbarButton>
</div> </div>
)} )}
{user && (user.role === "SCIENTIST" || user.role === "ADMIN") && ( {user && (
(user.role === "ADMIN" ||
(user.role === "SCIENTIST" && user.scientist?.level === "SENIOR")
) && (
<div className="flex h-full mr-5"> <div className="flex h-full mr-5">
<ManagementNavbarButton name="Scientist Management" href="/management"></ManagementNavbarButton> <ManagementNavbarButton name="Scientist Management" href="/management" />
</div> </div>
)
)} )}
{user && user.role === "ADMIN" && ( {user && user.role === "ADMIN" && (
<div className="flex h-full mr-5"> <div className="flex h-full mr-5">