Compare commits
8 Commits
7311e379e8
...
bec31f76c0
| Author | SHA1 | Date | |
|---|---|---|---|
| bec31f76c0 | |||
| 0a34470e65 | |||
|
|
265f6613d2 | ||
| 3142f73c80 | |||
|
|
3088c667fd | ||
|
|
d303083e0f | ||
| efc16aa92a | |||
|
|
1b0b751b32 |
@ -47,22 +47,19 @@ model Scientist {
|
||||
}
|
||||
|
||||
model Earthquake {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
date DateTime
|
||||
code String @unique
|
||||
magnitude Float
|
||||
type String // e.g. 'volcanic'
|
||||
latitude Float
|
||||
longitude Float
|
||||
location String
|
||||
depth String
|
||||
|
||||
creatorId Int?
|
||||
creator Scientist? @relation("ScientistEarthquakeCreator", fields: [creatorId], references: [id])
|
||||
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
date DateTime
|
||||
code String @unique
|
||||
magnitude Float
|
||||
type String // e.g. 'volcanic'
|
||||
latitude Float
|
||||
longitude Float
|
||||
location String
|
||||
depth String
|
||||
creatorId Int?
|
||||
creator Scientist? @relation("ScientistEarthquakeCreator", fields: [creatorId], references: [id])
|
||||
artefacts Artefact[]
|
||||
observatories Observatory[] @relation("EarthquakeObservatory")
|
||||
}
|
||||
@ -73,8 +70,8 @@ model Observatory {
|
||||
updatedAt DateTime @updatedAt
|
||||
name String
|
||||
location String
|
||||
longitude Float
|
||||
latitude Float
|
||||
longitude Float
|
||||
latitude Float
|
||||
dateEstablished DateTime
|
||||
isFunctional Boolean
|
||||
seismicSensorOnline Boolean @default(true)
|
||||
@ -102,6 +99,8 @@ model Artefact {
|
||||
isSold Boolean @default(false)
|
||||
purchasedById Int?
|
||||
purchasedBy User? @relation("UserPurchasedArtefacts", fields: [purchasedById], references: [id], onDelete: NoAction, onUpdate: NoAction)
|
||||
// todo unlink purchase from user
|
||||
// todo link purchase to order
|
||||
isCollected Boolean @default(false)
|
||||
}
|
||||
|
||||
@ -112,3 +111,11 @@ model Pallet {
|
||||
warehouseArea String
|
||||
palletNote String
|
||||
}
|
||||
|
||||
model Order {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
orderNumber String
|
||||
// todo link order to user
|
||||
}
|
||||
|
||||
@ -4,28 +4,28 @@ Silvershade Ash,Ash,ShelvingAreaD,Fine light-grey volcanic ash collected near a
|
||||
Strata Core,Soil,LoggingArea,Soil core with visible stratification showing evidence of liquefaction.,ET-6.9-Brazil-00046,30,no,no,StrataCore.PNG
|
||||
Opal Clast,Tephra,ShelvingAreaM,Pumice clast with sharp edges and clear layering.,EV-8.3-Iceland-00127,65,no,no,OpalClast.PNG
|
||||
Magnetite Ash,Ash,PalletDeliveryArea,Bagged black ash sample with high magnetic content.,EV-5.3-Myanmar-00088,28,yes,no,MagnetiteAsh.PNG
|
||||
Ropeform Fragment,Lava,ShelvingAreaR,Ropey pahoehoe lava fragment with preserved ripples.,EV-6.9-Ethiopia-00012,85,no,no,NoImageFound.PNG
|
||||
Ropeform Fragment,Lava,ShelvingAreaR,Ropey pahoehoe lava fragment with preserved ripples.,EV-6.9-Ethiopia-00012,85,no,no,RopeformFragment.PNG
|
||||
Glassy Bed,Soil,ShelvingAreaB,Sandy soil deposit with glassy volcanic spherules visible under microscope.,EV-7.8-USA-00167,48,no,no,NoImageFound.PNG
|
||||
Groundwater Shard,Tephra,ShelvingAreaJ,Layered tephra shard showing interaction with groundwater.,EV-4.9-Chile-00083,49,no,no,NoImageFound.PNG
|
||||
Groundwater Shard,Tephra,ShelvingAreaJ,Layered tephra shard showing interaction with groundwater.,EV-4.9-Chile-00083,49,no,no,GroundwaterShard.PNG
|
||||
Sulfur Ghost,Ash,ShelvingAreaG,Grey volcanic ash particle with very high sulfur content.,EV-6.4-Armenia-00219,38,no,no,NoImageFound.PNG
|
||||
Obsidian Pillar,Lava,ShelvingAreaC,Dense basalt sample with well-developed columnar structure.,EV-7.7-Jordan-00037,100,yes,no,NoImageFound.PNG
|
||||
Peat Nest,Soil,ShelvingAreaP,Peaty soil from earthquake liquefaction area with organic inclusions.,ET-5.1-New Zealand-00032,52,no,no,NoImageFound.PNG
|
||||
Obsidian Pillar,Lava,ShelvingAreaC,Dense basalt sample with well-developed columnar structure.,EV-7.7-Jordan-00037,100,yes,no,ObsidianPillar.PNG
|
||||
Peat Nest,Soil,ShelvingAreaP,Peaty soil from earthquake liquefaction area with organic inclusions.,ET-5.1-New Zealand-00032,52,no,no,PeatNest.PNG
|
||||
Biotite Veil,Ash,PalletDeliveryArea,Ash with visible biotite flakes: lightly compacted.,EV-3.1-USA-00101,32,no,no,NoImageFound.PNG
|
||||
Foamcore Pumice,Lava,ShelvingAreaF,High-porosity pumice formed during an explosive event.,EV-8.4-Barbados-00071,77,no,no,NoImageFound.PNG
|
||||
Redslide Soil,Soil,ShelvingAreaQ,Aggregated red clay soil collected from a collapsed hillside.,EC-7.5-Nicaragua-00078,26,no,no,NoImageFound.PNG
|
||||
Spectrum Clast,Tephra,LoggingArea,Multi-coloured tephra clast showing mixed eruption sources.,EV-8.3-Antarctica-00030,54,no,no,NoImageFound.PNG
|
||||
Olivine Stone,Lava,ShelvingAreaH,Dense polished lava with visible olivine inclusions.,EV-9.0-Russia-00171,132,no,no,NoImageFound.PNG
|
||||
Blended Bar,Soil,ShelvingAreaN,Sandy soil deeply mixed with volcanic tephra fragments.,EV-6.4-Panama-00197,45,no,no,NoImageFound.PNG
|
||||
Potash Powder,Ash,ShelvingAreaX,Very fine mantle ash with high potassium content: glassy texture.,EV-4.4-Ecuador-00253,56,no,no,NoImageFound.PNG
|
||||
Shatterrock,Lava,ShelvingAreaS,Massive hand specimen of aa lava showing sharp vesicular cavities.,EV-7.4-South Africa-00228,117,no,no,NoImageFound.PNG
|
||||
Humic Loam,Soil,PalletDeliveryArea,Dark humic soil showing traces of ash fallout.,EV-3.2-Argentina-00175,20,no,no,NoImageFound.PNG
|
||||
Charcoal Drift,Ash,ShelvingAreaK,Coarse ash mixed with fragmented charcoal.,EV-8.6-Myanmar-00056,47,yes,no,NoImageFound.PNG
|
||||
Spindle Bomb,Lava,ShelvingAreaU,Spindle-shaped lava bomb: partially vitrified.,EV-8.9-Japan-00076,84,no,no,NoImageFound.PNG
|
||||
Bubblewall Tephra,Tephra,ShelvingAreaV,Vitric tephra particle with bubble wall shards: transparent edges.,EV-7.5-Greece-00248,88,no,no,NoImageFound.PNG
|
||||
Layercake Earth,Soil,ShelvingAreaZ,Compacted soil with tephra lenses: showing sediment mixing.,EV-6.0-Guatemala-00165,39,no,no,NoImageFound.PNG
|
||||
Creamdust Sample,Ash,ShelvingAreaW,Bagged cream-coloured ash collected from roof dusting incident.,EV-4.1-Maldives-00185,25,no,no,NoImageFound.PNG
|
||||
Faultblock Tephra,Tephra,ShelvingAreaL,Blocky tephra fragment: impacted during ground rupture.,EV-5.6-Antarctica-00251,51,no,no,NoImageFound.PNG
|
||||
Basalt Boulder,Lava,LoggingArea,Weathered basalt boulder from lava flow front.,EV-5.4-Spain-00129,69,no,no,NoImageFound.PNG
|
||||
Glassloam,Soil,ShelvingAreaT,Loam with embedded tephric glass and altered feldspar.,EV-3.7-Argentina-00208,30,no,no,NoImageFound.PNG
|
||||
Nanoshade Ash,Ash,ShelvingAreaO,Ash sample with nano-crystalline silica.,EV-7.4-Mauritius-00183,42,no,no,NoImageFound.PNG
|
||||
Ironflame Scoria,Lava,ShelvingAreaE,Vesicular scoria: deep red: indicative of high iron content.,EV-3.4-Indonesia-00138,44,no,no,NoImageFound.PNG
|
||||
Foamcore Pumice,Lava,ShelvingAreaF,High-porosity pumice formed during an explosive event.,EV-8.4-Barbados-00071,77,no,no,FoamcorePumice.PNG
|
||||
Redslide Soil,Soil,ShelvingAreaQ,Aggregated red clay soil collected from a collapsed hillside.,EC-7.5-Nicaragua-00078,26,no,no,RedslideSoil.PNG
|
||||
Spectrum Clast,Tephra,LoggingArea,Multi-coloured tephra clast showing mixed eruption sources.,EV-8.3-Antarctica-00030,54,no,no,SpectrumClast.PNG
|
||||
Olivine Stone,Lava,ShelvingAreaH,Dense polished lava with visible olivine inclusions.,EV-9.0-Russia-00171,132,no,no,OlivineStone.PNG
|
||||
Blended Bar,Soil,ShelvingAreaN,Sandy soil deeply mixed with volcanic tephra fragments.,EV-6.4-Panama-00197,45,no,no,BlendedBar.PNG
|
||||
Potash Powder,Ash,ShelvingAreaX,Very fine mantle ash with high potassium content: glassy texture.,EV-4.4-Ecuador-00253,56,no,no,PotashPowder.PNG
|
||||
Shatterrock,Lava,ShelvingAreaS,Massive hand specimen of aa lava showing sharp vesicular cavities.,EV-7.4-South Africa-00228,117,no,no,Shatterrock.PNG
|
||||
Humic Loam,Soil,PalletDeliveryArea,Dark humic soil showing traces of ash fallout.,EV-3.2-Argentina-00175,20,no,no,HumicLoam.PNG
|
||||
Charcoal Drift,Ash,ShelvingAreaK,Coarse ash mixed with fragmented charcoal.,EV-8.6-Myanmar-00056,47,yes,no,CharcoalDrift.PNG
|
||||
Spindle Bomb,Lava,ShelvingAreaU,Spindle-shaped lava bomb: partially vitrified.,EV-8.9-Japan-00076,84,no,no,SpindleBomb.PNG
|
||||
Bubblewall Tephra,Tephra,ShelvingAreaV,Vitric tephra particle with bubble wall shards: transparent edges.,EV-7.5-Greece-00248,88,no,no,BubblewallTephra.PNG
|
||||
Layercake Earth,Soil,ShelvingAreaZ,Compacted soil with tephra lenses: showing sediment mixing.,EV-6.0-Guatemala-00165,39,no,no,LayercakeEarth.PNG
|
||||
Creamdust Sample,Ash,ShelvingAreaW,Bagged cream-coloured ash collected from roof dusting incident.,EV-4.1-Maldives-00185,25,no,no,CreamdustSample.PNG
|
||||
Faultblock Tephra,Tephra,ShelvingAreaL,Blocky tephra fragment: impacted during ground rupture.,EV-5.6-Antarctica-00251,51,no,no,FaultblockTephra.PNG
|
||||
Basalt Boulder,Lava,LoggingArea,Weathered basalt boulder from lava flow front.,EV-5.4-Spain-00129,69,no,no,BasaltBoulder.PNG
|
||||
Glassloam,Soil,ShelvingAreaT,Loam with embedded tephric glass and altered feldspar.,EV-3.7-Argentina-00208,30,no,no,Glassloam.PNG
|
||||
Nanoshade Ash,Ash,ShelvingAreaO,Ash sample with nano-crystalline silica.,EV-7.4-Mauritius-00183,42,no,no,NanoshadeAsh.PNG
|
||||
Ironflame Scoria,Lava,ShelvingAreaE,Vesicular scoria: deep red: indicative of high iron content.,EV-3.4-Indonesia-00138,44,no,no,IronflameScoria.PNG
|
||||
|
21
public/Users.csv
Normal file
@ -0,0 +1,21 @@
|
||||
id,createdAt,Name,Email,PasswordHash,Role,Scientist,PurchasedArtefacts,Requests
|
||||
1,2024-05-01T09:00:00,Dr. Emily Neighbour Carter,emily.carter@example.com,$2b$10$edPB/.npmb4FgU.rIoliMOE7JZrxYlRsNmbRXBMgqxVXw9LKdbpSW,SCIENTIST,null,[],[]
|
||||
2,2024-05-01T09:05:00,Dr. Rajiv Menon,rajiv.menon@example.com,$2b$10$edPB/.npmb4FgU.rIoliMOE7JZrxYlRsNmbRXBMgqxVXw9LKdbpSW,SCIENTIST,null,[],[]
|
||||
3,2024-05-01T09:10:00,Dr. Izzy Patterson,izzy.patterson@example.com,$2b$10$edPB/.npmb4FgU.rIoliMOE7JZrxYlRsNmbRXBMgqxVXw9LKdbpSW,SCIENTIST,null,[],[]
|
||||
4,2024-05-01T09:15:00,Dr. Hiroshi Takeda,hiroshi.takeda@example.com,$2b$10$edPB/.npmb4FgU.rIoliMOE7JZrxYlRsNmbRXBMgqxVXw9LKdbpSW,SCIENTIST,null,[],[]
|
||||
5,2024-05-01T09:20:00,Dr. Miriam Hassan,miriam.hassan@example.com,$2b$10$edPB/.npmb4FgU.rIoliMOE7JZrxYlRsNmbRXBMgqxVXw9LKdbpSW,SCIENTIST,null,[],[]
|
||||
6,2024-05-01T09:25:00,Dr. Alice Johnson,alice.johnson@example.com,$2b$10$edPB/.npmb4FgU.rIoliMOE7JZrxYlRsNmbRXBMgqxVXw9LKdbpSW,SCIENTIST,null,[],[]
|
||||
7,2024-05-01T09:30:00,Tim Howitz,tim.howitz@example.com,$2b$10$edPB/.npmb4FgU.rIoliMOE7JZrxYlRsNmbRXBMgqxVXw9LKdbpSW,ADMIN,null,[],[]
|
||||
8,2024-05-01T09:35:00,Dr. Natalia Petrova,natalia.petrova@example.com,$2b$10$edPB/.npmb4FgU.rIoliMOE7JZrxYlRsNmbRXBMgqxVXw9LKdbpSW,SCIENTIST,null,[],[]
|
||||
9,2024-05-01T09:40:00,Dr. Li Cheng,li.cheng@example.com,$2b$10$edPB/.npmb4FgU.rIoliMOE7JZrxYlRsNmbRXBMgqxVXw9LKdbpSW,SCIENTIST,null,[],[]
|
||||
10,2024-05-01T09:45:00,Dr. Javier Ortega,javier.ortega@example.com,$2b$10$edPB/.npmb4FgU.rIoliMOE7JZrxYlRsNmbRXBMgqxVXw9LKdbpSW,SCIENTIST,null,[],[]
|
||||
11,2024-05-01T09:50:00,Dr. Priya Sharma,priya.sharma@example.com,$2b$10$edPB/.npmb4FgU.rIoliMOE7JZrxYlRsNmbRXBMgqxVXw9LKdbpSW,SCIENTIST,null,[],[]
|
||||
12,2024-05-01T09:55:00,Dr. Lukeshan Thananchayan,lukeshan.thananchayan@example.com,$2b$10$edPB/.npmb4FgU.rIoliMOE7JZrxYlRsNmbRXBMgqxVXw9LKdbpSW,SCIENTIST,null,[],[]
|
||||
13,2024-05-01T10:00:00,Dr. Elena Fischer,elena.fischer@example.com,$2b$10$edPB/.npmb4FgU.rIoliMOE7JZrxYlRsNmbRXBMgqxVXw9LKdbpSW,SCIENTIST,null,[],[]
|
||||
14,2024-05-01T10:05:00,Dr. Mohammed Al-Farsi,mohammed.alfarsi@example.com,$2b$10$edPB/.npmb4FgU.rIoliMOE7JZrxYlRsNmbRXBMgqxVXw9LKdbpSW,SCIENTIST,null,[],[]
|
||||
15,2024-05-01T10:10:00,Dr. Jane Wong,jane.wong@example.com,$2b$10$edPB/.npmb4FgU.rIoliMOE7JZrxYlRsNmbRXBMgqxVXw9LKdbpSW,SCIENTIST,null,[],[]
|
||||
16,2024-05-01T10:15:00,Dr. Carlos Gutierrez,carlos.gutierrez@example.com,$2b$10$edPB/.npmb4FgU.rIoliMOE7JZrxYlRsNmbRXBMgqxVXw9LKdbpSW,SCIENTIST,null,[],[]
|
||||
17,2024-05-01T10:20:00,Dr. Wei Zhao,wei.zhao@example.com,$2b$10$edPB/.npmb4FgU.rIoliMOE7JZrxYlRsNmbRXBMgqxVXw9LKdbpSW,SCIENTIST,null,[],[]
|
||||
18,2024-05-01T10:25:00,Dr. Antonio Rosales,antonio.rosales@example.com,$2b$10$edPB/.npmb4FgU.rIoliMOE7JZrxYlRsNmbRXBMgqxVXw9LKdbpSW,SCIENTIST,null,[],[]
|
||||
19,2024-05-01T10:30:00,Dr. Kate Wilson,kate.wilson@example.com,$2b$10$edPB/.npmb4FgU.rIoliMOE7JZrxYlRsNmbRXBMgqxVXw9LKdbpSW,SCIENTIST,null,[],[]
|
||||
20,2024-05-01T10:35:00,Dr. Lina Malik,lina.malik@example.com,$2b$10$edPB/.npmb4FgU.rIoliMOE7JZrxYlRsNmbRXBMgqxVXw9LKdbpSW,SCIENTIST,null,[],[]
|
||||
|
BIN
public/artefactImages/BasaltBoulder.PNG
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
public/artefactImages/BlendedBar.PNG
Normal file
|
After Width: | Height: | Size: 672 KiB |
BIN
public/artefactImages/BubblewallTephra.PNG
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
public/artefactImages/CharcoalDrift.PNG
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
public/artefactImages/CreamdustSample.PNG
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
public/artefactImages/FaultblockTephra.PNG
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
public/artefactImages/FoamcorePumice.PNG
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
public/artefactImages/Glassloam.PNG
Normal file
|
After Width: | Height: | Size: 264 KiB |
BIN
public/artefactImages/GroundwaterShard.PNG
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
public/artefactImages/HumicLoam.PNG
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/artefactImages/IronflameScoria.PNG
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/artefactImages/LayercakeEarth.PNG
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
public/artefactImages/NanoshadeAsh.PNG
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/artefactImages/ObsidianPillar.PNG
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
public/artefactImages/OlivineStone.PNG
Normal file
|
After Width: | Height: | Size: 246 KiB |
BIN
public/artefactImages/PeatNest.PNG
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
public/artefactImages/PotashPowder.PNG
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
public/artefactImages/RedslideSoil.PNG
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
public/artefactImages/RopeformFragment.PNG
Normal file
|
After Width: | Height: | Size: 341 KiB |
BIN
public/artefactImages/Shatterrock.PNG
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/artefactImages/SpectrumClast.PNG
Normal file
|
After Width: | Height: | Size: 404 KiB |
BIN
public/artefactImages/SpindleBomb.PNG
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
@ -1,375 +1,384 @@
|
||||
"use client";
|
||||
import React, { useState, useRef } from "react";
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
type Role = "ADMIN" | "GUEST" | "SCIENTIST";
|
||||
const roleLabels: Record<Role, string> = {
|
||||
ADMIN: "Admin",
|
||||
GUEST: "Guest",
|
||||
SCIENTIST: "Scientist",
|
||||
};
|
||||
ADMIN: "Admin",
|
||||
GUEST: "Guest",
|
||||
SCIENTIST: "Scientist",
|
||||
};
|
||||
type User = {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
password: string;
|
||||
createdAt: string;
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
password: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
// todo create api route to get users, with auth for only admin
|
||||
// todo add management of only junior scientists if senior scientist
|
||||
const initialUsers: User[] = [
|
||||
{ email: "john@example.com", name: "John Doe", role: "ADMIN", password: "secret1", createdAt: "2024-06-21T09:15:01Z" ,id:1},
|
||||
{ email: "jane@example.com", name: "Jane Smith", role: "GUEST", password: "secret2", createdAt: "2024-06-21T10:01:09Z" ,id:2},
|
||||
{ email: "bob@example.com", name: "Bob Brown", role: "SCIENTIST", password: "secret3", createdAt: "2024-06-21T12:13:45Z" ,id:3},
|
||||
{ email: "alice@example.com", name: "Alice Johnson",role: "GUEST", password: "secret4", createdAt: "2024-06-20T18:43:20Z" ,id:4},
|
||||
{ email: "eve@example.com", name: "Eve Black", role: "ADMIN", password: "secret5", createdAt: "2024-06-20T19:37:10Z" ,id:5},
|
||||
{ email: "dave@example.com", name: "Dave Clark", role: "GUEST", password: "pw", createdAt: "2024-06-19T08:39:10Z" ,id:6},
|
||||
{ email: "fred@example.com", name: "Fred Fox", role: "GUEST", password: "pw", createdAt: "2024-06-19T09:11:52Z" ,id:7},
|
||||
{ email: "ginny@example.com", name: "Ginny Hall", role: "SCIENTIST", password: "pw", createdAt: "2024-06-17T14:56:27Z" ,id:8},
|
||||
{ email: "harry@example.com", name: "Harry Lee", role: "ADMIN", password: "pw", createdAt: "2024-06-16T19:28:11Z" ,id:9},
|
||||
{ email: "ivy@example.com", name: "Ivy Volt", role: "ADMIN", password: "pw", createdAt: "2024-06-15T21:04:05Z" ,id:10},
|
||||
{ email: "kate@example.com", name: "Kate Moss", role: "SCIENTIST", password: "pw", createdAt: "2024-06-14T11:16:35Z" ,id:11},
|
||||
{ email: "leo@example.com", name: "Leo Garrison", role: "GUEST", password: "pw", createdAt: "2024-06-12T08:02:51Z" ,id:12},
|
||||
{ email: "isaac@example.com", name: "Isaac Yang", role: "GUEST", password: "pw", createdAt: "2024-06-12T15:43:29Z" ,id:13},
|
||||
];
|
||||
const sortFields = [ // Sort box options
|
||||
{ label: "Name", value: "name" },
|
||||
{ label: "Email", value: "email" },
|
||||
{ email: "john@example.com", name: "John Doe", role: "ADMIN", password: "secret1", createdAt: "2024-06-21T09:15:01Z", id: 1 },
|
||||
{ email: "jane@example.com", name: "Jane Smith", role: "GUEST", password: "secret2", createdAt: "2024-06-21T10:01:09Z", id: 2 },
|
||||
{
|
||||
email: "bob@example.com",
|
||||
name: "Bob Brown",
|
||||
role: "SCIENTIST",
|
||||
password: "secret3",
|
||||
createdAt: "2024-06-21T12:13:45Z",
|
||||
id: 3,
|
||||
},
|
||||
{
|
||||
email: "alice@example.com",
|
||||
name: "Alice Johnson",
|
||||
role: "GUEST",
|
||||
password: "secret4",
|
||||
createdAt: "2024-06-20T18:43:20Z",
|
||||
id: 4,
|
||||
},
|
||||
{ email: "eve@example.com", name: "Eve Black", role: "ADMIN", password: "secret5", createdAt: "2024-06-20T19:37:10Z", id: 5 },
|
||||
{ email: "dave@example.com", name: "Dave Clark", role: "GUEST", password: "pw", createdAt: "2024-06-19T08:39:10Z", id: 6 },
|
||||
{ email: "fred@example.com", name: "Fred Fox", role: "GUEST", password: "pw", createdAt: "2024-06-19T09:11:52Z", id: 7 },
|
||||
{ email: "ginny@example.com", name: "Ginny Hall", role: "SCIENTIST", password: "pw", createdAt: "2024-06-17T14:56:27Z", id: 8 },
|
||||
{ email: "harry@example.com", name: "Harry Lee", role: "ADMIN", password: "pw", createdAt: "2024-06-16T19:28:11Z", id: 9 },
|
||||
{ email: "ivy@example.com", name: "Ivy Volt", role: "ADMIN", password: "pw", createdAt: "2024-06-15T21:04:05Z", id: 10 },
|
||||
{ email: "kate@example.com", name: "Kate Moss", role: "SCIENTIST", password: "pw", createdAt: "2024-06-14T11:16:35Z", id: 11 },
|
||||
{ email: "leo@example.com", name: "Leo Garrison", role: "GUEST", password: "pw", createdAt: "2024-06-12T08:02:51Z", id: 12 },
|
||||
{ email: "isaac@example.com", name: "Isaac Yang", role: "GUEST", password: "pw", createdAt: "2024-06-12T15:43:29Z", id: 13 },
|
||||
];
|
||||
const sortFields = [
|
||||
// Sort box options
|
||||
{ label: "Name", value: "name" },
|
||||
{ label: "Email", value: "email" },
|
||||
] as const;
|
||||
|
||||
type SortField = typeof sortFields[number]["value"];
|
||||
type SortField = (typeof sortFields)[number]["value"];
|
||||
type SortDir = "asc" | "desc";
|
||||
const dirLabels: Record<SortDir, string> = { asc: "ascending", desc: "descending" };
|
||||
const fieldLabels: Record<SortField, string> = { name: "Name", email: "Email" };
|
||||
|
||||
export default function AdminPage() {
|
||||
const [users, setUsers] = useState<User[]>(initialUsers);
|
||||
const [selectedEmail, setSelectedEmail] = useState<string | null>(null);
|
||||
const [users, setUsers] = useState<User[]>(initialUsers);
|
||||
const [selectedEmail, setSelectedEmail] = useState<string | null>(null);
|
||||
|
||||
// Local edit state for SCIENTIST form
|
||||
const [editUser, setEditUser] = useState<User | null>(null);
|
||||
// Reset editUser when the selected user changes
|
||||
React.useEffect(() => {
|
||||
if (!selectedEmail) setEditUser(null);
|
||||
else {
|
||||
const user = users.find(u => u.email === selectedEmail);
|
||||
setEditUser(user ? { ...user } : null);
|
||||
}
|
||||
}, [selectedEmail, users]);
|
||||
// Local edit state for SCIENTIST form
|
||||
const [editUser, setEditUser] = useState<User | null>(null);
|
||||
// Reset editUser when the selected user changes
|
||||
React.useEffect(() => {
|
||||
if (!selectedEmail) setEditUser(null);
|
||||
else {
|
||||
const user = users.find((u) => u.email === selectedEmail);
|
||||
setEditUser(user ? { ...user } : null);
|
||||
}
|
||||
}, [selectedEmail, users]);
|
||||
|
||||
// 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);
|
||||
// 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);
|
||||
}, []);
|
||||
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
|
||||
const filteredUsers = users.filter(
|
||||
(user) => roleFilter === "all" || user.role === roleFilter
|
||||
);
|
||||
const searchedUsers = filteredUsers.filter(user =>
|
||||
user[searchField].toLowerCase().includes(searchText.toLowerCase())
|
||||
);
|
||||
const sortedUsers = [...searchedUsers].sort((a, b) => {
|
||||
let cmp = a[sortField].localeCompare(b[sortField]);
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
// Filtering, searching, sorting logic
|
||||
const filteredUsers = users.filter((user) => roleFilter === "all" || user.role === roleFilter);
|
||||
const searchedUsers = filteredUsers.filter((user) => user[searchField].toLowerCase().includes(searchText.toLowerCase()));
|
||||
const sortedUsers = [...searchedUsers].sort((a, b) => {
|
||||
let cmp = a[sortField].localeCompare(b[sortField]);
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
|
||||
// Form input change handler
|
||||
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
if (!editUser) return;
|
||||
const { name, value } = e.target;
|
||||
setEditUser(prev =>
|
||||
prev ? { ...prev, [name]: value } : null
|
||||
);
|
||||
};
|
||||
// Form input change handler
|
||||
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
if (!editUser) return;
|
||||
const { name, value } = e.target;
|
||||
setEditUser((prev) => (prev ? { ...prev, [name]: value } : null));
|
||||
};
|
||||
|
||||
// Update button logic (compare original selectedUser and editUser)
|
||||
const selectedUser = users.find((u) => u.email === selectedEmail);
|
||||
const isEditChanged = React.useMemo(() => {
|
||||
if (!editUser || !selectedUser) return false;
|
||||
// Compare primitive fields
|
||||
return (
|
||||
editUser.name !== selectedUser.name ||
|
||||
editUser.role !== selectedUser.role ||
|
||||
editUser.password !== selectedUser.password
|
||||
);
|
||||
}, [editUser, selectedUser]);
|
||||
// Update button logic (compare original selectedUser and editUser)
|
||||
const selectedUser = users.find((u) => u.email === selectedEmail);
|
||||
const isEditChanged = React.useMemo(() => {
|
||||
if (!editUser || !selectedUser) return false;
|
||||
// Compare primitive fields
|
||||
return (
|
||||
editUser.name !== selectedUser.name || editUser.role !== selectedUser.role || editUser.password !== selectedUser.password
|
||||
);
|
||||
}, [editUser, selectedUser]);
|
||||
|
||||
// Update/save changes
|
||||
const handleUpdate = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editUser) return;
|
||||
setUsers(prev =>
|
||||
prev.map(u =>
|
||||
u.email === editUser.email ? { ...editUser } : u
|
||||
)
|
||||
);
|
||||
// After successful update, update selectedUser local state
|
||||
// (editUser will auto-sync due to useEffect on users)
|
||||
};
|
||||
// Update/save changes
|
||||
const handleUpdate = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editUser) return;
|
||||
setUsers((prev) => prev.map((u) => (u.email === editUser.email ? { ...editUser } : u)));
|
||||
// todo create receiving api route
|
||||
// todo send to api route
|
||||
// After successful update, update selectedUser local state
|
||||
// (editUser will auto-sync due to useEffect on users)
|
||||
};
|
||||
|
||||
// Delete user logic
|
||||
const handleDelete = () => {
|
||||
if (!selectedUser) return;
|
||||
if (!window.confirm(`Are you sure you want to delete "${selectedUser.name}"? This cannot be undone.`)) return;
|
||||
setUsers(prev => prev.filter(u => u.email !== selectedUser.email));
|
||||
setSelectedEmail(null);
|
||||
setEditUser(null);
|
||||
};
|
||||
// Delete user logic
|
||||
const handleDelete = () => {
|
||||
if (!selectedUser) return;
|
||||
if (!window.confirm(`Are you sure you want to delete "${selectedUser.name}"? This cannot be undone.`)) return;
|
||||
setUsers((prev) => prev.filter((u) => u.email !== selectedUser.email));
|
||||
setSelectedEmail(null);
|
||||
setEditUser(null);
|
||||
};
|
||||
|
||||
const allRoles: Role[] = ["ADMIN", "GUEST", "SCIENTIST"];
|
||||
const allRoles: Role[] = ["ADMIN", "GUEST", "SCIENTIST"];
|
||||
|
||||
// Tooltip handling for email field
|
||||
const [showEmailTooltip, setShowEmailTooltip] = useState(false);
|
||||
// Tooltip handling for email field
|
||||
const [showEmailTooltip, setShowEmailTooltip] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex h-full overflow-hidden bg-gray-50">
|
||||
{/* SIDEBAR */}
|
||||
<div className="w-80 h-full border-r border-neutral-200 bg-neutral-100 flex flex-col rounded-l-xl shadow-sm">
|
||||
<div className="p-4 flex flex-col h-full">
|
||||
{/* Search Bar */}
|
||||
<div className="mb-3 flex gap-2">
|
||||
<input
|
||||
className="flex-1 border rounded-lg px-2 py-1 text-sm"
|
||||
placeholder={`Search by ${searchField}`}
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="border rounded-lg px-2 py-1 text-sm bg-white hover:bg-neutral-100 transition font-semibold"
|
||||
style={{ width: "80px" }} // fixed width, adjust as needed
|
||||
onClick={() => setSearchField(field => field === "name" ? "email" : "name")}
|
||||
title={`Switch to searching by ${searchField === "name" ? "Email" : "Name"}`}
|
||||
>
|
||||
{searchField === "name" ? "Email" : "Name"}
|
||||
</button>
|
||||
</div>
|
||||
{/* Filter and Sort Buttons */}
|
||||
<div className="flex gap-2 items-center mb-2">
|
||||
{/* Filter */}
|
||||
<div className="relative" ref={filterDropdownRef}>
|
||||
<button
|
||||
className={`px-3 py-1 rounded-lg border font-semibold flex items-center transition
|
||||
${roleFilter !== "all"
|
||||
? "bg-blue-600 text-white border-blue-600 hover:bg-blue-700"
|
||||
: "bg-white text-gray-700 border hover:bg-neutral-200"}
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex h-full overflow-hidden bg-gray-50">
|
||||
{/* SIDEBAR */}
|
||||
<div className="w-80 h-full border-r border-neutral-200 bg-neutral-100 flex flex-col rounded-l-xl shadow-sm">
|
||||
<div className="p-4 flex flex-col h-full">
|
||||
{/* Search Bar */}
|
||||
<div className="mb-3 flex gap-2">
|
||||
<input
|
||||
className="flex-1 border rounded-lg px-2 py-1 text-sm"
|
||||
placeholder={`Search by ${searchField}`}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="border rounded-lg px-2 py-1 text-sm bg-white hover:bg-neutral-100 transition font-semibold"
|
||||
style={{ width: "80px" }} // fixed width, adjust as needed
|
||||
onClick={() => setSearchField((field) => (field === "name" ? "email" : "name"))}
|
||||
title={`Switch to searching by ${searchField === "name" ? "Email" : "Name"}`}
|
||||
>
|
||||
{searchField === "name" ? "Email" : "Name"}
|
||||
</button>
|
||||
</div>
|
||||
{/* Filter and Sort Buttons */}
|
||||
<div className="flex gap-2 items-center mb-2">
|
||||
{/* Filter */}
|
||||
<div className="relative" ref={filterDropdownRef}>
|
||||
<button
|
||||
className={`px-3 py-1 rounded-lg border font-semibold flex items-center transition
|
||||
${
|
||||
roleFilter !== "all"
|
||||
? "bg-blue-600 text-white border-blue-600 hover:bg-blue-700"
|
||||
: "bg-white text-gray-700 border hover:bg-neutral-200"
|
||||
}
|
||||
`}
|
||||
onClick={() => setFilterDropdownOpen(v => !v)}
|
||||
type="button"
|
||||
>
|
||||
Filter <svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" /></svg>
|
||||
</button>
|
||||
{filterDropdownOpen && (
|
||||
<div className="absolute z-10 mt-1 left-0 bg-white border rounded-lg shadow-sm w-28 py-1">
|
||||
<button
|
||||
onClick={() => { setRoleFilter("all"); setFilterDropdownOpen(false); }}
|
||||
className={`w-full text-left px-3 py-1 hover:bg-blue-50 border-b border-gray-100 last:border-0
|
||||
${roleFilter==="all" ? "font-bold text-blue-600" : ""}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{allRoles.map(role => (
|
||||
<button
|
||||
key={role}
|
||||
onClick={() => { setRoleFilter(role); setFilterDropdownOpen(false); }}
|
||||
className={`w-full text-left px-3 py-1 hover:bg-blue-50 border-b border-gray-100 last:border-0
|
||||
${roleFilter===role ? "font-bold text-blue-600" : ""}`}
|
||||
>
|
||||
{roleLabels[role]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Sort */}
|
||||
<div className="relative" ref={sortDropdownRef}>
|
||||
<button
|
||||
className="px-3 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200"
|
||||
onClick={() => setSortDropdownOpen(v => !v)}
|
||||
type="button"
|
||||
>
|
||||
Sort <svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" /></svg>
|
||||
</button>
|
||||
{sortDropdownOpen && (
|
||||
<div className="absolute z-10 mt-1 left-0 bg-white border rounded-lg shadow-sm w-28 ">
|
||||
{sortFields.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => {
|
||||
setSortField(opt.value);
|
||||
setSortDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 hover:bg-blue-50 border-b border-gray-100 last:border-0
|
||||
${sortField===opt.value ? "font-bold text-blue-600" : ""}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Asc/Desc Toggle */}
|
||||
<button
|
||||
className="ml-2 px-2 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200"
|
||||
onClick={() => setSortDir(d => (d === "asc" ? "desc" : "asc"))}
|
||||
title={sortDir === "asc" ? "Ascending" : "Descending"}
|
||||
type="button"
|
||||
>
|
||||
{sortDir === "asc" ? "↑" : "↓"}
|
||||
</button>
|
||||
</div>
|
||||
{/* Sort status text */}
|
||||
<small className="text-xs text-gray-500 mb-2 px-1">
|
||||
Users sorted by {fieldLabels[sortField]} {dirLabels[sortDir]}
|
||||
</small>
|
||||
{/* USERS LIST: full height, scrollable */}
|
||||
<ul className="overflow-y-auto flex-1 pr-1">
|
||||
{sortedUsers.map((user) => (
|
||||
<li
|
||||
key={user.email}
|
||||
onClick={() => setSelectedEmail(user.email)}
|
||||
className={`rounded-lg cursor-pointer border
|
||||
onClick={() => setFilterDropdownOpen((v) => !v)}
|
||||
type="button"
|
||||
>
|
||||
Filter{" "}
|
||||
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{filterDropdownOpen && (
|
||||
<div className="absolute z-10 mt-1 left-0 bg-white border rounded-lg shadow-sm w-28 py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setRoleFilter("all");
|
||||
setFilterDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-1 hover:bg-blue-50 border-b border-gray-100 last:border-0
|
||||
${roleFilter === "all" ? "font-bold text-blue-600" : ""}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{allRoles.map((role) => (
|
||||
<button
|
||||
key={role}
|
||||
onClick={() => {
|
||||
setRoleFilter(role);
|
||||
setFilterDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-1 hover:bg-blue-50 border-b border-gray-100 last:border-0
|
||||
${roleFilter === role ? "font-bold text-blue-600" : ""}`}
|
||||
>
|
||||
{roleLabels[role]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Sort */}
|
||||
<div className="relative" ref={sortDropdownRef}>
|
||||
<button
|
||||
className="px-3 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200"
|
||||
onClick={() => setSortDropdownOpen((v) => !v)}
|
||||
type="button"
|
||||
>
|
||||
Sort{" "}
|
||||
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{sortDropdownOpen && (
|
||||
<div className="absolute z-10 mt-1 left-0 bg-white border rounded-lg shadow-sm w-28 ">
|
||||
{sortFields.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => {
|
||||
setSortField(opt.value);
|
||||
setSortDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 hover:bg-blue-50 border-b border-gray-100 last:border-0
|
||||
${sortField === opt.value ? "font-bold text-blue-600" : ""}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Asc/Desc Toggle */}
|
||||
<button
|
||||
className="ml-2 px-2 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200"
|
||||
onClick={() => setSortDir((d) => (d === "asc" ? "desc" : "asc"))}
|
||||
title={sortDir === "asc" ? "Ascending" : "Descending"}
|
||||
type="button"
|
||||
>
|
||||
{sortDir === "asc" ? "↑" : "↓"}
|
||||
</button>
|
||||
</div>
|
||||
{/* Sort status text */}
|
||||
<small className="text-xs text-gray-500 mb-2 px-1">
|
||||
Users sorted by {fieldLabels[sortField]} {dirLabels[sortDir]}
|
||||
</small>
|
||||
{/* USERS LIST: full height, scrollable */}
|
||||
<ul className="overflow-y-auto flex-1 pr-1">
|
||||
{sortedUsers.map((user) => (
|
||||
<li
|
||||
key={user.email}
|
||||
onClick={() => setSelectedEmail(user.email)}
|
||||
className={`rounded-lg cursor-pointer border
|
||||
${selectedEmail === user.email ? "bg-blue-100 border-blue-400" : "hover:bg-gray-200 border-transparent"}
|
||||
transition px-2 py-1 mb-1`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium truncate">{user.name}</span>
|
||||
<span className="ml-1 text-xs px-2 py-0.5 rounded-lg bg-gray-200 text-gray-700">{roleLabels[user.role]}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-0.5">
|
||||
<span className="text-xs text-gray-600 truncate">{user.email}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{sortedUsers.length === 0 && (
|
||||
<li className="text-gray-400 text-center py-6">No users found.</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{/* MAIN PANEL */}
|
||||
<div className="flex-1 p-10 bg-white overflow-y-auto">
|
||||
{editUser ? (
|
||||
<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>
|
||||
<form className="space-y-4" onSubmit={handleUpdate}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<label className="text-sm font-medium text-gray-700">Account Creation Time:</label>
|
||||
<span className="text-sm text-gray-500">{editUser.createdAt}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<label className="text-sm font-medium text-gray-700">Account ID Number:</label>
|
||||
<span className="text-sm text-gray-500">{editUser.id}</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email (unique):
|
||||
</label>
|
||||
<input
|
||||
className="w-full border px-3 py-2 rounded-lg outline-none bg-gray-100 cursor-not-allowed"
|
||||
type="email"
|
||||
name="email"
|
||||
value={editUser.email}
|
||||
readOnly
|
||||
onMouseEnter={() => setShowEmailTooltip(true)}
|
||||
onMouseLeave={() => setShowEmailTooltip(false)}
|
||||
/>
|
||||
{/* Custom tooltip */}
|
||||
{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">
|
||||
This field cannot be changed. <br />
|
||||
To change the email, delete and re-add the user.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Name:
|
||||
</label>
|
||||
<input
|
||||
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||
type="text"
|
||||
name="name"
|
||||
value={editUser.name}
|
||||
onChange={handleEditChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Role:
|
||||
</label>
|
||||
<select
|
||||
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||
name="role"
|
||||
value={editUser.role}
|
||||
onChange={handleEditChange}
|
||||
>
|
||||
{allRoles.map((role) => (
|
||||
<option key={role} value={role}>{roleLabels[role]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password:
|
||||
</label>
|
||||
<input
|
||||
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||
type="text"
|
||||
name="password"
|
||||
value={editUser.password}
|
||||
onChange={handleEditChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end pt-6">
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 bg-red-500 hover:bg-red-600 text-white font-semibold rounded-lg shadow transition"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={`px-4 py-2 rounded-lg font-semibold transition
|
||||
${isEditChanged
|
||||
? "bg-blue-600 hover:bg-blue-700 text-white shadow"
|
||||
: "bg-gray-300 text-gray-500 cursor-not-allowed"
|
||||
}`}
|
||||
disabled={!isEditChanged}
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-400 mt-16 text-lg">
|
||||
Select a user...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium truncate">{user.name}</span>
|
||||
<span className="ml-1 text-xs px-2 py-0.5 rounded-lg bg-gray-200 text-gray-700">{roleLabels[user.role]}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-0.5">
|
||||
<span className="text-xs text-gray-600 truncate">{user.email}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{sortedUsers.length === 0 && <li className="text-gray-400 text-center py-6">No users found.</li>}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{/* MAIN PANEL */}
|
||||
<div className="flex-1 p-24 bg-white overflow-y-auto">
|
||||
{editUser ? (
|
||||
<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>
|
||||
<form className="space-y-4" onSubmit={handleUpdate}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<label className="text-sm font-medium text-gray-700">Account Creation Time:</label>
|
||||
<span className="text-sm text-gray-500">{editUser.createdAt}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<label className="text-sm font-medium text-gray-700">Account ID Number:</label>
|
||||
<span className="text-sm text-gray-500">{editUser.id}</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email (unique):</label>
|
||||
<input
|
||||
className="w-full border px-3 py-2 rounded-lg outline-none bg-gray-100 cursor-not-allowed"
|
||||
type="email"
|
||||
name="email"
|
||||
value={editUser.email}
|
||||
readOnly
|
||||
onMouseEnter={() => setShowEmailTooltip(true)}
|
||||
onMouseLeave={() => setShowEmailTooltip(false)}
|
||||
/>
|
||||
{/* Custom tooltip */}
|
||||
{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">
|
||||
This field cannot be changed. <br />
|
||||
To change the email, delete and re-add the user.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name:</label>
|
||||
<input
|
||||
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||
type="text"
|
||||
name="name"
|
||||
value={editUser.name}
|
||||
onChange={handleEditChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Role:</label>
|
||||
<select
|
||||
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||
name="role"
|
||||
value={editUser.role}
|
||||
onChange={handleEditChange}
|
||||
>
|
||||
{allRoles.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{roleLabels[role]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password:</label>
|
||||
<input
|
||||
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||
type="text"
|
||||
name="password"
|
||||
value={editUser.password}
|
||||
onChange={handleEditChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end pt-6">
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 bg-red-500 hover:bg-red-600 text-white font-semibold rounded-lg shadow transition"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={`px-4 py-2 rounded-lg font-semibold transition
|
||||
${
|
||||
isEditChanged
|
||||
? "bg-blue-600 hover:bg-blue-700 text-white shadow"
|
||||
: "bg-gray-300 text-gray-500 cursor-not-allowed"
|
||||
}`}
|
||||
disabled={!isEditChanged}
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-400 mt-16 text-lg">Select a user...</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { prisma } from "@utils/prisma";
|
||||
import { prisma } from '@utils/prisma';
|
||||
|
||||
// todo add specification of date range in request
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
|
||||
@ -21,7 +21,7 @@ export async function readUserCsv(): Promise<User[]> {
|
||||
csvPath = path.dirname(csvPath); // /src
|
||||
csvPath = path.dirname(csvPath); // /[project]
|
||||
csvPath = path.dirname(csvPath); // /termor-tracker
|
||||
csvPath = path.join(csvPath, "src", "databases", "Users.csv");
|
||||
csvPath = path.join(csvPath, "public","Users.csv");
|
||||
|
||||
// Forms array for user data
|
||||
let results: User[] = [];
|
||||
|
||||
@ -2,8 +2,9 @@ import { parse } from "csv-parse/sync";
|
||||
import fs from "fs/promises";
|
||||
import { NextResponse } from "next/server";
|
||||
import path from "path";
|
||||
import { prisma } from "@utils/prisma";
|
||||
|
||||
import { getRandomNumber } from "@utils/maths";
|
||||
import { prisma } from "@utils/prisma";
|
||||
|
||||
const csvFilePath = path.resolve(process.cwd(), "public/earthquakes.csv");
|
||||
const DISTANCE_THRESHOLD_KM = 500; // Max distance for observatory matching
|
||||
|
||||
@ -2,6 +2,9 @@ import { NextResponse } from "next/server";
|
||||
|
||||
import { prisma } from "@utils/prisma";
|
||||
|
||||
// todo remove if (usingPrisma) code
|
||||
// todo add specification of date range in request
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const events = [
|
||||
|
||||
@ -2,8 +2,8 @@ import bcryptjs from "bcryptjs";
|
||||
import { SignJWT } from "jose";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { prisma } from "@utils/prisma";
|
||||
import { env } from "@utils/env";
|
||||
import { prisma } from "@utils/prisma";
|
||||
|
||||
import { findUserByEmail, passwordStrengthCheck, readUserCsv, User, writeUserCsv } from "../functions/csvReadWrite";
|
||||
|
||||
@ -14,6 +14,7 @@ export async function POST(req: Request) {
|
||||
|
||||
const userData = await readUserCsv();
|
||||
|
||||
// todo remove console logs
|
||||
console.log(userData);
|
||||
console.log("Name:", name); // ! remove
|
||||
console.log("Email:", email); // ! remove
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { JWTPayload } from "@appTypes/JWT";
|
||||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { prisma } from "@utils/prisma";
|
||||
import { env } from "@utils/env";
|
||||
import { verifyJwt } from "@utils/verifyJwt";
|
||||
import { ExtendedArtefact } from "@appTypes/ApiTypes";
|
||||
import { JWTPayload } from "@appTypes/JWT";
|
||||
import { apiAuthMiddleware } from "@utils/apiAuthMiddleware";
|
||||
import { env } from "@utils/env";
|
||||
import { prisma } from "@utils/prisma";
|
||||
import { verifyJwt } from "@utils/verifyJwt";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
|
||||
705
src/app/management/page.tsx
Normal file
@ -0,0 +1,705 @@
|
||||
"use client";
|
||||
import React, { useRef, useState } from "react";
|
||||
|
||||
type Level = "JUNIOR" | "SENIOR";
|
||||
const levelLabels: Record<Level, string> = { JUNIOR: "Junior", SENIOR: "Senior" };
|
||||
type User = { id: number; email: string; name: string; };
|
||||
type Earthquakes = { id: number; code: string; location: string; };
|
||||
type Observatory = { id: number; name: string; location: string; };
|
||||
type Artefact = { id: number; name: string; type: string; };
|
||||
type Scientist = {
|
||||
id: number;
|
||||
createdAt: string;
|
||||
name: string;
|
||||
level: Level;
|
||||
user: User;
|
||||
userId: User["id"];
|
||||
superior: Scientist | null;
|
||||
superiorId: Scientist["id"] | null;
|
||||
subordinates: Scientist[];
|
||||
earthquakes: Earthquakes[];
|
||||
earthquakeIds: number[];
|
||||
observatories: Observatory[];
|
||||
observatoryIds: number[];
|
||||
artefacts: Artefact[];
|
||||
artefactIds: number[];
|
||||
};
|
||||
const users: User[] = [
|
||||
{ id: 1, name: "Albert Einstein", email: "ae@uni.edu" },
|
||||
{ id: 2, name: "Marie Curie", email: "mc@uni.edu" },
|
||||
{ id: 3, name: "Ada Lovelace", email: "al@uni.edu" },
|
||||
{ id: 4, name: "Carl Sagan", email: "cs@uni.edu" },
|
||||
{ id: 5, name: "Isaac Newton", email: "in@uni.edu" }
|
||||
];
|
||||
const artefacts: Artefact[] = [
|
||||
{ id: 1, name: "SeismoRing", type: "Instrument" },
|
||||
{ id: 2, name: "QuakeCube", type: "Sensor" },
|
||||
{ id: 3, name: "WavePen", type: "Recorder" },
|
||||
{ id: 4, name: "TremorNet", type: "AI Chip" }
|
||||
];
|
||||
const observatories: Observatory[] = [
|
||||
{ id: 1, name: "Stanford Observatory", location: "Stanford" },
|
||||
{ id: 2, name: "Tokyo Seismic Center", location: "Tokyo" },
|
||||
{ id: 3, name: "Oxford Observatory", location: "Oxford" },
|
||||
{ id: 4, name: "Mount Wilson", location: "Pasadena" }
|
||||
];
|
||||
const earthquakes: Earthquakes[] = [
|
||||
{ id: 1, code: "EQ-001", location: "San Francisco" },
|
||||
{ id: 2, code: "EQ-002", location: "Tokyo" },
|
||||
{ id: 3, code: "EQ-003", location: "Istanbul" },
|
||||
{ id: 4, code: "EQ-004", location: "Mexico City" },
|
||||
{ id: 5, code: "EQ-005", location: "Rome" }
|
||||
];
|
||||
const scientistList: Scientist[] = [
|
||||
{
|
||||
id: 1,
|
||||
createdAt: "2024-06-01T09:00:00Z",
|
||||
name: "Dr. John Junior",
|
||||
level: "JUNIOR",
|
||||
user: users[0],
|
||||
userId: 1,
|
||||
superior: null,
|
||||
superiorId: 2,
|
||||
subordinates: [],
|
||||
earthquakes: [earthquakes[0], earthquakes[2]],
|
||||
earthquakeIds: [1, 3],
|
||||
observatories: [observatories[0], observatories[1]],
|
||||
observatoryIds: [1, 2],
|
||||
artefacts: [artefacts[0], artefacts[2]],
|
||||
artefactIds: [1, 3],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
createdAt: "2024-06-01T10:00:00Z",
|
||||
name: "Dr. Jane Senior",
|
||||
level: "SENIOR",
|
||||
user: users[1],
|
||||
userId: 2,
|
||||
superior: null,
|
||||
superiorId: null,
|
||||
subordinates: [],
|
||||
earthquakes: [earthquakes[1], earthquakes[3], earthquakes[4]],
|
||||
earthquakeIds: [2, 4, 5],
|
||||
observatories: [observatories[1], observatories[2]],
|
||||
observatoryIds: [2, 3],
|
||||
artefacts: [artefacts[1]],
|
||||
artefactIds: [2],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
createdAt: "2024-06-02T08:00:00Z",
|
||||
name: "Dr. Amy Junior",
|
||||
level: "JUNIOR",
|
||||
user: users[2],
|
||||
userId: 3,
|
||||
superior: null,
|
||||
superiorId: 2,
|
||||
subordinates: [],
|
||||
earthquakes: [earthquakes[0]],
|
||||
earthquakeIds: [1],
|
||||
observatories: [observatories[2]],
|
||||
observatoryIds: [3],
|
||||
artefacts: [artefacts[2], artefacts[3]],
|
||||
artefactIds: [3, 4],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
createdAt: "2024-06-02T08:15:00Z",
|
||||
name: "Prof. Isaac Senior",
|
||||
level: "SENIOR",
|
||||
user: users[4],
|
||||
userId: 5,
|
||||
superior: null,
|
||||
superiorId: null,
|
||||
subordinates: [],
|
||||
earthquakes: [earthquakes[2], earthquakes[3]],
|
||||
earthquakeIds: [3, 4],
|
||||
observatories: [observatories[3]],
|
||||
observatoryIds: [4],
|
||||
artefacts: [artefacts[3]],
|
||||
artefactIds: [4],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
createdAt: "2024-06-02T08:20:00Z",
|
||||
name: "Dr. Carl Junior",
|
||||
level: "JUNIOR",
|
||||
user: users[3],
|
||||
userId: 4,
|
||||
superior: null,
|
||||
superiorId: 4,
|
||||
subordinates: [],
|
||||
earthquakes: [earthquakes[3]],
|
||||
earthquakeIds: [4],
|
||||
observatories: [observatories[1], observatories[2]],
|
||||
observatoryIds: [2, 3],
|
||||
artefacts: [artefacts[0]],
|
||||
artefactIds: [1],
|
||||
}
|
||||
];
|
||||
scientistList[0].superior = scientistList[1];
|
||||
scientistList[2].superior = scientistList[1];
|
||||
scientistList[4].superior = scientistList[3];
|
||||
scientistList[1].subordinates = [scientistList[0], scientistList[2]];
|
||||
scientistList[3].subordinates = [scientistList[4]];
|
||||
const sortFields = [
|
||||
{ label: "Name", value: "name" },
|
||||
{ label: "Level", value: "level" },
|
||||
] as const;
|
||||
type SortField = (typeof sortFields)[number]["value"];
|
||||
type SortDir = "asc" | "desc";
|
||||
const dirLabels: Record<SortDir, string> = { asc: "ascending", desc: "descending" };
|
||||
const fieldLabels: Record<SortField, string> = { name: "Name", level: "Level" };
|
||||
|
||||
export default function Scientist() {
|
||||
const [scientists, setScientists] = useState<Scientist[]>(scientistList);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [editScientist, setEditScientist] = useState<Scientist | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (selectedId == null) setEditScientist(null);
|
||||
else {
|
||||
const sc = scientists.find((x) => x.id === selectedId);
|
||||
setEditScientist(sc ? { ...sc } : null);
|
||||
}
|
||||
}, [selectedId, scientists]);
|
||||
const [searchField, setSearchField] = useState<SortField>("name");
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [levelFilter, setLevelFilter] = useState<Level | "all">("all");
|
||||
const [sortField, setSortField] = useState<SortField>("name");
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||
const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
|
||||
const [sortDropdownOpen, setSortDropdownOpen] = useState(false);
|
||||
const filterDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const sortDropdownRef = useRef<HTMLDivElement>(null);
|
||||
React.useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (filterDropdownRef.current && !filterDropdownRef.current.contains(e.target as Node)) setFilterDropdownOpen(false);
|
||||
if (sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as Node)) setSortDropdownOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, []);
|
||||
const filtered = scientists.filter((s) => levelFilter === "all" || s.level === levelFilter);
|
||||
const searched = filtered.filter((s) => s[searchField].toLowerCase().includes(searchText.toLowerCase()));
|
||||
const sorted = [...searched].sort((a, b) => {
|
||||
let cmp = a[sortField].localeCompare(b[sortField]);
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
const allLevels: Level[] = ["JUNIOR", "SENIOR"];
|
||||
const allUsers = users;
|
||||
const allObservatories = observatories;
|
||||
const allArtefacts = artefacts;
|
||||
const allEarthquakes = earthquakes;
|
||||
const allOtherScientistOptions = (curId?: number) =>
|
||||
scientists.filter((s) => s.id !== curId);
|
||||
// -- Queries for selectors
|
||||
const [artefactQuery, setArtefactQuery] = useState("");
|
||||
const [earthquakeQuery, setEarthquakeQuery] = useState("");
|
||||
const [observatoryQuery, setObservatoryQuery] = useState("");
|
||||
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
if (!editScientist) return;
|
||||
const { name, value } = e.target;
|
||||
if (name === "superiorId") {
|
||||
const supId = value === "" ? null : Number(value);
|
||||
setEditScientist((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
superiorId: supId,
|
||||
superior: supId ? scientists.find((s) => s.id === supId) ?? null : null,
|
||||
}
|
||||
: null
|
||||
);
|
||||
} else if (name === "level") {
|
||||
setEditScientist((prev) => (prev ? { ...prev, level: value as Level } : null));
|
||||
} else if (name === "userId") {
|
||||
const user = users.find((u) => u.id === Number(value));
|
||||
setEditScientist((prev) => (prev && user ? { ...prev, user, userId: user.id } : prev));
|
||||
} else {
|
||||
setEditScientist((prev) => (prev ? { ...prev, [name]: value } : null));
|
||||
}
|
||||
};
|
||||
function handleArtefactCheck(id: number) {
|
||||
if (!editScientist) return;
|
||||
let nextIds = editScientist.artefactIds.includes(id)
|
||||
? editScientist.artefactIds.filter((ai) => ai !== id)
|
||||
: [...editScientist.artefactIds, id];
|
||||
setEditScientist((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
artefactIds: nextIds,
|
||||
artefacts: allArtefacts.filter((a) => nextIds.includes(a.id)),
|
||||
}
|
||||
: null
|
||||
);
|
||||
}
|
||||
function handleEarthquakeCheck(id: number) {
|
||||
if (!editScientist) return;
|
||||
let nextIds = editScientist.earthquakeIds.includes(id)
|
||||
? editScientist.earthquakeIds.filter((ei) => ei !== id)
|
||||
: [...editScientist.earthquakeIds, id];
|
||||
setEditScientist((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
earthquakeIds: nextIds,
|
||||
earthquakes: allEarthquakes.filter((e) => nextIds.includes(e.id)),
|
||||
}
|
||||
: null
|
||||
);
|
||||
}
|
||||
function handleObservatoryCheck(id: number) {
|
||||
if (!editScientist) return;
|
||||
let nextIds = editScientist.observatoryIds.includes(id)
|
||||
? editScientist.observatoryIds.filter((oi) => oi !== id)
|
||||
: [...editScientist.observatoryIds, id];
|
||||
setEditScientist((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
observatoryIds: nextIds,
|
||||
observatories: allObservatories.filter((obs) => nextIds.includes(obs.id)),
|
||||
}
|
||||
: null
|
||||
);
|
||||
}
|
||||
const selectedScientist = scientists.find((u) => u.id === selectedId);
|
||||
function arraysEqualSet(a: number[], b: number[]) {
|
||||
return a.length === b.length && a.every((v) => b.includes(v));
|
||||
}
|
||||
const isEditChanged = React.useMemo(() => {
|
||||
if (!editScientist || !selectedScientist) return false;
|
||||
return (
|
||||
editScientist.name !== selectedScientist.name ||
|
||||
editScientist.level !== selectedScientist.level ||
|
||||
editScientist.superiorId !== selectedScientist.superiorId ||
|
||||
editScientist.userId !== selectedScientist.userId ||
|
||||
!arraysEqualSet(editScientist.observatoryIds, selectedScientist.observatoryIds) ||
|
||||
!arraysEqualSet(editScientist.artefactIds, selectedScientist.artefactIds) ||
|
||||
!arraysEqualSet(editScientist.earthquakeIds, selectedScientist.earthquakeIds)
|
||||
);
|
||||
}, [editScientist, selectedScientist]);
|
||||
const handleUpdate = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editScientist) return;
|
||||
setScientists((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === editScientist.id
|
||||
? {
|
||||
...editScientist,
|
||||
artefacts: allArtefacts.filter((a) => editScientist.artefactIds.includes(a.id)),
|
||||
earthquakes: allEarthquakes.filter((eq) => editScientist.earthquakeIds.includes(eq.id)),
|
||||
observatories: allObservatories.filter((obs) => editScientist.observatoryIds.includes(obs.id)),
|
||||
subordinates: scientistList.filter((s) => s.superiorId === editScientist.id),
|
||||
}
|
||||
: item
|
||||
)
|
||||
);
|
||||
};
|
||||
const handleDelete = () => {
|
||||
if (!selectedScientist) return;
|
||||
if (!window.confirm(`Are you sure you want to delete "${selectedScientist.name}"?`)) return;
|
||||
setScientists((prev) => prev.filter((i) => i.id !== selectedScientist.id));
|
||||
setSelectedId(null);
|
||||
setEditScientist(null);
|
||||
};
|
||||
const searchedArtefacts = allArtefacts.filter(
|
||||
(a) =>
|
||||
artefactQuery.trim() === "" ||
|
||||
a.name.toLowerCase().includes(artefactQuery.toLowerCase()) ||
|
||||
a.id.toString().includes(artefactQuery)
|
||||
);
|
||||
const searchedEarthquakes = allEarthquakes.filter(
|
||||
(eq) =>
|
||||
earthquakeQuery.trim() === "" ||
|
||||
eq.id.toString().includes(earthquakeQuery) ||
|
||||
eq.code.toLowerCase().includes(earthquakeQuery.toLowerCase())
|
||||
);
|
||||
const searchedObservatories = allObservatories.filter(
|
||||
(obs) =>
|
||||
observatoryQuery.trim() === "" ||
|
||||
obs.name.toLowerCase().includes(observatoryQuery.toLowerCase()) ||
|
||||
obs.location.toLowerCase().includes(observatoryQuery.toLowerCase()) ||
|
||||
obs.id.toString().includes(observatoryQuery)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex h-full overflow-hidden bg-gray-50">
|
||||
{/* SIDEBAR */}
|
||||
<div className="w-80 h-full border-r bg-neutral-100 flex flex-col rounded-l-xl shadow-sm">
|
||||
<div className="p-4 flex flex-col h-full">
|
||||
<div className="mb-3 flex gap-2">
|
||||
<input
|
||||
className="flex-1 border rounded-lg px-2 py-1 text-sm"
|
||||
placeholder={`Search by ${searchField}`}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="border rounded-lg px-2 py-1 text-sm bg-white hover:bg-neutral-100 transition font-semibold"
|
||||
style={{ width: "80px" }}
|
||||
onClick={() => setSearchField((field) => (field === "name" ? "level" : "name"))}
|
||||
title={`Switch to searching by ${searchField === "name" ? "Level" : "Name"}`}
|
||||
>
|
||||
{searchField === "name" ? "Level" : "Name"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center mb-2">
|
||||
{/* Filter dropdown */}
|
||||
<div className="relative" ref={filterDropdownRef}>
|
||||
<button
|
||||
className={`px-3 py-1 rounded-lg border font-semibold flex items-center transition
|
||||
${
|
||||
levelFilter !== "all"
|
||||
? "bg-blue-600 text-white border-blue-600 hover:bg-blue-700"
|
||||
: "bg-white text-gray-700 border hover:bg-neutral-200"
|
||||
}`}
|
||||
onClick={() => setFilterDropdownOpen((v) => !v)}
|
||||
type="button"
|
||||
>
|
||||
Filter
|
||||
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{filterDropdownOpen && (
|
||||
<div className="absolute z-10 mt-1 left-0 bg-white border rounded-lg shadow-sm w-28 py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setLevelFilter("all");
|
||||
setFilterDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-1 hover:bg-blue-50 border-b border-gray-100 last:border-0
|
||||
${levelFilter === "all" ? "font-bold text-blue-600" : ""}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{allLevels.map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => {
|
||||
setLevelFilter(level);
|
||||
setFilterDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-1 hover:bg-blue-50 border-b border-gray-100 last:border-0
|
||||
${levelFilter === level ? "font-bold text-blue-600" : ""}`}
|
||||
>
|
||||
{levelLabels[level]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* sort dropdown */}
|
||||
<div className="relative" ref={sortDropdownRef}>
|
||||
<button
|
||||
className="px-3 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200"
|
||||
onClick={() => setSortDropdownOpen((v) => !v)}
|
||||
type="button"
|
||||
>
|
||||
Sort
|
||||
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{sortDropdownOpen && (
|
||||
<div className="absolute z-10 mt-1 left-0 bg-white border rounded-lg shadow-sm w-28 ">
|
||||
{sortFields.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => {
|
||||
setSortField(opt.value);
|
||||
setSortDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 hover:bg-blue-50 border-b border-gray-100 last:border-0
|
||||
${sortField === opt.value ? "font-bold text-blue-600" : ""}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="ml-2 px-2 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200"
|
||||
onClick={() => setSortDir((d) => (d === "asc" ? "desc" : "asc"))}
|
||||
title={sortDir === "asc" ? "Ascending" : "Descending"}
|
||||
type="button"
|
||||
>
|
||||
{sortDir === "asc" ? "↑" : "↓"}
|
||||
</button>
|
||||
</div>
|
||||
<small className="text-xs text-gray-500 mb-2 px-1">
|
||||
Scientists sorted by {fieldLabels[sortField]} {dirLabels[sortDir]}
|
||||
</small>
|
||||
<ul className="overflow-y-auto flex-1 pr-1">
|
||||
{sorted.map((sci) => (
|
||||
<li
|
||||
key={sci.id}
|
||||
onClick={() => setSelectedId(sci.id)}
|
||||
className={`rounded-lg cursor-pointer border
|
||||
${selectedId === sci.id ? "bg-blue-100 border-blue-400" : "hover:bg-gray-200 border-transparent"}
|
||||
transition px-2 py-1 mb-1`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium truncate">{sci.name}</span>
|
||||
<span className="ml-1 text-xs px-2 py-0.5 rounded-lg bg-gray-200 text-gray-700">{levelLabels[sci.level]}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-0.5">
|
||||
<span className="text-xs text-gray-600 truncate">{sci.user.name} ({sci.user.email})</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{sorted.length === 0 && <li className="text-gray-400 text-center py-6">No scientists found.</li>}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{/* MAIN PANEL */}
|
||||
<div className="flex-1 flex justify-center p-24 bg-white overflow-y-auto">
|
||||
{editScientist ? (
|
||||
<div
|
||||
className="
|
||||
max-w-4xl w-full bg-white rounded-xl shadow flex flex-col pt-4 pb-3 px-5
|
||||
"
|
||||
style={{
|
||||
minHeight: 0,
|
||||
maxHeight: 780,
|
||||
overflow: "hidden"
|
||||
}}
|
||||
>
|
||||
{/* Heading */}
|
||||
<div className="text-xl font-bold text-gray-900 mb-3 px-1">Edit Scientist</div>
|
||||
<form className="flex flex-col flex-1 min-h-0" onSubmit={handleUpdate}>
|
||||
<div className="flex flex-col lg:flex-row gap-8 min-h-0 flex-1">
|
||||
{/* LEFT COLUMN */}
|
||||
<div className="flex-1 flex flex-col gap-4 min-h-0 overflow-y-auto">
|
||||
<div>
|
||||
<div className="text-xs text-gray-400">Created at</div>
|
||||
<div className="font-mono text-sm">{editScientist.createdAt}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-400">ID</div>
|
||||
<div className="font-mono text-sm">{editScientist.id}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name:</label>
|
||||
<input
|
||||
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||
type="text"
|
||||
name="name"
|
||||
value={editScientist.name}
|
||||
onChange={handleEditChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Level:</label>
|
||||
<select
|
||||
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||
name="level"
|
||||
value={editScientist.level}
|
||||
onChange={handleEditChange}
|
||||
>
|
||||
{allLevels.map((lvl) => (
|
||||
<option key={lvl} value={lvl}>{levelLabels[lvl]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">User (email):</label>
|
||||
<select
|
||||
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||
name="userId"
|
||||
value={editScientist.userId}
|
||||
onChange={handleEditChange}
|
||||
>
|
||||
{allUsers.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name} ({u.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Superior:</label>
|
||||
<select
|
||||
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||
name="superiorId"
|
||||
value={editScientist.superiorId ?? ""}
|
||||
onChange={handleEditChange}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{allOtherScientistOptions(editScientist.id).map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Subordinates:</label>
|
||||
<div className="flex flex-wrap gap-1 bg-gray-200 rounded px-2 py-2 min-h-[28px]">
|
||||
{editScientist.subordinates.length > 0
|
||||
? editScientist.subordinates.map((s) => (
|
||||
<span
|
||||
key={s.id}
|
||||
className="px-2 py-1 rounded-full bg-gray-300 text-gray-700 text-xs font-medium"
|
||||
>
|
||||
{s.name}
|
||||
</span>
|
||||
))
|
||||
: <span className="text-sm text-gray-400">None</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* RIGHT COLUMN */}
|
||||
<div className="flex-1 flex flex-col gap-4 min-h-0 overflow-y-auto">
|
||||
{/* Observatories Box */}
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex justify-between items-end">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Observatories:</label>
|
||||
<input
|
||||
type="text"
|
||||
className="ml-2 text-xs border rounded px-2 py-1"
|
||||
placeholder="Search by name/location/id..."
|
||||
value={observatoryQuery}
|
||||
onChange={(e) => setObservatoryQuery(e.target.value)}
|
||||
style={{maxWidth: "55%"}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="border rounded px-2 py-1 mt-1 bg-gray-50 flex-1"
|
||||
style={{
|
||||
minHeight: 160,
|
||||
maxHeight: 230,
|
||||
overflowY: "auto"
|
||||
}}
|
||||
>
|
||||
{searchedObservatories.length === 0 ? (
|
||||
<div className="text-xs text-gray-400 text-center py-2">No observatories</div>
|
||||
) : (
|
||||
searchedObservatories.map((obs) => (
|
||||
<label key={obs.id} className="block text-xs py-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mr-2"
|
||||
checked={editScientist.observatoryIds.includes(obs.id)}
|
||||
onChange={() => handleObservatoryCheck(obs.id)}
|
||||
/>
|
||||
#{obs.id} {obs.name} ({obs.location})
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Earthquakes Box */}
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex justify-between items-end">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Earthquakes:</label>
|
||||
<input
|
||||
type="text"
|
||||
className="ml-2 text-xs border rounded px-2 py-1"
|
||||
placeholder="Search ID or code..."
|
||||
value={earthquakeQuery}
|
||||
onChange={(e) => setEarthquakeQuery(e.target.value)}
|
||||
style={{maxWidth: "55%"}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="border rounded px-2 py-1 mt-1 bg-gray-50 flex-1"
|
||||
style={{
|
||||
minHeight: 160,
|
||||
maxHeight: 230,
|
||||
overflowY: "auto"
|
||||
}}
|
||||
>
|
||||
{searchedEarthquakes.length === 0 ? (
|
||||
<div className="text-xs text-gray-400 text-center py-2">No earthquakes</div>
|
||||
) : (
|
||||
searchedEarthquakes.map((eq) => (
|
||||
<label key={eq.id} className="block text-xs py-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mr-2"
|
||||
checked={editScientist.earthquakeIds.includes(eq.id)}
|
||||
onChange={() => handleEarthquakeCheck(eq.id)}
|
||||
/>
|
||||
#{eq.id} ({eq.code}) – {eq.location}
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Artefacts Box */}
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex justify-between items-end">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Artefacts:</label>
|
||||
<input
|
||||
type="text"
|
||||
className="ml-2 text-xs border rounded px-2 py-1"
|
||||
placeholder="Search ID or name..."
|
||||
value={artefactQuery}
|
||||
onChange={(e) => setArtefactQuery(e.target.value)}
|
||||
style={{maxWidth: "55%"}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="border rounded px-2 py-1 mt-1 bg-gray-50 flex-1"
|
||||
style={{
|
||||
minHeight: 160,
|
||||
maxHeight: 230,
|
||||
overflowY: "auto"
|
||||
}}
|
||||
>
|
||||
{searchedArtefacts.length === 0 ? (
|
||||
<div className="text-xs text-gray-400 text-center py-2">No artefacts</div>
|
||||
) : (
|
||||
searchedArtefacts.map((a) => (
|
||||
<label key={a.id} className="block text-xs py-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mr-2"
|
||||
checked={editScientist.artefactIds.includes(a.id)}
|
||||
onChange={() => handleArtefactCheck(a.id)}
|
||||
/>
|
||||
#{a.id} {a.name} ({a.type})
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* BUTTONS */}
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 bg-red-500 hover:bg-red-600 text-white font-semibold rounded-lg shadow transition"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={`px-4 py-2 rounded-lg font-semibold transition
|
||||
${
|
||||
isEditChanged
|
||||
? "bg-blue-600 hover:bg-blue-700 text-white shadow"
|
||||
: "bg-gray-300 text-gray-500 cursor-not-allowed"
|
||||
}`}
|
||||
disabled={!isEditChanged}
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-400 mt-16 text-lg">Select a scientist...</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useStoreActions } from "@hooks/store";
|
||||
import axios, { AxiosError } from "axios";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { User } from "@appTypes/Prisma";
|
||||
import { FaSignOutAlt } from "react-icons/fa";
|
||||
import { FaUser } from "react-icons/fa6";
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FaSignOutAlt } from 'react-icons/fa';
|
||||
import { FaUser } from 'react-icons/fa6';
|
||||
|
||||
import { User } from '@appTypes/Prisma';
|
||||
import { useStoreActions } from '@hooks/store';
|
||||
|
||||
export default function Profile() {
|
||||
const router = useRouter();
|
||||
@ -50,6 +51,8 @@ export default function Profile() {
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// todo create receiving api route
|
||||
// todo handle sending fields to api route
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
setUserState({ ...user!, name, email, role });
|
||||
alert("Profile updated successfully.");
|
||||
|
||||
@ -321,6 +321,10 @@ export default function Shop() {
|
||||
setError("CVC must be 3 or 4 digits.");
|
||||
return;
|
||||
}
|
||||
// todo create receiving api route
|
||||
// todo handle sending to api route
|
||||
// todo remove order number generation - we don't need one
|
||||
// todo add option to save details in new account
|
||||
const genOrder = () => "#" + Math.random().toString(36).substring(2, 10).toUpperCase();
|
||||
setOrderNumber(genOrder());
|
||||
onClose();
|
||||
|
||||
@ -3,6 +3,7 @@ import { Dispatch, SetStateAction, useMemo, useState } from "react";
|
||||
import { FaTimes } from "react-icons/fa";
|
||||
import { FaCalendarPlus, FaCartShopping, FaWarehouse } from "react-icons/fa6";
|
||||
import { IoFilter, IoFilterCircleOutline, IoFilterOutline, IoToday } from "react-icons/io5";
|
||||
|
||||
import { ExtendedArtefact } from "@appTypes/ApiTypes";
|
||||
|
||||
// import { Artefact } from "@appTypes/Prisma";
|
||||
@ -272,6 +273,8 @@ function LogModal({ onClose }: { onClose: () => void }) {
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// todo create receiving api route
|
||||
// todo handle sending fields to api route
|
||||
await new Promise((resolve) => setTimeout(resolve, 500)); // Simulated API call
|
||||
alert(`Logged ${name} to storage: ${storageLocation}`);
|
||||
onClose();
|
||||
@ -403,6 +406,8 @@ function BulkLogModal({ onClose }: { onClose: () => void }) {
|
||||
};
|
||||
|
||||
const handleLog = async () => {
|
||||
// todo create receiving api route
|
||||
// todo handle sending fields to api route
|
||||
if (!palletNote || !storageLocation) {
|
||||
setError("All fields are required.");
|
||||
return;
|
||||
@ -501,6 +506,8 @@ function EditModal({ artefact, onClose }: { artefact: Artefact; onClose: () => v
|
||||
const [error, setError] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// todo add image display
|
||||
|
||||
const handleOverlayClick = (e: { target: any; currentTarget: any }) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
|
||||
@ -50,6 +50,7 @@ export default function Sidebar({
|
||||
button1Name,
|
||||
button2Name,
|
||||
}: SidebarProps) {
|
||||
// todo add buttons 1 and 2 click handlers
|
||||
return (
|
||||
<div className={`flex flex-col h-full w-80 relative bg-gradient-to-b from-neutral-100 to-neutral-50 shadow-lg`}>
|
||||
<div className="py-6">
|
||||
|
||||
@ -133,6 +133,11 @@ export default function Navbar({}: // currencySelector,
|
||||
<ManagementNavbarButton name="Warehouse" href="/warehouse"></ManagementNavbarButton>
|
||||
</div>
|
||||
)}
|
||||
{user && (user.role === "SCIENTIST" || user.role === "ADMIN") && (
|
||||
<div className="flex h-full mr-5">
|
||||
<ManagementNavbarButton name="Scientist Management" href="/management"></ManagementNavbarButton>
|
||||
</div>
|
||||
)}
|
||||
{user && user.role === "ADMIN" && (
|
||||
<div className="flex h-full mr-5">
|
||||
<ManagementNavbarButton name="Admin" href="/administrator"></ManagementNavbarButton>
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
name,email,password,level
|
||||
Lukeshan Thananchayan,lukeshan@mail.com,$2b$10$PQ2n3suP1bj8cfpi.58ffO5ip3vOi2IlDUUcCRWRObeU4MOCcD5ge,undefined
|
||||
Tim Howitz,tim.howitz@dyson.com,$2b$10$lzDeZYjPNRlme1RI9zaozeVnnFRPdQPH/DTouseAU.8ZLzT14GKxy,basic
|
||||
|