feat: Add UserDetailModal component and update API for user status management
- Implemented UserDetailModal component for displaying and editing user details. - Added functionality for archiving/unarchiving users and toggling admin verification. - Enhanced user profile editing capabilities with form inputs for personal and company profiles. - Introduced loading and error handling states for better user experience. - Updated API utility to include a new endpoint for updating user status. - Modified DetailedUserInfo interface to accommodate new user role 'super_admin'.
This commit is contained in:
parent
294d4eb8a3
commit
6f8573fe16
@ -13,7 +13,7 @@ import { AdminAPI } from '../../utils/api'
|
||||
import useAuthStore from '../../store/authStore'
|
||||
|
||||
type UserType = 'personal' | 'company'
|
||||
type UserStatus = 'active' | 'pending' | 'disabled' | 'inactive'
|
||||
type UserStatus = 'active' | 'pending' | 'disabled' | 'inactive' | 'suspended' | 'archived'
|
||||
type UserRole = 'user' | 'admin'
|
||||
|
||||
interface User {
|
||||
@ -99,12 +99,13 @@ export default function AdminUserManagementPage() {
|
||||
const fullName = u.user_type === 'company' ? companyName : `${firstName} ${lastName}`
|
||||
|
||||
// Map backend status to frontend status
|
||||
// Backend status can be: 'pending', 'active', 'suspended', 'inactive', etc.
|
||||
// Backend status can be: 'pending', 'active', 'suspended', 'inactive', 'archived'
|
||||
// is_admin_verified: 1 = verified by admin, 0 = not verified
|
||||
const userStatus: UserStatus = u.status === 'inactive' ? 'inactive' :
|
||||
const userStatus: UserStatus = u.status === 'archived' ? 'archived' :
|
||||
u.status === 'inactive' ? 'inactive' :
|
||||
u.status === 'suspended' ? 'suspended' :
|
||||
u.is_admin_verified === 1 ? 'active' :
|
||||
u.status === 'pending' ? 'pending' :
|
||||
u.status === 'suspended' ? 'disabled' :
|
||||
'pending' // default fallback
|
||||
|
||||
return (
|
||||
@ -173,10 +174,11 @@ export default function AdminUserManagementPage() {
|
||||
]
|
||||
const rows = filtered.map(u => {
|
||||
// Map backend to friendly values
|
||||
const userStatus: UserStatus = u.status === 'inactive' ? 'inactive' :
|
||||
const userStatus: UserStatus = u.status === 'archived' ? 'archived' :
|
||||
u.status === 'inactive' ? 'inactive' :
|
||||
u.status === 'suspended' ? 'suspended' :
|
||||
u.is_admin_verified === 1 ? 'active' :
|
||||
u.status === 'pending' ? 'pending' :
|
||||
u.status === 'suspended' ? 'disabled' : 'pending'
|
||||
u.status === 'pending' ? 'pending' : 'pending'
|
||||
return [
|
||||
u.id,
|
||||
u.email,
|
||||
@ -220,8 +222,10 @@ export default function AdminUserManagementPage() {
|
||||
const statusBadge = (s: UserStatus) =>
|
||||
s==='active' ? badge('Active','green')
|
||||
: s==='pending' ? badge('Pending','amber')
|
||||
: s==='suspended' ? badge('Suspended','rose')
|
||||
: s==='archived' ? badge('Archived','gray')
|
||||
: s==='inactive' ? badge('Inactive','gray')
|
||||
: badge('Disabled','rose')
|
||||
: badge('Unknown','gray')
|
||||
|
||||
const typeBadge = (t: UserType) =>
|
||||
t==='personal' ? badge('Personal','blue') : badge('Company','purple')
|
||||
@ -291,7 +295,7 @@ export default function AdminUserManagementPage() {
|
||||
<select
|
||||
value={fType}
|
||||
onChange={e => setFType(e.target.value as any)}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="personal">Personal</option>
|
||||
@ -303,7 +307,7 @@ export default function AdminUserManagementPage() {
|
||||
<select
|
||||
value={fStatus}
|
||||
onChange={e => setFStatus(e.target.value as any)}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
{STATUSES.map(s => <option key={s} value={s}>{s[0].toUpperCase()+s.slice(1)}</option>)}
|
||||
@ -314,7 +318,7 @@ export default function AdminUserManagementPage() {
|
||||
<select
|
||||
value={fRole}
|
||||
onChange={e => setFRole(e.target.value as any)}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">All Roles</option>
|
||||
{ROLES.map(r => <option key={r} value={r}>{r[0].toUpperCase()+r.slice(1)}</option>)}
|
||||
@ -383,10 +387,11 @@ export default function AdminUserManagementPage() {
|
||||
: `${u.first_name?.[0] || 'U'}${u.last_name?.[0] || 'U'}`.toUpperCase()
|
||||
|
||||
// Map backend status to frontend status for display
|
||||
const userStatus: UserStatus = u.status === 'inactive' ? 'inactive' :
|
||||
const userStatus: UserStatus = u.status === 'archived' ? 'archived' :
|
||||
u.status === 'inactive' ? 'inactive' :
|
||||
u.status === 'suspended' ? 'suspended' :
|
||||
u.is_admin_verified === 1 ? 'active' :
|
||||
u.status === 'pending' ? 'pending' :
|
||||
u.status === 'suspended' ? 'disabled' :
|
||||
'pending' // default fallback
|
||||
|
||||
const createdDate = new Date(u.created_at).toLocaleDateString()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
860
src/app/components/UserDetailModal_Old.tsx
Normal file
860
src/app/components/UserDetailModal_Old.tsx
Normal file
@ -0,0 +1,860 @@
|
||||
'use client'
|
||||
|
||||
import { Fragment, useState, useEffect } from 'react'
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
import {
|
||||
XMarkIcon,
|
||||
UserIcon,
|
||||
DocumentTextIcon,
|
||||
ShieldCheckIcon,
|
||||
CalendarIcon,
|
||||
EnvelopeIcon,
|
||||
PhoneIcon,
|
||||
MapPinIcon,
|
||||
BuildingOfficeIcon,
|
||||
IdentificationIcon,
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
ExclamationTriangleIcon
|
||||
} from '@heroicons/react/24/outline'
|
||||
import { AdminAPI, DetailedUserInfo } from '../utils/api'
|
||||
import useAuthStore from '../store/authStore'
|
||||
|
||||
interface UserDetailModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
userId: string | null
|
||||
onUserUpdated?: () => void
|
||||
}
|
||||
|
||||
export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated }: UserDetailModalProps) {
|
||||
const [userDetails, setUserDetails] = useState<DetailedUserInfo | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [archiving, setArchiving] = useState(false)
|
||||
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false)
|
||||
const [editedProfile, setEditedProfile] = useState<any>(null)
|
||||
const token = useAuthStore(state => state.accessToken)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && userId && token) {
|
||||
fetchUserDetails()
|
||||
setIsEditing(false)
|
||||
setShowArchiveConfirm(false)
|
||||
setEditedProfile(null)
|
||||
}
|
||||
}, [isOpen, userId, token])
|
||||
|
||||
const fetchUserDetails = async () => {
|
||||
if (!userId || !token) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await AdminAPI.getDetailedUserInfo(token, userId)
|
||||
if (response.success) {
|
||||
setUserDetails(response)
|
||||
// Initialize edited profile with current data
|
||||
if (response.personalProfile) {
|
||||
setEditedProfile(response.personalProfile)
|
||||
} else if (response.companyProfile) {
|
||||
setEditedProfile(response.companyProfile)
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.message || 'Failed to fetch user details')
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch user details'
|
||||
setError(errorMessage)
|
||||
console.error('UserDetailModal.fetchUserDetails error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleArchiveUser = async () => {
|
||||
if (!userId || !token) return
|
||||
|
||||
setArchiving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const isCurrentlyInactive = userDetails?.userStatus?.status === 'inactive'
|
||||
|
||||
if (isCurrentlyInactive) {
|
||||
// Unarchive user
|
||||
const response = await AdminAPI.unarchiveUser(token, userId)
|
||||
if (response.success) {
|
||||
onClose()
|
||||
if (onUserUpdated) {
|
||||
onUserUpdated()
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.message || 'Failed to unarchive user')
|
||||
}
|
||||
} else {
|
||||
// Archive user
|
||||
const response = await AdminAPI.archiveUser(token, userId)
|
||||
if (response.success) {
|
||||
onClose()
|
||||
if (onUserUpdated) {
|
||||
onUserUpdated()
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.message || 'Failed to archive user')
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to archive/unarchive user'
|
||||
setError(errorMessage)
|
||||
console.error('UserDetailModal.handleArchiveUser error:', err)
|
||||
} finally {
|
||||
setArchiving(false)
|
||||
setShowArchiveConfirm(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
if (!userId || !token || !editedProfile || !userDetails) return
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const userType = userDetails.user.user_type
|
||||
const response = await AdminAPI.updateUserProfile(token, userId, editedProfile, userType)
|
||||
if (response.success) {
|
||||
// Refresh user details
|
||||
await fetchUserDetails()
|
||||
setIsEditing(false)
|
||||
if (onUserUpdated) {
|
||||
onUserUpdated()
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.message || 'Failed to update user profile')
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to update user profile'
|
||||
setError(errorMessage)
|
||||
console.error('UserDetailModal.handleSaveProfile error:', err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleAdminVerification = async () => {
|
||||
if (!userId || !token || !userDetails) return
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const newVerificationStatus = userDetails.userStatus?.is_admin_verified === 1 ? 0 : 1
|
||||
// Note: You'll need to implement this API method
|
||||
const response = await AdminAPI.updateUserVerification(token, userId, newVerificationStatus)
|
||||
if (response.success) {
|
||||
// Refresh user details
|
||||
await fetchUserDetails()
|
||||
if (onUserUpdated) {
|
||||
onUserUpdated()
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.message || 'Failed to update verification status')
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to update verification status'
|
||||
setError(errorMessage)
|
||||
console.error('UserDetailModal.handleToggleAdminVerification error:', err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const StatusBadge = ({ status, verified }: { status: boolean, verified?: boolean }) => {
|
||||
if (verified) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">
|
||||
<CheckCircleIcon className="h-3 w-3" />
|
||||
Verified
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${
|
||||
status
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{status ? <CheckCircleIcon className="h-3 w-3" /> : <XCircleIcon className="h-3 w-3" />}
|
||||
{status ? 'Complete' : 'Incomplete'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-50" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-6">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white shadow-xl transition-all w-full max-w-4xl max-h-[85vh] flex flex-col">
|
||||
<div className="absolute right-0 top-0 z-10 pr-4 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
onClick={onClose}
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content Area */}
|
||||
<div className="overflow-y-auto px-4 pb-4 pt-5 sm:p-6">
|
||||
<div className="w-full">
|
||||
<Dialog.Title as="h3" className="text-lg font-semibold leading-6 text-gray-900 mb-6 flex items-center gap-2 pr-8">
|
||||
User Details
|
||||
{isEditing && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">
|
||||
<PencilSquareIcon className="h-3 w-3" />
|
||||
Edit Mode
|
||||
</span>
|
||||
)}
|
||||
</Dialog.Title>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 rounded-full border-2 border-blue-500 border-b-transparent animate-spin" />
|
||||
<span className="ml-3 text-gray-600">Loading user details...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4 mb-6">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{userDetails && (
|
||||
<div className="space-y-6">
|
||||
{/* Basic User Info */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<UserIcon className="h-5 w-5 text-gray-600" />
|
||||
<h4 className="text-sm font-medium text-gray-900">Basic Information</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Email:</span>
|
||||
<span className="ml-2 text-gray-600">{userDetails.user.email}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Type:</span>
|
||||
<span className="ml-2 text-gray-600 capitalize">{userDetails.user.user_type}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Role:</span>
|
||||
<span className="ml-2 text-gray-600 capitalize">{userDetails.user.role}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Created:</span>
|
||||
<span className="ml-2 text-gray-600">{formatDate(userDetails.user.created_at)}</span>
|
||||
</div>
|
||||
{userDetails.user.last_login_at && (
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Last Login:</span>
|
||||
<span className="ml-2 text-gray-600">{formatDate(userDetails.user.last_login_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verification Status */}
|
||||
{userDetails.userStatus && (
|
||||
<div className="bg-blue-50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<ShieldCheckIcon className="h-5 w-5 text-blue-600" />
|
||||
<h4 className="text-sm font-medium text-gray-900">Verification Status</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-700">Email</span>
|
||||
<StatusBadge status={userDetails.userStatus.email_verified === 1} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-700">Profile</span>
|
||||
<StatusBadge status={userDetails.userStatus.profile_completed === 1} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-700">Documents</span>
|
||||
<StatusBadge status={userDetails.userStatus.documents_uploaded === 1} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-700">Contract</span>
|
||||
<StatusBadge status={userDetails.userStatus.contract_signed === 1} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-700">Admin Verified</span>
|
||||
<StatusBadge
|
||||
status={userDetails.userStatus.is_admin_verified === 1}
|
||||
verified={userDetails.userStatus.is_admin_verified === 1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profile Information */}
|
||||
{(userDetails.personalProfile || userDetails.companyProfile) && (
|
||||
<div className="bg-green-50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
{userDetails.user.user_type === 'personal' ? (
|
||||
<UserIcon className="h-5 w-5 text-green-600" />
|
||||
) : (
|
||||
<BuildingOfficeIcon className="h-5 w-5 text-green-600" />
|
||||
)}
|
||||
<h4 className="text-sm font-medium text-gray-900">Profile Information</h4>
|
||||
</div>
|
||||
|
||||
{isEditing && editedProfile ? (
|
||||
// Edit mode - show input fields
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||
{userDetails.personalProfile && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block font-medium text-gray-700 mb-1">First Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedProfile.first_name || ''}
|
||||
onChange={(e) => setEditedProfile({...editedProfile, first_name: e.target.value})}
|
||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-medium text-gray-700 mb-1">Last Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedProfile.last_name || ''}
|
||||
onChange={(e) => setEditedProfile({...editedProfile, last_name: e.target.value})}
|
||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-medium text-gray-700 mb-1">Phone</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedProfile.phone || ''}
|
||||
onChange={(e) => setEditedProfile({...editedProfile, phone: e.target.value})}
|
||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-medium text-gray-700 mb-1">Date of Birth</label>
|
||||
<input
|
||||
type="date"
|
||||
value={editedProfile.date_of_birth || ''}
|
||||
onChange={(e) => setEditedProfile({...editedProfile, date_of_birth: e.target.value})}
|
||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block font-medium text-gray-700 mb-1">Address</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedProfile.address || ''}
|
||||
onChange={(e) => setEditedProfile({...editedProfile, address: e.target.value})}
|
||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-medium text-gray-700 mb-1">City</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedProfile.city || ''}
|
||||
onChange={(e) => setEditedProfile({...editedProfile, city: e.target.value})}
|
||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-medium text-gray-700 mb-1">Postal Code</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedProfile.zip_code || ''}
|
||||
onChange={(e) => setEditedProfile({...editedProfile, zip_code: e.target.value})}
|
||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-medium text-gray-700 mb-1">Country</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedProfile.country || ''}
|
||||
onChange={(e) => setEditedProfile({...editedProfile, country: e.target.value})}
|
||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{userDetails.companyProfile && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block font-medium text-gray-700 mb-1">Company Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedProfile.company_name || ''}
|
||||
onChange={(e) => setEditedProfile({...editedProfile, company_name: e.target.value})}
|
||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-medium text-gray-700 mb-1">Tax ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedProfile.tax_id || ''}
|
||||
onChange={(e) => setEditedProfile({...editedProfile, tax_id: e.target.value})}
|
||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-medium text-gray-700 mb-1">Registration Number</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedProfile.registration_number || ''}
|
||||
onChange={(e) => setEditedProfile({...editedProfile, registration_number: e.target.value})}
|
||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-medium text-gray-700 mb-1">Phone</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedProfile.phone || ''}
|
||||
onChange={(e) => setEditedProfile({...editedProfile, phone: e.target.value})}
|
||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block font-medium text-gray-700 mb-1">Address</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedProfile.address || ''}
|
||||
onChange={(e) => setEditedProfile({...editedProfile, address: e.target.value})}
|
||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-medium text-gray-700 mb-1">City</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedProfile.city || ''}
|
||||
onChange={(e) => setEditedProfile({...editedProfile, city: e.target.value})}
|
||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-medium text-gray-700 mb-1">Postal Code</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedProfile.zip_code || ''}
|
||||
onChange={(e) => setEditedProfile({...editedProfile, zip_code: e.target.value})}
|
||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-medium text-gray-700 mb-1">Country</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editedProfile.country || ''}
|
||||
onChange={(e) => setEditedProfile({...editedProfile, country: e.target.value})}
|
||||
className="w-full rounded border-gray-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// View mode - show readonly data
|
||||
<>
|
||||
{userDetails.personalProfile && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Name:</span>
|
||||
<span className="ml-2 text-gray-600">
|
||||
{userDetails.personalProfile.first_name} {userDetails.personalProfile.last_name}
|
||||
</span>
|
||||
</div>
|
||||
{userDetails.personalProfile.phone && (
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Phone:</span>
|
||||
<span className="ml-2 text-gray-600">{userDetails.personalProfile.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
{userDetails.personalProfile.date_of_birth && (
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Date of Birth:</span>
|
||||
<span className="ml-2 text-gray-600">{formatDate(userDetails.personalProfile.date_of_birth)}</span>
|
||||
</div>
|
||||
)}
|
||||
{userDetails.personalProfile.address && (
|
||||
<div className="sm:col-span-2">
|
||||
<span className="font-medium text-gray-700">Address:</span>
|
||||
<span className="ml-2 text-gray-600">
|
||||
{userDetails.personalProfile.address}, {userDetails.personalProfile.zip_code} {userDetails.personalProfile.city}, {userDetails.personalProfile.country}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{userDetails.companyProfile && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Company Name:</span>
|
||||
<span className="ml-2 text-gray-600">{userDetails.companyProfile.company_name}</span>
|
||||
</div>
|
||||
{userDetails.companyProfile.tax_id && (
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Tax ID:</span>
|
||||
<span className="ml-2 text-gray-600">{userDetails.companyProfile.tax_id}</span>
|
||||
</div>
|
||||
)}
|
||||
{userDetails.companyProfile.registration_number && (
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Registration Number:</span>
|
||||
<span className="ml-2 text-gray-600">{userDetails.companyProfile.registration_number}</span>
|
||||
</div>
|
||||
)}
|
||||
{userDetails.companyProfile.phone && (
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Phone:</span>
|
||||
<span className="ml-2 text-gray-600">{userDetails.companyProfile.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
{userDetails.companyProfile.address && (
|
||||
<div className="sm:col-span-2">
|
||||
<span className="font-medium text-gray-700">Address:</span>
|
||||
<span className="ml-2 text-gray-600">
|
||||
{userDetails.companyProfile.address}, {userDetails.companyProfile.zip_code} {userDetails.companyProfile.city}, {userDetails.companyProfile.country}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documents */}
|
||||
{(userDetails.documents.length > 0 || userDetails.contracts.length > 0 || userDetails.idDocuments.length > 0) && (
|
||||
<div className="bg-purple-50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<DocumentTextIcon className="h-5 w-5 text-purple-600" />
|
||||
<h4 className="text-sm font-medium text-gray-900">Documents</h4>
|
||||
</div>
|
||||
|
||||
{/* Regular Documents */}
|
||||
{userDetails.documents.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h5 className="text-xs font-medium text-gray-700 mb-2">Uploaded Documents</h5>
|
||||
<div className="space-y-2">
|
||||
{userDetails.documents.map((doc) => (
|
||||
<div key={doc.id} className="flex items-center justify-between bg-white p-2 rounded border">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900">{doc.file_name}</span>
|
||||
<span className="text-xs text-gray-500 ml-2">({formatFileSize(doc.file_size)})</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{formatDate(doc.uploaded_at)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contracts */}
|
||||
{userDetails.contracts.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h5 className="text-xs font-medium text-gray-700 mb-2">Contracts</h5>
|
||||
<div className="space-y-2">
|
||||
{userDetails.contracts.map((contract) => (
|
||||
<div key={contract.id} className="flex items-center justify-between bg-white p-2 rounded border">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900">{contract.file_name}</span>
|
||||
<span className="text-xs text-gray-500 ml-2">({formatFileSize(contract.file_size)})</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{formatDate(contract.uploaded_at)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ID Documents */}
|
||||
{userDetails.idDocuments.length > 0 && (
|
||||
<div>
|
||||
<h5 className="text-xs font-medium text-gray-700 mb-2">ID Documents</h5>
|
||||
<div className="space-y-4">
|
||||
{userDetails.idDocuments.map((idDoc) => (
|
||||
<div key={idDoc.id} className="bg-white p-3 rounded border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<IdentificationIcon className="h-4 w-4 text-gray-600" />
|
||||
<span className="text-sm font-medium text-gray-900">{idDoc.document_type}</span>
|
||||
<span className="text-xs text-gray-500">{formatDate(idDoc.uploaded_at)}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{idDoc.frontUrl && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-700 mb-1">Front:</p>
|
||||
<img
|
||||
src={idDoc.frontUrl}
|
||||
alt="ID Front"
|
||||
className="max-w-full h-32 object-contain border rounded"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{idDoc.backUrl && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-700 mb-1">Back:</p>
|
||||
<img
|
||||
src={idDoc.backUrl}
|
||||
alt="ID Back"
|
||||
className="max-w-full h-32 object-contain border rounded"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Permissions */}
|
||||
{userDetails.permissions.length > 0 && (
|
||||
<div className="bg-indigo-50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<ShieldCheckIcon className="h-5 w-5 text-indigo-600" />
|
||||
<h4 className="text-sm font-medium text-gray-900">Permissions</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{userDetails.permissions.map((permission) => (
|
||||
<div key={permission.id} className="bg-white p-2 rounded border">
|
||||
<div className="text-sm font-medium text-gray-900">{permission.name}</div>
|
||||
{permission.description && (
|
||||
<div className="text-xs text-gray-600">{permission.description}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 sm:mt-6">
|
||||
{showArchiveConfirm ? (
|
||||
// Archive/Unarchive Confirmation Dialog
|
||||
<div className={`${userDetails?.userStatus?.status === 'inactive' ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'} border rounded-lg p-4 mb-4`}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<ExclamationTriangleIcon className={`h-5 w-5 ${userDetails?.userStatus?.status === 'inactive' ? 'text-green-600' : 'text-red-600'}`} />
|
||||
<h4 className={`text-sm font-medium ${userDetails?.userStatus?.status === 'inactive' ? 'text-green-900' : 'text-red-900'}`}>
|
||||
{userDetails?.userStatus?.status === 'inactive' ? 'Unarchive User' : 'Archive User'}
|
||||
</h4>
|
||||
</div>
|
||||
<p className={`text-sm ${userDetails?.userStatus?.status === 'inactive' ? 'text-green-700' : 'text-red-700'} mb-4`}>
|
||||
{userDetails?.userStatus?.status === 'inactive'
|
||||
? 'Are you sure you want to unarchive this user? This will reactivate their account.'
|
||||
: 'Are you sure you want to archive this user? This action will disable their account but preserve all their data.'}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleArchiveUser}
|
||||
disabled={archiving}
|
||||
className={`inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-semibold text-white shadow-sm focus-visible:outline focus-visible:outline-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
userDetails?.userStatus?.status === 'inactive'
|
||||
? 'bg-green-600 hover:bg-green-500 focus-visible:outline-green-600'
|
||||
: 'bg-red-600 hover:bg-red-500 focus-visible:outline-red-600'
|
||||
}`}
|
||||
>
|
||||
{archiving ? (
|
||||
<>
|
||||
<div className="h-4 w-4 border-2 border-white border-b-transparent rounded-full animate-spin" />
|
||||
{userDetails?.userStatus?.status === 'inactive' ? 'Unarchiving...' : 'Archiving...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
{userDetails?.userStatus?.status === 'inactive' ? 'Unarchive User' : 'Archive User'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowArchiveConfirm(false)}
|
||||
disabled={archiving}
|
||||
className="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Normal action buttons
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveProfile}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-md bg-green-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-500 focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-green-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<div className="h-4 w-4 border-2 border-white border-b-transparent rounded-full animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircleIcon className="h-4 w-4" />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsEditing(false)
|
||||
// Reset edited profile to original
|
||||
if (userDetails?.personalProfile) {
|
||||
setEditedProfile(userDetails.personalProfile)
|
||||
} else if (userDetails?.companyProfile) {
|
||||
setEditedProfile(userDetails.companyProfile)
|
||||
}
|
||||
}}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-md bg-gray-200 px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm hover:bg-gray-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<XCircleIcon className="h-4 w-4" />
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(true)}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<PencilSquareIcon className="h-4 w-4" />
|
||||
Edit Profile
|
||||
</button>
|
||||
|
||||
{userDetails?.userStatus && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleAdminVerification}
|
||||
disabled={saving}
|
||||
className={`inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-semibold shadow-sm focus-visible:outline focus-visible:outline-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
userDetails.userStatus.is_admin_verified === 1
|
||||
? 'bg-amber-600 hover:bg-amber-500 text-white focus-visible:outline-amber-600'
|
||||
: 'bg-green-600 hover:bg-green-500 text-white focus-visible:outline-green-600'
|
||||
}`}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<div className="h-4 w-4 border-2 border-white border-b-transparent rounded-full animate-spin" />
|
||||
Updating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldCheckIcon className="h-4 w-4" />
|
||||
{userDetails.userStatus.is_admin_verified === 1 ? 'Unverify User' : 'Verify User'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowArchiveConfirm(true)}
|
||||
className={`inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-semibold text-white shadow-sm focus-visible:outline focus-visible:outline-offset-2 ${
|
||||
userDetails?.userStatus?.status === 'inactive'
|
||||
? 'bg-green-600 hover:bg-green-500 focus-visible:outline-green-600'
|
||||
: 'bg-red-600 hover:bg-red-500 focus-visible:outline-red-600'
|
||||
}`}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
{userDetails?.userStatus?.status === 'inactive' ? 'Unarchive User' : 'Archive User'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-md bg-gray-200 px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm hover:bg-gray-300 focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-gray-500"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
)
|
||||
}
|
||||
@ -45,6 +45,7 @@ export const API_ENDPOINTS = {
|
||||
ADMIN_UNARCHIVE_USER: '/api/admin/unarchive-user/:id',
|
||||
ADMIN_UPDATE_USER_VERIFICATION: '/api/admin/update-verification/:id',
|
||||
ADMIN_UPDATE_USER_PROFILE: '/api/admin/update-user-profile/:id',
|
||||
ADMIN_UPDATE_USER_STATUS: '/api/admin/update-user-status/:id',
|
||||
}
|
||||
|
||||
// API Helper Functions
|
||||
@ -333,6 +334,16 @@ export class AdminAPI {
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
static async updateUserStatus(token: string, userId: string, status: string) {
|
||||
const endpoint = API_ENDPOINTS.ADMIN_UPDATE_USER_STATUS.replace(':id', userId)
|
||||
const response = await ApiClient.patch(endpoint, { status }, token)
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.message || 'Failed to update user status')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
}
|
||||
|
||||
// Response Types
|
||||
@ -375,7 +386,7 @@ export interface DetailedUserInfo {
|
||||
id: number
|
||||
email: string
|
||||
user_type: 'personal' | 'company'
|
||||
role: 'user' | 'admin'
|
||||
role: 'user' | 'admin' | 'super_admin'
|
||||
created_at: string
|
||||
last_login_at: string | null
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user