Merge branch 'master' of ssh://stash.dyson.global.corp:7999/~thowitz/tremor-tracker
This commit is contained in:
commit
8d3575591d
154
package-lock.json
generated
154
package-lock.json
generated
@ -28,6 +28,7 @@
|
|||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"mapbox-gl": "^3.10.0",
|
"mapbox-gl": "^3.10.0",
|
||||||
"next": "^15.1.7",
|
"next": "^15.1.7",
|
||||||
|
"next-auth": "^4.24.11",
|
||||||
"path": "^0.12.7",
|
"path": "^0.12.7",
|
||||||
"prisma": "^6.4.1",
|
"prisma": "^6.4.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
@ -1070,6 +1071,15 @@
|
|||||||
"node": ">=12.4.0"
|
"node": ">=12.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@panva/hkdf": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@pkgjs/parseargs": {
|
"node_modules/@pkgjs/parseargs": {
|
||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
@ -5670,6 +5680,47 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/next-auth": {
|
||||||
|
"version": "4.24.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.11.tgz",
|
||||||
|
"integrity": "sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.20.13",
|
||||||
|
"@panva/hkdf": "^1.0.2",
|
||||||
|
"cookie": "^0.7.0",
|
||||||
|
"jose": "^4.15.5",
|
||||||
|
"oauth": "^0.9.15",
|
||||||
|
"openid-client": "^5.4.0",
|
||||||
|
"preact": "^10.6.3",
|
||||||
|
"preact-render-to-string": "^5.1.19",
|
||||||
|
"uuid": "^8.3.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@auth/core": "0.34.2",
|
||||||
|
"next": "^12.2.5 || ^13 || ^14 || ^15",
|
||||||
|
"nodemailer": "^6.6.5",
|
||||||
|
"react": "^17.0.2 || ^18 || ^19",
|
||||||
|
"react-dom": "^17.0.2 || ^18 || ^19"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@auth/core": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"nodemailer": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/next-auth/node_modules/jose": {
|
||||||
|
"version": "4.15.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||||
|
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/next/node_modules/postcss": {
|
"node_modules/next/node_modules/postcss": {
|
||||||
"version": "8.4.31",
|
"version": "8.4.31",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||||
@ -5708,6 +5759,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/oauth": {
|
||||||
|
"version": "0.9.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
|
||||||
|
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@ -5840,6 +5897,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/oidc-token-hash": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^10.13.0 || >=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/on-finished": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
@ -5861,6 +5927,51 @@
|
|||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/openid-client": {
|
||||||
|
"version": "5.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||||
|
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jose": "^4.15.9",
|
||||||
|
"lru-cache": "^6.0.0",
|
||||||
|
"object-hash": "^2.2.0",
|
||||||
|
"oidc-token-hash": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openid-client/node_modules/jose": {
|
||||||
|
"version": "4.15.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||||
|
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openid-client/node_modules/lru-cache": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"yallist": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openid-client/node_modules/object-hash": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@ -6212,6 +6323,28 @@
|
|||||||
"integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==",
|
"integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/preact": {
|
||||||
|
"version": "10.26.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.26.8.tgz",
|
||||||
|
"integrity": "sha512-1nMfdFjucm5hKvq0IClqZwK4FJkGXhRrQstOQ3P4vp8HxKrJEMFcY6RdBRVTdfQS/UlnX6gfbPuTvaqx/bDoeQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/preact-render-to-string": {
|
||||||
|
"version": "5.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz",
|
||||||
|
"integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pretty-format": "^3.8.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"preact": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@ -6222,6 +6355,12 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pretty-format": {
|
||||||
|
"version": "3.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
|
||||||
|
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/prisma": {
|
"node_modules/prisma": {
|
||||||
"version": "6.8.2",
|
"version": "6.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.8.2.tgz",
|
||||||
@ -7927,6 +8066,15 @@
|
|||||||
"integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
|
"integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "8.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||||
|
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
@ -8163,6 +8311,12 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/yallist": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/yaml": {
|
||||||
"version": "2.8.0",
|
"version": "2.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
|
||||||
|
|||||||
@ -31,6 +31,7 @@
|
|||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"mapbox-gl": "^3.10.0",
|
"mapbox-gl": "^3.10.0",
|
||||||
"next": "^15.1.7",
|
"next": "^15.1.7",
|
||||||
|
"next-auth": "^4.24.11",
|
||||||
"path": "^0.12.7",
|
"path": "^0.12.7",
|
||||||
"prisma": "^6.4.1",
|
"prisma": "^6.4.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"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";
|
type Role = "ADMIN" | "GUEST" | "SCIENTIST";
|
||||||
const roleLabels: Record<Role, string> = {
|
const roleLabels: Record<Role, string> = {
|
||||||
ADMIN: "Admin",
|
ADMIN: "Admin",
|
||||||
@ -12,147 +14,233 @@ type User = {
|
|||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: Role;
|
role: Role;
|
||||||
password: string;
|
|
||||||
createdAt: string;
|
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[] = [
|
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",
|
email: "users-loading@admin.api",
|
||||||
name: "Bob Brown",
|
name: "Loading Users",
|
||||||
role: "SCIENTIST",
|
role: "ADMIN",
|
||||||
password: "secret3",
|
createdAt: "Check admin api and frontend",
|
||||||
createdAt: "2024-06-21T12:13:45Z",
|
id: 0,
|
||||||
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 },
|
|
||||||
];
|
];
|
||||||
const sortFields = [
|
const sortFields = [
|
||||||
// Sort box options
|
|
||||||
{ label: "Name", value: "name" },
|
{ label: "Name", value: "name" },
|
||||||
{ label: "Email", value: "email" },
|
{ label: "Email", value: "email" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type SortField = (typeof sortFields)[number]["value"];
|
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", email: "Email" };
|
const fieldLabels: Record<SortField, string> = { name: "Name", email: "Email" };
|
||||||
|
|
||||||
|
// =========== THE PAGE =============
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const [users, setUsers] = useState<User[]>(initialUsers);
|
// ---- All hooks at the top!
|
||||||
const [selectedEmail, setSelectedEmail] = useState<string | null>(null);
|
const user = useStoreState((state) => state.user);
|
||||||
|
|
||||||
// Local edit state for SCIENTIST form
|
const [selectedEmail, setSelectedEmail] = useState<string | null>(null);
|
||||||
|
const [users, setUsers] = useState<User[]>(initialUsers);
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [addForm, setAddForm] = useState<{ name: string; email: string; role: Role; password: string }>({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
role: "SCIENTIST",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
const [addError, setAddError] = useState<string | null>(null);
|
||||||
|
const [addLoading, setAddLoading] = useState(false);
|
||||||
const [editUser, setEditUser] = useState<User | null>(null);
|
const [editUser, setEditUser] = useState<User | null>(null);
|
||||||
// Reset editUser when the selected user changes
|
const [searchField, setSearchField] = useState<"name" | "email">("name");
|
||||||
React.useEffect(() => {
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [roleFilter, setRoleFilter] = useState<Role | "all">("all");
|
||||||
|
const [sortField, setSortField] = useState<SortField>("name");
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||||
|
const [newPassword, setNewPassword] = useState<string>("");
|
||||||
|
const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
|
||||||
|
const [sortDropdownOpen, setSortDropdownOpen] = useState(false);
|
||||||
|
const filterDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const sortDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [showEmailTooltip, setShowEmailTooltip] = useState(false);
|
||||||
|
|
||||||
|
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);
|
if (!selectedEmail) setEditUser(null);
|
||||||
else {
|
else {
|
||||||
const user = users.find((u) => u.email === selectedEmail);
|
const user = users.find((u) => u.email === selectedEmail);
|
||||||
setEditUser(user ? { ...user } : null);
|
setEditUser(user ? { ...user } : null);
|
||||||
}
|
}
|
||||||
}, [selectedEmail, users]);
|
}, [selectedEmail, users]);
|
||||||
|
|
||||||
// Search/filter/sort state
|
|
||||||
const [searchField, setSearchField] = useState<"name" | "email">("name");
|
|
||||||
const [searchText, setSearchText] = useState("");
|
|
||||||
const [roleFilter, setRoleFilter] = useState<Role | "all">("all");
|
|
||||||
const [sortField, setSortField] = useState<SortField>("name");
|
|
||||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
|
||||||
// Dropdown states
|
|
||||||
const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
|
|
||||||
const [sortDropdownOpen, setSortDropdownOpen] = useState(false);
|
|
||||||
const filterDropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
const sortDropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
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);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Filtering, searching, sorting logic
|
// Filtering, searching, sorting logic
|
||||||
const filteredUsers = users.filter((user) => roleFilter === "all" || user.role === roleFilter);
|
const filteredUsers = users.filter(
|
||||||
const searchedUsers = filteredUsers.filter((user) => user[searchField].toLowerCase().includes(searchText.toLowerCase()));
|
(user) => roleFilter === "all" || user.role === roleFilter
|
||||||
|
);
|
||||||
|
const searchedUsers = filteredUsers.filter((user) =>
|
||||||
|
user[searchField].toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
);
|
||||||
const sortedUsers = [...searchedUsers].sort((a, b) => {
|
const sortedUsers = [...searchedUsers].sort((a, b) => {
|
||||||
let cmp = a[sortField].localeCompare(b[sortField]);
|
let cmp = a[sortField].localeCompare(b[sortField]);
|
||||||
return sortDir === "asc" ? cmp : -cmp;
|
return sortDir === "asc" ? cmp : -cmp;
|
||||||
});
|
});
|
||||||
|
async function handleAddUser(e: React.FormEvent) {
|
||||||
// Form input change handler
|
e.preventDefault();
|
||||||
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
setAddError(null);
|
||||||
|
if (!addForm.name || !addForm.email || !addForm.password) {
|
||||||
|
setAddError("All fields are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setAddLoading(true);
|
||||||
|
const res = await fetch("/api/admin", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(addForm),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data?.error || "Failed to add user");
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setUsers((prev) => [...prev, data.user]);
|
||||||
|
setAddOpen(false);
|
||||||
|
setAddForm({ name: "", email: "", role: "SCIENTIST", password: "" });
|
||||||
|
} catch (err: any) {
|
||||||
|
setAddError(err?.message || "Unknown error");
|
||||||
|
} finally {
|
||||||
|
setAddLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleEditChange = (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||||
|
) => {
|
||||||
|
if (!editUser) return;
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setEditUser((prev) => (prev ? { ...prev, [name]: value } : null));
|
||||||
|
};
|
||||||
|
const handlePasswordChange = (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||||
|
) => {
|
||||||
if (!editUser) return;
|
if (!editUser) return;
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setEditUser((prev) => (prev ? { ...prev, [name]: value } : null));
|
setEditUser((prev) => (prev ? { ...prev, [name]: value } : null));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update button logic (compare original selectedUser and editUser)
|
|
||||||
const selectedUser = users.find((u) => u.email === selectedEmail);
|
const selectedUser = users.find((u) => u.email === selectedEmail);
|
||||||
const isEditChanged = React.useMemo(() => {
|
const isEditChanged = React.useMemo(() => {
|
||||||
if (!editUser || !selectedUser) return false;
|
if (!editUser || !selectedUser) return false;
|
||||||
// Compare primitive fields
|
|
||||||
return (
|
return (
|
||||||
editUser.name !== selectedUser.name || editUser.role !== selectedUser.role || editUser.password !== selectedUser.password
|
editUser.name !== selectedUser.name ||
|
||||||
|
editUser.role !== selectedUser.role ||
|
||||||
|
newPassword
|
||||||
);
|
);
|
||||||
}, [editUser, selectedUser]);
|
}, [editUser, selectedUser, newPassword]);
|
||||||
|
async function updateUserOnServer(user: User, password: string) {
|
||||||
// Update/save changes
|
const body: any = {
|
||||||
const handleUpdate = (e: React.FormEvent) => {
|
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();
|
e.preventDefault();
|
||||||
if (!editUser) return;
|
if (!editUser) return;
|
||||||
setUsers((prev) => prev.map((u) => (u.email === editUser.email ? { ...editUser } : u)));
|
try {
|
||||||
// todo create receiving api route
|
const updated = await updateUserOnServer(editUser, newPassword);
|
||||||
// todo send to api route
|
setUsers((prev) =>
|
||||||
// After successful update, update selectedUser local state
|
prev.map((u) => (u.id === updated.id ? { ...updated } : u))
|
||||||
// (editUser will auto-sync due to useEffect on users)
|
);
|
||||||
|
setNewPassword("");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update user:", err);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
const handleDelete = async () => {
|
||||||
// Delete user logic
|
|
||||||
const handleDelete = () => {
|
|
||||||
if (!selectedUser) return;
|
if (!selectedUser) return;
|
||||||
if (!window.confirm(`Are you sure you want to delete "${selectedUser.name}"? This cannot be undone.`)) return;
|
if (
|
||||||
setUsers((prev) => prev.filter((u) => u.email !== selectedUser.email));
|
!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);
|
setSelectedEmail(null);
|
||||||
setEditUser(null);
|
setEditUser(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err?.message || "Delete failed!");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const allRoles: Role[] = ["ADMIN", "GUEST", "SCIENTIST"];
|
const allRoles: Role[] = ["ADMIN", "GUEST", "SCIENTIST"];
|
||||||
|
|
||||||
// Tooltip handling for email field
|
// --- ADMIN ONLY:
|
||||||
const [showEmailTooltip, setShowEmailTooltip] = useState(false);
|
if (!user || user.role !== "ADMIN") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[70vh] flex-col">
|
||||||
|
<h1 className="text-2xl font-bold text-red-500 mb-4">
|
||||||
|
Unauthorized Access
|
||||||
|
</h1>
|
||||||
|
<div className="text-gray-600">You do not have access to this page.</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Render admin UI
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex h-full overflow-hidden bg-gray-50">
|
<div className="flex h-full overflow-hidden bg-gray-50">
|
||||||
{/* SIDEBAR */}
|
{/* SIDEBAR */}
|
||||||
<div className="w-80 h-full border-r border-neutral-200 bg-neutral-100 flex flex-col rounded-l-xl shadow-sm">
|
<div className="w-80 h-full border-r border-neutral-200 bg-neutral-100 flex flex-col rounded-l-xl shadow-sm">
|
||||||
<div className="p-4 flex flex-col h-full">
|
<div className="p-4 flex flex-col h-full">
|
||||||
{/* Search Bar */}
|
{/* Search, filter, sort controls ... (your code unchanged) */}
|
||||||
<div className="mb-3 flex gap-2">
|
<div className="mb-3 flex gap-2">
|
||||||
<input
|
<input
|
||||||
className="flex-1 border rounded-lg px-2 py-1 text-sm"
|
className="flex-1 border rounded-lg px-2 py-1 text-sm"
|
||||||
@ -163,21 +251,19 @@ export default function AdminPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="border rounded-lg px-2 py-1 text-sm bg-white hover:bg-neutral-100 transition font-semibold"
|
className="border rounded-lg px-2 py-1 text-sm bg-white hover:bg-neutral-100 transition font-semibold"
|
||||||
style={{ width: "80px" }} // fixed width, adjust as needed
|
style={{ width: "80px" }}
|
||||||
onClick={() => setSearchField((field) => (field === "name" ? "email" : "name"))}
|
onClick={() => setSearchField((field) => (field === "name" ? "email" : "name"))}
|
||||||
title={`Switch to searching by ${searchField === "name" ? "Email" : "Name"}`}
|
title={`Switch to searching by ${searchField === "name" ? "Email" : "Name"}`}
|
||||||
>
|
>
|
||||||
{searchField === "name" ? "Email" : "Name"}
|
{searchField === "name" ? "Email" : "Name"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* Filter and Sort Buttons */}
|
|
||||||
<div className="flex gap-2 items-center mb-2">
|
<div className="flex gap-2 items-center mb-2">
|
||||||
{/* Filter */}
|
{/* Filter */}
|
||||||
<div className="relative" ref={filterDropdownRef}>
|
<div className="relative" ref={filterDropdownRef}>
|
||||||
<button
|
<button
|
||||||
className={`px-3 py-1 rounded-lg border font-semibold flex items-center transition
|
className={`px-3 py-1 rounded-lg border font-semibold flex items-center transition
|
||||||
${
|
${roleFilter !== "all"
|
||||||
roleFilter !== "all"
|
|
||||||
? "bg-blue-600 text-white border-blue-600 hover:bg-blue-700"
|
? "bg-blue-600 text-white border-blue-600 hover:bg-blue-700"
|
||||||
: "bg-white text-gray-700 border hover:bg-neutral-200"
|
: "bg-white text-gray-700 border hover:bg-neutral-200"
|
||||||
}
|
}
|
||||||
@ -257,12 +343,24 @@ export default function AdminPage() {
|
|||||||
>
|
>
|
||||||
{sortDir === "asc" ? "↑" : "↓"}
|
{sortDir === "asc" ? "↑" : "↓"}
|
||||||
</button>
|
</button>
|
||||||
|
{/* ADD BUTTON */}
|
||||||
|
<button
|
||||||
|
className="ml-2 px-2 py-1 rounded-lg bg-green-600 hover:bg-green-700 text-white font-bold flex items-center shadow transition duration-150"
|
||||||
|
type="button"
|
||||||
|
style={{ minWidth: 36, minHeight: 36 }}
|
||||||
|
onClick={() => setAddOpen(true)}
|
||||||
|
disabled={addOpen}
|
||||||
|
title="Add user"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth={2.2} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m7-7H5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* Sort status text */}
|
|
||||||
<small className="text-xs text-gray-500 mb-2 px-1">
|
<small className="text-xs text-gray-500 mb-2 px-1">
|
||||||
Users sorted by {fieldLabels[sortField]} {dirLabels[sortDir]}
|
Users sorted by {fieldLabels[sortField]} {dirLabels[sortDir]}
|
||||||
</small>
|
</small>
|
||||||
{/* USERS LIST: full height, scrollable */}
|
{/* USERS LIST */}
|
||||||
<ul className="overflow-y-auto flex-1 pr-1">
|
<ul className="overflow-y-auto flex-1 pr-1">
|
||||||
{sortedUsers.map((user) => (
|
{sortedUsers.map((user) => (
|
||||||
<li
|
<li
|
||||||
@ -287,20 +385,98 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
{/* 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">
|
||||||
|
{/* Add User Modal */}
|
||||||
|
{addOpen && (
|
||||||
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black bg-opacity-30">
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-8 max-w-sm w-full relative">
|
||||||
|
<h3 className="text-lg font-bold mb-4">Add New User</h3>
|
||||||
|
<form onSubmit={handleAddUser} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
className="w-full border px-2 py-1 rounded-lg"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={addForm.email}
|
||||||
|
onChange={e => setAddForm(f => ({ ...f, email: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Name</label>
|
||||||
|
<input
|
||||||
|
className="w-full border px-2 py-1 rounded-lg"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={addForm.name}
|
||||||
|
onChange={e => setAddForm(f => ({ ...f, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Role</label>
|
||||||
|
<select
|
||||||
|
className="w-full border px-2 py-1 rounded-lg"
|
||||||
|
value={addForm.role}
|
||||||
|
onChange={e => setAddForm(f => ({ ...f, role: e.target.value as Role }))}
|
||||||
|
>
|
||||||
|
{allRoles.map(role => (
|
||||||
|
<option value={role} key={role}>{roleLabels[role]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
className="w-full border px-2 py-1 rounded-lg"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={addForm.password}
|
||||||
|
onChange={e => setAddForm(f => ({ ...f, password: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{addError && <div className="text-red-600 text-xs">{addError}</div>}
|
||||||
|
<div className="flex gap-2 justify-end pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-3 py-1 rounded-lg bg-gray-200 text-gray-700 hover:bg-gray-300 transition"
|
||||||
|
onClick={() => setAddOpen(false)}
|
||||||
|
disabled={addLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`px-3 py-1 rounded-lg text-white font-semibold ${addLoading ? "bg-green-400" : "bg-green-600 hover:bg-green-700"}`}
|
||||||
|
disabled={addLoading}
|
||||||
|
>
|
||||||
|
{addLoading ? "Adding..." : "Add"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit User Panel */}
|
||||||
{editUser ? (
|
{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>
|
||||||
<form className="space-y-4" onSubmit={handleUpdate}>
|
<form className="space-y-4" onSubmit={handleUpdate}>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<label className="text-sm font-medium text-gray-700">Account Creation Time:</label>
|
<label className="text-sm font-medium text-gray-700">
|
||||||
|
Account Creation Time:
|
||||||
|
</label>
|
||||||
<span className="text-sm text-gray-500">{editUser.createdAt}</span>
|
<span className="text-sm text-gray-500">{editUser.createdAt}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<label className="text-sm font-medium text-gray-700">Account ID Number:</label>
|
<label className="text-sm font-medium text-gray-700">
|
||||||
|
Account ID Number:
|
||||||
|
</label>
|
||||||
<span className="text-sm text-gray-500">{editUser.id}</span>
|
<span className="text-sm text-gray-500">{editUser.id}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email (unique):</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Email (unique):
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
className="w-full border px-3 py-2 rounded-lg outline-none bg-gray-100 cursor-not-allowed"
|
className="w-full border px-3 py-2 rounded-lg outline-none bg-gray-100 cursor-not-allowed"
|
||||||
type="email"
|
type="email"
|
||||||
@ -310,7 +486,6 @@ export default function AdminPage() {
|
|||||||
onMouseEnter={() => setShowEmailTooltip(true)}
|
onMouseEnter={() => setShowEmailTooltip(true)}
|
||||||
onMouseLeave={() => setShowEmailTooltip(false)}
|
onMouseLeave={() => setShowEmailTooltip(false)}
|
||||||
/>
|
/>
|
||||||
{/* Custom tooltip */}
|
|
||||||
{showEmailTooltip && (
|
{showEmailTooltip && (
|
||||||
<div className="absolute left-0 top-full mt-1 z-10 w-max max-w-xs bg-gray-800 text-gray-100 text-xs px-3 py-2 rounded-md shadow-lg border border-gray-700">
|
<div className="absolute left-0 top-full mt-1 z-10 w-max max-w-xs bg-gray-800 text-gray-100 text-xs px-3 py-2 rounded-md shadow-lg border border-gray-700">
|
||||||
This field cannot be changed. <br />
|
This field cannot be changed. <br />
|
||||||
@ -319,7 +494,9 @@ export default function AdminPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name:</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Name:
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||||
type="text"
|
type="text"
|
||||||
@ -329,7 +506,9 @@ export default function AdminPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Role:</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Role:
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||||
name="role"
|
name="role"
|
||||||
@ -344,13 +523,15 @@ export default function AdminPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password:</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Password:
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||||
type="text"
|
type="text"
|
||||||
name="password"
|
name="password"
|
||||||
value={editUser.password}
|
value={newPassword}
|
||||||
onChange={handleEditChange}
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 justify-end pt-6">
|
<div className="flex gap-2 justify-end pt-6">
|
||||||
@ -377,7 +558,9 @@ export default function AdminPage() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center text-gray-400 mt-16 text-lg">Select a user...</div>
|
<div className="text-center text-gray-400 mt-16 text-lg">
|
||||||
|
Select a user...
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
126
src/app/api/admin/route.ts
Normal file
126
src/app/api/admin/route.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { prisma } from "@utils/prisma";
|
||||||
|
import { env } from "@utils/env";
|
||||||
|
import { verifyJwt } from "@utils/verifyJwt";
|
||||||
|
import bcryptjs from "bcryptjs";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// Helper
|
||||||
|
async function getUserFromRequest() {
|
||||||
|
const cookieStore = cookies();
|
||||||
|
const token = (await cookieStore).get("jwt")?.value;
|
||||||
|
if (!token) return null;
|
||||||
|
const payload = await verifyJwt({ token, secret: env.JWT_SECRET_KEY });
|
||||||
|
if (!payload?.userId) return null;
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: payload.userId as number },
|
||||||
|
select: { id: true, role: true },
|
||||||
|
});
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await getUserFromRequest();
|
||||||
|
if (!user || user.role !== "ADMIN") {
|
||||||
|
return NextResponse.json({ error: "Not authorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
select: { id: true, email: true, name: true, role: true, createdAt: true },
|
||||||
|
});
|
||||||
|
const cleanedUsers = users.map(u => ({
|
||||||
|
...u,
|
||||||
|
createdAt: u.createdAt instanceof Date ? u.createdAt.toISOString() : u.createdAt,
|
||||||
|
}));
|
||||||
|
return NextResponse.json({ users: cleanedUsers }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching users:", error);
|
||||||
|
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
try {
|
||||||
|
const user = await getUserFromRequest();
|
||||||
|
if (!user || user.role !== "ADMIN") {
|
||||||
|
return NextResponse.json({ error: "Not authorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
const body = await request.json();
|
||||||
|
const { id, name, role, password } = body;
|
||||||
|
const updateData: any = { name, role };
|
||||||
|
if (typeof password === "string" && password.trim() !== "") {
|
||||||
|
updateData.passwordHash = await bcryptjs.hash(password, 10);
|
||||||
|
}
|
||||||
|
const updated = await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ user: updated }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Update error:", error);
|
||||||
|
return NextResponse.json({ error: "Update failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const user = await getUserFromRequest();
|
||||||
|
if (!user || user.role !== "ADMIN") {
|
||||||
|
return NextResponse.json({ error: "Not authorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
const body = await request.json();
|
||||||
|
// Validate input (simple for demo, use zod or similar in prod)
|
||||||
|
const schema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
name: z.string().min(1),
|
||||||
|
role: z.enum(["ADMIN", "SCIENTIST", "GUEST"]),
|
||||||
|
password: z.string().min(6)
|
||||||
|
});
|
||||||
|
const { email, name, role, password } = schema.parse(body);
|
||||||
|
|
||||||
|
// Check uniqueness
|
||||||
|
const exists = await prisma.user.findUnique({ where: { email } });
|
||||||
|
if (exists) {
|
||||||
|
return NextResponse.json({ error: "Email already exists" }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcryptjs.hash(password, 10);
|
||||||
|
const created = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
passwordHash,
|
||||||
|
},
|
||||||
|
select: { id: true, email: true, name: true, role: true, createdAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ user: { ...created, createdAt: created.createdAt instanceof Date ? created.createdAt.toISOString() : created.createdAt } }, { status: 201 });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Create user error:", error);
|
||||||
|
return NextResponse.json({ error: error?.message ?? "Failed to create user" }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
const user = await getUserFromRequest();
|
||||||
|
if (!user || user.role !== "ADMIN") {
|
||||||
|
return NextResponse.json({ error: "Not authorized" }, { status: 403 });
|
||||||
|
}
|
||||||
|
const body = await request.json();
|
||||||
|
const { id } = body;
|
||||||
|
if (typeof id !== "number" || isNaN(id)) {
|
||||||
|
return NextResponse.json({ error: "Invalid id" }, { status: 400 });
|
||||||
|
}
|
||||||
|
await prisma.user.delete({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
return NextResponse.json({ success: true }, { status: 200 });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Delete error:", error);
|
||||||
|
return NextResponse.json({ error: error.message || "Delete failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/app/api/management/request/route.ts
Normal file
21
src/app/api/management/request/route.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@utils/prisma";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const { requestType, requestingUserId, scientistId, comment } = await req.json();
|
||||||
|
const request = await prisma.request.create({
|
||||||
|
data: {
|
||||||
|
requestType,
|
||||||
|
requestingUser: { connect: { id: requestingUserId } },
|
||||||
|
outcome: "IN_PROGRESS",
|
||||||
|
// Optionally you can connect to Scientist via an inline relation if you have a foreign key
|
||||||
|
// If the model has comment or details fields, add it!
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json({ request }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Request create error:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to create request" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/app/api/management/route.ts
Normal file
80
src/app/api/management/route.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@utils/prisma";
|
||||||
|
|
||||||
|
// GET all scientists (with user, superior.user, subordinates)
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const scientists = await prisma.scientist.findMany({
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
superior: { include: { user: true } },
|
||||||
|
subordinates: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json({ scientists }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching scientists:", error);
|
||||||
|
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CREATE scientist
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const { name, level, userId, superiorId } = await req.json();
|
||||||
|
const scientist = await prisma.scientist.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
level,
|
||||||
|
user: { connect: { id: userId } },
|
||||||
|
superior: superiorId ? { connect: { id: superiorId } } : undefined,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
superior: { include: { user: true } },
|
||||||
|
subordinates: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json({ scientist }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Scientist create error:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to create scientist" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UPDATE scientist
|
||||||
|
export async function PUT(req: Request) {
|
||||||
|
try {
|
||||||
|
const { id, name, level, userId, superiorId } = await req.json();
|
||||||
|
const updatedScientist = await prisma.scientist.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
level,
|
||||||
|
user: { connect: { id: userId } },
|
||||||
|
superior: superiorId ? { connect: { id: superiorId } } : { disconnect: true },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
superior: { include: { user: true } },
|
||||||
|
subordinates: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json({ scientist: updatedScientist }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Update error:", error);
|
||||||
|
return NextResponse.json({ error: "Update failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE scientist
|
||||||
|
export async function DELETE(req: Request) {
|
||||||
|
try {
|
||||||
|
const { id } = await req.json();
|
||||||
|
await prisma.scientist.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ success: true }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Delete error:", error);
|
||||||
|
return NextResponse.json({ error: "Delete failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/app/api/users/all/route.ts
Normal file
16
src/app/api/users/all/route.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@utils/prisma";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
include: {
|
||||||
|
scientist: true, // So you know if the user already has a scientist
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return NextResponse.json({ users }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching all users:", error);
|
||||||
|
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,29 @@ import { getRelativeDate } from "@utils/formatters";
|
|||||||
import GeologicalEvent from "@appTypes/Event";
|
import GeologicalEvent from "@appTypes/Event";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import EarthquakeLogModal from "@components/EarthquakeLogModal";
|
import EarthquakeLogModal from "@components/EarthquakeLogModal";
|
||||||
|
import { useStoreState } from "@hooks/store";
|
||||||
|
|
||||||
|
// --- NO ACCESS MODAL ---
|
||||||
|
function NoAccessModal({ open, onClose }) {
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<div className="fixed z-50 inset-0 bg-black/40 flex items-center justify-center">
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-8 max-w-xs w-full text-center relative">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute right-4 top-4 text-gray-500 hover:text-black text-lg"
|
||||||
|
aria-label="Close"
|
||||||
|
>×</button>
|
||||||
|
<h2 className="font-bold text-xl mb-4">Access Denied</h2>
|
||||||
|
<p className="text-gray-600 mb-3">Sorry, you do not have access rights to Log an Earthquake. Please Log in here, or contact an Admin if you believe this is a mistake</p>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 mt-2"
|
||||||
|
>OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- SEARCH MODAL COMPONENT ---
|
// --- SEARCH MODAL COMPONENT ---
|
||||||
function EarthquakeSearchModal({ open, onClose, onSelect }) {
|
function EarthquakeSearchModal({ open, onClose, onSelect }) {
|
||||||
@ -86,10 +109,17 @@ export default function Earthquakes() {
|
|||||||
const [selectedEventId, setSelectedEventId] = useState("");
|
const [selectedEventId, setSelectedEventId] = useState("");
|
||||||
const [hoveredEventId, setHoveredEventId] = useState("");
|
const [hoveredEventId, setHoveredEventId] = useState("");
|
||||||
const [searchModalOpen, setSearchModalOpen] = useState(false);
|
const [searchModalOpen, setSearchModalOpen] = useState(false);
|
||||||
const [logModalOpen, setLogModalOpen] = useState(false); // <-- Move here!
|
const [logModalOpen, setLogModalOpen] = useState(false);
|
||||||
// Fetch recent earthquakes as before
|
const [noAccessModalOpen, setNoAccessModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const user = useStoreState((state) => state.user);
|
||||||
|
const role: "GUEST" | "SCIENTIST" | "ADMIN" = user?.role ?? "GUEST";
|
||||||
|
const canLogEarthquake = role === "SCIENTIST" || role === "ADMIN";
|
||||||
|
|
||||||
|
// Fetch recent earthquakes
|
||||||
const { data, error, isLoading, mutate } = useSWR("/api/earthquakes", createPoster({ rangeDaysPrev: 10 }));
|
const { data, error, isLoading, mutate } = useSWR("/api/earthquakes", createPoster({ rangeDaysPrev: 10 }));
|
||||||
// Prepare events for maps/sidebar
|
|
||||||
|
// Prepare events
|
||||||
const earthquakeEvents = useMemo(
|
const earthquakeEvents = useMemo(
|
||||||
() =>
|
() =>
|
||||||
data && data.earthquakes
|
data && data.earthquakes
|
||||||
@ -110,6 +140,16 @@ export default function Earthquakes() {
|
|||||||
: [],
|
: [],
|
||||||
[data]
|
[data]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// This handler is always called, regardless of button state!
|
||||||
|
const handleLogClick = () => {
|
||||||
|
if (canLogEarthquake) {
|
||||||
|
setLogModalOpen(true);
|
||||||
|
} else {
|
||||||
|
setNoAccessModalOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-3.5rem)] w-full overflow-hidden">
|
<div className="flex h-[calc(100vh-3.5rem)] w-full overflow-hidden">
|
||||||
<div className="flex-grow h-full">
|
<div className="flex-grow h-full">
|
||||||
@ -133,22 +173,24 @@ export default function Earthquakes() {
|
|||||||
setHoveredEventId={setHoveredEventId}
|
setHoveredEventId={setHoveredEventId}
|
||||||
button1Name="Log an Earthquake"
|
button1Name="Log an Earthquake"
|
||||||
button2Name="Search Earthquakes"
|
button2Name="Search Earthquakes"
|
||||||
onButton1Click={() => setLogModalOpen(true)} // Correct!
|
onButton1Click={handleLogClick} // <--- Important!
|
||||||
onButton2Click={() => setSearchModalOpen(true)}
|
onButton2Click={() => setSearchModalOpen(true)}
|
||||||
|
button1Disabled={!canLogEarthquake} // <--- For style only!
|
||||||
/>
|
/>
|
||||||
<EarthquakeSearchModal
|
<EarthquakeSearchModal
|
||||||
open={searchModalOpen}
|
open={searchModalOpen}
|
||||||
onClose={() => setSearchModalOpen(false)}
|
onClose={() => setSearchModalOpen(false)}
|
||||||
onSelect={(eq) => {
|
onSelect={(eq) => setSelectedEventId(eq.code)}
|
||||||
setSelectedEventId(eq.code);
|
|
||||||
// setSelectedSearchResult(eq); // optional
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<EarthquakeLogModal
|
<EarthquakeLogModal
|
||||||
open={logModalOpen}
|
open={logModalOpen}
|
||||||
onClose={() => setLogModalOpen(false)}
|
onClose={() => setLogModalOpen(false)}
|
||||||
onSuccess={() => mutate()} // To refresh
|
onSuccess={() => mutate()} // To refresh
|
||||||
/>
|
/>
|
||||||
|
<NoAccessModal
|
||||||
|
open={noAccessModalOpen}
|
||||||
|
onClose={() => setNoAccessModalOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,177 +1,200 @@
|
|||||||
"use client";
|
"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";
|
type Level = "JUNIOR" | "SENIOR";
|
||||||
const levelLabels: Record<Level, string> = { JUNIOR: "Junior", SENIOR: "Senior" };
|
const levelLabels: Record<Level, string> = { JUNIOR: "Junior", SENIOR: "Senior" };
|
||||||
type User = { id: number; email: string; name: string; };
|
type User = {
|
||||||
type Earthquakes = { id: number; code: string; location: string; };
|
id: number;
|
||||||
type Observatory = { id: number; name: string; location: string; };
|
name: string;
|
||||||
type Artefact = { id: number; name: string; type: string; };
|
email: string;
|
||||||
|
role?: string;
|
||||||
|
scientist?: Scientist | null;
|
||||||
|
};
|
||||||
type Scientist = {
|
type Scientist = {
|
||||||
id: number;
|
id: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
name: string;
|
name: string;
|
||||||
level: Level;
|
level: Level;
|
||||||
user: User;
|
user: User;
|
||||||
userId: User["id"];
|
userId: number;
|
||||||
superior: Scientist | null;
|
superior: Scientist | null;
|
||||||
superiorId: Scientist["id"] | null;
|
superiorId: number | null;
|
||||||
subordinates: Scientist[];
|
subordinates: Scientist[];
|
||||||
earthquakes: Earthquakes[];
|
|
||||||
earthquakeIds: number[];
|
|
||||||
observatories: Observatory[];
|
|
||||||
observatoryIds: number[];
|
|
||||||
artefacts: Artefact[];
|
|
||||||
artefactIds: number[];
|
|
||||||
};
|
};
|
||||||
const users: User[] = [
|
// --- Helpers ---
|
||||||
{ id: 1, name: "Albert Einstein", email: "ae@uni.edu" },
|
const initialScientists: Scientist[] = [
|
||||||
{ 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,
|
id: 0,
|
||||||
createdAt: "2024-06-01T09:00:00Z",
|
name: "Loading Scientist",
|
||||||
name: "Dr. John Junior",
|
|
||||||
level: "JUNIOR",
|
level: "JUNIOR",
|
||||||
user: users[0],
|
createdAt: "",
|
||||||
userId: 1,
|
user: { id: 0, name: "Loading...", email: "--" },
|
||||||
superior: null,
|
userId: 0,
|
||||||
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,
|
superior: null,
|
||||||
superiorId: null,
|
superiorId: null,
|
||||||
subordinates: [],
|
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 = [
|
const sortFields = [
|
||||||
{ label: "Name", value: "name" },
|
{ label: "Name", value: "name" },
|
||||||
{ label: "Level", value: "level" },
|
{ label: "Level", value: "level" }
|
||||||
] as const;
|
] as const;
|
||||||
type SortField = (typeof sortFields)[number]["value"];
|
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" };
|
||||||
|
|
||||||
export default function Scientist() {
|
// --- Updated RequestModal (only level/removal, no comment)
|
||||||
const [scientists, setScientists] = useState<Scientist[]>(scientistList);
|
type RequestModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
requestingUserId: number;
|
||||||
|
scientist?: Scientist | null;
|
||||||
|
};
|
||||||
|
function RequestModal({ open, onClose, requestingUserId, scientist }: RequestModalProps) {
|
||||||
|
const [requestType, setRequestType] = useState<string>("CHANGE_LEVEL");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(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 ? (
|
||||||
|
<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">
|
||||||
|
<h3 className="text-lg font-bold mb-4">Request Action</h3>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Action Type</label>
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
className="w-full border px-2 py-1 rounded-lg"
|
||||||
|
value={requestType}
|
||||||
|
onChange={e=>setRequestType(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="CHANGE_LEVEL">Request Change Level</option>
|
||||||
|
<option value="DELETE">Request Removal</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{error && <div className="text-red-600 text-xs">{error}</div>}
|
||||||
|
{success && <div className="text-green-600 text-xs">{success}</div>}
|
||||||
|
<div className="flex gap-2 justify-end pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-3 py-1 rounded-lg bg-gray-200 text-gray-700 hover:bg-gray-300 transition"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`px-3 py-1 rounded-lg text-white font-semibold ${loading ? "bg-blue-300" : "bg-blue-600 hover:bg-blue-700"}`}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? "Submitting..." : "Submit"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
// ========================================================
|
||||||
|
export default function ScientistManagementPage() {
|
||||||
|
// All hooks first
|
||||||
|
const user = useStoreState((state)=>state.user);
|
||||||
|
const [scientists, setScientists] = useState<Scientist[]>(initialScientists);
|
||||||
|
const [allUsers, setAllUsers] = useState<User[]>([]);
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
const [editScientist, setEditScientist] = useState<Scientist | null>(null);
|
const [editScientist, setEditScientist] = useState<Scientist | null>(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<string | null>(null);
|
||||||
|
const [addLoading, setAddLoading] = useState(false);
|
||||||
|
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 [success, setSuccess] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
|
||||||
|
const [sortDropdownOpen, setSortDropdownOpen] = useState(false);
|
||||||
|
const filterDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const sortDropdownRef = useRef<HTMLDivElement>(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);
|
if (selectedId == null) setEditScientist(null);
|
||||||
else {
|
else {
|
||||||
const sc = scientists.find((x) => x.id === selectedId);
|
const sc = scientists.find((x) => x.id === selectedId);
|
||||||
setEditScientist(sc ? { ...sc } : null);
|
setEditScientist(sc ? { ...sc } : null);
|
||||||
}
|
}
|
||||||
}, [selectedId, scientists]);
|
}, [selectedId, scientists]);
|
||||||
const [searchField, setSearchField] = useState<SortField>("name");
|
useEffect(() => {
|
||||||
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) => {
|
const handleClick = (e: MouseEvent) => {
|
||||||
if (filterDropdownRef.current && !filterDropdownRef.current.contains(e.target as Node)) setFilterDropdownOpen(false);
|
if (filterDropdownRef.current && !filterDropdownRef.current.contains(e.target as Node)) setFilterDropdownOpen(false);
|
||||||
if (sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as Node)) setSortDropdownOpen(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);
|
document.addEventListener("mousedown", handleClick);
|
||||||
return () => document.removeEventListener("mousedown", handleClick);
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
}, []);
|
}, []);
|
||||||
const filtered = scientists.filter((s) => levelFilter === "all" || s.level === levelFilter);
|
const filtered = scientists.filter(s => levelFilter === "all" || s.level === levelFilter);
|
||||||
const searched = filtered.filter((s) => s[searchField].toLowerCase().includes(searchText.toLowerCase()));
|
const searched = filtered.filter(s => String(s[searchField]).toLowerCase().includes(searchText.toLowerCase()));
|
||||||
const sorted = [...searched].sort((a, b) => {
|
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;
|
return sortDir === "asc" ? cmp : -cmp;
|
||||||
});
|
});
|
||||||
const allLevels: Level[] = ["JUNIOR", "SENIOR"];
|
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>) => {
|
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
if (!editScientist) return;
|
if (!editScientist) return;
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
if (name === "superiorId") {
|
if (name === "superiorId") {
|
||||||
const supId = value === "" ? null : Number(value);
|
const supId = value === "" ? null : Number(value);
|
||||||
setEditScientist((prev) =>
|
setEditScientist(prev => prev ? {
|
||||||
prev
|
|
||||||
? {
|
|
||||||
...prev,
|
...prev,
|
||||||
superiorId: supId,
|
superiorId: supId,
|
||||||
superior: supId ? scientists.find((s) => s.id === supId) ?? null : null,
|
superior: supId ? scientists.find((s) => s.id === supId) ?? null : null
|
||||||
}
|
} : null);
|
||||||
: null
|
|
||||||
);
|
|
||||||
} else if (name === "level") {
|
} else if (name === "level") {
|
||||||
setEditScientist((prev) => (prev ? { ...prev, level: value as Level } : null));
|
setEditScientist(prev => prev ? { ...prev, level: value as Level } : null);
|
||||||
} else if (name === "userId") {
|
} else if (name === "userId") {
|
||||||
const user = users.find((u) => u.id === Number(value));
|
const user = allUsers.find((u) => u.id === Number(value));
|
||||||
setEditScientist((prev) => (prev && user ? { ...prev, user, userId: user.id } : prev));
|
setEditScientist(prev => (prev && user ? { ...prev, user, userId: user.id } : prev));
|
||||||
} else {
|
} else {
|
||||||
setEditScientist((prev) => (prev ? { ...prev, [name]: value } : null));
|
setEditScientist(prev => prev ? { ...prev, [name]: value } : null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
function handleArtefactCheck(id: number) {
|
async function handleAddScientist(e: React.FormEvent) {
|
||||||
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();
|
e.preventDefault();
|
||||||
if (!editScientist) return;
|
setAddError(null);
|
||||||
setScientists((prev) =>
|
if (!addForm.name || !addForm.level || !addForm.userId) {
|
||||||
prev.map((item) =>
|
setAddError("All fields are required.");
|
||||||
item.id === editScientist.id
|
return;
|
||||||
? {
|
}
|
||||||
...editScientist,
|
setAddLoading(true);
|
||||||
artefacts: allArtefacts.filter((a) => editScientist.artefactIds.includes(a.id)),
|
try {
|
||||||
earthquakes: allEarthquakes.filter((eq) => editScientist.earthquakeIds.includes(eq.id)),
|
const res = await fetch("/api/management", {
|
||||||
observatories: allObservatories.filter((obs) => editScientist.observatoryIds.includes(obs.id)),
|
method: "POST",
|
||||||
subordinates: scientistList.filter((s) => s.superiorId === editScientist.id),
|
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;
|
||||||
|
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");
|
||||||
}
|
}
|
||||||
: item
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
const handleDelete = () => {
|
const handleDeleteScientist = async () => {
|
||||||
if (!selectedScientist) return;
|
if (!editScientist) return;
|
||||||
if (!window.confirm(`Are you sure you want to delete "${selectedScientist.name}"?`)) return;
|
if (!window.confirm(`Are you sure you want to delete "${editScientist.name}"?`)) return;
|
||||||
setScientists((prev) => prev.filter((i) => i.id !== selectedScientist.id));
|
try {
|
||||||
setSelectedId(null);
|
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);
|
setEditScientist(null);
|
||||||
|
setSelectedId(null);
|
||||||
|
} catch {
|
||||||
|
alert("Delete failed");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const searchedArtefacts = allArtefacts.filter(
|
const usersWithNoScientist = allUsers.filter(u => !u.scientist);
|
||||||
(a) =>
|
if (!isAdmin && !isSeniorScientist) {
|
||||||
artefactQuery.trim() === "" ||
|
return (
|
||||||
a.name.toLowerCase().includes(artefactQuery.toLowerCase()) ||
|
<div className="flex items-center justify-center min-h-[70vh] flex-col">
|
||||||
a.id.toString().includes(artefactQuery)
|
<h1 className="text-2xl font-bold text-red-500 mb-4">Unauthorized Access</h1>
|
||||||
|
<div className="text-gray-600">You do not have access to this page.</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex h-full overflow-hidden bg-gray-50">
|
<div className="flex h-full overflow-hidden bg-gray-50">
|
||||||
@ -348,7 +334,6 @@ export default function Scientist() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center mb-2">
|
<div className="flex gap-2 items-center mb-2">
|
||||||
{/* Filter dropdown */}
|
|
||||||
<div className="relative" ref={filterDropdownRef}>
|
<div className="relative" ref={filterDropdownRef}>
|
||||||
<button
|
<button
|
||||||
className={`px-3 py-1 rounded-lg border font-semibold flex items-center transition
|
className={`px-3 py-1 rounded-lg border font-semibold flex items-center transition
|
||||||
@ -393,7 +378,6 @@ export default function Scientist() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* sort dropdown */}
|
|
||||||
<div className="relative" ref={sortDropdownRef}>
|
<div className="relative" ref={sortDropdownRef}>
|
||||||
<button
|
<button
|
||||||
className="px-3 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200"
|
className="px-3 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200"
|
||||||
@ -431,6 +415,20 @@ export default function Scientist() {
|
|||||||
>
|
>
|
||||||
{sortDir === "asc" ? "↑" : "↓"}
|
{sortDir === "asc" ? "↑" : "↓"}
|
||||||
</button>
|
</button>
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
className="ml-2 px-2 py-1 rounded-lg bg-green-600 hover:bg-green-700 text-white font-bold flex items-center shadow transition duration-150"
|
||||||
|
type="button"
|
||||||
|
style={{ minWidth: 36, minHeight: 36 }}
|
||||||
|
onClick={() => setAddOpen(true)}
|
||||||
|
disabled={addOpen}
|
||||||
|
title="Add scientist"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth={2.2} viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14m7-7H5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<small className="text-xs text-gray-500 mb-2 px-1">
|
<small className="text-xs text-gray-500 mb-2 px-1">
|
||||||
Scientists sorted by {fieldLabels[sortField]} {dirLabels[sortDir]}
|
Scientists sorted by {fieldLabels[sortField]} {dirLabels[sortDir]}
|
||||||
@ -457,33 +455,86 @@ export default function Scientist() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* MAIN PANEL */}
|
{/* Add Scientist Modal */}
|
||||||
<div className="flex-1 flex justify-center p-24 bg-white overflow-y-auto">
|
{addOpen && (
|
||||||
{editScientist ? (
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black bg-opacity-30">
|
||||||
<div
|
<div className="bg-white rounded-lg shadow-lg p-8 max-w-sm w-full relative">
|
||||||
className="
|
<h3 className="text-lg font-bold mb-4">Add New Scientist</h3>
|
||||||
max-w-4xl w-full bg-white rounded-xl shadow flex flex-col pt-4 pb-3 px-5
|
<form onSubmit={handleAddScientist} className="space-y-3">
|
||||||
"
|
<div>
|
||||||
style={{
|
<label className="block text-sm font-medium mb-1">Name</label>
|
||||||
minHeight: 0,
|
<input
|
||||||
maxHeight: 780,
|
className="w-full border px-2 py-1 rounded-lg"
|
||||||
overflow: "hidden"
|
type="text"
|
||||||
}}
|
required
|
||||||
|
value={addForm.name}
|
||||||
|
onChange={e => setAddForm(f => ({ ...f, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Level</label>
|
||||||
|
<select
|
||||||
|
className="w-full border px-2 py-1 rounded-lg"
|
||||||
|
value={addForm.level}
|
||||||
|
onChange={e => setAddForm(f => ({ ...f, level: e.target.value as Level }))}
|
||||||
>
|
>
|
||||||
{/* Heading */}
|
{allLevels.map(level => (
|
||||||
<div className="text-xl font-bold text-gray-900 mb-3 px-1">Edit Scientist</div>
|
<option value={level} key={level}>{levelLabels[level]}</option>
|
||||||
<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">
|
</select>
|
||||||
{/* 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>
|
<div>
|
||||||
<div className="text-xs text-gray-400">ID</div>
|
<label className="block text-sm font-medium mb-1">User (select by email)</label>
|
||||||
<div className="font-mono text-sm">{editScientist.id}</div>
|
<select
|
||||||
|
className="w-full border px-2 py-1 rounded-lg"
|
||||||
|
required
|
||||||
|
value={addForm.userId}
|
||||||
|
onChange={e => setAddForm(f => ({ ...f, userId: Number(e.target.value) }))}
|
||||||
|
>
|
||||||
|
<option value={0} disabled>Choose user...</option>
|
||||||
|
{usersWithNoScientist.map(u =>
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.name} ({u.email})
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
{addError && <div className="text-red-600 text-xs">{addError}</div>}
|
||||||
|
<div className="flex gap-2 justify-end pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-3 py-1 rounded-lg bg-gray-200 text-gray-700 hover:bg-gray-300 transition"
|
||||||
|
onClick={() => setAddOpen(false)}
|
||||||
|
disabled={addLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`px-3 py-1 rounded-lg text-white font-semibold ${addLoading ? "bg-green-400" : "bg-green-600 hover:bg-green-700"}`}
|
||||||
|
disabled={addLoading}
|
||||||
|
>
|
||||||
|
{addLoading ? "Adding..." : "Add"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Request Modal */}
|
||||||
|
<RequestModal
|
||||||
|
open={requestOpen}
|
||||||
|
onClose={()=>setRequestOpen(false)}
|
||||||
|
requestingUserId={user?.id}
|
||||||
|
scientist={editScientist}
|
||||||
|
/>
|
||||||
|
{/* MAIN PANEL */}
|
||||||
|
<div className="flex-1 p-24 bg-white overflow-y-auto">
|
||||||
|
{editScientist ? (
|
||||||
|
<form className="max-w-xl mx-auto bg-white p-6 rounded-lg shadow" onSubmit={handleUpdate}>
|
||||||
|
<h2 className="text-lg font-bold mb-4">Scientist Details</h2>
|
||||||
|
{!!success && <div className="text-green-600 text-sm">{success}</div>}
|
||||||
|
{!!error && <div className="text-red-600 text-xs">{error}</div>}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name:</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Name:</label>
|
||||||
<input
|
<input
|
||||||
@ -492,6 +543,7 @@ export default function Scientist() {
|
|||||||
name="name"
|
name="name"
|
||||||
value={editScientist.name}
|
value={editScientist.name}
|
||||||
onChange={handleEditChange}
|
onChange={handleEditChange}
|
||||||
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -501,200 +553,80 @@ export default function Scientist() {
|
|||||||
name="level"
|
name="level"
|
||||||
value={editScientist.level}
|
value={editScientist.level}
|
||||||
onChange={handleEditChange}
|
onChange={handleEditChange}
|
||||||
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
{allLevels.map((lvl) => (
|
{allLevels.map(lvl => <option key={lvl} value={lvl}>{levelLabels[lvl]}</option>)}
|
||||||
<option key={lvl} value={lvl}>{levelLabels[lvl]}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">User (email):</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">User (email):</label>
|
||||||
<select
|
<select
|
||||||
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300 bg-gray-100"
|
||||||
name="userId"
|
name="userId"
|
||||||
value={editScientist.userId}
|
value={editScientist.user.id}
|
||||||
onChange={handleEditChange}
|
onChange={handleEditChange}
|
||||||
|
disabled
|
||||||
>
|
>
|
||||||
{allUsers.map((u) => (
|
<option value={editScientist.user.id}>{editScientist.user.name} ({editScientist.user.email})</option>
|
||||||
<option key={u.id} value={u.id}>
|
|
||||||
{u.name} ({u.email})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Superior:</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Supervisor:</label>
|
||||||
|
{isAdmin ? (
|
||||||
<select
|
<select
|
||||||
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||||
name="superiorId"
|
name="superiorId"
|
||||||
value={editScientist.superiorId ?? ""}
|
value={editScientist.superiorId ?? ""}
|
||||||
onChange={handleEditChange}
|
onChange={handleEditChange}
|
||||||
|
disabled={readOnly}
|
||||||
>
|
>
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
{allOtherScientistOptions(editScientist.id).map((s) => (
|
{scientists
|
||||||
<option key={s.id} value={s.id}>{s.name}</option>
|
.filter(s => s.id !== editScientist.id)
|
||||||
|
.map(s => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name} ({s.user.email})
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
) : (
|
||||||
<div>
|
<div className="border px-3 py-2 rounded-lg bg-gray-100">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Subordinates:</label>
|
{editScientist.superior && editScientist.superior.user ? (
|
||||||
<div className="flex flex-wrap gap-1 bg-gray-200 rounded px-2 py-2 min-h-[28px]">
|
<>
|
||||||
{editScientist.subordinates.length > 0
|
<span>{editScientist.superior.name}</span>
|
||||||
? editScientist.subordinates.map((s) => (
|
<span className="ml-2 text-gray-500 text-xs">
|
||||||
<span
|
({editScientist.superior.user.email})
|
||||||
key={s.id}
|
|
||||||
className="px-2 py-1 rounded-full bg-gray-300 text-gray-700 text-xs font-medium"
|
|
||||||
>
|
|
||||||
{s.name}
|
|
||||||
</span>
|
</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) => (
|
<span className="text-gray-400">None</span>
|
||||||
<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>
|
||||||
</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>
|
||||||
</div>
|
<div className="flex gap-2 justify-end pt-8">
|
||||||
{/* Artefacts Box */}
|
{isAdmin && (
|
||||||
<div className="flex flex-col h-full">
|
<>
|
||||||
<div className="flex justify-between items-end">
|
<button type="button" onClick={handleDeleteScientist}
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Artefacts:</label>
|
className="px-4 py-2 bg-red-500 hover:bg-red-600 text-white font-semibold rounded-lg shadow transition">
|
||||||
<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
|
Delete
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="submit"
|
||||||
type="submit"
|
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold shadow transition ml-3">
|
||||||
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
|
Update
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{readOnly && (
|
||||||
|
<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>
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center text-gray-400 mt-16 text-lg">Select a scientist...</div>
|
<div className="text-center text-gray-400 mt-16 text-lg">Select a scientist...</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -2,15 +2,15 @@ import React, { useCallback, useRef, useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
export default function BottomFooter() {
|
export default function BottomFooter() {
|
||||||
// Lava flood state & timer
|
// ig easter egg
|
||||||
const [lavaActive, setLavaActive] = useState(false);
|
const [lavaActive, setLavaActive] = useState(false);
|
||||||
const lavaTimeout = useRef<any>(null);
|
const lavaTimeout = useRef<any>(null);
|
||||||
|
|
||||||
// LinkedIn shake state & timer
|
// LinkedIn easter egg
|
||||||
const [shaking, setShaking] = useState(false);
|
const [shaking, setShaking] = useState(false);
|
||||||
const shakeTimeout = useRef<any>(null);
|
const shakeTimeout = useRef<any>(null);
|
||||||
|
|
||||||
// Crack+collapse states for the X logo
|
// x easter egg
|
||||||
const [showCracks, setShowCracks] = useState(false);
|
const [showCracks, setShowCracks] = useState(false);
|
||||||
const [collapse, setCollapse] = useState(false);
|
const [collapse, setCollapse] = useState(false);
|
||||||
const crackTimeout = useRef<any>(null);
|
const crackTimeout = useRef<any>(null);
|
||||||
@ -131,6 +131,14 @@ export default function BottomFooter() {
|
|||||||
|
|
||||||
{/* Bottom bar */}
|
{/* Bottom bar */}
|
||||||
<div className="max-w-6xl mx-auto mt-8 pt-6 flex flex-col md:flex-row items-center justify-between border-t border-gray-200/30">
|
<div className="max-w-6xl mx-auto mt-8 pt-6 flex flex-col md:flex-row items-center justify-between border-t border-gray-200/30">
|
||||||
|
<div className="flex flex-row items-center w-full md:w-auto">
|
||||||
|
<img
|
||||||
|
src="/logo.png"
|
||||||
|
alt="TremorTracker logo"
|
||||||
|
className="h-16 w-auto mr-4 object-contain"
|
||||||
|
style={{ maxHeight: 75 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<span className="text-sm flex items-center">
|
<span className="text-sm flex items-center">
|
||||||
<span className="mr-2">©</span> TremorTracker 2025
|
<span className="mr-2">©</span> TremorTracker 2025
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -134,10 +134,14 @@ 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") && (
|
{user && (
|
||||||
|
(user.role === "ADMIN" ||
|
||||||
|
(user.role === "SCIENTIST" && user.scientist?.level === "SENIOR")
|
||||||
|
) && (
|
||||||
<div className="flex h-full mr-5">
|
<div className="flex h-full mr-5">
|
||||||
<ManagementNavbarButton name="Scientist Management" href="/management"></ManagementNavbarButton>
|
<ManagementNavbarButton name="Scientist Management" href="/management" />
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
{user && user.role === "ADMIN" && (
|
{user && user.role === "ADMIN" && (
|
||||||
<div className="flex h-full mr-5">
|
<div className="flex h-full mr-5">
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import React, { Dispatch, SetStateAction, useEffect, useRef } from "react";
|
import React, { Dispatch, SetStateAction, useEffect, useRef } from "react";
|
||||||
import { TbHexagon } from "react-icons/tb";
|
import { TbHexagon } from "react-icons/tb";
|
||||||
|
|
||||||
import GeologicalEvent from "@appTypes/Event";
|
import GeologicalEvent from "@appTypes/Event";
|
||||||
import getMagnitudeColor from "@utils/getMagnitudeColour";
|
import getMagnitudeColor from "@utils/getMagnitudeColour";
|
||||||
|
|
||||||
@ -18,12 +16,12 @@ interface SidebarProps {
|
|||||||
button2Name: string;
|
button2Name: string;
|
||||||
onButton2Click?: () => void;
|
onButton2Click?: () => void;
|
||||||
onButton1Click?: () => void;
|
onButton1Click?: () => void;
|
||||||
|
button1Disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
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" />
|
||||||
@ -38,8 +36,6 @@ function MagnitudeNumber({ magnitude }: { magnitude: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// todo change sidebar event highlighting on selection
|
|
||||||
|
|
||||||
export default function Sidebar({
|
export default function Sidebar({
|
||||||
logTitle,
|
logTitle,
|
||||||
logSubtitle,
|
logSubtitle,
|
||||||
@ -53,9 +49,9 @@ export default function Sidebar({
|
|||||||
button2Name,
|
button2Name,
|
||||||
onButton2Click,
|
onButton2Click,
|
||||||
onButton1Click,
|
onButton1Click,
|
||||||
|
button1Disabled = false,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const eventsContainerRef = useRef<HTMLDivElement>(null);
|
const eventsContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedEventId && eventsContainerRef.current) {
|
if (selectedEventId && eventsContainerRef.current) {
|
||||||
const selectedEventElement = eventsContainerRef.current.querySelector(`[data-event-id="${selectedEventId}"]`);
|
const selectedEventElement = eventsContainerRef.current.querySelector(`[data-event-id="${selectedEventId}"]`);
|
||||||
@ -74,26 +70,20 @@ export default function Sidebar({
|
|||||||
<div className="px-6 pb-8 border-b border-neutral-200">
|
<div className="px-6 pb-8 border-b border-neutral-200">
|
||||||
<h2 className="text-2xl font-bold text-neutral-800 mb-2">{logTitle}</h2>
|
<h2 className="text-2xl font-bold text-neutral-800 mb-2">{logTitle}</h2>
|
||||||
<p className="text-sm text-neutral-600 leading-relaxed">{logSubtitle}</p>
|
<p className="text-sm text-neutral-600 leading-relaxed">{logSubtitle}</p>
|
||||||
|
|
||||||
{onButton1Click ? (
|
|
||||||
<button
|
<button
|
||||||
className="mt-4 w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg transition-colors duration-200 font-medium"
|
className={`mt-4 w-full py-2 px-4 rounded-lg transition-colors duration-200 font-medium
|
||||||
|
${button1Disabled
|
||||||
|
? "bg-gray-300 text-gray-500 cursor-not-allowed"
|
||||||
|
: "bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
|
}`}
|
||||||
onClick={onButton1Click}
|
onClick={onButton1Click}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
|
||||||
{button1Name}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<Link href="/">
|
|
||||||
<button
|
|
||||||
className="mt-4 w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg transition-colors duration-200 font-medium"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{button1Name}
|
|
||||||
</button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
tabIndex={button1Disabled ? -1 : 0}
|
||||||
|
aria-disabled={button1Disabled ? "true" : "false"}
|
||||||
|
>
|
||||||
|
{button1Name}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="mt-4 w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg transition-colors duration-200 font-medium"
|
className="mt-4 w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg transition-colors duration-200 font-medium"
|
||||||
onClick={onButton2Click}
|
onClick={onButton2Click}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user