diff --git a/package-lock.json b/package-lock.json index 1ed5173..2574ef0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "csv-parse": "^5.6.0", "csv-parser": "^3.2.0", "date-fns": "^4.1.0", + "DatePicker": "^2.0.0", "dotenv": "^16.5.0", "easy-peasy": "^6.1.0", "express": "^5.1.0", @@ -2867,6 +2868,12 @@ "url": "https://github.com/sponsors/kossnocorp" } }, + "node_modules/DatePicker": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/DatePicker/-/DatePicker-2.0.0.tgz", + "integrity": "sha512-x5zuXdURsRHGtLJAufN+LaR7EaisJ8HUDrfoHmOHQ5xfqYQa35kIwaQQeAQgc14puau2t1A2slDTuKqJwfZAdA==", + "license": "ISC" + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", diff --git a/package.json b/package.json index 0c79f56..36742f0 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "csv-parse": "^5.6.0", "csv-parser": "^3.2.0", "date-fns": "^4.1.0", + "DatePicker": "^2.0.0", "dotenv": "^16.5.0", "easy-peasy": "^6.1.0", "express": "^5.1.0", diff --git a/public/learn.jpg b/public/learn.jpg new file mode 100644 index 0000000..e462428 Binary files /dev/null and b/public/learn.jpg differ diff --git a/src/app/api/requests/route.ts b/src/app/api/requests/route.ts new file mode 100644 index 0000000..0df7883 --- /dev/null +++ b/src/app/api/requests/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@utils/prisma"; + +// GET requests, just requestingUser only +export async function GET() { + try { + const requests = await prisma.request.findMany({ + orderBy: { createdAt: "desc" }, + include: { + requestingUser: true, + }, + }); + return NextResponse.json({ requests }, { status: 200 }); + } catch (err) { + console.error("Failed to get requests", err); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } +} + +export async function PUT(req: NextRequest) { + try { + const { id, outcome } = await req.json(); + if (!["FULFILLED", "REJECTED"].includes(outcome)) { + return NextResponse.json({ error: "Invalid outcome" }, { status: 400 }); + } + const existing = await prisma.request.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: "Request not found" }, { status: 404 }); + } + const updated = await prisma.request.update({ + where: { id }, + data: { + outcome, + }, + }); + return NextResponse.json({ request: updated }, { status: 200 }); + } catch (err) { + console.error("Update request failed", err); + return NextResponse.json({ error: "Update failed" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/management/page.tsx b/src/app/management/page.tsx index 53be8b0..4de9c16 100644 --- a/src/app/management/page.tsx +++ b/src/app/management/page.tsx @@ -23,6 +23,37 @@ type Scientist = { subordinates: Scientist[]; }; // --- Helpers --- + +function normalizeScientist(input: any): Scientist { + return { + id: input.id, + createdAt: + typeof input.createdAt === "string" + ? input.createdAt + : input.createdAt instanceof Date + ? input.createdAt.toISOString() + : String(input.createdAt), + name: input.name, + level: + input.level === "JUNIOR" + ? "JUNIOR" + : input.level === "SENIOR" + ? "SENIOR" + : ("JUNIOR" as Level), // fallback/safe - but this should never happen + user: input.user, + userId: input.userId, + superior: input.superior ? normalizeScientist(input.superior) : null, + superiorId: + input.superiorId === undefined + ? null + : input.superiorId, + subordinates: + Array.isArray(input.subordinates) + ? input.subordinates.map(normalizeScientist) + : [], + }; + } + const initialScientists: Scientist[] = [ { id: 0, @@ -44,12 +75,11 @@ type SortField = (typeof sortFields)[number]["value"]; type SortDir = "asc" | "desc"; const dirLabels: Record = { asc: "ascending", desc: "descending" }; const fieldLabels: Record = { name: "Name", level: "Level" }; - -// --- Updated RequestModal (only level/removal, no comment) +// --- Updated RequestModal (clearer wording for "myself" & only level/removal) type RequestModalProps = { open: boolean; onClose: () => void; - requestingUserId: number; + requestingUserId: number | undefined; scientist?: Scientist | null; }; function RequestModal({ open, onClose, requestingUserId, scientist }: RequestModalProps) { @@ -57,7 +87,6 @@ function RequestModal({ open, onClose, requestingUserId, scientist }: RequestMod 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); @@ -78,7 +107,7 @@ function RequestModal({ open, onClose, requestingUserId, scientist }: RequestMod const d = await res.json().catch(() => ({})); throw new Error(d?.error || "Request failed"); } - setSuccess("Request submitted for review."); + setSuccess("Your request has been submitted for review."); } catch (e: any) { setError(e?.message || "Unknown error"); } finally { @@ -89,18 +118,31 @@ function RequestModal({ open, onClose, requestingUserId, scientist }: RequestMod return open ? (
-

