Merge branch 'master' of ssh://stash.dyson.global.corp:7999/~thowitz/tremor-tracker

This commit is contained in:
Tim Howitz 2025-06-01 14:19:08 +01:00
commit 74ed7bd50a
8 changed files with 597 additions and 225 deletions

7
package-lock.json generated
View File

@ -17,6 +17,7 @@
"csv-parse": "^5.6.0", "csv-parse": "^5.6.0",
"csv-parser": "^3.2.0", "csv-parser": "^3.2.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"DatePicker": "^2.0.0",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"easy-peasy": "^6.1.0", "easy-peasy": "^6.1.0",
"express": "^5.1.0", "express": "^5.1.0",
@ -2867,6 +2868,12 @@
"url": "https://github.com/sponsors/kossnocorp" "url": "https://github.com/sponsors/kossnocorp"
} }
}, },
"node_modules/DatePicker": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/DatePicker/-/DatePicker-2.0.0.tgz",
"integrity": "sha512-x5zuXdURsRHGtLJAufN+LaR7EaisJ8HUDrfoHmOHQ5xfqYQa35kIwaQQeAQgc14puau2t1A2slDTuKqJwfZAdA==",
"license": "ISC"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",

View File

@ -20,6 +20,7 @@
"csv-parse": "^5.6.0", "csv-parse": "^5.6.0",
"csv-parser": "^3.2.0", "csv-parser": "^3.2.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"DatePicker": "^2.0.0",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"easy-peasy": "^6.1.0", "easy-peasy": "^6.1.0",
"express": "^5.1.0", "express": "^5.1.0",

BIN
public/learn.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@utils/prisma";
// GET requests, just requestingUser only
export async function GET() {
try {
const requests = await prisma.request.findMany({
orderBy: { createdAt: "desc" },
include: {
requestingUser: true,
},
});
return NextResponse.json({ requests }, { status: 200 });
} catch (err) {
console.error("Failed to get requests", err);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
export async function PUT(req: NextRequest) {
try {
const { id, outcome } = await req.json();
if (!["FULFILLED", "REJECTED"].includes(outcome)) {
return NextResponse.json({ error: "Invalid outcome" }, { status: 400 });
}
const existing = await prisma.request.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: "Request not found" }, { status: 404 });
}
const updated = await prisma.request.update({
where: { id },
data: {
outcome,
},
});
return NextResponse.json({ request: updated }, { status: 200 });
} catch (err) {
console.error("Update request failed", err);
return NextResponse.json({ error: "Update failed" }, { status: 500 });
}
}

View File

