2025-05-12 12:20:28 +01:00
|
|
|
"use client";
|
|
|
|
|
import React, { useState, useRef } from "react";
|
2025-05-19 12:28:38 +01:00
|
|
|
|
|
|
|
|
// The roles in UserSeed are ALL CAPS as per your data
|
|
|
|
|
type Role = "ADMIN" | "GUEST" | "SCIENTIST";
|
|
|
|
|
// Forward declarations if you don't have these yet
|
|
|
|
|
// You can replace Scientist or User with real types if you have them
|
|
|
|
|
|
|
|
|
|
type Scientist = {
|
|
|
|
|
id: number;
|
|
|
|
|
createdAt: Date | string;
|
|
|
|
|
name: string;
|
|
|
|
|
level: string; // "JUNIOR" | "SENIOR"
|
|
|
|
|
userId: number;
|
|
|
|
|
user: User;
|
|
|
|
|
superiorId?: number | null;
|
|
|
|
|
superior?: Scientist | null;
|
|
|
|
|
subordinates: Scientist[];
|
|
|
|
|
earthquakes: Earthquake[];
|
|
|
|
|
observatories: Observatory[];
|
|
|
|
|
artefacts: Artefact[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type Earthquake = {
|
|
|
|
|
id: number;
|
|
|
|
|
createdAt: Date | string;
|
|
|
|
|
updatedAt: Date | string;
|
|
|
|
|
date: Date | string;
|
|
|
|
|
location: string;
|
|
|
|
|
latitude: string;
|
|
|
|
|
longitude: string;
|
|
|
|
|
magnitude: number;
|
|
|
|
|
depth: number;
|
|
|
|
|
creatorId?: number | null;
|
|
|
|
|
creator?: Scientist | null;
|
|
|
|
|
artefacts: Artefact[];
|
|
|
|
|
observatories: Observatory[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type Observatory = {
|
|
|
|
|
id: number;
|
|
|
|
|
createdAt: Date | string;
|
|
|
|
|
updatedAt: Date | string;
|
|
|
|
|
name: string;
|
|
|
|
|
location: string;
|
|
|
|
|
longitude: string;
|
|
|
|
|
latitude: string;
|
|
|
|
|
dateEstablished?: number | null;
|
|
|
|
|
functional: boolean;
|
|
|
|
|
seismicSensorOnline: boolean;
|
|
|
|
|
creatorId?: number | null;
|
|
|
|
|
creator?: Scientist | null;
|
|
|
|
|
earthquakes: Earthquake[];
|
|
|
|
|
};
|
|
|
|
|
type Artefact = {
|
|
|
|
|
id: number;
|
|
|
|
|
createdAt: Date | string;
|
|
|
|
|
updatedAt: Date | string;
|
|
|
|
|
type: string;
|
|
|
|
|
warehouseArea: string;
|
|
|
|
|
earthquakeId: number;
|
|
|
|
|
earthquake: Earthquake;
|
|
|
|
|
creatorId?: number | null;
|
|
|
|
|
creator?: Scientist | null;
|
|
|
|
|
required: boolean;
|
|
|
|
|
shopPrice?: number | null;
|
|
|
|
|
purchasedById?: number | null;
|
|
|
|
|
purchasedBy?: User | null;
|
|
|
|
|
pickedUp: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type User= {
|
|
|
|
|
id: number;
|
|
|
|
|
createdAt: string;
|
2025-05-12 12:20:28 +01:00
|
|
|
name: string;
|
2025-05-19 12:28:38 +01:00
|
|
|
email: string;
|
|
|
|
|
passwordHash: string;
|
|
|
|
|
role: Role; // "ADMIN" | "GUEST" | "SCIENTIST"
|
|
|
|
|
scientist : Scientist | null
|
|
|
|
|
purchasedArtefacts: Artefact[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Example users with scientist references
|
|
|
|
|
|
|
|
|
|
const initialUsers: User[] = [
|
|
|
|
|
{
|
|
|
|
|
id: 1,
|
|
|
|
|
createdAt: "2024-06-20T08:00:00Z",
|
|
|
|
|
name: "Dr. Alice Volcano",
|
|
|
|
|
email: "alice.volcano@example.com",
|
|
|
|
|
passwordHash: "hashed_password_1",
|
|
|
|
|
role: "SCIENTIST",
|
|
|
|
|
scientist: {
|
|
|
|
|
id: 101,
|
|
|
|
|
createdAt: "2024-06-18T09:00:00Z",
|
|
|
|
|
name: "Dr. Alice Volcano",
|
|
|
|
|
level: "SENIOR",
|
|
|
|
|
userId: 1,
|
|
|
|
|
user: null as any, // Will be populated after object creation to avoid circular reference, see note below
|
|
|
|
|
superiorId: null,
|
|
|
|
|
superior: null,
|
|
|
|
|
subordinates: [],
|
|
|
|
|
earthquakes: [
|
|
|
|
|
{
|
|
|
|
|
id: 201,
|
|
|
|
|
createdAt: "2024-06-01T04:00:00Z",
|
|
|
|
|
updatedAt: "2024-06-10T10:00:00Z",
|
|
|
|
|
date: "2024-06-01T05:00:00Z",
|
|
|
|
|
location: "Fuego Ridge",
|
|
|
|
|
latitude: "14.23",
|
|
|
|
|
longitude: "-90.88",
|
|
|
|
|
magnitude: 7.2,
|
|
|
|
|
depth: 10.1,
|
|
|
|
|
creatorId: 101,
|
|
|
|
|
creator: null,
|
|
|
|
|
artefacts: [],
|
|
|
|
|
observatories: [],
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
observatories: [
|
|
|
|
|
{
|
|
|
|
|
id: 301,
|
|
|
|
|
createdAt: "2024-01-01T08:00:00Z",
|
|
|
|
|
updatedAt: "2024-06-05T16:30:00Z",
|
|
|
|
|
name: "Central Vulcanology Lab",
|
|
|
|
|
location: "Fuego City",
|
|
|
|
|
longitude: "-90.88",
|
|
|
|
|
latitude: "14.23",
|
|
|
|
|
dateEstablished: 2011,
|
|
|
|
|
functional: true,
|
|
|
|
|
seismicSensorOnline: true,
|
|
|
|
|
creatorId: 101,
|
|
|
|
|
creator: null,
|
|
|
|
|
earthquakes: [],
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
artefacts: [
|
|
|
|
|
{
|
|
|
|
|
id: 401,
|
|
|
|
|
createdAt: "2024-06-11T09:00:00Z",
|
|
|
|
|
updatedAt: "2024-06-12T10:45:00Z",
|
|
|
|
|
type: "Lava",
|
|
|
|
|
warehouseArea: "ZoneA-Shelf1",
|
|
|
|
|
earthquakeId: 201,
|
|
|
|
|
earthquake: {
|
|
|
|
|
id: 201,
|
|
|
|
|
createdAt: "2024-06-01T04:00:00Z",
|
|
|
|
|
updatedAt: "2024-06-10T10:00:00Z",
|
|
|
|
|
date: "2024-06-01T05:00:00Z",
|
|
|
|
|
location: "Fuego Ridge",
|
|
|
|
|
latitude: "14.23",
|
|
|
|
|
longitude: "-90.88",
|
|
|
|
|
magnitude: 7.2,
|
|
|
|
|
depth: 10.1,
|
|
|
|
|
creatorId: 101,
|
|
|
|
|
creator: null,
|
|
|
|
|
artefacts: [],
|
|
|
|
|
observatories: [],
|
|
|
|
|
},
|
|
|
|
|
creatorId: 101,
|
|
|
|
|
creator: null,
|
|
|
|
|
required: true,
|
|
|
|
|
shopPrice: 500.0,
|
|
|
|
|
purchasedById: null,
|
|
|
|
|
purchasedBy: null,
|
|
|
|
|
pickedUp: false,
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
purchasedArtefacts: [],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 2,
|
|
|
|
|
createdAt: "2024-06-21T08:00:00Z",
|
|
|
|
|
name: "Dr. Bob Lava",
|
|
|
|
|
email: "bob.lava@example.com",
|
|
|
|
|
passwordHash: "hashed_password_2",
|
|
|
|
|
role: "SCIENTIST",
|
|
|
|
|
scientist: {
|
|
|
|
|
id: 102,
|
|
|
|
|
createdAt: "2024-06-19T13:00:00Z",
|
|
|
|
|
name: "Dr. Bob Lava",
|
|
|
|
|
level: "JUNIOR",
|
|
|
|
|
userId: 2,
|
|
|
|
|
user: null as any, // Populated after object creation if circular needed
|
|
|
|
|
superiorId: 101,
|
|
|
|
|
superior: null,
|
|
|
|
|
subordinates: [],
|
|
|
|
|
earthquakes: [],
|
|
|
|
|
observatories: [],
|
|
|
|
|
artefacts: [],
|
|
|
|
|
},
|
|
|
|
|
purchasedArtefacts: [],
|
|
|
|
|
}
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// Optionally, populate the scientist.user field with the parent user (avoiding circular reference on first creation):
|
|
|
|
|
if (initialUsers[0].scientist && initialUsers[1].scientist){
|
|
|
|
|
initialUsers[0].scientist.user = initialUsers[0];
|
|
|
|
|
initialUsers[1].scientist.user = initialUsers[1];
|
|
|
|
|
initialUsers[1].scientist.superior = initialUsers[1].scientist;}
|
|
|
|
|
|
|
|
|
|
const allArtefacts: Artefact[] = [
|
|
|
|
|
{
|
|
|
|
|
id: 401,
|
|
|
|
|
createdAt: "2024-06-11T09:00:00Z",
|
|
|
|
|
updatedAt: "2024-06-12T10:45:00Z",
|
|
|
|
|
type: "Lava",
|
|
|
|
|
warehouseArea: "ZoneA-Shelf1",
|
|
|
|
|
earthquakeId: 201,
|
|
|
|
|
earthquake: {
|
|
|
|
|
id: 201,
|
|
|
|
|
createdAt: "2024-06-01T04:00:00Z",
|
|
|
|
|
updatedAt: "2024-06-10T10:00:00Z",
|
|
|
|
|
date: "2024-06-01T05:00:00Z",
|
|
|
|
|
location: "Fuego Ridge",
|
|
|
|
|
latitude: "14.23",
|
|
|
|
|
longitude: "-90.88",
|
|
|
|
|
magnitude: 7.2,
|
|
|
|
|
depth: 10.1,
|
|
|
|
|
creatorId: 101,
|
|
|
|
|
creator: null,
|
|
|
|
|
artefacts: [],
|
|
|
|
|
observatories: [],
|
|
|
|
|
},
|
|
|
|
|
creatorId: 101,
|
|
|
|
|
creator: null,
|
|
|
|
|
required: true,
|
|
|
|
|
shopPrice: 500.0,
|
|
|
|
|
purchasedById: null,
|
|
|
|
|
purchasedBy: null,
|
|
|
|
|
pickedUp: false,
|
|
|
|
|
}
|
|
|
|
|
];
|
|
|
|
|
const allEarthquakes: Earthquake[] = [
|
|
|
|
|
{
|
|
|
|
|
id: 201,
|
|
|
|
|
createdAt: "2024-06-01T04:00:00Z",
|
|
|
|
|
updatedAt: "2024-06-10T10:00:00Z",
|
|
|
|
|
date: "2024-06-01T05:00:00Z",
|
|
|
|
|
location: "Fuego Ridge",
|
|
|
|
|
latitude: "14.23",
|
|
|
|
|
longitude: "-90.88",
|
|
|
|
|
magnitude: 7.2,
|
|
|
|
|
depth: 10.1,
|
|
|
|
|
creatorId: 101,
|
|
|
|
|
creator: null,
|
|
|
|
|
artefacts: [],
|
|
|
|
|
observatories: [],
|
|
|
|
|
}
|
|
|
|
|
];
|
|
|
|
|
const allScientists: Scientist[] = initialUsers.map(u => u.scientist as Scientist).filter(Boolean);
|
|
|
|
|
|
|
|
|
|
// Human readable role labels
|
|
|
|
|
const roleLabels: Record<Role, string> = {
|
|
|
|
|
ADMIN: "Admin",
|
|
|
|
|
GUEST: "Guest",
|
|
|
|
|
SCIENTIST: "Scientist"
|
2025-05-12 12:20:28 +01:00
|
|
|
};
|
2025-05-19 12:28:38 +01:00
|
|
|
const allRoles: Role[] = ["ADMIN", "GUEST", "SCIENTIST"];
|
2025-05-12 12:20:28 +01:00
|
|
|
|
2025-05-19 12:28:38 +01:00
|
|
|
const sortFields = [
|
2025-05-12 12:20:28 +01:00
|
|
|
{ label: "Name", value: "name" },
|
2025-05-19 12:28:38 +01:00
|
|
|
{ label: "Email", value: "email" }
|
2025-05-12 12:20:28 +01:00
|
|
|
] as const;
|
2025-05-19 12:28:38 +01:00
|
|
|
type SortField = (typeof sortFields)[number]["value"];
|
2025-05-12 12:20:28 +01:00
|
|
|
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() {
|
2025-05-19 12:28:38 +01:00
|
|
|
const [users, setUsers] = useState<User[]>(initialUsers);
|
|
|
|
|
const [selectedEmail, setSelectedEmail] = useState<string | null>(null);
|
|
|
|
|
const [editUser, setEditUser] = useState<User & { password?: string } | null>(null);
|
|
|
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
|
|
|
const [showAssignmentPanel, setShowAssignmentPanel] = useState<null | 'artefact' | 'earthquake' | 'scientist'>(null);
|
2025-05-12 12:20:28 +01:00
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!selectedEmail) setEditUser(null);
|
|
|
|
|
else {
|
|
|
|
|
const user = users.find(u => u.email === selectedEmail);
|
2025-05-19 12:28:38 +01:00
|
|
|
// Add a dummy password field just for edit box (not stored)
|
|
|
|
|
setEditUser(user ? { ...user, password: "" } : null);
|
2025-05-12 12:20:28 +01:00
|
|
|
}
|
|
|
|
|
}, [selectedEmail, users]);
|
|
|
|
|
|
|
|
|
|
// Search/filter/sort state
|
2025-05-19 12:28:38 +01:00
|
|
|
const [searchField, setSearchField] = useState<SortField>("name");
|
2025-05-12 12:20:28 +01:00
|
|
|
const [searchText, setSearchText] = useState("");
|
2025-05-19 12:28:38 +01:00
|
|
|
// The filter UI must use ALL CAPS for roles in UserSeed
|
2025-05-12 12:20:28 +01:00
|
|
|
const [roleFilter, setRoleFilter] = useState<Role | "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);
|
|
|
|
|
|
2025-05-19 12:28:38 +01:00
|
|
|
// Dropdown auto-close
|
2025-05-12 12:20:28 +01:00
|
|
|
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);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-05-19 12:28:38 +01:00
|
|
|
// Handler: assign/unassign scientist
|
|
|
|
|
const handleAssignScientist = (scientistId: number) => {
|
|
|
|
|
setEditUser(prev =>
|
|
|
|
|
prev ? {
|
|
|
|
|
...prev,
|
|
|
|
|
scientist: allScientists.find(s => s.id === scientistId) || null,
|
|
|
|
|
} : null
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Filter, search, sort logic
|
2025-05-12 12:20:28 +01:00
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
|
2025-05-19 12:28:38 +01:00
|
|
|
// Edit form handler (special-case password, don't modify passwordHash)
|
2025-05-12 12:20:28 +01:00
|
|
|
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
|
|
|
|
if (!editUser) return;
|
|
|
|
|
const { name, value } = e.target;
|
|
|
|
|
setEditUser(prev =>
|
|
|
|
|
prev ? { ...prev, [name]: value } : null
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2025-05-19 12:28:38 +01:00
|
|
|
// Selected "actual" user object
|
|
|
|
|
const selectedUser = users.find(u => u.email === selectedEmail);
|
2025-05-12 12:20:28 +01:00
|
|
|
const isEditChanged = React.useMemo(() => {
|
|
|
|
|
if (!editUser || !selectedUser) return false;
|
|
|
|
|
return (
|
|
|
|
|
editUser.name !== selectedUser.name ||
|
|
|
|
|
editUser.role !== selectedUser.role ||
|
2025-05-19 12:28:38 +01:00
|
|
|
(editUser.password && editUser.password !== "") // Only trigger update if changed
|
2025-05-12 12:20:28 +01:00
|
|
|
);
|
|
|
|
|
}, [editUser, selectedUser]);
|
|
|
|
|
|
|
|
|
|
// Update/save changes
|
|
|
|
|
const handleUpdate = (e: React.FormEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (!editUser) return;
|
|
|
|
|
setUsers(prev =>
|
|
|
|
|
prev.map(u =>
|
2025-05-19 12:28:38 +01:00
|
|
|
u.email === editUser.email
|
|
|
|
|
? { ...editUser } // Copies ALL user properties, including scientist, artefacts, etc.
|
|
|
|
|
: u
|
2025-05-12 12:20:28 +01:00
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2025-05-19 12:28:38 +01:00
|
|
|
// Delete user
|
2025-05-12 12:20:28 +01:00
|
|
|
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 [showEmailTooltip, setShowEmailTooltip] = useState(false);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col h-full">
|
|
|
|
|
<div className="flex h-full overflow-hidden bg-gray-50">
|
|
|
|
|
{/* SIDEBAR */}
|
2025-05-19 12:28:38 +01:00
|
|
|
<div className=" w-80 h-full border-r border-neutral-200 bg-neutral-100 flex flex-col rounded-3xl shadow-sm">
|
2025-05-12 12:20:28 +01:00
|
|
|
<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)}
|
|
|
|
|
/>
|
|
|
|
|
<select
|
|
|
|
|
value={searchField}
|
2025-05-19 12:28:38 +01:00
|
|
|
onChange={e => setSearchField(e.target.value as SortField)}
|
2025-05-12 12:20:28 +01:00
|
|
|
className="border rounded-lg px-2 py-1 text-sm"
|
|
|
|
|
>
|
|
|
|
|
<option value="name">Name</option>
|
|
|
|
|
<option value="email">Email</option>
|
|
|
|
|
</select>
|
|
|
|
|
</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
|
2025-05-19 12:28:38 +01:00
|
|
|
${roleFilter !== "all"
|
|
|
|
|
? "bg-blue-600 text-white border-blue-600 hover:bg-blue-700"
|
|
|
|
|
: "bg-white text-gray-700 border hover:bg-neutral-200"}
|
2025-05-12 12:20:28 +01:00
|
|
|
`}
|
|
|
|
|
onClick={() => setFilterDropdownOpen(v => !v)}
|
|
|
|
|
type="button"
|
|
|
|
|
>
|
2025-05-19 12:28:38 +01:00
|
|
|
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>
|
2025-05-12 12:20:28 +01:00
|
|
|
</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
|
2025-05-19 12:28:38 +01:00
|
|
|
${roleFilter === "all" ? "font-bold text-blue-600" : ""}`}
|
2025-05-12 12:20:28 +01:00
|
|
|
>
|
|
|
|
|
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
|
2025-05-19 12:28:38 +01:00
|
|
|
${roleFilter === role ? "font-bold text-blue-600" : ""}`}
|
2025-05-12 12:20:28 +01:00
|
|
|
>
|
2025-05-19 12:28:38 +01:00
|
|
|
{roleLabels[role]}
|
2025-05-12 12:20:28 +01:00
|
|
|
</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
|
2025-05-19 12:28:38 +01:00
|
|
|
${sortField === opt.value ? "font-bold text-blue-600" : ""}`}
|
2025-05-12 12:20:28 +01:00
|
|
|
>
|
|
|
|
|
{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>
|
2025-05-19 12:28:38 +01:00
|
|
|
<span className="ml-1 text-xs px-2 py-0.5 rounded-lg bg-gray-200 text-gray-700">{roleLabels[user.role]}</span>
|
2025-05-12 12:20:28 +01:00
|
|
|
</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 ? (
|
2025-05-19 12:28:38 +01:00
|
|
|
<div className="max-w-2xl mx-auto bg-white p-6 rounded-lg shadow">
|
2025-05-12 12:20:28 +01:00
|
|
|
<h2 className="text-lg font-bold mb-6">Edit User</h2>
|
2025-05-19 12:28:38 +01:00
|
|
|
<form className="space-y-4" onSubmit={e => { e.preventDefault(); }}>
|
|
|
|
|
{/* Creation Time */}
|
|
|
|
|
<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>
|
|
|
|
|
{/* Email */}
|
2025-05-12 12:20:28 +01:00
|
|
|
<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
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2025-05-19 12:28:38 +01:00
|
|
|
{/* Name */}
|
2025-05-12 12:20:28 +01:00
|
|
|
<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}
|
2025-05-19 12:28:38 +01:00
|
|
|
onChange={e => setEditUser(prev => prev ? { ...prev, name: e.target.value } : null)}
|
2025-05-12 12:20:28 +01:00
|
|
|
/>
|
|
|
|
|
</div>
|
2025-05-19 12:28:38 +01:00
|
|
|
{/* Role */}
|
2025-05-12 12:20:28 +01:00
|
|
|
<div>
|
2025-05-19 12:28:38 +01:00
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Role:</label>
|
2025-05-12 12:20:28 +01:00
|
|
|
<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}
|
2025-05-19 12:28:38 +01:00
|
|
|
onChange={e => setEditUser(prev => prev ? { ...prev, role: e.target.value as Role } : null)}
|
2025-05-12 12:20:28 +01:00
|
|
|
>
|
|
|
|
|
{allRoles.map((role) => (
|
2025-05-19 12:28:38 +01:00
|
|
|
<option key={role} value={role}>{roleLabels[role]}</option>
|
2025-05-12 12:20:28 +01:00
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
2025-05-19 12:28:38 +01:00
|
|
|
{/* Scientist assignment */}
|
2025-05-12 12:20:28 +01:00
|
|
|
<div>
|
2025-05-19 12:28:38 +01:00
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Scientist:</label>
|
|
|
|
|
<select
|
|
|
|
|
className="w-full border px-3 py-2 rounded-lg"
|
|
|
|
|
value={editUser.scientist?.id ?? ""}
|
|
|
|
|
onChange={e => handleAssignScientist(Number(e.target.value))}
|
|
|
|
|
>
|
|
|
|
|
<option value={""}>None</option>
|
|
|
|
|
{allScientists.map(sc => (
|
|
|
|
|
<option key={sc.id} value={sc.id}>{sc.name}</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
{/* Show all scientist data */}
|
|
|
|
|
{editUser.scientist && (
|
|
|
|
|
<div className="flex items-center gap-2 py-6">
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700">Scientist:</label>
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
{editUser.scientist
|
|
|
|
|
? `${editUser.scientist.name} (${editUser.scientist.level})`
|
|
|
|
|
: <span className="text-gray-400">None</span>
|
|
|
|
|
}
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 shadow text-sm"
|
|
|
|
|
onClick={() => setShowAssignmentPanel("scientist")}
|
|
|
|
|
>
|
|
|
|
|
Edit Scientist
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{/* Password - eye toggle only (view, not edit) */}
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Password:</label>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<input
|
|
|
|
|
className="w-full border px-3 py-2 rounded-lg outline-none bg-gray-100"
|
|
|
|
|
type={showPassword ? "text" : "password"}
|
|
|
|
|
value={editUser.passwordHash || ""}
|
|
|
|
|
readOnly
|
|
|
|
|
autoComplete="off"
|
|
|
|
|
/>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
title={showPassword ? "Hide password" : "Show password"}
|
|
|
|
|
className="text-gray-500 hover:text-gray-800"
|
|
|
|
|
onClick={() => setShowPassword(s => !s)}
|
|
|
|
|
>
|
|
|
|
|
{showPassword ? (
|
|
|
|
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-5.523 0-10-4.477-10-10a9.953 9.953 0 013.37-7.53M4.22 4.22l15.56 15.56M19.77 19.77L4.22 4.22M19.77 19.77l.01-.01M21 12a9.953 9.953 0 01-3.37 7.53M4.22 19.77l.01-.01" />
|
|
|
|
|
</svg>
|
|
|
|
|
) : (
|
|
|
|
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0zm6 0C21 7.03 16.97 3 12 3S3 7.03 3 12c0 4.97 4.03 9 9 9s9-4.03 9-9z" />
|
|
|
|
|
</svg>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{/* Trigger artefact assignment panel */}
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Purchased Artefacts:</label>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 shadow"
|
|
|
|
|
onClick={() => setShowAssignmentPanel('artefact')}
|
|
|
|
|
>
|
|
|
|
|
Assign Artefacts
|
|
|
|
|
</button>
|
|
|
|
|
<div className="mt-1 text-xs text-gray-500">
|
|
|
|
|
Currently assigned: {editUser.purchasedArtefacts.map(a => a.type).join(", ") || "None"}
|
|
|
|
|
</div>
|
2025-05-12 12:20:28 +01:00
|
|
|
</div>
|
2025-05-19 12:28:38 +01:00
|
|
|
|
|
|
|
|
{/* Trigger earthquake assignment panel */}
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Scientist's Earthquakes:</label>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 shadow"
|
|
|
|
|
onClick={() => setShowAssignmentPanel('earthquake')}
|
|
|
|
|
disabled={!editUser.scientist}
|
|
|
|
|
>
|
|
|
|
|
Assign Earthquakes
|
|
|
|
|
</button>
|
|
|
|
|
<div className="mt-1 text-xs text-gray-500">
|
|
|
|
|
{editUser.scientist
|
|
|
|
|
? "Current: " + (editUser.scientist.earthquakes.map(e => e.location).join(", ") || "None")
|
|
|
|
|
: "Assign a scientist to manage earthquakes"}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-05-12 12:20:28 +01:00
|
|
|
<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
|
2025-05-19 12:28:38 +01:00
|
|
|
bg-blue-600 hover:bg-blue-700 text-white shadow
|
|
|
|
|
`}
|
|
|
|
|
onClick={handleUpdate}
|
2025-05-12 12:20:28 +01:00
|
|
|
>
|
|
|
|
|
Update
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="text-center text-gray-400 mt-16 text-lg">
|
|
|
|
|
Select a user...
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-05-19 12:28:38 +01:00
|
|
|
|
|
|
|
|
{/* Right-side assignment panel */}
|
|
|
|
|
{showAssignmentPanel && editUser &&(
|
|
|
|
|
<div className="fixed z-50 right-6 top-6 bottom-6 w-96 rounded-3xl bg-white shadow-2xl border border-gray-200 flex flex-col">
|
|
|
|
|
<div className="flex justify-between items-center p-4 border-b rounded-t-3xl">
|
|
|
|
|
<span className="font-bold text-lg">
|
|
|
|
|
{showAssignmentPanel === 'artefact' ? 'Assign Artefacts' : 'Assign Earthquakes'}
|
|
|
|
|
</span>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setShowAssignmentPanel(null)}
|
|
|
|
|
className="text-gray-500 hover:text-black text-xl px-3 py-1"
|
|
|
|
|
title="Close"
|
|
|
|
|
>
|
|
|
|
|
×
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1 overflow-y-auto p-4">
|
|
|
|
|
{showAssignmentPanel === 'artefact' && (
|
|
|
|
|
<>
|
|
|
|
|
{allArtefacts.map(a => (
|
|
|
|
|
<label key={a.id} className="flex items-center gap-2 mb-2">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={!!editUser.purchasedArtefacts.find(b => b.id === a.id)}
|
|
|
|
|
onChange={e => {
|
|
|
|
|
const has = !!editUser.purchasedArtefacts.find(b => b.id === a.id)
|
|
|
|
|
setEditUser(prev => prev ? {
|
|
|
|
|
...prev,
|
|
|
|
|
purchasedArtefacts: has
|
|
|
|
|
? prev.purchasedArtefacts.filter(b => b.id !== a.id)
|
|
|
|
|
: [...prev.purchasedArtefacts, a]
|
|
|
|
|
} : null)
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<span>
|
|
|
|
|
{a.type} <span className="text-xs text-gray-400">({a.warehouseArea})</span>
|
|
|
|
|
</span>
|
|
|
|
|
</label>
|
|
|
|
|
))}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
{showAssignmentPanel === 'earthquake' && editUser &&(
|
|
|
|
|
<>
|
|
|
|
|
{!editUser.scientist
|
|
|
|
|
? <div className="text-sm text-gray-400 mt-8">Assign a scientist first.</div>
|
|
|
|
|
: allEarthquakes.map(eq => (
|
|
|
|
|
<label key={eq.id} className="flex items-center gap-2 mb-2">
|
|
|
|
|
{editUser.scientist &&
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={!!editUser.scientist.earthquakes.find(e => e.id === eq.id)}
|
|
|
|
|
onChange={e => {
|
|
|
|
|
if (!editUser.scientist) return;
|
|
|
|
|
const has = !!editUser.scientist.earthquakes.find(x => x.id === eq.id);
|
|
|
|
|
setEditUser(prev => prev ? {
|
|
|
|
|
...prev,
|
|
|
|
|
scientist: {
|
|
|
|
|
...prev.scientist!,
|
|
|
|
|
earthquakes: has
|
|
|
|
|
? prev.scientist!.earthquakes.filter(x => x.id !== eq.id)
|
|
|
|
|
: [...prev.scientist!.earthquakes, eq]
|
|
|
|
|
}
|
|
|
|
|
} : null)
|
|
|
|
|
}}
|
|
|
|
|
disabled={!editUser.scientist}
|
|
|
|
|
/>}
|
|
|
|
|
<span>
|
|
|
|
|
{eq.location} <span className="text-xs text-gray-400">(Mag {eq.magnitude})</span>
|
|
|
|
|
</span>
|
|
|
|
|
</label>
|
|
|
|
|
))}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
{showAssignmentPanel === 'scientist' && editUser.scientist &&(
|
|
|
|
|
<>
|
|
|
|
|
{/* Scientist edit form */}
|
|
|
|
|
<div className="space-y-5">
|
|
|
|
|
{/* Name */}
|
|
|
|
|
<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"
|
|
|
|
|
type="text"
|
|
|
|
|
value={editUser.scientist.name}
|
|
|
|
|
onChange={e =>
|
|
|
|
|
setEditUser(prev => prev && prev.scientist ? {
|
|
|
|
|
...prev,
|
|
|
|
|
scientist: { ...prev.scientist, name: e.target.value }
|
|
|
|
|
} : prev)
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
{/* Level */}
|
|
|
|
|
<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"
|
|
|
|
|
value={editUser.scientist.level}
|
|
|
|
|
onChange={e =>
|
|
|
|
|
setEditUser(prev => prev && prev.scientist ? {
|
|
|
|
|
...prev,
|
|
|
|
|
scientist: { ...prev.scientist, level: e.target.value }
|
|
|
|
|
} : prev)
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<option value="SENIOR">Senior</option>
|
|
|
|
|
<option value="JUNIOR">Junior</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
{/* Assign/Remove Superior */}
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Senior Scientist:</label>
|
|
|
|
|
<select
|
|
|
|
|
className="w-full border px-3 py-2 rounded-lg outline-none"
|
|
|
|
|
value={editUser.scientist.superiorId || ""}
|
|
|
|
|
onChange={e => {
|
|
|
|
|
const val = e.target.value ? Number(e.target.value) : null;
|
|
|
|
|
setEditUser(prev => prev && prev.scientist ? {
|
|
|
|
|
...prev,
|
|
|
|
|
scientist: {
|
|
|
|
|
...prev.scientist,
|
|
|
|
|
superiorId: val,
|
|
|
|
|
superior: val ? allScientists.find(sc => sc.id === val) || null : null
|
|
|
|
|
}
|
|
|
|
|
} : prev)
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<option value="">None</option>
|
|
|
|
|
{allScientists
|
|
|
|
|
.filter(sc => sc.id !== editUser.scientist!.id)
|
|
|
|
|
.map(sc => (
|
|
|
|
|
<option key={sc.id} value={sc.id}>
|
|
|
|
|
{sc.name} ({sc.level})
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
{/* Subordinates readonly */}
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Subordinates:</label>
|
|
|
|
|
<div className="text-sm text-gray-700 pl-2">
|
|
|
|
|
{editUser.scientist.subordinates.length
|
|
|
|
|
? editUser.scientist.subordinates.map(sc => sc.name).join(", ")
|
|
|
|
|
: <span className="text-gray-400">None</span>}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="p-4 border-t flex justify-end rounded-b-3xl">
|
|
|
|
|
<button
|
|
|
|
|
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
|
|
|
|
onClick={() => setShowAssignmentPanel(null)}
|
|
|
|
|
>
|
|
|
|
|
Done
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2025-05-12 12:20:28 +01:00
|
|
|
</div>
|
2025-05-19 12:28:38 +01:00
|
|
|
)}
|
2025-05-12 12:20:28 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-05-19 12:28:38 +01:00
|
|
|
</div>
|
2025-05-12 12:20:28 +01:00
|
|
|
);
|
|
|
|
|
}
|