Merge branch 'master' of ssh://stash.dyson.global.corp:7999/~thowitz/tremor-tracker
This commit is contained in:
commit
c2211747e2
@ -1,386 +1,569 @@
|
||||
"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";
|
||||
const roleLabels: Record<Role, string> = {
|
||||
ADMIN: "Admin",
|
||||
GUEST: "Guest",
|
||||
SCIENTIST: "Scientist",
|
||||
ADMIN: "Admin",
|
||||
GUEST: "Guest",
|
||||
SCIENTIST: "Scientist",
|
||||
};
|
||||
type User = {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
password: string;
|
||||
createdAt: string;
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
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[] = [
|
||||
{ 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",
|
||||
name: "Bob Brown",
|
||||
role: "SCIENTIST",
|
||||
password: "secret3",
|
||||
createdAt: "2024-06-21T12:13:45Z",
|
||||
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 },
|
||||
{
|
||||
email: "users-loading@admin.api",
|
||||
name: "Loading Users",
|
||||
role: "ADMIN",
|
||||
createdAt: "Check admin api and frontend",
|
||||
id: 0,
|
||||
},
|
||||
];
|
||||
const sortFields = [
|
||||
// Sort box options
|
||||
{ label: "Name", value: "name" },
|
||||
{ label: "Email", value: "email" },
|
||||
{ label: "Name", value: "name" },
|
||||
{ label: "Email", value: "email" },
|
||||
] as const;
|
||||
|
||||
type SortField = (typeof sortFields)[number]["value"];
|
||||
type SortDir = "asc" | "desc";
|
||||
const dirLabels: Record<SortDir, string> = { asc: "ascending", desc: "descending" };
|
||||
const fieldLabels: Record<SortField, string> = { name: "Name", email: "Email" };
|
||||
|
||||
// =========== THE PAGE =============
|
||||
export default function AdminPage() {
|
||||
const [users, setUsers] = useState<User[]>(initialUsers);
|
||||
const [selectedEmail, setSelectedEmail] = useState<string | null>(null);
|
||||
// ---- All hooks at the top!
|
||||
const user = useStoreState((state) => state.user);
|
||||
|
||||
// Local edit state for SCIENTIST form
|
||||
const [editUser, setEditUser] = useState<User | null>(null);
|
||||
// Reset editUser when the selected user changes
|
||||
React.useEffect(() => {
|
||||
if (!selectedEmail) setEditUser(null);
|
||||
else {
|
||||
const user = users.find((u) => u.email === selectedEmail);
|
||||
setEditUser(user ? { ...user } : null);
|
||||
}
|
||||
}, [selectedEmail, users]);
|
||||
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 [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");
|
||||
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);
|
||||
|
||||
// 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);
|
||||
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);
|
||||
else {
|
||||
const user = users.find((u) => u.email === selectedEmail);
|
||||
setEditUser(user ? { ...user } : null);
|
||||
}
|
||||
}, [selectedEmail, users]);
|
||||
// Filtering, searching, sorting logic
|
||||
const filteredUsers = users.filter(
|
||||
(user) => roleFilter === "all" || user.role === roleFilter
|
||||
);
|
||||
const searchedUsers = filteredUsers.filter((user) =>
|
||||
user[searchField].toLowerCase().includes(searchText.toLowerCase())
|
||||
);
|
||||
const sortedUsers = [...searchedUsers].sort((a, b) => {
|
||||
let cmp = a[sortField].localeCompare(b[sortField]);
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
async function handleAddUser(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
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;
|
||||
const { name, value } = e.target;
|
||||
setEditUser((prev) => (prev ? { ...prev, [name]: value } : null));
|
||||
};
|
||||
const selectedUser = users.find((u) => u.email === selectedEmail);
|
||||
const isEditChanged = React.useMemo(() => {
|
||||
if (!editUser || !selectedUser) return false;
|
||||
return (
|
||||
editUser.name !== selectedUser.name ||
|
||||
editUser.role !== selectedUser.role ||
|
||||
newPassword
|
||||
);
|
||||
}, [editUser, selectedUser, newPassword]);
|
||||
async function updateUserOnServer(user: User, password: string) {
|
||||
const body: any = {
|
||||
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();
|
||||
if (!editUser) return;
|
||||
try {
|
||||
const updated = await updateUserOnServer(editUser, newPassword);
|
||||
setUsers((prev) =>
|
||||
prev.map((u) => (u.id === updated.id ? { ...updated } : u))
|
||||
);
|
||||
setNewPassword("");
|
||||
} catch (err) {
|
||||
console.error("Failed to update user:", err);
|
||||
}
|
||||
};
|
||||
const handleDelete = async () => {
|
||||
if (!selectedUser) return;
|
||||
if (
|
||||
!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);
|
||||
setEditUser(null);
|
||||
} catch (err: any) {
|
||||
alert(err?.message || "Delete failed!");
|
||||
}
|
||||
};
|
||||
const allRoles: Role[] = ["ADMIN", "GUEST", "SCIENTIST"];
|
||||
|
||||
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);
|
||||
}, []);
|
||||
// --- ADMIN ONLY:
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// Filtering, searching, sorting logic
|
||||
const filteredUsers = users.filter((user) => roleFilter === "all" || user.role === roleFilter);
|
||||
const searchedUsers = filteredUsers.filter((user) => user[searchField].toLowerCase().includes(searchText.toLowerCase()));
|
||||
const sortedUsers = [...searchedUsers].sort((a, b) => {
|
||||
let cmp = a[sortField].localeCompare(b[sortField]);
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
|
||||
// Form input change handler
|
||||
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
if (!editUser) return;
|
||||
const { name, value } = e.target;
|
||||
setEditUser((prev) => (prev ? { ...prev, [name]: value } : null));
|
||||
};
|
||||
|
||||
// Update button logic (compare original selectedUser and editUser)
|
||||
const selectedUser = users.find((u) => u.email === selectedEmail);
|
||||
const isEditChanged = React.useMemo(() => {
|
||||
if (!editUser || !selectedUser) return false;
|
||||
// Compare primitive fields
|
||||
return (
|
||||
editUser.name !== selectedUser.name || editUser.role !== selectedUser.role || editUser.password !== selectedUser.password
|
||||
);
|
||||
}, [editUser, selectedUser]);
|
||||
|
||||
// Update/save changes
|
||||
const handleUpdate = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editUser) return;
|
||||
setUsers((prev) => prev.map((u) => (u.email === editUser.email ? { ...editUser } : u)));
|
||||
// todo create receiving api route
|
||||
// todo send to api route
|
||||
// After successful update, update selectedUser local state
|
||||
// (editUser will auto-sync due to useEffect on users)
|
||||
};
|
||||
|
||||
// Delete user logic
|
||||
const handleDelete = () => {
|
||||
if (!selectedUser) return;
|
||||
if (!window.confirm(`Are you sure you want to delete "${selectedUser.name}"? This cannot be undone.`)) return;
|
||||
setUsers((prev) => prev.filter((u) => u.email !== selectedUser.email));
|
||||
setSelectedEmail(null);
|
||||
setEditUser(null);
|
||||
};
|
||||
|
||||
const allRoles: Role[] = ["ADMIN", "GUEST", "SCIENTIST"];
|
||||
|
||||
// Tooltip handling for email field
|
||||
const [showEmailTooltip, setShowEmailTooltip] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex h-full overflow-hidden bg-gray-50">
|
||||
{/* 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="p-4 flex flex-col h-full">
|
||||
{/* Search Bar */}
|
||||
<div className="mb-3 flex gap-2">
|
||||
<input
|
||||
className="flex-1 border rounded-lg px-2 py-1 text-sm"
|
||||
placeholder={`Search by ${searchField}`}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
onClick={() => setSearchField((field) => (field === "name" ? "email" : "name"))}
|
||||
title={`Switch to searching by ${searchField === "name" ? "Email" : "Name"}`}
|
||||
>
|
||||
{searchField === "name" ? "Email" : "Name"}
|
||||
</button>
|
||||
</div>
|
||||
{/* Filter and Sort Buttons */}
|
||||
<div className="flex gap-2 items-center mb-2">
|
||||
{/* Filter */}
|
||||
<div className="relative" ref={filterDropdownRef}>
|
||||
<button
|
||||
className={`px-3 py-1 rounded-lg border font-semibold flex items-center transition
|
||||
${
|
||||
roleFilter !== "all"
|
||||
? "bg-blue-600 text-white border-blue-600 hover:bg-blue-700"
|
||||
: "bg-white text-gray-700 border hover:bg-neutral-200"
|
||||
}
|
||||
// --- Render admin UI
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex h-full overflow-hidden bg-gray-50">
|
||||
{/* 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="p-4 flex flex-col h-full">
|
||||
{/* Search, filter, sort controls ... (your code unchanged) */}
|
||||
<div className="mb-3 flex gap-2">
|
||||
<input
|
||||
className="flex-1 border rounded-lg px-2 py-1 text-sm"
|
||||
placeholder={`Search by ${searchField}`}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="border rounded-lg px-2 py-1 text-sm bg-white hover:bg-neutral-100 transition font-semibold"
|
||||
style={{ width: "80px" }}
|
||||
onClick={() => setSearchField((field) => (field === "name" ? "email" : "name"))}
|
||||
title={`Switch to searching by ${searchField === "name" ? "Email" : "Name"}`}
|
||||
>
|
||||
{searchField === "name" ? "Email" : "Name"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center mb-2">
|
||||
{/* Filter */}
|
||||
<div className="relative" ref={filterDropdownRef}>
|
||||
<button
|
||||
className={`px-3 py-1 rounded-lg border font-semibold flex items-center transition
|
||||
${roleFilter !== "all"
|
||||
? "bg-blue-600 text-white border-blue-600 hover:bg-blue-700"
|
||||
: "bg-white text-gray-700 border hover:bg-neutral-200"
|
||||
}
|
||||
`}
|
||||
onClick={() => setFilterDropdownOpen((v) => !v)}
|
||||
type="button"
|
||||
>
|
||||
Filter{" "}
|
||||
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{filterDropdownOpen && (
|
||||
<div className="absolute z-10 mt-1 left-0 bg-white border rounded-lg shadow-sm w-28 py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setRoleFilter("all");
|
||||
setFilterDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-1 hover:bg-blue-50 border-b border-gray-100 last:border-0
|
||||
onClick={() => setFilterDropdownOpen((v) => !v)}
|
||||
type="button"
|
||||
>
|
||||
Filter{" "}
|
||||
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{filterDropdownOpen && (
|
||||
<div className="absolute z-10 mt-1 left-0 bg-white border rounded-lg shadow-sm w-28 py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setRoleFilter("all");
|
||||
setFilterDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-1 hover:bg-blue-50 border-b border-gray-100 last:border-0
|
||||
${roleFilter === "all" ? "font-bold text-blue-600" : ""}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{allRoles.map((role) => (
|
||||
<button
|
||||
key={role}
|
||||
onClick={() => {
|
||||
setRoleFilter(role);
|
||||
setFilterDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-1 hover:bg-blue-50 border-b border-gray-100 last:border-0
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{allRoles.map((role) => (
|
||||
<button
|
||||
key={role}
|
||||
onClick={() => {
|
||||
setRoleFilter(role);
|
||||
setFilterDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-1 hover:bg-blue-50 border-b border-gray-100 last:border-0
|
||||
${roleFilter === role ? "font-bold text-blue-600" : ""}`}
|
||||
>
|
||||
{roleLabels[role]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Sort */}
|
||||
<div className="relative" ref={sortDropdownRef}>
|
||||
<button
|
||||
className="px-3 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200"
|
||||
onClick={() => setSortDropdownOpen((v) => !v)}
|
||||
type="button"
|
||||
>
|
||||
Sort{" "}
|
||||
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{sortDropdownOpen && (
|
||||
<div className="absolute z-10 mt-1 left-0 bg-white border rounded-lg shadow-sm w-28 ">
|
||||
{sortFields.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => {
|
||||
setSortField(opt.value);
|
||||
setSortDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 hover:bg-blue-50 border-b border-gray-100 last:border-0
|
||||
>
|
||||
{roleLabels[role]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Sort */}
|
||||
<div className="relative" ref={sortDropdownRef}>
|
||||
<button
|
||||
className="px-3 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200"
|
||||
onClick={() => setSortDropdownOpen((v) => !v)}
|
||||
type="button"
|
||||
>
|
||||
Sort{" "}
|
||||
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{sortDropdownOpen && (
|
||||
<div className="absolute z-10 mt-1 left-0 bg-white border rounded-lg shadow-sm w-28 ">
|
||||
{sortFields.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => {
|
||||
setSortField(opt.value);
|
||||
setSortDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 hover:bg-blue-50 border-b border-gray-100 last:border-0
|
||||
${sortField === opt.value ? "font-bold text-blue-600" : ""}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Asc/Desc Toggle */}
|
||||
<button
|
||||
className="ml-2 px-2 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200"
|
||||
onClick={() => setSortDir((d) => (d === "asc" ? "desc" : "asc"))}
|
||||
title={sortDir === "asc" ? "Ascending" : "Descending"}
|
||||
type="button"
|
||||
>
|
||||
{sortDir === "asc" ? "↑" : "↓"}
|
||||
</button>
|
||||
</div>
|
||||
{/* Sort status text */}
|
||||
<small className="text-xs text-gray-500 mb-2 px-1">
|
||||
Users sorted by {fieldLabels[sortField]} {dirLabels[sortDir]}
|
||||
</small>
|
||||
{/* USERS LIST: full height, scrollable */}
|
||||
<ul className="overflow-y-auto flex-1 pr-1">
|
||||
{sortedUsers.map((user) => (
|
||||
<li
|
||||
key={user.email}
|
||||
onClick={() => setSelectedEmail(user.email)}
|
||||
className={`rounded-lg cursor-pointer border
|
||||
${selectedEmail === user.email ? "bg-blue-100 border-blue-400" : "hover:bg-gray-200 border-transparent"}
|
||||
transition px-2 py-1 mb-1`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium truncate">{user.name}</span>
|
||||
<span className="ml-1 text-xs px-2 py-0.5 rounded-lg bg-gray-200 text-gray-700">{roleLabels[user.role]}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-0.5">
|
||||
<span className="text-xs text-gray-600 truncate">{user.email}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{sortedUsers.length === 0 && <li className="text-gray-400 text-center py-6">No users found.</li>}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{/* MAIN PANEL */}
|
||||
<div className="flex-1 p-24 bg-white overflow-y-auto">
|
||||
{editUser ? (
|
||||
<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>
|
||||
<form className="space-y-4" onSubmit={handleUpdate}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<label className="text-sm font-medium text-gray-700">Account Creation Time:</label>
|
||||
<span className="text-sm text-gray-500">{editUser.createdAt}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<label className="text-sm font-medium text-gray-700">Account ID Number:</label>
|
||||
<span className="text-sm text-gray-500">{editUser.id}</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email (unique):</label>
|
||||
<input
|
||||
className="w-full border px-3 py-2 rounded-lg outline-none bg-gray-100 cursor-not-allowed"
|
||||
type="email"
|
||||
name="email"
|
||||
value={editUser.email}
|
||||
readOnly
|
||||
onMouseEnter={() => setShowEmailTooltip(true)}
|
||||
onMouseLeave={() => setShowEmailTooltip(false)}
|
||||
/>
|
||||
{/* Custom tooltip */}
|
||||
{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">
|
||||
This field cannot be changed. <br />
|
||||
To change the email, delete and re-add the user.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name:</label>
|
||||
<input
|
||||
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||
type="text"
|
||||
name="name"
|
||||
value={editUser.name}
|
||||
onChange={handleEditChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Role:</label>
|
||||
<select
|
||||
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||
name="role"
|
||||
value={editUser.role}
|
||||
onChange={handleEditChange}
|
||||
>
|
||||
{allRoles.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{roleLabels[role]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password:</label>
|
||||
<input
|
||||
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||
type="text"
|
||||
name="password"
|
||||
value={editUser.password}
|
||||
onChange={handleEditChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end pt-6">
|
||||
<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
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-400 mt-16 text-lg">Select a user...</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Asc/Desc Toggle */}
|
||||
<button
|
||||
className="ml-2 px-2 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200"
|
||||
onClick={() => setSortDir((d) => (d === "asc" ? "desc" : "asc"))}
|
||||
title={sortDir === "asc" ? "Ascending" : "Descending"}
|
||||
type="button"
|
||||
>
|
||||
{sortDir === "asc" ? "↑" : "↓"}
|
||||
</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>
|
||||
<small className="text-xs text-gray-500 mb-2 px-1">
|
||||
Users sorted by {fieldLabels[sortField]} {dirLabels[sortDir]}
|
||||
</small>
|
||||
{/* USERS LIST */}
|
||||
<ul className="overflow-y-auto flex-1 pr-1">
|
||||
{sortedUsers.map((user) => (
|
||||
<li
|
||||
key={user.email}
|
||||
onClick={() => setSelectedEmail(user.email)}
|
||||
className={`rounded-lg cursor-pointer border
|
||||
${selectedEmail === user.email ? "bg-blue-100 border-blue-400" : "hover:bg-gray-200 border-transparent"}
|
||||
transition px-2 py-1 mb-1`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium truncate">{user.name}</span>
|
||||
<span className="ml-1 text-xs px-2 py-0.5 rounded-lg bg-gray-200 text-gray-700">{roleLabels[user.role]}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-0.5">
|
||||
<span className="text-xs text-gray-600 truncate">{user.email}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{sortedUsers.length === 0 && <li className="text-gray-400 text-center py-6">No users found.</li>}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{/* MAIN PANEL */}
|
||||
<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 ? (
|
||||
<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>
|
||||
<form className="space-y-4" onSubmit={handleUpdate}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Account Creation Time:
|
||||
</label>
|
||||
<span className="text-sm text-gray-500">{editUser.createdAt}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Account ID Number:
|
||||
</label>
|
||||
<span className="text-sm text-gray-500">{editUser.id}</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email (unique):
|
||||
</label>
|
||||
<input
|
||||
className="w-full border px-3 py-2 rounded-lg outline-none bg-gray-100 cursor-not-allowed"
|
||||
type="email"
|
||||
name="email"
|
||||
value={editUser.email}
|
||||
readOnly
|
||||
onMouseEnter={() => setShowEmailTooltip(true)}
|
||||
onMouseLeave={() => setShowEmailTooltip(false)}
|
||||
/>
|
||||
{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">
|
||||
This field cannot be changed. <br />
|
||||
To change the email, delete and re-add the user.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Name:
|
||||
</label>
|
||||
<input
|
||||
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||
type="text"
|
||||
name="name"
|
||||
value={editUser.name}
|
||||
onChange={handleEditChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Role:
|
||||
</label>
|
||||
<select
|
||||
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||
name="role"
|
||||
value={editUser.role}
|
||||
onChange={handleEditChange}
|
||||
>
|
||||
{allRoles.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{roleLabels[role]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password:
|
||||
</label>
|
||||
<input
|
||||
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||
type="text"
|
||||
name="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end pt-6">
|
||||
<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
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-400 mt-16 text-lg">
|
||||
Select a user...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
src/app/api/admin/route.ts
Normal file
126
src/app/api/admin/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
21
src/app/api/management/request/route.ts
Normal file
21
src/app/api/management/request/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
80
src/app/api/management/route.ts
Normal file
80
src/app/api/management/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
16
src/app/api/users/all/route.ts
Normal file
16
src/app/api/users/all/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -134,11 +134,15 @@ export default function Navbar({}: // currencySelector,
|
||||
<ManagementNavbarButton name="Warehouse" href="/warehouse"></ManagementNavbarButton>
|
||||
</div>
|
||||
)}
|
||||
{user && (user.role === "SCIENTIST" || user.role === "ADMIN") && (
|
||||
<div className="flex h-full mr-5">
|
||||
<ManagementNavbarButton name="Scientist Management" href="/management"></ManagementNavbarButton>
|
||||
</div>
|
||||
)}
|
||||
{user && (
|
||||
(user.role === "ADMIN" ||
|
||||
(user.role === "SCIENTIST" && user.scientist?.level === "SENIOR")
|
||||
) && (
|
||||
<div className="flex h-full mr-5">
|
||||
<ManagementNavbarButton name="Scientist Management" href="/management" />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{user && user.role === "ADMIN" && (
|
||||
<div className="flex h-full mr-5">
|
||||
<ManagementNavbarButton name="Admin" href="/administrator"></ManagementNavbarButton>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user