@ -23,6 +23,37 @@ type Scientist = {
subordinates: Scientist[]; subordinates: Scientist[];
}; };
// --- Helpers --- // --- Helpers ---
function normalizeScientist(input: any): Scientist {
return {
id: input.id,
createdAt:
typeof input.createdAt === "string"
? input.createdAt
: input.createdAt instanceof Date
? input.createdAt.toISOString()
: String(input.createdAt),
name: input.name,
level:
input.level === "JUNIOR"
? "JUNIOR"
: input.level === "SENIOR"
? "SENIOR"
: ("JUNIOR" as Level), // fallback/safe - but this should never happen
user: input.user,
userId: input.userId,
superior: input.superior ? normalizeScientist(input.superior) : null,
superiorId:
input.superiorId === undefined
? null
: input.superiorId,
subordinates:
Array.isArray(input.subordinates)
? input.subordinates.map(normalizeScientist)
: [],
};
}
const initialScientists: Scientist[] = [ const initialScientists: Scientist[] = [
{ {
id: 0, id: 0,
@ -44,12 +75,11 @@ type SortField = (typeof sortFields)[number]["value"];
type SortDir = "asc" | "desc"; type SortDir = "asc" | "desc";
const dirLabels: Record<SortDir, string> = { asc: "ascending", desc: "descending" }; const dirLabels: Record<SortDir, string> = { asc: "ascending", desc: "descending" };
const fieldLabels: Record<SortField, string> = { name: "Name", level: "Level" }; const fieldLabels: Record<SortField, string> = { name: "Name", level: "Level" };
// --- Updated RequestModal (clearer wording for "myself" & only level/removal)
// --- Updated RequestModal (only level/removal, no comment)
type RequestModalProps = { type RequestModalProps = {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
requestingUserId: number; requestingUserId: number | undefined;
scientist?: Scientist | null; scientist?: Scientist | null;
}; };
function RequestModal({ open, onClose, requestingUserId, scientist }: RequestModalProps) { function RequestModal({ open, onClose, requestingUserId, scientist }: RequestModalProps) {
@ -57,7 +87,6 @@ function RequestModal({ open, onClose, requestingUserId, scientist }: RequestMod
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setError(null); setSuccess(null); setError(null); setSuccess(null);
@ -78,7 +107,7 @@ function RequestModal({ open, onClose, requestingUserId, scientist }: RequestMod
const d = await res.json().catch(() => ({})); const d = await res.json().catch(() => ({}));
throw new Error(d?.error || "Request failed"); throw new Error(d?.error || "Request failed");
} }
setSuccess("Request submitted for review."); setSuccess("Your request has been submitted for review.");
} catch (e: any) { } catch (e: any) {
setError(e?.message || "Unknown error"); setError(e?.message || "Unknown error");
} finally { } finally {
@ -89,18 +118,31 @@ function RequestModal({ open, onClose, requestingUserId, scientist }: RequestMod
return open ? ( return open ? (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black bg-opacity-30"> <div className="fixed inset-0 z-40 flex items-center justify-center bg-black bg-opacity-30">
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full relative"> <div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full relative">
<h3 className="text-lg font-bold mb-4">Request Action</h3> <h3 className="text-lg font-bold mb-2">
Request a Change to Your Profile
</h3>
<div className="mb-2 text-gray-600 text-sm">
{/* Explain exactly what the request is for */}
{scientist
? (
<>You are requesting a change for your own scientist profile: <b>{scientist.name}</b> ({scientist.user.email}).</>
)
: (
<>You are requesting a change for your own scientist profile.</>
)
}
</div>
<form onSubmit={handleSubmit} className="space-y-3"> <form onSubmit={handleSubmit} className="space-y-3">
<div> <div>
<label className="block text-sm font-medium mb-1">Action Type</label> <label className="block text-sm font-medium mb-1">Request Action Type</label>
<select <select
required required
className="w-full border px-2 py-1 rounded-lg" className="w-full border px-2 py-1 rounded-lg"
value={requestType} value={requestType}
onChange={e=>setRequestType(e.target.value)} onChange={e=>setRequestType(e.target.value)}
> >
<option value="CHANGE_LEVEL">Request Change Level</option> <option value="CHANGE_LEVEL">Request level change</option>
<option value="DELETE">Request Removal</option> <option value="DELETE">Remove yourself from scientists</option>
</select> </select>
</div> </div>
{error && <div className="text-red-600 text-xs">{error}</div>} {error && <div className="text-red-600 text-xs">{error}</div>}
@ -160,6 +202,7 @@ export default function ScientistManagementPage() {
const isAdmin = userRole === "ADMIN"; const isAdmin = userRole === "ADMIN";
const isSeniorScientist = userRole === "SCIENTIST" && user?.scientist?.level === "SENIOR"; const isSeniorScientist = userRole === "SCIENTIST" && user?.scientist?.level === "SENIOR";
const readOnly = isSeniorScientist && !isAdmin; const readOnly = isSeniorScientist && !isAdmin;
// Data loading effects // Data loading effects
useEffect(() => { useEffect(() => {
async function fetchAllUsers() { async function fetchAllUsers() {
@ -302,6 +345,18 @@ export default function ScientistManagementPage() {
} }
}; };
const usersWithNoScientist = allUsers.filter(u => !u.scientist); const usersWithNoScientist = allUsers.filter(u => !u.scientist);
// Find "my" scientist for the modal as senior scientist
const rawMyScientist =
user?.scientist
? scientists.find(s => s.id === user.scientist?.id) || user.scientist
: undefined;
// Only pass to modal if available, and fully normalized:
const myScientist = rawMyScientist
? normalizeScientist(rawMyScientist)
: undefined;
if (!isAdmin && !isSeniorScientist) { if (!isAdmin && !isSeniorScientist) {
return ( return (
<div className="flex items-center justify-center min-h-[70vh] flex-col"> <div className="flex items-center justify-center min-h-[70vh] flex-col">
@ -416,18 +471,34 @@ export default function ScientistManagementPage() {
{sortDir === "asc" ? "↑" : "↓"} {sortDir === "asc" ? "↑" : "↓"}
</button> </button>
{isAdmin && ( {isAdmin && (
<button <button
className="ml-2 px-2 py-1 rounded-lg bg-green-600 hover:bg-green-700 text-white font-bold flex items-center shadow transition duration-150" className="ml-2 px-2 py-1 rounded-lg bg-green-600 hover:bg-green-700 text-white font-bold flex items-center shadow transition duration-150"
type="button" type="button"
style={{ minWidth: 36, minHeight: 36 }} style={{ minWidth: 36, minHeight: 36 }}
onClick={() => setAddOpen(true)} onClick={() => setAddOpen(true)}
disabled={addOpen} disabled={addOpen}
title="Add scientist" title="Add scientist"
> >
<svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth={2.2} viewBox="0 0 24 24"> <svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth={2.2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m7-7H5" /> <path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m7-7H5" />
</svg> </svg>
</button> </button>
)}
{/* Senior scientist: Request Change button (for myself) */}
{!isAdmin && isSeniorScientist && !!myScientist && (
<button
className="ml-2 px-2 py-1 rounded-lg bg-yellow-500 hover:bg-yellow-600 text-white font-bold flex items-center shadow transition duration-150"
type="button"
style={{ minWidth: 36, minHeight: 36 }}
onClick={() => setRequestOpen(true)}
disabled={requestOpen}
title="Request changes to your own scientist profile"
>
<svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth={2.2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 12H4m8 8V4" />
</svg>
<span className="ml-2 text-xs font-normal text-center">🔁</span>
</button>
)} )}
</div> </div>
<small className="text-xs text-gray-500 mb-2 px-1"> <small className="text-xs text-gray-500 mb-2 px-1">
@ -526,7 +597,7 @@ export default function ScientistManagementPage() {
open={requestOpen} open={requestOpen}
onClose={()=>setRequestOpen(false)} onClose={()=>setRequestOpen(false)}
requestingUserId={user?.id} requestingUserId={user?.id}
scientist={editScientist} scientist={myScientist}
/> />
{/* MAIN PANEL */} {/* MAIN PANEL */}
<div className="flex-1 p-24 bg-white overflow-y-auto"> <div className="flex-1 p-24 bg-white overflow-y-auto">
@ -617,14 +688,7 @@ export default function ScientistManagementPage() {
</button> </button>
</> </>
)} )}
{readOnly && ( {/* No request change button here for readOnly/SCIENTIST users anymore */}
<button type="button"
onClick={()=>setRequestOpen(true)}
className="px-4 py-2 rounded-lg bg-yellow-500 hover:bg-yellow-600 text-white font-semibold shadow transition ml-3"
>
Request Change
</button>
)}
</div> </div>
</form> </form>
) : ( ) : (

View File

@ -3,212 +3,220 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { TbHexagon } from "react-icons/tb"; import { TbHexagon } from "react-icons/tb";
import useSWR from "swr"; import useSWR from "swr";
import BottomFooter from "@components/BottomFooter"; import BottomFooter from "@components/BottomFooter";
import { createPoster } from "@utils/axiosHelpers"; import { createPoster } from "@utils/axiosHelpers";
import getMagnitudeColor from "@utils/getMagnitudeColour"; import getMagnitudeColor from "@utils/getMagnitudeColour";
// formats the date // formats the date
function getRelativeDate(dateString: string): string { function getRelativeDate(dateString: string): string {
const date = new Date(dateString); const date = new Date(dateString);
const now = new Date(); const now = new Date();
const diffMs = now.getTime() - date.getTime(); const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return "today"; if (diffDays === 0) return "today";
if (diffDays === 1) return "yesterday"; if (diffDays === 1) return "yesterday";
return date.toLocaleDateString(); return date.toLocaleDateString();
} }
// copied from sidebar // copied from sidebar
function MagnitudeNumber({ magnitude }: { magnitude: number }) { function MagnitudeNumber({ magnitude }: { magnitude: number }) {
const magnitudeStr = magnitude.toFixed(1); const magnitudeStr = magnitude.toFixed(1);
const [whole, decimal] = magnitudeStr.split("."); const [whole, decimal] = magnitudeStr.split(".");
return ( return (
<div className="relative" style={{ color: getMagnitudeColor(magnitude) }}> <div className="relative" style={{ color: getMagnitudeColor(magnitude) }}>
<TbHexagon size={40} className="drop-shadow-sm" /> <TbHexagon size={40} className="drop-shadow-sm" />
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<div className="flex items-baseline font-mono font-bold tracking-tight"> <div className="flex items-baseline font-mono font-bold tracking-tight">
<span className="text-xl -mr-1">{whole}</span> <span className="text-xl -mr-1">{whole}</span>
<span className="text-xs ml-[1.5px] -mr-[2.5px]">.</span> <span className="text-xs ml-[1.5px] -mr-[2.5px]">.</span>
<span className="text-xs -mr-[1px]">{decimal}</span> <span className="text-xs -mr-[1px]">{decimal}</span>
</div> </div>
</div> </div>
</div> </div>
); );
} }
export default function Home() { export default function Home() {
const { data, error, isLoading } = useSWR( const { data, error, isLoading } = useSWR("/api/earthquakes", createPoster({ rangeDaysPrev: 6 }));
"/api/earthquakes", // Take 5 most recent
createPoster({ rangeDaysPrev: 6 }) const recents = (data?.earthquakes ?? []).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).slice(0, 5);
);
// 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 ( return (
<main className="min-h-screen text-black"> <main className="min-h-screen text-black">
<div className="w-full relative"> <div className="w-full relative">
<div> <div>
<Image height={2000} width={2000} alt="Background Image" src="/lava_flows.jpg" /> <Image height={2000} width={2000} alt="Background Image" src="/lava_flows.jpg" />
</div> </div>
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-black/10 to-black/40"></div> <div className="absolute inset-0 bg-gradient-to-b from-transparent via-black/10 to-black/40"></div>
<div className="absolute inset-0 top-[30%]"> <div className="absolute inset-0 top-[30%]">
<Image className="mx-auto" height={300} width={1000} alt="Title Image" src="/tremortrackertext.png" /> <Image className="mx-auto" height={300} width={1000} alt="Title Image" src="/tremortrackertext.png" />
</div> </div>
</div> </div>
<p className="mt-2"></p> <p className="mt-2"></p>
<div className="flex flex-col md:flex-row md:justify-evenly gap-6 mt-2"> <div className="flex flex-col md:flex-row md:justify-evenly gap-6 mt-2">
<Link href="/earthquakes" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300"> <Link href="/earthquakes" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300">
<Image height={100} width={100} src="/earthquake.png" alt="Education Icon" className="h-40 w-40 mb-4" /> <Image height={100} width={100} src="/earthquake.png" alt="Education Icon" className="h-40 w-40 mb-4" />
<h3 className="text-xl font-bold text-black mb-4">Earthquakes</h3> <h3 className="text-xl font-bold text-black mb-4">Earthquakes</h3>
<p className="text-md text-black text-center max-w-xs opacity-90"> <p className="text-md text-black text-center max-w-xs opacity-90">
Log new earthquakes with their required details or search past seismic events Log new earthquakes with their required details or search past seismic events
</p> </p>
</Link> </Link>
<Link <Link
href="/observatories" href="/observatories"
className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300"
> >
<Image height={100} width={100} src="/observe.png" alt="Research Icon" className="h-40 w-40 mb-4" /> <Image height={100} width={100} src="/observe.png" alt="Research Icon" className="h-40 w-40 mb-4" />
<h3 className="text-xl font-bold text-black mb-4">Observatories</h3> <h3 className="text-xl font-bold text-black mb-4">Observatories</h3>
<p className="text-md text-black text-center max-w-xs opacity-90"> <p className="text-md text-black text-center max-w-xs opacity-90">
Find recently active observatories, and newly opened/closed sites Find recently active observatories, and newly opened/closed sites
</p> </p>
</Link> </Link>
<Link href="/shop" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300"> <Link href="/shop" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300">
<Image height={100} width={100} src="/artefact.png" alt="Technology Icon" className="h-40 w-40 mb-4" /> <Image height={100} width={100} src="/artefact.png" alt="Aftefacts Icon" className="h-40 w-40 mb-4" />
<h3 className="text-xl font-bold text-black mb-4">Artefacts</h3> <h3 className="text-xl font-bold text-black mb-4">Artefacts</h3>
<p className="text-md text-black text-center max-w-xs opacity-90"> <p className="text-md text-black text-center max-w-xs opacity-90">
View or purchase recently discovered artefacts from seismic events View or purchase recently discovered artefacts from seismic events
</p> </p>
</Link> </Link>
</div> </div>
<p className="mt-18"></p> <p className="mt-18"></p>
<section className="min-h-screen text-black"> <section className="min-h-screen text-black">
<div className="w-full relative z-40"> <div className="w-full relative z-40">
<div> <div>
<Image height={2500} width={2000} alt="Background Image" src="/earthquakesMap.jpg" /> <Image height={2500} width={2000} alt="Background Image" src="/earthquakesMap.jpg" />
</div> </div>
<div className="border absolute top-0 inset-0 bg-gradient-to-b from-transparent via-black/10 to-black/40"> <div className="border absolute top-0 inset-0 bg-gradient-to-b from-transparent via-black/10 to-black/40">
<section className="relative z-10 flex flex-col items-center text-center w-full px-4 py-14 mt-6"> <section className="relative z-10 flex flex-col items-center text-center w-full px-4 py-14 mt-6">
<h1 className="text-4xl md:text-5xl font-sans font-bold text-white drop-shadow-lg mb-4 tracking-tight z-10"> <h1 className="text-4xl md:text-5xl font-sans font-bold text-white drop-shadow-lg mb-4 tracking-tight z-10">
Welcome to Tremor Tracker Welcome to Tremor Tracker
</h1> </h1>
<p className="text-lg md:text-xl font-sans text-white w-4/6 mx-auto drop-shadow-md z-10"> <p className="text-lg md:text-xl font-sans text-white w-4/6 mx-auto drop-shadow-md z-10">
TremorTracker is a non-profit website and research company, that aims to provide true, reliable data. Our mission TremorTracker is a non-profit website and research company, that aims to provide seismic education and aid
is seismic education and preparation for all preparation
</p> </p>
<p className="mt-20"></p> <p className="mt-10"></p>
<p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10">What is an earthquake?</p> <p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10">What is an earthquake?</p>
<p className="text-lg md:text-xl text-white w-4/6 mx-auto drop-shadow-md z-10"> <p className="text-lg md:text-xl text-white w-4/6 mx-auto drop-shadow-md z-10">
Earthquakes are a shaking of the earth's surface caused by a sudden release of energy underground. They can range An earthquake is a sudden shaking of the Earths surface, triggered by a rapid release of energy deep underground.
in size, from tiny trembles to large quakes, which can cause destruction and even tsunamis. Hundreds of This usually happens because the Earths outer shell, called the crust, is made up of large pieces known as
earthquakes happen every daybut most are too small to feel. tectonic plates. These plates are always moving, but sometimes they get stuck at their edges; when stress builds
</p> up and is finally released, it causes the ground to shakean earthquake. Earthquakes can vary greatly in sizefrom
<p className="mt-20"></p> barely noticeable tremors to powerful quakes capable of causing widespread destruction. There are several types:
<p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10"> Tectonic, Volcanic and Collapse earthquakes. Understanding why and how earthquakes happen helps scientists predict
How do we log earthquakes? where they are most likely to occur and how to lessen their impact.
</p> </p>
<p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10"> <p className="mt-10"></p>
What information are we interested in? <p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10">
</p> How do we log earthquakes?
<p className="text-lg md:text-xl text-white w-4/6 mx-auto drop-shadow-md z-10">info</p> </p>
<p className="mt-20"></p> <p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10">
<p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10"> What information are we interested in?
What are observatories? </p>
</p> <p className="text-lg md:text-xl text-white w-4/6 mx-auto drop-shadow-md z-10">
<p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10">What is their role?</p> Scientists record earthquakes using special instruments called seismometers, which detect and measure the
<p className="text-lg md:text-xl text-white w-4/6 mx-auto drop-shadow-md z-10">info</p> vibrations in the ground. When an earthquake occurs, the seismometer produces a trace known as a seismogram,
</section> showing the strength and duration of the shaking. Information from seismometers around the world is sent to data
</div> centers, where experts analyze it to pinpoint the earthquakes location, type, depth, and magnitude. This process
</div> is called logging or recording earthquakes, and it helps track seismic activity globally.
</section> </p>
<p className="mt-20"></p> <p className="mt-10"></p>
<section className="relative z-10 flex flex-col items-start text-left w-5/6 mx-auto px-2 -mt-5 mb-2"> <p className="text-lg md:text-3xl font-bold text-white w-4/6 mx-auto drop-shadow-md z-10">
<h1 className="text-3xl md:text-3xl font-bold text-black drop-shadow-lg mb-3 tracking-tight"> What are observatories?
Recent Earthquake Events </p>
</h1> <p className="text-lg md:text-xl text-white w-4/6 mx-auto drop-shadow-md z-10">
<p className="text-lg md:text-xl text-black drop-shadow-md"> An earthquake observatory is a specialized facility where scientists monitor and study seismic activity. These
Learn about the most recent earthquake events from around the world: observatories are equipped with sensitive instruments that can detect and record even the smallest tremors deep
</p> within the Earth. Observatories collect important data about the strength, location, and timing of each earthquake
</section> that can be shared with the general public. Scientists at the observatory use this data to better understand how
<p className="mt-6"></p> and why earthquakes occur, track earthquake patterns, and issue warnings if a major quake is detected. The
<div className="mx-auto w-5/6 px-2"> information gathered also helps in designing safer buildings and improving emergency response plans.
{error && ( </p>
<div className="border rounded-xl bg-white bg-opacity-90 shadow-md p-4 mb-2"> </section>
<p>Failed to load earthquakes.</p> </div>
</div> </div>
)} </section>
{isLoading && ( <p className="mt-20"></p>
<div className="border rounded-xl bg-white bg-opacity-90 shadow-md p-4 mb-2"> <section className="relative z-10 flex flex-col items-start text-left w-5/6 mx-auto px-2 -mt-5 mb-2">
<p>Loading...</p> <h1 className="text-3xl md:text-3xl font-bold text-black drop-shadow-lg mb-3 tracking-tight">Recent Earthquake Events</h1>
</div> <p className="text-lg md:text-xl text-black drop-shadow-md">
)} Learn about the most recent earthquake events from around the world:
{!isLoading && recents.length === 0 && ( </p>
<div className="border rounded-xl bg-white bg-opacity-90 shadow-md p-4 mb-2"> </section>
<p>No earthquakes found.</p> <p className="mt-6"></p>
</div> <div className="mx-auto w-5/6 px-2">
)} {error && (
<div className="flex flex-col gap-4"> <div className="border rounded-xl bg-white bg-opacity-90 shadow-md p-4 mb-2">
{recents.map((eq) => ( <p>Failed to load earthquakes.</p>
<div </div>
key={eq.code} )}
className="flex items-center justify-between p-4 bg-white rounded-xl shadow border" {isLoading && (
> <div className="border rounded-xl bg-white bg-opacity-90 shadow-md p-4 mb-2">
<div> <p>Loading...</p>
<div className="font-semibold"> </div>
Earthquake in {eq.location || (eq.code && eq.code.split("-")[2])} )}
</div> {!isLoading && recents.length === 0 && (
<div className="text-sm text-gray-500">{getRelativeDate(eq.date)}</div> <div className="border rounded-xl bg-white bg-opacity-90 shadow-md p-4 mb-2">
</div> <p>No earthquakes found.</p>
<MagnitudeNumber magnitude={eq.magnitude} /> </div>
</div> )}
))} <div className="flex flex-col gap-4">
</div> {recents.map((eq) => (
</div> <div key={eq.code} className="flex items-center justify-between p-4 bg-white rounded-xl shadow border">
<p className="mt-20"></p> <div>
<section className="relative z-10 flex flex-col items-start text-left w-5/6 mx-auto px-2 -mt-5 mb-2"> <div className="font-semibold">Earthquake in {eq.location || (eq.code && eq.code.split("-")[2])}</div>
<h1 className="text-3xl md:text-3xl font-bold text-black drop-shadow-lg mb-3 tracking-tight"> <div className="text-sm text-gray-500">{getRelativeDate(eq.date)}</div>
Find Out More! </div>
</h1> <MagnitudeNumber magnitude={eq.magnitude} />
<p className="text-lg md:text-xl text-black drop-shadow-md"> </div>
Explore more of our website... ))}
</p> </div>
</section> </div>
<p className="mt-2"></p> <p className="mt-20"></p>
<div className="flex flex-col md:flex-row md:justify-evenly gap-6 mt-2"> <section className="relative z-10 flex flex-col items-start text-left w-5/6 mx-auto px-2 -mt-5 mb-2">
<Link href="/contact-us" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300"> <h1 className="text-3xl md:text-3xl font-bold text-black drop-shadow-lg mb-3 tracking-tight">Find Out More!</h1>
<Image height={100} width={100} src="/contactUs.jpg" alt="Education Icon" className="h-20 w-20 mb-4" /> <p className="text-lg md:text-xl text-black drop-shadow-md">Explore more of our website...</p>
<h3 className="text-xl font-bold text-black mb-4">Contact us directly</h3> </section>
<p className="text-md text-black text-center max-w-xs opacity-90"> <p className="mt-2"></p>
Visit our socials or leave us a message via phone or email. <div className="flex flex-col md:flex-row md:justify-evenly gap-6 mt-2">
</p> <Link href="/contact-us" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300">
</Link> <Image height={100} width={100} src="/contactUs.jpg" alt="Contact Us Icon" className="h-20 w-20 mb-4" />
<Link href="/our-mission" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300"> <h3 className="text-xl font-bold text-black mb-4">Contact us directly</h3>
<Image height={100} width={100} src="/mission.jpg" alt="Research Icon" className="h-20 w-20 mb-4" /> <p className="text-md text-black text-center max-w-xs opacity-90">
<h3 className="text-xl font-bold text-black mb-4">Our Mission</h3> Visit our socials or leave us a message via phone or email.
<p className="text-md text-black text-center max-w-xs opacity-90"> </p>
Find out more about our purpose and the features we offer. </Link>
</p> <Link href="/our-mission" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300">
</Link> <Image height={100} width={100} src="/mission.jpg" alt="Our Mission Icon" className="h-20 w-20 mb-4" />
<Link href="/the-team" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300"> <h3 className="text-xl font-bold text-black mb-4">Our Mission</h3>
<Image height={100} width={100} src="/team.jpg" alt="Technology Icon" className="h-20 w-20 mb-4" /> <p className="text-md text-black text-center max-w-xs opacity-90">
<h3 className="text-xl font-bold text-black mb-4">Meet the Team</h3> Find out more about our purpose and the features we offer.
<p className="text-md text-black text-center max-w-xs opacity-90"> </p>
Learn about our team leads and their responsibilities. </Link>
</p> <Link href="/the-team" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300">
</Link> <Image height={100} width={100} src="/team.jpg" alt="Team Icon" className="h-20 w-20 mb-4" />
</div> <h3 className="text-xl font-bold text-black mb-4">Meet the Team</h3>
<p className="mt-10"></p> <p className="text-md text-black text-center max-w-xs opacity-90">
<section style={{ height: 500 }} className="text-black"> Learn about our team leads and their responsibilities.
<div className="w-full relative overflow-hidden z=10"> </p>
<div className="flex justify-center"> </Link>
<Image height={400} width={800} alt="Background Image" src="/team.PNG" /> <Link href="/the-team" className="icon-link flex flex-col items-center p-6 rounded-xl transition-colors duration-300">
</div> <Image height={100} width={100} src="/learn.jpg" alt="Learn Icon" className="h-20 w-20 mb-4" />
<BottomFooter /> <h3 className="text-xl font-bold text-black mb-4">Learn</h3>
</div> <p className="text-md text-black text-center max-w-xs opacity-90">
</section> Find out more about earthquakes, what causes them and how to prepare.
</main> </p>
); </Link>
</div>
<p className="mt-10"></p>
<section style={{ height: 500 }} className="text-black">
<div className="w-full relative overflow-hidden z=10">
<div className="flex justify-center">
<Image height={400} width={800} alt="Background Image" src="/team.PNG" />
</div>
<BottomFooter />
</div>
</section>
</main>
);
} }

242
src/app/requests/page.tsx Normal file
View File

@ -0,0 +1,242 @@
"use client";
import React, { useState, useEffect } from "react";
import { useStoreState } from "@hooks/store";
// Helper dicts/types
const outcomeColors: Record<string, string> = {
FULFILLED: "text-green-700 bg-green-100",
REJECTED: "text-red-700 bg-red-100",
IN_PROGRESS: "text-blue-700 bg-blue-100",
CANCELLED: "text-gray-600 bg-gray-100",
OTHER: "text-yellow-800 bg-yellow-100",
};
const requestTypeLabels: Record<string, string> = {
NEW_USER: "New User",
CHANGE_LEVEL: "Change Level",
DELETE: "Removal",
};
function formatDate(val?: string) {
if (!val) return "--";
return new Date(val).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
// Minimal types
type User = {
id: number;
name: string;
email: string;
role?: string;
scientist?: { level: string } | null;
};
type Request = {
id: number;
createdAt: string;
requestType: string;
requestingUser: User;
outcome: string;
};
export default function RequestManagementPage() {
const user = useStoreState((s) => s.user);
// All hooks first!
const [requests, setRequests] = useState<Request[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [actionLoading, setActionLoading] = useState<number | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
// User role logic must remain invariant per render
const userRole = user?.role as string | undefined;
const isAdmin = userRole === "ADMIN";
const isSeniorScientist = userRole === "SCIENTIST" && user?.scientist?.level === "SENIOR";
const userId = user?.id;
// Requests fetch
useEffect(() => {
setLoading(true);
setError(null);
fetch("/api/requests")
.then((res) => {
if (!res.ok) throw new Error("Failed to fetch requests");
return res.json();
})
.then((data) => {
setRequests(data.requests || []);
setLoading(false);
})
.catch((err) => {
setError("Failed to load requests.");
setLoading(false);
});
}, []);
// Filtering for non-admins to only their requests
const filteredRequests = React.useMemo(
() =>
isAdmin
? requests
: requests.filter((r) => r.requestingUser.id === userId),
[isAdmin, requests, userId]
);
// Sorted: newest first
filteredRequests.sort((a, b) =>
(b.createdAt ?? "").localeCompare(a.createdAt ?? "")
);
async function handleAction(
requestId: number,
action: "FULFILLED" | "REJECTED"
) {
setActionLoading(requestId);
setActionError(null);
setActionSuccess(null);
try {
const res = await fetch("/api/requests", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: requestId, outcome: action }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error || "Failed to update request");
}
setRequests((prev) =>
prev.map((r) =>
r.id === requestId ? { ...r, outcome: action } : r
)
);
setActionSuccess("Request updated.");
} catch (err: any) {
setActionError(err?.message || "Failed to update request");
} finally {
setActionLoading(null);
}
}
// Unauthorized access should return early, but not before hooks!
if (!isAdmin && !isSeniorScientist) {
return (
<div className="min-h-[60vh] flex flex-col items-center justify-center">
<h1 className="text-2xl font-bold text-red-600 mb-4">Unauthorized Access</h1>
<div className="text-gray-600">You do not have access to this page.</div>
</div>
);
}
return (
<div className="max-w-5xl mx-auto py-10 px-4">
<div className="mb-8">
<h1 className="text-2xl font-bold">
{isAdmin ? "All Requests" : "My Requests"}
</h1>
<p className="text-gray-600 mt-2">
View {isAdmin ? "and manage pending" : "your"} requests related to scientist management.
</p>
</div>
{loading ? (
<div className="text-gray-500 text-center py-20">Loading...</div>
) : error ? (
<div className="text-red-600 text-center py-10">{error}</div>
) : (
<div className="bg-white rounded-lg shadow p-5 overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b">
<th className="px-4 py-2">Date</th>
<th className="px-4 py-2">Type</th>
<th className="px-4 py-2">Requested By</th>
<th className="px-4 py-2">Status</th>
{isAdmin && <th className="px-4 py-2">Actions</th>}
</tr>
</thead>
<tbody>
{filteredRequests.length === 0 ? (
<tr>
<td
className="py-12 text-center text-gray-400 font-semibold"
colSpan={isAdmin ? 5 : 4}
>
No requests found.
</td>
</tr>
) : (
filteredRequests.map((req) => (
<tr key={req.id} className="border-b last:border-0 hover:bg-neutral-50">
<td className="px-4 py-2 whitespace-nowrap">
{formatDate(req.createdAt)}
</td>
<td className="px-4 py-2">
{requestTypeLabels[req.requestType] || req.requestType}
</td>
<td className="px-4 py-2 whitespace-nowrap">
<span className="font-medium">{req.requestingUser.name}</span>
<span className="ml-1 text-xs text-gray-500">
({req.requestingUser.email})
</span>
</td>
<td className="px-4 py-2">
<span className={
"inline-block px-2 py-1 rounded-xl text-xs font-semibold " +
(outcomeColors[req.outcome] ||
"bg-gray-100 text-gray-600")
}>
{req.outcome.replace(/_/g, " ")}
</span>
</td>
{isAdmin && (
<td className="px-4 py-2">
{req.outcome === "IN_PROGRESS" ? (
<div className="flex gap-2">
<button
onClick={() => handleAction(req.id, "FULFILLED")}
className={
"px-3 py-1 rounded-lg bg-green-600 hover:bg-green-700 text-white" +
(actionLoading === req.id ? " opacity-60" : "")
}
disabled={actionLoading === req.id}
>
Fulfilled
</button>
<button
onClick={() => handleAction(req.id, "REJECTED")}
className={
"px-3 py-1 rounded-lg bg-red-500 hover:bg-red-600 text-white" +
(actionLoading === req.id ? " opacity-60" : "")
}
disabled={actionLoading === req.id}
>
Reject
</button>
</div>
) : (
<span className="text-xs text-gray-400">No actions</span>
)}
{(actionError && actionLoading === req.id) && (
<div className="text-xs text-red-600">{actionError}</div>
)}
{(actionSuccess && actionLoading === req.id) && (
<div className="text-xs text-green-600">{actionSuccess}</div>
)}
</td>
)}
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

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