Files
periodic-table/frontend/src/pages/AdminPage.tsx
2025-12-01 12:39:13 -03:00

815 lines
31 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth, useProject } from '@/hooks'
import {
projectService,
relationshipService,
userService,
RelationshipType,
ProjectMember,
Role,
} from '@/services'
type TabType = 'project' | 'members' | 'relationships'
export default function AdminPage() {
const { user } = useAuth()
const { currentProject, setCurrentProject } = useProject()
const navigate = useNavigate()
// Tab state
const [activeTab, setActiveTab] = useState<TabType>('project')
// Project settings state
const [projectName, setProjectName] = useState('')
const [projectDesc, setProjectDesc] = useState('')
const [projectLoading, setProjectLoading] = useState(false)
const [projectError, setProjectError] = useState<string | null>(null)
const [projectSuccess, setProjectSuccess] = useState<string | null>(null)
// Members state
const [members, setMembers] = useState<ProjectMember[]>([])
const [roles, setRoles] = useState<Role[]>([])
const [membersLoading, setMembersLoading] = useState(true)
const [membersError, setMembersError] = useState<string | null>(null)
const [updatingMember, setUpdatingMember] = useState<number | null>(null)
// Relationship types state
const [relationshipTypes, setRelationshipTypes] = useState<RelationshipType[]>([])
const [relTypesLoading, setRelTypesLoading] = useState(true)
const [relTypesError, setRelTypesError] = useState<string | null>(null)
// Modal states
const [showCreateRelTypeModal, setShowCreateRelTypeModal] = useState(false)
const [showEditRelTypeModal, setShowEditRelTypeModal] = useState(false)
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false)
const [selectedRelType, setSelectedRelType] = useState<RelationshipType | null>(null)
// Relationship type form state
const [relTypeName, setRelTypeName] = useState('')
const [relTypeDesc, setRelTypeDesc] = useState('')
const [relTypeInverse, setRelTypeInverse] = useState('')
const [relTypeFormLoading, setRelTypeFormLoading] = useState(false)
const [relTypeFormError, setRelTypeFormError] = useState<string | null>(null)
// Check if user is admin
const isAdmin = user?.role_id === 3
// Redirect if not admin
useEffect(() => {
if (!isAdmin) {
navigate('/dashboard')
}
}, [isAdmin, navigate])
// Initialize project form
useEffect(() => {
if (currentProject) {
setProjectName(currentProject.project_name)
setProjectDesc(currentProject.project_desc || '')
}
}, [currentProject])
// Fetch members and roles
useEffect(() => {
const fetchMembersAndRoles = async () => {
if (!currentProject) return
try {
setMembersLoading(true)
setMembersError(null)
const [fetchedMembers, fetchedRoles] = await Promise.all([
userService.getProjectMembers(currentProject.id),
userService.getRoles(),
])
setMembers(fetchedMembers)
setRoles(fetchedRoles)
} catch (err) {
console.error('Failed to fetch members:', err)
setMembersError('Failed to load project members')
} finally {
setMembersLoading(false)
}
}
fetchMembersAndRoles()
}, [currentProject])
// Fetch relationship types
useEffect(() => {
const fetchRelTypes = async () => {
if (!currentProject) return
try {
setRelTypesLoading(true)
setRelTypesError(null)
const fetchedTypes = await relationshipService.getRelationshipTypes(currentProject.id)
setRelationshipTypes(fetchedTypes)
} catch (err) {
console.error('Failed to fetch relationship types:', err)
setRelTypesError('Failed to load relationship types')
} finally {
setRelTypesLoading(false)
}
}
fetchRelTypes()
}, [currentProject])
// Handle project update
const handleProjectUpdate = async (e: React.FormEvent) => {
e.preventDefault()
if (!currentProject) return
try {
setProjectLoading(true)
setProjectError(null)
setProjectSuccess(null)
const updatedProject = await projectService.updateProject(currentProject.id, {
project_name: projectName.trim(),
project_desc: projectDesc.trim() || undefined,
})
setCurrentProject(updatedProject)
setProjectSuccess('Project updated successfully!')
setTimeout(() => setProjectSuccess(null), 3000)
} catch (err) {
console.error('Failed to update project:', err)
setProjectError('Failed to update project')
} finally {
setProjectLoading(false)
}
}
// Handle member role update
const handleRoleUpdate = async (memberId: number, newRoleId: number) => {
if (!currentProject) return
// Prevent self-demotion
if (memberId === user?.db_user_id && newRoleId !== 3) {
setMembersError('You cannot demote yourself. Ask another admin to change your role.')
setTimeout(() => setMembersError(null), 5000)
return
}
try {
setUpdatingMember(memberId)
setMembersError(null)
const updatedMember = await userService.updateMemberRole(
currentProject.id,
memberId,
newRoleId
)
setMembers((prev) =>
prev.map((m) => (m.id === memberId ? updatedMember : m))
)
} catch (err: any) {
console.error('Failed to update member role:', err)
setMembersError(err.message || 'Failed to update member role')
setTimeout(() => setMembersError(null), 5000)
} finally {
setUpdatingMember(null)
}
}
// Handle create relationship type
const handleCreateRelType = async (e: React.FormEvent) => {
e.preventDefault()
if (!currentProject) return
try {
setRelTypeFormLoading(true)
setRelTypeFormError(null)
const newType = await relationshipService.createRelationshipType(currentProject.id, {
type_name: relTypeName.trim(),
type_description: relTypeDesc.trim() || null,
inverse_type_name: relTypeInverse.trim() || null,
})
setRelationshipTypes((prev) => [...prev, newType])
setShowCreateRelTypeModal(false)
resetRelTypeForm()
} catch (err: any) {
console.error('Failed to create relationship type:', err)
setRelTypeFormError(err.message || 'Failed to create relationship type')
} finally {
setRelTypeFormLoading(false)
}
}
// Handle update relationship type
const handleUpdateRelType = async (e: React.FormEvent) => {
e.preventDefault()
if (!currentProject || !selectedRelType) return
try {
setRelTypeFormLoading(true)
setRelTypeFormError(null)
const updatedType = await relationshipService.updateRelationshipType(
currentProject.id,
selectedRelType.id,
{
type_name: relTypeName.trim() || null,
type_description: relTypeDesc.trim() || null,
inverse_type_name: relTypeInverse.trim() || null,
}
)
setRelationshipTypes((prev) =>
prev.map((t) => (t.id === selectedRelType.id ? updatedType : t))
)
setShowEditRelTypeModal(false)
setSelectedRelType(null)
resetRelTypeForm()
} catch (err: any) {
console.error('Failed to update relationship type:', err)
setRelTypeFormError(err.message || 'Failed to update relationship type')
} finally {
setRelTypeFormLoading(false)
}
}
// Handle delete relationship type
const handleDeleteRelType = async () => {
if (!currentProject || !selectedRelType) return
try {
setRelTypeFormLoading(true)
setRelTypeFormError(null)
await relationshipService.deleteRelationshipType(currentProject.id, selectedRelType.id)
setRelationshipTypes((prev) => prev.filter((t) => t.id !== selectedRelType.id))
setShowDeleteConfirmModal(false)
setSelectedRelType(null)
} catch (err: any) {
console.error('Failed to delete relationship type:', err)
setRelTypeFormError(err.message || 'Failed to delete relationship type')
} finally {
setRelTypeFormLoading(false)
}
}
// Reset relationship type form
const resetRelTypeForm = () => {
setRelTypeName('')
setRelTypeDesc('')
setRelTypeInverse('')
setRelTypeFormError(null)
}
// Open edit modal
const openEditModal = (relType: RelationshipType) => {
setSelectedRelType(relType)
setRelTypeName(relType.type_name)
setRelTypeDesc(relType.type_description || '')
setRelTypeInverse(relType.inverse_type_name || '')
setRelTypeFormError(null)
setShowEditRelTypeModal(true)
}
// Open delete modal
const openDeleteModal = (relType: RelationshipType) => {
setSelectedRelType(relType)
setRelTypeFormError(null)
setShowDeleteConfirmModal(true)
}
if (!isAdmin) {
return null
}
if (!currentProject) {
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center">
<h2 className="text-xl font-semibold text-gray-700 mb-2">No Project Selected</h2>
<p className="text-gray-500 mb-4">Please select a project from the dashboard first.</p>
<button
onClick={() => navigate('/dashboard')}
className="px-4 py-2 bg-teal-600 text-white rounded hover:bg-teal-700"
>
Go to Dashboard
</button>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-white">
{/* Header */}
<header className="py-6 text-center border-b border-gray-200">
<h1 className="text-3xl font-semibold text-teal-700">Admin Panel</h1>
<p className="text-gray-500 mt-1">
Managing: <span className="font-medium">{currentProject.project_name}</span>
</p>
</header>
{/* Navigation */}
<div className="border-b border-gray-200">
<div className="max-w-4xl mx-auto px-8">
<nav className="flex gap-8">
<button
onClick={() => setActiveTab('project')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'project'
? 'border-teal-600 text-teal-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Project Settings
</button>
<button
onClick={() => setActiveTab('members')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'members'
? 'border-teal-600 text-teal-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Member Roles
</button>
<button
onClick={() => setActiveTab('relationships')}
className={`py-4 px-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'relationships'
? 'border-teal-600 text-teal-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Relationship Types
</button>
</nav>
</div>
</div>
{/* Back Button */}
<div className="max-w-4xl mx-auto px-8 pt-6">
<button
onClick={() => navigate('/dashboard')}
className="flex items-center gap-2 text-gray-600 hover:text-gray-800"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to Dashboard
</button>
</div>
{/* Content */}
<div className="max-w-4xl mx-auto px-8 py-8">
{/* Project Settings Tab */}
{activeTab === 'project' && (
<div className="bg-white border border-gray-200 rounded-lg p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-6">Project Settings</h2>
<form onSubmit={handleProjectUpdate} className="space-y-6">
{projectError && (
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
{projectError}
</div>
)}
{projectSuccess && (
<div className="p-3 bg-green-100 border border-green-400 text-green-700 rounded text-sm">
{projectSuccess}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Project Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={projectDesc}
onChange={(e) => setProjectDesc(e.target.value)}
rows={4}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 resize-none"
/>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={projectLoading}
className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50"
>
{projectLoading ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
)}
{/* Members Tab */}
{activeTab === 'members' && (
<div className="bg-white border border-gray-200 rounded-lg p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-6">Member Roles</h2>
{membersError && (
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
{membersError}
</div>
)}
{membersLoading ? (
<div className="text-center py-8 text-gray-500">Loading members...</div>
) : members.length === 0 ? (
<div className="text-center py-8 text-gray-500">No members found</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 font-medium text-gray-700">User</th>
<th className="text-left py-3 px-4 font-medium text-gray-700">Role</th>
<th className="text-left py-3 px-4 font-medium text-gray-700">Actions</th>
</tr>
</thead>
<tbody>
{members.map((member) => {
const isCurrentUser = member.id === user?.db_user_id
return (
<tr key={member.id} className="border-b border-gray-100">
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-900">{member.sub}</span>
{isCurrentUser && (
<span className="text-xs bg-teal-100 text-teal-700 px-2 py-0.5 rounded">
You
</span>
)}
</div>
</td>
<td className="py-3 px-4">
<span
className={`text-xs px-2 py-1 rounded ${
member.role_name === 'admin'
? 'bg-purple-100 text-purple-700'
: member.role_name === 'auditor'
? 'bg-blue-100 text-blue-700'
: 'bg-gray-100 text-gray-700'
}`}
>
{member.role_display_name}
</span>
</td>
<td className="py-3 px-4">
<select
value={member.role_id}
onChange={(e) => handleRoleUpdate(member.id, parseInt(e.target.value))}
disabled={updatingMember === member.id || (isCurrentUser && member.role_id === 3)}
className={`px-3 py-1.5 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 ${
isCurrentUser && member.role_id === 3 ? 'bg-gray-100 cursor-not-allowed' : ''
}`}
title={isCurrentUser && member.role_id === 3 ? 'You cannot demote yourself' : ''}
>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.display_name}
</option>
))}
</select>
{updatingMember === member.id && (
<span className="ml-2 text-xs text-gray-500">Saving...</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Relationship Types Tab */}
{activeTab === 'relationships' && (
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-gray-800">Relationship Types</h2>
<button
onClick={() => {
resetRelTypeForm()
setShowCreateRelTypeModal(true)
}}
className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700"
>
+ Add Type
</button>
</div>
{relTypesError && (
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
{relTypesError}
</div>
)}
{relTypesLoading ? (
<div className="text-center py-8 text-gray-500">Loading relationship types...</div>
) : relationshipTypes.length === 0 ? (
<div className="text-center py-8 text-gray-500">
No relationship types defined yet. Create one to link requirements.
</div>
) : (
<div className="space-y-3">
{relationshipTypes.map((relType) => (
<div
key={relType.id}
className="flex items-center justify-between p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<div>
<div className="font-medium text-gray-900">{relType.type_name}</div>
{relType.inverse_type_name && (
<div className="text-sm text-gray-500">
Inverse: {relType.inverse_type_name}
</div>
)}
{relType.type_description && (
<div className="text-sm text-gray-400 mt-1">{relType.type_description}</div>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => openEditModal(relType)}
className="px-3 py-1.5 border border-gray-300 rounded text-sm text-gray-700 hover:bg-gray-100"
>
Edit
</button>
<button
onClick={() => openDeleteModal(relType)}
className="px-3 py-1.5 border border-red-300 rounded text-sm text-red-600 hover:bg-red-50"
>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Create Relationship Type Modal */}
{showCreateRelTypeModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-800">Create Relationship Type</h2>
<button
onClick={() => {
setShowCreateRelTypeModal(false)
resetRelTypeForm()
}}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form onSubmit={handleCreateRelType}>
<div className="px-6 py-4 space-y-4">
{relTypeFormError && (
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
{relTypeFormError}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Type Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={relTypeName}
onChange={(e) => setRelTypeName(e.target.value)}
placeholder="e.g., Depends On"
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Inverse Name
</label>
<input
type="text"
value={relTypeInverse}
onChange={(e) => setRelTypeInverse(e.target.value)}
placeholder="e.g., Depended By"
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
/>
<p className="text-xs text-gray-500 mt-1">
Optional. The name shown when viewing from the target requirement.
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={relTypeDesc}
onChange={(e) => setRelTypeDesc(e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 resize-none"
/>
</div>
</div>
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg">
<button
type="button"
onClick={() => {
setShowCreateRelTypeModal(false)
resetRelTypeForm()
}}
className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-100"
disabled={relTypeFormLoading}
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50"
disabled={relTypeFormLoading}
>
{relTypeFormLoading ? 'Creating...' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Edit Relationship Type Modal */}
{showEditRelTypeModal && selectedRelType && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-800">Edit Relationship Type</h2>
<button
onClick={() => {
setShowEditRelTypeModal(false)
setSelectedRelType(null)
resetRelTypeForm()
}}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form onSubmit={handleUpdateRelType}>
<div className="px-6 py-4 space-y-4">
{relTypeFormError && (
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
{relTypeFormError}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Type Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={relTypeName}
onChange={(e) => setRelTypeName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Inverse Name
</label>
<input
type="text"
value={relTypeInverse}
onChange={(e) => setRelTypeInverse(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={relTypeDesc}
onChange={(e) => setRelTypeDesc(e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 resize-none"
/>
</div>
</div>
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg">
<button
type="button"
onClick={() => {
setShowEditRelTypeModal(false)
setSelectedRelType(null)
resetRelTypeForm()
}}
className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-100"
disabled={relTypeFormLoading}
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50"
disabled={relTypeFormLoading}
>
{relTypeFormLoading ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{showDeleteConfirmModal && selectedRelType && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-800">Delete Relationship Type</h2>
</div>
<div className="px-6 py-4">
{relTypeFormError && (
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
{relTypeFormError}
</div>
)}
<p className="text-gray-700">
Are you sure you want to delete the relationship type{' '}
<span className="font-semibold">"{selectedRelType.type_name}"</span>?
</p>
<p className="text-sm text-red-600 mt-2">
This will also delete all requirement links using this type.
</p>
</div>
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg">
<button
onClick={() => {
setShowDeleteConfirmModal(false)
setSelectedRelType(null)
setRelTypeFormError(null)
}}
className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-100"
disabled={relTypeFormLoading}
>
Cancel
</button>
<button
onClick={handleDeleteRelType}
className="px-4 py-2 bg-red-600 text-white rounded text-sm font-medium hover:bg-red-700 disabled:opacity-50"
disabled={relTypeFormLoading}
>
{relTypeFormLoading ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
)}
</div>
)
}