diff --git a/src/app/administrator/page.tsx b/src/app/administrator/page.tsx index 49fd4ec..2d14294 100644 --- a/src/app/administrator/page.tsx +++ b/src/app/administrator/page.tsx @@ -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 = { - 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 = { asc: "ascending", desc: "descending" }; const fieldLabels: Record = { name: "Name", email: "Email" }; +// =========== THE PAGE ============= export default function AdminPage() { - const [users, setUsers] = useState(initialUsers); - const [selectedEmail, setSelectedEmail] = useState(null); + // ---- All hooks at the top! + const user = useStoreState((state) => state.user); - // Local edit state for SCIENTIST form - const [editUser, setEditUser] = useState(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(null); + const [users, setUsers] = useState(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(null); + const [addLoading, setAddLoading] = useState(false); + const [editUser, setEditUser] = useState(null); + const [searchField, setSearchField] = useState<"name" | "email">("name"); + const [searchText, setSearchText] = useState(""); + const [roleFilter, setRoleFilter] = useState("all"); + const [sortField, setSortField] = useState("name"); + const [sortDir, setSortDir] = useState("asc"); + const [newPassword, setNewPassword] = useState(""); + const [filterDropdownOpen, setFilterDropdownOpen] = useState(false); + const [sortDropdownOpen, setSortDropdownOpen] = useState(false); + const filterDropdownRef = useRef(null); + const sortDropdownRef = useRef(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("all"); - const [sortField, setSortField] = useState("name"); - const [sortDir, setSortDir] = useState("asc"); - // Dropdown states - const [filterDropdownOpen, setFilterDropdownOpen] = useState(false); - const [sortDropdownOpen, setSortDropdownOpen] = useState(false); - const filterDropdownRef = useRef(null); - const sortDropdownRef = useRef(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, + ) => { + if (!editUser) return; + const { name, value } = e.target; + setEditUser((prev) => (prev ? { ...prev, [name]: value } : null)); + }; + const handlePasswordChange = ( + e: React.ChangeEvent, + ) => { + 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 ( +
+

+ Unauthorized Access +

+
You do not have access to this page.
+
+ ); + } - // 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) => { - 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 ( -
-
- {/* SIDEBAR */} -
-
- {/* Search Bar */} -
- setSearchText(e.target.value)} - /> - -
- {/* Filter and Sort Buttons */} -
- {/* Filter */} -
- +
+
+ {/* Filter */} +
+ - {filterDropdownOpen && ( -
- + {filterDropdownOpen && ( +
+ - {allRoles.map((role) => ( - + {allRoles.map((role) => ( + - ))} -
- )} -
- {/* Sort */} -
- - {sortDropdownOpen && ( -
- {sortFields.map((opt) => ( - + ))} +
+ )} +
+ {/* Sort */} +
+ + {sortDropdownOpen && ( +
+ {sortFields.map((opt) => ( + - ))} -
- )} -
- {/* Asc/Desc Toggle */} - -
- {/* Sort status text */} - - Users sorted by {fieldLabels[sortField]} {dirLabels[sortDir]} - - {/* USERS LIST: full height, scrollable */} -
    - {sortedUsers.map((user) => ( -
  • 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`} - > -
    - {user.name} - {roleLabels[user.role]} -
    -
    - {user.email} -
    -
  • - ))} - {sortedUsers.length === 0 &&
  • No users found.
  • } -
-
-
- {/* MAIN PANEL */} -
- {editUser ? ( -
-

Edit User

-
-
- - {editUser.createdAt} -
-
- - {editUser.id} -
-
- - setShowEmailTooltip(true)} - onMouseLeave={() => setShowEmailTooltip(false)} - /> - {/* Custom tooltip */} - {showEmailTooltip && ( -
- This field cannot be changed.
- To change the email, delete and re-add the user. -
- )} -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- ) : ( -
Select a user...
- )} -
-
-
- ); -} + > + {opt.label} + + ))} +
+ )} +
+ {/* Asc/Desc Toggle */} + + {/* ADD BUTTON */} + + + + Users sorted by {fieldLabels[sortField]} {dirLabels[sortDir]} + + {/* USERS LIST */} +
    + {sortedUsers.map((user) => ( +
  • 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`} + > +
    + {user.name} + {roleLabels[user.role]} +
    +
    + {user.email} +
    +
  • + ))} + {sortedUsers.length === 0 &&
  • No users found.
  • } +
+ + + {/* MAIN PANEL */} +
+ {/* Add User Modal */} + {addOpen && ( +
+
+

Add New User

+
+
+ + setAddForm(f => ({ ...f, email: e.target.value }))} + /> +
+
+ + setAddForm(f => ({ ...f, name: e.target.value }))} + /> +
+
+ + +
+
+ + setAddForm(f => ({ ...f, password: e.target.value }))} + /> +
+ {addError &&
{addError}
} +
+ + +
+
+
+
+ )} + + {/* Edit User Panel */} + {editUser ? ( +
+

Edit User

+
+
+ + {editUser.createdAt} +
+
+ + {editUser.id} +
+
+ + setShowEmailTooltip(true)} + onMouseLeave={() => setShowEmailTooltip(false)} + /> + {showEmailTooltip && ( +
+ This field cannot be changed.
+ To change the email, delete and re-add the user. +
+ )} +
+
+ + +
+
+ + +
+
+ + setNewPassword(e.target.value)} + /> +
+
+ + +
+
+
+ ) : ( +
+ Select a user... +
+ )} +
+ + + ); +} \ No newline at end of file diff --git a/src/app/api/admin/route.ts b/src/app/api/admin/route.ts new file mode 100644 index 0000000..b131add --- /dev/null +++ b/src/app/api/admin/route.ts @@ -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 }); + } + } \ No newline at end of file diff --git a/src/app/api/management/request/route.ts b/src/app/api/management/request/route.ts new file mode 100644 index 0000000..784d42a --- /dev/null +++ b/src/app/api/management/request/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/api/management/route.ts b/src/app/api/management/route.ts new file mode 100644 index 0000000..6c58ad4 --- /dev/null +++ b/src/app/api/management/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/api/users/all/route.ts b/src/app/api/users/all/route.ts new file mode 100644 index 0000000..766a151 --- /dev/null +++ b/src/app/api/users/all/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/management/page.tsx b/src/app/management/page.tsx index 6c2f53c..53be8b0 100644 --- a/src/app/management/page.tsx +++ b/src/app/management/page.tsx @@ -1,177 +1,200 @@ "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"; const levelLabels: Record = { JUNIOR: "Junior", SENIOR: "Senior" }; -type User = { id: number; email: string; name: string; }; -type Earthquakes = { id: number; code: string; location: string; }; -type Observatory = { id: number; name: string; location: string; }; -type Artefact = { id: number; name: string; type: string; }; +type User = { + id: number; + name: string; + email: string; + role?: string; + scientist?: Scientist | null; +}; type Scientist = { id: number; createdAt: string; name: string; level: Level; user: User; - userId: User["id"]; + userId: number; superior: Scientist | null; - superiorId: Scientist["id"] | null; + superiorId: number | null; subordinates: Scientist[]; - earthquakes: Earthquakes[]; - earthquakeIds: number[]; - observatories: Observatory[]; - observatoryIds: number[]; - artefacts: Artefact[]; - artefactIds: number[]; }; -const users: User[] = [ - { id: 1, name: "Albert Einstein", email: "ae@uni.edu" }, - { 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[] = [ +// --- Helpers --- +const initialScientists: Scientist[] = [ { - id: 1, - createdAt: "2024-06-01T09:00:00Z", - name: "Dr. John Junior", + id: 0, + name: "Loading Scientist", level: "JUNIOR", - user: users[0], - userId: 1, - superior: null, - 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, + createdAt: "", + user: { id: 0, name: "Loading...", email: "--" }, + userId: 0, superior: null, superiorId: null, 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 = [ { label: "Name", value: "name" }, - { label: "Level", value: "level" }, + { label: "Level", value: "level" } ] as const; type SortField = (typeof sortFields)[number]["value"]; type SortDir = "asc" | "desc"; const dirLabels: Record = { asc: "ascending", desc: "descending" }; const fieldLabels: Record = { name: "Name", level: "Level" }; -export default function Scientist() { - const [scientists, setScientists] = useState(scientistList); +// --- Updated RequestModal (only level/removal, no comment) +type RequestModalProps = { + open: boolean; + onClose: () => void; + requestingUserId: number; + scientist?: Scientist | null; +}; +function RequestModal({ open, onClose, requestingUserId, scientist }: RequestModalProps) { + const [requestType, setRequestType] = useState("CHANGE_LEVEL"); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(null); + const [error, setError] = useState(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); setSuccess(null); + 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 ? ( +
+
+

Request Action

+
+
+ + +
+ {error &&
{error}
} + {success &&
{success}
} +
+ + +
+
+
+
+ ) : null; +} +// ======================================================== +export default function ScientistManagementPage() { + // All hooks first + const user = useStoreState((state)=>state.user); + const [scientists, setScientists] = useState(initialScientists); + const [allUsers, setAllUsers] = useState([]); const [selectedId, setSelectedId] = useState(null); const [editScientist, setEditScientist] = useState(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(null); + const [addLoading, setAddLoading] = useState(false); + const [searchField, setSearchField] = useState("name"); + const [searchText, setSearchText] = useState(""); + const [levelFilter, setLevelFilter] = useState("all"); + const [sortField, setSortField] = useState("name"); + const [sortDir, setSortDir] = useState("asc"); + const [success, setSuccess] = useState(null); + const [error, setError] = useState(null); + const [filterDropdownOpen, setFilterDropdownOpen] = useState(false); + const [sortDropdownOpen, setSortDropdownOpen] = useState(false); + const filterDropdownRef = useRef(null); + const sortDropdownRef = useRef(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); else { const sc = scientists.find((x) => x.id === selectedId); setEditScientist(sc ? { ...sc } : null); } }, [selectedId, scientists]); - const [searchField, setSearchField] = useState("name"); - const [searchText, setSearchText] = useState(""); - const [levelFilter, setLevelFilter] = useState("all"); - const [sortField, setSortField] = useState("name"); - const [sortDir, setSortDir] = useState("asc"); - const [filterDropdownOpen, setFilterDropdownOpen] = useState(false); - const [sortDropdownOpen, setSortDropdownOpen] = useState(false); - const filterDropdownRef = useRef(null); - const sortDropdownRef = useRef(null); - React.useEffect(() => { + 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); @@ -179,151 +202,114 @@ export default function Scientist() { document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); }, []); - const filtered = scientists.filter((s) => levelFilter === "all" || s.level === levelFilter); - const searched = filtered.filter((s) => s[searchField].toLowerCase().includes(searchText.toLowerCase())); + const filtered = scientists.filter(s => levelFilter === "all" || s.level === levelFilter); + const searched = filtered.filter(s => String(s[searchField]).toLowerCase().includes(searchText.toLowerCase())); 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; }); 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) => { if (!editScientist) return; const { name, value } = e.target; if (name === "superiorId") { - const supId = value === "" ? null : Number(value); - setEditScientist((prev) => - prev - ? { - ...prev, - superiorId: supId, - superior: supId ? scientists.find((s) => s.id === supId) ?? null : null, - } - : null - ); - } else if (name === "level") { - setEditScientist((prev) => (prev ? { ...prev, level: value as Level } : null)); + const supId = value === "" ? null : Number(value); + setEditScientist(prev => prev ? { + ...prev, + superiorId: supId, + superior: supId ? scientists.find((s) => s.id === supId) ?? null : null + } : null); + } else if (name === "level") { + setEditScientist(prev => prev ? { ...prev, level: value as Level } : null); } else if (name === "userId") { - const user = users.find((u) => u.id === Number(value)); - setEditScientist((prev) => (prev && user ? { ...prev, user, userId: user.id } : prev)); + const user = allUsers.find((u) => u.id === Number(value)); + setEditScientist(prev => (prev && user ? { ...prev, user, userId: user.id } : prev)); } else { - setEditScientist((prev) => (prev ? { ...prev, [name]: value } : null)); + setEditScientist(prev => prev ? { ...prev, [name]: value } : null); } }; - function handleArtefactCheck(id: number) { - 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) => { + async function handleAddScientist(e: React.FormEvent) { e.preventDefault(); + setAddError(null); + if (!addForm.name || !addForm.level || !addForm.userId) { + setAddError("All fields are required."); + return; + } + setAddLoading(true); + try { + const res = await fetch("/api/management", { + method: "POST", + 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; - setScientists((prev) => - prev.map((item) => - item.id === editScientist.id - ? { - ...editScientist, - artefacts: allArtefacts.filter((a) => editScientist.artefactIds.includes(a.id)), - earthquakes: allEarthquakes.filter((eq) => editScientist.earthquakeIds.includes(eq.id)), - observatories: allObservatories.filter((obs) => editScientist.observatoryIds.includes(obs.id)), - subordinates: scientistList.filter((s) => s.superiorId === editScientist.id), - } - : item - ) + 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"); + } + }; + const handleDeleteScientist = async () => { + if (!editScientist) return; + if (!window.confirm(`Are you sure you want to delete "${editScientist.name}"?`)) return; + try { + 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); + setSelectedId(null); + } catch { + alert("Delete failed"); + } + }; + const usersWithNoScientist = allUsers.filter(u => !u.scientist); + if (!isAdmin && !isSeniorScientist) { + return ( +
+

Unauthorized Access

+
You do not have access to this page.
+
); - }; - const handleDelete = () => { - if (!selectedScientist) return; - if (!window.confirm(`Are you sure you want to delete "${selectedScientist.name}"?`)) return; - setScientists((prev) => prev.filter((i) => i.id !== selectedScientist.id)); - setSelectedId(null); - setEditScientist(null); - }; - const searchedArtefacts = allArtefacts.filter( - (a) => - artefactQuery.trim() === "" || - a.name.toLowerCase().includes(artefactQuery.toLowerCase()) || - a.id.toString().includes(artefactQuery) - ); - 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 (
@@ -348,15 +334,14 @@ export default function Scientist() {
- {/* Filter dropdown */}
)}
- {/* sort dropdown */}
+ {isAdmin && ( + + )}
Scientists sorted by {fieldLabels[sortField]} {dirLabels[sortDir]} @@ -457,247 +455,181 @@ export default function Scientist() {
- {/* MAIN PANEL */} -
- {editScientist ? ( -
- {/* Heading */} -
Edit Scientist
-
-
- {/* LEFT COLUMN */} -
-
-
Created at
-
{editScientist.createdAt}
-
-
-
ID
-
{editScientist.id}
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
- {editScientist.subordinates.length > 0 - ? editScientist.subordinates.map((s) => ( - - {s.name} - - )) - : None - } -
-
-
- {/* RIGHT COLUMN */} -
- {/* Observatories Box */} -
-
- - setObservatoryQuery(e.target.value)} - style={{maxWidth: "55%"}} - /> -
-
- {searchedObservatories.length === 0 ? ( -
No observatories
- ) : ( - searchedObservatories.map((obs) => ( - - )) - )} -
-
- {/* Earthquakes Box */} -
-
- - setEarthquakeQuery(e.target.value)} - style={{maxWidth: "55%"}} - /> -
-
- {searchedEarthquakes.length === 0 ? ( -
No earthquakes
- ) : ( - searchedEarthquakes.map((eq) => ( - - )) - )} -
-
- {/* Artefacts Box */} -
-
- - setArtefactQuery(e.target.value)} - style={{maxWidth: "55%"}} - /> -
-
- {searchedArtefacts.length === 0 ? ( -
No artefacts
- ) : ( - searchedArtefacts.map((a) => ( - - )) - )} -
-
-
+ {/* Add Scientist Modal */} + {addOpen && ( +
+
+

Add New Scientist

+ +
+ + setAddForm(f => ({ ...f, name: e.target.value }))} + />
- {/* BUTTONS */} -
-
+
+ + +
+ {addError &&
{addError}
} +
+ - + + 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"} +
- +
+
+ )} + {/* Request Modal */} + setRequestOpen(false)} + requestingUserId={user?.id} + scientist={editScientist} + /> + {/* MAIN PANEL */} +
+ {editScientist ? ( +
+

Scientist Details

+ {!!success &&
{success}
} + {!!error &&
{error}
} +
+ + +
+
+ + +
+
+ + +
+
+ + {isAdmin ? ( + ) : ( -
Select a scientist...
+
+ {editScientist.superior && editScientist.superior.user ? ( + <> + {editScientist.superior.name} + + ({editScientist.superior.user.email}) + + + ) : ( + None + )} +
)} +
+
+ {isAdmin && ( + <> + + + + )} + {readOnly && ( + + )} +
+
+ ) : ( +
Select a scientist...
+ )}
diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 9da51ba..4d654b6 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -134,11 +134,15 @@ export default function Navbar({}: // currencySelector,
)} - {user && (user.role === "SCIENTIST" || user.role === "ADMIN") && ( -
- -
- )} + {user && ( + (user.role === "ADMIN" || + (user.role === "SCIENTIST" && user.scientist?.level === "SENIOR") + ) && ( +
+ +
+ ) + )} {user && user.role === "ADMIN" && (