Merge branch 'master' of ssh://stash.dyson.global.corp:7999/~thowitz/tremor-tracker
This commit is contained in:
commit
bec31f76c0
@ -284,7 +284,7 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* MAIN PANEL */}
|
{/* MAIN PANEL */}
|
||||||
<div className="flex-1 p-10 bg-white overflow-y-auto">
|
<div className="flex-1 p-24 bg-white overflow-y-auto">
|
||||||
{editUser ? (
|
{editUser ? (
|
||||||
<div className="max-w-lg mx-auto bg-white p-6 rounded-lg shadow">
|
<div className="max-w-lg mx-auto bg-white p-6 rounded-lg shadow">
|
||||||
<h2 className="text-lg font-bold mb-6">Edit User</h2>
|
<h2 className="text-lg font-bold mb-6">Edit User</h2>
|
||||||
|
|||||||
705
src/app/management/page.tsx
Normal file
705
src/app/management/page.tsx
Normal file
@ -0,0 +1,705 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
|
||||||
|
type Level = "JUNIOR" | "SENIOR";
|
||||||
|
const levelLabels: Record<Level, string> = { 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 Scientist = {
|
||||||
|
id: number;
|
||||||
|
createdAt: string;
|
||||||
|
name: string;
|
||||||
|
level: Level;
|
||||||
|
user: User;
|
||||||
|
userId: User["id"];
|
||||||
|
superior: Scientist | null;
|
||||||
|
superiorId: Scientist["id"] | 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[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
createdAt: "2024-06-01T09:00:00Z",
|
||||||
|
name: "Dr. John Junior",
|
||||||
|
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,
|
||||||
|
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" },
|
||||||
|
] as const;
|
||||||
|
type SortField = (typeof sortFields)[number]["value"];
|
||||||
|
type SortDir = "asc" | "desc";
|
||||||
|
const dirLabels: Record<SortDir, string> = { asc: "ascending", desc: "descending" };
|
||||||
|
const fieldLabels: Record<SortField, string> = { name: "Name", level: "Level" };
|
||||||
|
|
||||||
|
export default function Scientist() {
|
||||||
|
const [scientists, setScientists] = useState<Scientist[]>(scientistList);
|
||||||
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
|
const [editScientist, setEditScientist] = useState<Scientist | null>(null);
|
||||||
|
React.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<SortField>("name");
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [levelFilter, setLevelFilter] = useState<Level | "all">("all");
|
||||||
|
const [sortField, setSortField] = useState<SortField>("name");
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||||
|
const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
|
||||||
|
const [sortDropdownOpen, setSortDropdownOpen] = useState(false);
|
||||||
|
const filterDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const sortDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
if (filterDropdownRef.current && !filterDropdownRef.current.contains(e.target as Node)) setFilterDropdownOpen(false);
|
||||||
|
if (sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as Node)) setSortDropdownOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClick);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
|
}, []);
|
||||||
|
const filtered = scientists.filter((s) => levelFilter === "all" || s.level === levelFilter);
|
||||||
|
const searched = filtered.filter((s) => s[searchField].toLowerCase().includes(searchText.toLowerCase()));
|
||||||
|
const sorted = [...searched].sort((a, b) => {
|
||||||
|
let cmp = a[sortField].localeCompare(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<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
|
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));
|
||||||
|
} else if (name === "userId") {
|
||||||
|
const user = users.find((u) => u.id === Number(value));
|
||||||
|
setEditScientist((prev) => (prev && user ? { ...prev, user, userId: user.id } : prev));
|
||||||
|
} else {
|
||||||
|
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) => {
|
||||||
|
e.preventDefault();
|
||||||
|
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
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex h-full overflow-hidden bg-gray-50">
|
||||||
|
{/* SIDEBAR */}
|
||||||
|
<div className="w-80 h-full border-r bg-neutral-100 flex flex-col rounded-l-xl shadow-sm">
|
||||||
|
<div className="p-4 flex flex-col h-full">
|
||||||
|
<div className="mb-3 flex gap-2">
|
||||||
|
<input
|
||||||
|
className="flex-1 border rounded-lg px-2 py-1 text-sm"
|
||||||
|
placeholder={`Search by ${searchField}`}
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="border rounded-lg px-2 py-1 text-sm bg-white hover:bg-neutral-100 transition font-semibold"
|
||||||
|
style={{ width: "80px" }}
|
||||||
|
onClick={() => setSearchField((field) => (field === "name" ? "level" : "name"))}
|
||||||
|
title={`Switch to searching by ${searchField === "name" ? "Level" : "Name"}`}
|
||||||
|
>
|
||||||
|
{searchField === "name" ? "Level" : "Name"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center mb-2">
|
||||||
|
{/* Filter dropdown */}
|
||||||
|
<div className="relative" ref={filterDropdownRef}>
|
||||||
|
<button
|
||||||
|
className={`px-3 py-1 rounded-lg border font-semibold flex items-center transition
|
||||||
|
${
|
||||||
|
levelFilter !== "all"
|
||||||
|
? "bg-blue-600 text-white border-blue-600 hover:bg-blue-700"
|
||||||
|
: "bg-white text-gray-700 border hover:bg-neutral-200"
|
||||||
|
}`}
|
||||||
|
onClick={() => setFilterDropdownOpen((v) => !v)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Filter
|
||||||
|
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{filterDropdownOpen && (
|
||||||
|
<div className="absolute z-10 mt-1 left-0 bg-white border rounded-lg shadow-sm w-28 py-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLevelFilter("all");
|
||||||
|
setFilterDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className={`w-full text-left px-3 py-1 hover:bg-blue-50 border-b border-gray-100 last:border-0
|
||||||
|
${levelFilter === "all" ? "font-bold text-blue-600" : ""}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{allLevels.map((level) => (
|
||||||
|
<button
|
||||||
|
key={level}
|
||||||
|
onClick={() => {
|
||||||
|
setLevelFilter(level);
|
||||||
|
setFilterDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className={`w-full text-left px-3 py-1 hover:bg-blue-50 border-b border-gray-100 last:border-0
|
||||||
|
${levelFilter === level ? "font-bold text-blue-600" : ""}`}
|
||||||
|
>
|
||||||
|
{levelLabels[level]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* sort dropdown */}
|
||||||
|
<div className="relative" ref={sortDropdownRef}>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200"
|
||||||
|
onClick={() => setSortDropdownOpen((v) => !v)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Sort
|
||||||
|
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{sortDropdownOpen && (
|
||||||
|
<div className="absolute z-10 mt-1 left-0 bg-white border rounded-lg shadow-sm w-28 ">
|
||||||
|
{sortFields.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => {
|
||||||
|
setSortField(opt.value);
|
||||||
|
setSortDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className={`w-full text-left px-3 py-2 hover:bg-blue-50 border-b border-gray-100 last:border-0
|
||||||
|
${sortField === opt.value ? "font-bold text-blue-600" : ""}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="ml-2 px-2 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200"
|
||||||
|
onClick={() => setSortDir((d) => (d === "asc" ? "desc" : "asc"))}
|
||||||
|
title={sortDir === "asc" ? "Ascending" : "Descending"}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{sortDir === "asc" ? "↑" : "↓"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small className="text-xs text-gray-500 mb-2 px-1">
|
||||||
|
Scientists sorted by {fieldLabels[sortField]} {dirLabels[sortDir]}
|
||||||
|
</small>
|
||||||
|
<ul className="overflow-y-auto flex-1 pr-1">
|
||||||
|
{sorted.map((sci) => (
|
||||||
|
<li
|
||||||
|
key={sci.id}
|
||||||
|
onClick={() => setSelectedId(sci.id)}
|
||||||
|
className={`rounded-lg cursor-pointer border
|
||||||
|
${selectedId === sci.id ? "bg-blue-100 border-blue-400" : "hover:bg-gray-200 border-transparent"}
|
||||||
|
transition px-2 py-1 mb-1`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium truncate">{sci.name}</span>
|
||||||
|
<span className="ml-1 text-xs px-2 py-0.5 rounded-lg bg-gray-200 text-gray-700">{levelLabels[sci.level]}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-0.5">
|
||||||
|
<span className="text-xs text-gray-600 truncate">{sci.user.name} ({sci.user.email})</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{sorted.length === 0 && <li className="text-gray-400 text-center py-6">No scientists found.</li>}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* MAIN PANEL */}
|
||||||
|
<div className="flex-1 flex justify-center p-24 bg-white overflow-y-auto">
|
||||||
|
{editScientist ? (
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
max-w-4xl w-full bg-white rounded-xl shadow flex flex-col pt-4 pb-3 px-5
|
||||||
|
"
|
||||||
|
style={{
|
||||||
|
minHeight: 0,
|
||||||
|
maxHeight: 780,
|
||||||
|
overflow: "hidden"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Heading */}
|
||||||
|
<div className="text-xl font-bold text-gray-900 mb-3 px-1">Edit Scientist</div>
|
||||||
|
<form className="flex flex-col flex-1 min-h-0" onSubmit={handleUpdate}>
|
||||||
|
<div className="flex flex-col lg:flex-row gap-8 min-h-0 flex-1">
|
||||||
|
{/* LEFT COLUMN */}
|
||||||
|
<div className="flex-1 flex flex-col gap-4 min-h-0 overflow-y-auto">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-400">Created at</div>
|
||||||
|
<div className="font-mono text-sm">{editScientist.createdAt}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-400">ID</div>
|
||||||
|
<div className="font-mono text-sm">{editScientist.id}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Name:</label>
|
||||||
|
<input
|
||||||
|
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
value={editScientist.name}
|
||||||
|
onChange={handleEditChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Level:</label>
|
||||||
|
<select
|
||||||
|
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||||
|
name="level"
|
||||||
|
value={editScientist.level}
|
||||||
|
onChange={handleEditChange}
|
||||||
|
>
|
||||||
|
{allLevels.map((lvl) => (
|
||||||
|
<option key={lvl} value={lvl}>{levelLabels[lvl]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">User (email):</label>
|
||||||
|
<select
|
||||||
|
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||||
|
name="userId"
|
||||||
|
value={editScientist.userId}
|
||||||
|
onChange={handleEditChange}
|
||||||
|
>
|
||||||
|
{allUsers.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.name} ({u.email})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Superior:</label>
|
||||||
|
<select
|
||||||
|
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||||
|
name="superiorId"
|
||||||
|
value={editScientist.superiorId ?? ""}
|
||||||
|
onChange={handleEditChange}
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
{allOtherScientistOptions(editScientist.id).map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Subordinates:</label>
|
||||||
|
<div className="flex flex-wrap gap-1 bg-gray-200 rounded px-2 py-2 min-h-[28px]">
|
||||||
|
{editScientist.subordinates.length > 0
|
||||||
|
? editScientist.subordinates.map((s) => (
|
||||||
|
<span
|
||||||
|
key={s.id}
|
||||||
|
className="px-2 py-1 rounded-full bg-gray-300 text-gray-700 text-xs font-medium"
|
||||||
|
>
|
||||||
|
{s.name}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
: <span className="text-sm text-gray-400">None</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* RIGHT COLUMN */}
|
||||||
|
<div className="flex-1 flex flex-col gap-4 min-h-0 overflow-y-auto">
|
||||||
|
{/* Observatories Box */}
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex justify-between items-end">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Observatories:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="ml-2 text-xs border rounded px-2 py-1"
|
||||||
|
placeholder="Search by name/location/id..."
|
||||||
|
value={observatoryQuery}
|
||||||
|
onChange={(e) => setObservatoryQuery(e.target.value)}
|
||||||
|
style={{maxWidth: "55%"}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="border rounded px-2 py-1 mt-1 bg-gray-50 flex-1"
|
||||||
|
style={{
|
||||||
|
minHeight: 160,
|
||||||
|
maxHeight: 230,
|
||||||
|
overflowY: "auto"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{searchedObservatories.length === 0 ? (
|
||||||
|
<div className="text-xs text-gray-400 text-center py-2">No observatories</div>
|
||||||
|
) : (
|
||||||
|
searchedObservatories.map((obs) => (
|
||||||
|
<label key={obs.id} className="block text-xs py-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mr-2"
|
||||||
|
checked={editScientist.observatoryIds.includes(obs.id)}
|
||||||
|
onChange={() => handleObservatoryCheck(obs.id)}
|
||||||
|
/>
|
||||||
|
#{obs.id} {obs.name} ({obs.location})
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Earthquakes Box */}
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex justify-between items-end">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Earthquakes:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="ml-2 text-xs border rounded px-2 py-1"
|
||||||
|
placeholder="Search ID or code..."
|
||||||
|
value={earthquakeQuery}
|
||||||
|
onChange={(e) => setEarthquakeQuery(e.target.value)}
|
||||||
|
style={{maxWidth: "55%"}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="border rounded px-2 py-1 mt-1 bg-gray-50 flex-1"
|
||||||
|
style={{
|
||||||
|
minHeight: 160,
|
||||||
|
maxHeight: 230,
|
||||||
|
overflowY: "auto"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{searchedEarthquakes.length === 0 ? (
|
||||||
|
<div className="text-xs text-gray-400 text-center py-2">No earthquakes</div>
|
||||||
|
) : (
|
||||||
|
searchedEarthquakes.map((eq) => (
|
||||||
|
<label key={eq.id} className="block text-xs py-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mr-2"
|
||||||
|
checked={editScientist.earthquakeIds.includes(eq.id)}
|
||||||
|
onChange={() => handleEarthquakeCheck(eq.id)}
|
||||||
|
/>
|
||||||
|
#{eq.id} ({eq.code}) – {eq.location}
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Artefacts Box */}
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex justify-between items-end">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Artefacts:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="ml-2 text-xs border rounded px-2 py-1"
|
||||||
|
placeholder="Search ID or name..."
|
||||||
|
value={artefactQuery}
|
||||||
|
onChange={(e) => setArtefactQuery(e.target.value)}
|
||||||
|
style={{maxWidth: "55%"}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="border rounded px-2 py-1 mt-1 bg-gray-50 flex-1"
|
||||||
|
style={{
|
||||||
|
minHeight: 160,
|
||||||
|
maxHeight: 230,
|
||||||
|
overflowY: "auto"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{searchedArtefacts.length === 0 ? (
|
||||||
|
<div className="text-xs text-gray-400 text-center py-2">No artefacts</div>
|
||||||
|
) : (
|
||||||
|
searchedArtefacts.map((a) => (
|
||||||
|
<label key={a.id} className="block text-xs py-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mr-2"
|
||||||
|
checked={editScientist.artefactIds.includes(a.id)}
|
||||||
|
onChange={() => handleArtefactCheck(a.id)}
|
||||||
|
/>
|
||||||
|
#{a.id} {a.name} ({a.type})
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* BUTTONS */}
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-4 py-2 bg-red-500 hover:bg-red-600 text-white font-semibold rounded-lg shadow transition"
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`px-4 py-2 rounded-lg font-semibold transition
|
||||||
|
${
|
||||||
|
isEditChanged
|
||||||
|
? "bg-blue-600 hover:bg-blue-700 text-white shadow"
|
||||||
|
: "bg-gray-300 text-gray-500 cursor-not-allowed"
|
||||||
|
}`}
|
||||||
|
disabled={!isEditChanged}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-gray-400 mt-16 text-lg">Select a scientist...</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -133,6 +133,11 @@ export default function Navbar({}: // currencySelector,
|
|||||||
<ManagementNavbarButton name="Warehouse" href="/warehouse"></ManagementNavbarButton>
|
<ManagementNavbarButton name="Warehouse" href="/warehouse"></ManagementNavbarButton>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{user && (user.role === "SCIENTIST" || user.role === "ADMIN") && (
|
||||||
|
<div className="flex h-full mr-5">
|
||||||
|
<ManagementNavbarButton name="Scientist Management" href="/management"></ManagementNavbarButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{user && user.role === "ADMIN" && (
|
{user && user.role === "ADMIN" && (
|
||||||
<div className="flex h-full mr-5">
|
<div className="flex h-full mr-5">
|
||||||
<ManagementNavbarButton name="Admin" href="/administrator"></ManagementNavbarButton>
|
<ManagementNavbarButton name="Admin" href="/administrator"></ManagementNavbarButton>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user