Request Action

+

+ Request a Change to Your Profile +

+
+ {/* Explain exactly what the request is for */} + {scientist + ? ( + <>You are requesting a change for your own scientist profile: {scientist.name} ({scientist.user.email}). + ) + : ( + <>You are requesting a change for your own scientist profile. + ) + } +
- +
{error &&
{error}
} @@ -160,6 +202,7 @@ export default function ScientistManagementPage() { const isAdmin = userRole === "ADMIN"; const isSeniorScientist = userRole === "SCIENTIST" && user?.scientist?.level === "SENIOR"; const readOnly = isSeniorScientist && !isAdmin; + // Data loading effects useEffect(() => { async function fetchAllUsers() { @@ -302,6 +345,18 @@ export default function ScientistManagementPage() { } }; const usersWithNoScientist = allUsers.filter(u => !u.scientist); + + // Find "my" scientist for the modal as senior scientist + const rawMyScientist = + user?.scientist + ? scientists.find(s => s.id === user.scientist?.id) || user.scientist + : undefined; + + // Only pass to modal if available, and fully normalized: + const myScientist = rawMyScientist + ? normalizeScientist(rawMyScientist) + : undefined; + if (!isAdmin && !isSeniorScientist) { return (
@@ -416,18 +471,34 @@ export default function ScientistManagementPage() { {sortDir === "asc" ? "↑" : "↓"} {isAdmin && ( - + + )} + {/* Senior scientist: Request Change button (for myself) */} + {!isAdmin && isSeniorScientist && !!myScientist && ( + )}
@@ -526,7 +597,7 @@ export default function ScientistManagementPage() { open={requestOpen} onClose={()=>setRequestOpen(false)} requestingUserId={user?.id} - scientist={editScientist} + scientist={myScientist} /> {/* MAIN PANEL */}
@@ -617,14 +688,7 @@ export default function ScientistManagementPage() { )} - {readOnly && ( - - )} + {/* No request change button here for readOnly/SCIENTIST users anymore */}
) : ( diff --git a/src/app/page.tsx b/src/app/page.tsx index 2bcd8ea..75d36fd 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,212 +3,220 @@ import Image from "next/image"; import Link from "next/link"; import { TbHexagon } from "react-icons/tb"; import useSWR from "swr"; + import BottomFooter from "@components/BottomFooter"; import { createPoster } from "@utils/axiosHelpers"; import getMagnitudeColor from "@utils/getMagnitudeColour"; // formats the date function getRelativeDate(dateString: string): string { - const date = new Date(dateString); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - if (diffDays === 0) return "today"; - if (diffDays === 1) return "yesterday"; - return date.toLocaleDateString(); + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + if (diffDays === 0) return "today"; + if (diffDays === 1) return "yesterday"; + return date.toLocaleDateString(); } // copied from sidebar function MagnitudeNumber({ magnitude }: { magnitude: number }) { - const magnitudeStr = magnitude.toFixed(1); - const [whole, decimal] = magnitudeStr.split("."); - return ( -
- -
-
- {whole} - . - {decimal} -
-
-
- ); + const magnitudeStr = magnitude.toFixed(1); + const [whole, decimal] = magnitudeStr.split("."); + return ( +
+ +
+
+ {whole} + . + {decimal} +
+
+
+ ); } export default function Home() { - const { data, error, isLoading } = useSWR( - "/api/earthquakes", - createPoster({ rangeDaysPrev: 6 }) - ); - // Take 5 most recent - const recents = (data?.earthquakes ?? []) - .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) - .slice(0, 5); + const { data, error, isLoading } = useSWR("/api/earthquakes", createPoster({ rangeDaysPrev: 6 })); + // Take 5 most recent + const recents = (data?.earthquakes ?? []).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).slice(0, 5); - return ( -
-
-
- Background Image -
-
-
- Title Image -
-
-

-
- - Education Icon -

Earthquakes

-

- Log new earthquakes with their required details or search past seismic events -

- - - Research Icon -

Observatories

-

- Find recently active observatories, and newly opened/closed sites -

- - - Technology Icon -

Artefacts

-

- View or purchase recently discovered artefacts from seismic events -

- -
-

-
-
-
- Background Image -
-
-
-

- Welcome to Tremor Tracker -

-

- TremorTracker is a non-profit website and research company, that aims to provide true, reliable data. Our mission - is seismic education and preparation for all -

-

-

What is an earthquake?

-

- Earthquakes are a shaking of the earth's surface caused by a sudden release of energy underground. They can range - in size, from tiny trembles to large quakes, which can cause destruction and even tsunamis. Hundreds of - earthquakes happen every day—but most are too small to feel. -

-

-

- How do we log earthquakes? -

-

- What information are we interested in? -

-

info

-

-

- What are observatories? -

-

What is their role?

-

info

-
-
-
-
-

-
-

- Recent Earthquake Events -

-

- Learn about the most recent earthquake events from around the world: -

-
-

-
- {error && ( -
-

Failed to load earthquakes.

-
- )} - {isLoading && ( -
-

Loading...

-
- )} - {!isLoading && recents.length === 0 && ( -
-

No earthquakes found.

-
- )} -
- {recents.map((eq) => ( -
-
-
- Earthquake in {eq.location || (eq.code && eq.code.split("-")[2])} -
-
{getRelativeDate(eq.date)}
-
- -
- ))} -
-
-

-
-

- Find Out More! -

-

- Explore more of our website... -

-
-

-
- - Education Icon -

Contact us directly

-

- Visit our socials or leave us a message via phone or email. -

- - - Research Icon -

Our Mission

-

- Find out more about our purpose and the features we offer. -

- - - Technology Icon -

Meet the Team

-

- Learn about our team leads and their responsibilities. -

- -
-

-
-
-
- Background Image -
- -
-
-
- ); -} \ No newline at end of file + return ( +
+
+
+ Background Image +
+
+
+ Title Image +
+
+

+
+ + Education Icon +

Earthquakes

+

+ Log new earthquakes with their required details or search past seismic events +

+ + + Research Icon +

Observatories

+

+ Find recently active observatories, and newly opened/closed sites +

+ + + Aftefacts Icon +

Artefacts

+

+ View or purchase recently discovered artefacts from seismic events +

+ +
+

+
+
+
+ Background Image +
+
+
+

+ Welcome to Tremor Tracker +

+

+ TremorTracker is a non-profit website and research company, that aims to provide seismic education and aid + preparation +

+

+

What is an earthquake?

+

+ An earthquake is a sudden shaking of the Earth’s surface, triggered by a rapid release of energy deep underground. + This usually happens because the Earth’s outer shell, called the crust, is made up of large pieces known as + tectonic plates. These plates are always moving, but sometimes they get stuck at their edges; when stress builds + up and is finally released, it causes the ground to shake—an earthquake. Earthquakes can vary greatly in size—from + barely noticeable tremors to powerful quakes capable of causing widespread destruction. There are several types: + Tectonic, Volcanic and Collapse earthquakes. Understanding why and how earthquakes happen helps scientists predict + where they are most likely to occur and how to lessen their impact. +

+

+

+ How do we log earthquakes? +

+

+ What information are we interested in? +

+

+ Scientists record earthquakes using special instruments called seismometers, which detect and measure the + vibrations in the ground. When an earthquake occurs, the seismometer produces a trace known as a seismogram, + showing the strength and duration of the shaking. Information from seismometers around the world is sent to data + centers, where experts analyze it to pinpoint the earthquake’s location, type, depth, and magnitude. This process + is called “logging” or recording earthquakes, and it helps track seismic activity globally. +

+

+

+ What are observatories? +

+

+ An earthquake observatory is a specialized facility where scientists monitor and study seismic activity. These + observatories are equipped with sensitive instruments that can detect and record even the smallest tremors deep + within the Earth. Observatories collect important data about the strength, location, and timing of each earthquake + that can be shared with the general public. Scientists at the observatory use this data to better understand how + and why earthquakes occur, track earthquake patterns, and issue warnings if a major quake is detected. The + information gathered also helps in designing safer buildings and improving emergency response plans. +

+
+
+
+
+

+
+

Recent Earthquake Events

+

+ Learn about the most recent earthquake events from around the world: +

+
+

+
+ {error && ( +
+

Failed to load earthquakes.

+
+ )} + {isLoading && ( +
+

Loading...

+
+ )} + {!isLoading && recents.length === 0 && ( +
+

No earthquakes found.

+
+ )} +
+ {recents.map((eq) => ( +
+
+
Earthquake in {eq.location || (eq.code && eq.code.split("-")[2])}
+
{getRelativeDate(eq.date)}
+
+ +
+ ))} +
+
+

+
+

Find Out More!

+

Explore more of our website...

+
+

+
+ + Contact Us Icon +

Contact us directly

+

+ Visit our socials or leave us a message via phone or email. +

+ + + Our Mission Icon +

Our Mission

+

+ Find out more about our purpose and the features we offer. +

+ + + Team Icon +

Meet the Team

+

+ Learn about our team leads and their responsibilities. +

+ + + Learn Icon +

Learn

+

+ Find out more about earthquakes, what causes them and how to prepare. +

+ +
+

+
+
+
+ Background Image +
+ +
+
+
+ ); +} diff --git a/src/app/requests/page.tsx b/src/app/requests/page.tsx new file mode 100644 index 0000000..9d0807f --- /dev/null +++ b/src/app/requests/page.tsx @@ -0,0 +1,242 @@ +"use client"; +import React, { useState, useEffect } from "react"; +import { useStoreState } from "@hooks/store"; + +// Helper dicts/types +const outcomeColors: Record = { + FULFILLED: "text-green-700 bg-green-100", + REJECTED: "text-red-700 bg-red-100", + IN_PROGRESS: "text-blue-700 bg-blue-100", + CANCELLED: "text-gray-600 bg-gray-100", + OTHER: "text-yellow-800 bg-yellow-100", +}; +const requestTypeLabels: Record = { + NEW_USER: "New User", + CHANGE_LEVEL: "Change Level", + DELETE: "Removal", +}; +function formatDate(val?: string) { + if (!val) return "--"; + return new Date(val).toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + +// Minimal types +type User = { + id: number; + name: string; + email: string; + role?: string; + scientist?: { level: string } | null; +}; + +type Request = { + id: number; + createdAt: string; + requestType: string; + requestingUser: User; + outcome: string; +}; + +export default function RequestManagementPage() { + const user = useStoreState((s) => s.user); + + // All hooks first! + const [requests, setRequests] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [actionLoading, setActionLoading] = useState(null); + const [actionError, setActionError] = useState(null); + const [actionSuccess, setActionSuccess] = useState(null); + + // User role logic must remain invariant per render + const userRole = user?.role as string | undefined; + const isAdmin = userRole === "ADMIN"; + const isSeniorScientist = userRole === "SCIENTIST" && user?.scientist?.level === "SENIOR"; + const userId = user?.id; + + // Requests fetch + useEffect(() => { + setLoading(true); + setError(null); + fetch("/api/requests") + .then((res) => { + if (!res.ok) throw new Error("Failed to fetch requests"); + return res.json(); + }) + .then((data) => { + setRequests(data.requests || []); + setLoading(false); + }) + .catch((err) => { + setError("Failed to load requests."); + setLoading(false); + }); + }, []); + + // Filtering for non-admins to only their requests + const filteredRequests = React.useMemo( + () => + isAdmin + ? requests + : requests.filter((r) => r.requestingUser.id === userId), + [isAdmin, requests, userId] + ); + + // Sorted: newest first + filteredRequests.sort((a, b) => + (b.createdAt ?? "").localeCompare(a.createdAt ?? "") + ); + + async function handleAction( + requestId: number, + action: "FULFILLED" | "REJECTED" + ) { + setActionLoading(requestId); + setActionError(null); + setActionSuccess(null); + try { + const res = await fetch("/api/requests", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: requestId, outcome: action }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data?.error || "Failed to update request"); + } + setRequests((prev) => + prev.map((r) => + r.id === requestId ? { ...r, outcome: action } : r + ) + ); + setActionSuccess("Request updated."); + } catch (err: any) { + setActionError(err?.message || "Failed to update request"); + } finally { + setActionLoading(null); + } + } + + // Unauthorized access should return early, but not before hooks! + if (!isAdmin && !isSeniorScientist) { + return ( +
+

Unauthorized Access

+
You do not have access to this page.
+
+ ); + } + + return ( +
+
+

+ {isAdmin ? "All Requests" : "My Requests"} +

+

+ View {isAdmin ? "and manage pending" : "your"} requests related to scientist management. +

+
+ + {loading ? ( +
Loading...
+ ) : error ? ( +
{error}
+ ) : ( +
+ + + + + + + + {isAdmin && } + + + + {filteredRequests.length === 0 ? ( + + + + ) : ( + filteredRequests.map((req) => ( + + + + + + {isAdmin && ( + + )} + + )) + )} + +
DateTypeRequested ByStatusActions
+ No requests found. +
+ {formatDate(req.createdAt)} + + {requestTypeLabels[req.requestType] || req.requestType} + + {req.requestingUser.name} + + ({req.requestingUser.email}) + + + + {req.outcome.replace(/_/g, " ")} + + + {req.outcome === "IN_PROGRESS" ? ( +
+ + +
+ ) : ( + No actions + )} + {(actionError && actionLoading === req.id) && ( +
{actionError}
+ )} + {(actionSuccess && actionLoading === req.id) && ( +
{actionSuccess}
+ )} +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 4d654b6..44d4bb1 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -142,6 +142,15 @@ export default function Navbar({}: // currencySelector,
) + )} + {user && ( + (user.role === "ADMIN" || + (user.role === "SCIENTIST" && user.scientist?.level === "SENIOR") + ) && ( +
+ +
+ ) )} {user && user.role === "ADMIN" && (