* feat(backoffice): create a boilerplate page for Hackathon dashboard - Create an empty page for Hackathon dashboard - Comment out and hide the existing backoffice sidebar * feat(backoffice): Create a nested/dropdown sidebar list - Create a dropdown sidebar list - Show back the old navigation and group them - Make the sidebar responsive for mobile view * test datatable with mock data * base UI for Hackathon backoffice TODO: - Organize table schema for users, teams, and submissions management - Create API Contract for additional back-end endpoint * feat(hackathon): draft data table column & API Contract * update endpoint * feat(backoffice): update page hackathon user management - update data table component - update filtering & pagination - add modal display to edit and add user - hide notification icon in backoffice wrapper * feat(backoffice): add API contract for hackathon users * feat(backoffice): little adjustment in hackathon users management UI and API contract * feat(backoffice): update hackathon team management page - add modal for manage team, add new team, and view project submission - reorganize the table column and data table * feat(backoffice): add searchable city filter - add component for city filter - apply to user management and team management pages * feat(backoffice): improve hackathon team modal UI and add API contract - Add feature to select city in team detail modal using CityFilterSelect component - Add feature to change team logo and banner - Add API contract documentation for hackathon teams in backoffice * feat(backoffice): authentication middleware, error pages, and 404 page * feat(backoffice): hackathon dashboard integration * feat(backoffice): users page integration - Get users data from API - Set up server-side pagination and match URL params - Hide filter that doesn't exist in back-end * feat(backoffice): teams page integration - Get teams data from API - Hide filter that doesn't exists in back-end - Simplify modal according to the back-end * feat(backoffice): submission page integration - Fetch submissions data from API - Move submission modal to hackathon-submissions page
656 lines
25 KiB
TypeScript
656 lines
25 KiB
TypeScript
import { FC, useState, useEffect, useMemo, useRef } from 'react';
|
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
|
import { cn } from '@imphnen-frontend-service/utils';
|
|
import {
|
|
UserOutlined,
|
|
EnvironmentOutlined,
|
|
CalendarOutlined,
|
|
SaveOutlined,
|
|
CloseOutlined,
|
|
ExclamationOutlined,
|
|
CameraOutlined,
|
|
DeleteOutlined,
|
|
UploadOutlined,
|
|
} from '@ant-design/icons';
|
|
|
|
interface UserType {
|
|
id: string;
|
|
avatar?: string | null;
|
|
fullname: string;
|
|
bio?: string;
|
|
location: string | null;
|
|
is_active: boolean;
|
|
skills: string[];
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
interface ModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
user: UserType | null;
|
|
}
|
|
|
|
const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|
const [formData, setFormData] = useState<UserType | null>(null);
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
const [showAvatarMenu, setShowAvatarMenu] = useState(false);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Initialize form data when modal opens
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
if (user) {
|
|
// Edit existing user
|
|
setFormData({ ...user });
|
|
} else {
|
|
// Create new user
|
|
setFormData({
|
|
id: '', // Will be generated by backend
|
|
fullname: '',
|
|
bio: '',
|
|
location: '',
|
|
is_active: true,
|
|
skills: [],
|
|
avatar: undefined,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
});
|
|
}
|
|
}
|
|
}, [isOpen, user]);
|
|
|
|
// Check if form has changes
|
|
const hasChanges = useMemo(() => {
|
|
if (!formData) return false;
|
|
if (!user) return true; // New user always has changes
|
|
return (
|
|
formData.fullname !== user.fullname ||
|
|
formData.location !== user.location ||
|
|
formData.is_active !== user.is_active ||
|
|
formData.avatar !== user.avatar ||
|
|
JSON.stringify(formData.skills) !== JSON.stringify(user.skills) ||
|
|
formData.bio !== user.bio
|
|
);
|
|
}, [formData, user]);
|
|
|
|
// Check if required fields are filled
|
|
const isFormValid = useMemo(() => {
|
|
if (!formData) return false;
|
|
return formData.fullname?.trim() !== '' && formData.location?.trim() !== '';
|
|
}, [formData]);
|
|
|
|
const canSave = hasChanges && isFormValid;
|
|
|
|
if (!isOpen || !formData) return null;
|
|
|
|
const handleInputChange = (
|
|
field: keyof UserType,
|
|
value: string | boolean | string[] | undefined
|
|
) => {
|
|
setFormData((prev) => (prev ? { ...prev, [field]: value } : null));
|
|
};
|
|
|
|
const handleSkillsChange = (skills: string[]) => {
|
|
setFormData((prev) => (prev ? { ...prev, skills } : null));
|
|
};
|
|
|
|
const handleSave = () => {
|
|
if (!formData) return;
|
|
|
|
if (user) {
|
|
// Update existing user
|
|
console.log('Update user data:', formData);
|
|
} else {
|
|
// Create new user
|
|
console.log('Create new user:', formData);
|
|
}
|
|
// Here you would typically make an API call to save the data
|
|
onClose();
|
|
};
|
|
|
|
const handleCancel = () => {
|
|
if (user) {
|
|
setFormData({ ...user }); // Reset to original for edit mode
|
|
}
|
|
onClose();
|
|
};
|
|
|
|
const handleDeleteAccount = () => {
|
|
if (!user) return; // Can't delete new user
|
|
console.log('Delete user:', user.id);
|
|
setShowDeleteConfirm(false);
|
|
onClose();
|
|
};
|
|
|
|
const handleAvatarUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = event.target.files?.[0];
|
|
if (file) {
|
|
// Validate file type
|
|
if (!file.type.startsWith('image/')) {
|
|
alert('Please select an image file');
|
|
return;
|
|
}
|
|
|
|
// Validate file size (max 5MB)
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
alert('Image size must be less than 5MB');
|
|
return;
|
|
}
|
|
|
|
// Create preview URL
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
const avatarUrl = e.target?.result as string;
|
|
handleInputChange('avatar', avatarUrl);
|
|
setShowAvatarMenu(false);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
};
|
|
|
|
const handleRemoveAvatar = () => {
|
|
handleInputChange('avatar', undefined);
|
|
setShowAvatarMenu(false);
|
|
};
|
|
|
|
const triggerFileUpload = () => {
|
|
fileInputRef.current?.click();
|
|
};
|
|
|
|
const availableSkills = [
|
|
'Frontend Developer',
|
|
'Backend Developer',
|
|
'Full Stack Developer',
|
|
'DevOps Engineer',
|
|
'UI/UX Designer',
|
|
'Product Manager',
|
|
'Data Scientist',
|
|
'Mobile Developer',
|
|
];
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50">
|
|
<div
|
|
className="fixed inset-0 bg-black/50"
|
|
onClick={(e) => {
|
|
setShowAvatarMenu(false);
|
|
onClose();
|
|
}}
|
|
/>
|
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
|
<div
|
|
className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Hidden File Input */}
|
|
<input
|
|
type="file"
|
|
ref={fileInputRef}
|
|
onChange={handleAvatarUpload}
|
|
accept="image/*"
|
|
className="hidden"
|
|
/>
|
|
{/* Header */}
|
|
<div className="border-b border-neutral-200 px-8 py-6 flex justify-between items-start">
|
|
<div className="flex items-center gap-4">
|
|
{/* Interactive User Avatar */}
|
|
<div className="relative group ">
|
|
<div className="w-16 h-16 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden border-2 border-transparent group-hover:border-primary-300 transition-colors">
|
|
{formData.avatar ? (
|
|
<img
|
|
src={formData.avatar}
|
|
alt={formData.fullname}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
) : (
|
|
<UserOutlined className="text-neutral-500 text-2xl" />
|
|
)}
|
|
</div>
|
|
|
|
{/* Avatar Hover Overlay */}
|
|
<button
|
|
onClick={() => setShowAvatarMenu(!showAvatarMenu)}
|
|
className="absolute inset-0 bg-neutral-400 cursor-pointer rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
|
>
|
|
<CameraOutlined className="text-white text-lg" />
|
|
</button>
|
|
|
|
{/* Avatar Menu Dropdown */}
|
|
{showAvatarMenu && (
|
|
<div className="absolute top-full left-0 mt-2 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[140px] z-10">
|
|
<button
|
|
onClick={triggerFileUpload}
|
|
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-2 cursor-pointer"
|
|
>
|
|
<UploadOutlined className="text-sm" />
|
|
{formData.avatar ? 'Change Photo' : 'Upload Photo'}
|
|
</button>
|
|
{formData.avatar && (
|
|
<button
|
|
onClick={handleRemoveAvatar}
|
|
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 cursor-pointer"
|
|
>
|
|
<DeleteOutlined className="text-sm" />
|
|
Remove Photo
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<h2 className="text-2xl font-bold text-neutral-900">
|
|
{user ? 'Edit User Profile' : 'Create New User'}
|
|
</h2>
|
|
{user && (
|
|
<span className="px-3 py-1 bg-info-100 text-info-700 text-xs font-medium rounded-2xl">
|
|
Hover avatar to change
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="text-sm text-neutral-500">
|
|
{user
|
|
? `Make changes to ${
|
|
formData.fullname || 'this user'
|
|
}'s profile information`
|
|
: 'Fill in the information below to create a new user account'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors cursor-pointer"
|
|
onClick={() => {
|
|
setShowAvatarMenu(false);
|
|
handleCancel();
|
|
}}
|
|
>
|
|
<CloseOutlined className="text-neutral-400 text-lg" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="p-8" onClick={() => setShowAvatarMenu(false)}>
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
{/* Left Column - Basic Info */}
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
|
Basic Information
|
|
</h3>
|
|
<div className="space-y-4">
|
|
{/* Full Name - Required */}
|
|
<div className="flex items-center gap-3">
|
|
<UserOutlined className="text-neutral-400" />
|
|
<div className="flex-1">
|
|
<label className="text-sm text-neutral-500 block mb-1">
|
|
Full Name <span className="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.fullname}
|
|
onChange={(e) =>
|
|
handleInputChange('fullname', e.target.value)
|
|
}
|
|
className={cn(
|
|
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none',
|
|
!formData.fullname ||
|
|
formData.fullname.trim() === ''
|
|
? 'border-red-300 bg-red-50'
|
|
: 'border-neutral-300'
|
|
)}
|
|
placeholder="Enter full name"
|
|
/>
|
|
{(!formData.fullname ||
|
|
formData.fullname.trim() === '') && (
|
|
<p className="text-red-500 text-xs mt-1">
|
|
Full name is required
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Location - Required */}
|
|
<div className="flex items-center gap-3">
|
|
<EnvironmentOutlined className="text-neutral-400" />
|
|
<div className="flex-1">
|
|
<label className="text-sm text-neutral-500 block mb-1">
|
|
Location <span className="text-red-500">*</span>
|
|
</label>
|
|
<select
|
|
value={formData.location || ''}
|
|
onChange={(e) =>
|
|
handleInputChange('location', e.target.value)
|
|
}
|
|
className={cn(
|
|
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white',
|
|
!formData.location ||
|
|
formData.location.trim() === ''
|
|
? 'border-red-300 bg-red-50'
|
|
: 'border-neutral-300'
|
|
)}
|
|
>
|
|
<option value="">Select location</option>
|
|
<option value="Jakarta">Jakarta</option>
|
|
<option value="Bandung">Bandung</option>
|
|
<option value="Surabaya">Surabaya</option>
|
|
<option value="Medan">Medan</option>
|
|
<option value="Yogyakarta">Yogyakarta</option>
|
|
</select>
|
|
{(!formData.location ||
|
|
formData.location.trim() === '') && (
|
|
<p className="text-red-500 text-xs mt-1">
|
|
Location is required
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Joined Date - Read Only - Only show for existing users */}
|
|
{user && (
|
|
<div className="flex items-center gap-3">
|
|
<CalendarOutlined className="text-neutral-400" />
|
|
<div>
|
|
<p className="text-sm text-neutral-500">
|
|
Joined Date
|
|
</p>
|
|
<p className="font-medium">
|
|
{new Date(formData.created_at).toLocaleDateString(
|
|
'en-US',
|
|
{
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
}
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bio Section - Optional */}
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-neutral-900 mb-3">
|
|
Bio{' '}
|
|
<span className="text-neutral-400 text-sm font-normal">
|
|
(Optional)
|
|
</span>
|
|
</h3>
|
|
<textarea
|
|
value={formData.bio || ''}
|
|
onChange={(e) =>
|
|
handleInputChange('bio', e.target.value || undefined)
|
|
}
|
|
placeholder="Tell us about yourself..."
|
|
rows={4}
|
|
className="w-full border border-neutral-300 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none resize-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Column - Skills & Status */}
|
|
<div className="space-y-6">
|
|
{/* Account Status - Enhanced Tab Design */}
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
|
Account Status
|
|
</h3>
|
|
<div className="flex bg-neutral-100 p-1 rounded-lg">
|
|
<button
|
|
onClick={() => handleInputChange('is_active', true)}
|
|
className={cn(
|
|
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 cursor-pointer',
|
|
formData.is_active
|
|
? 'bg-white text-success-700 shadow-sm ring-1 ring-success-200'
|
|
: 'text-neutral-600 hover:text-neutral-800'
|
|
)}
|
|
>
|
|
<div className="flex items-center justify-center gap-2">
|
|
<div
|
|
className={cn(
|
|
'w-2 h-2 rounded-full',
|
|
formData.is_active
|
|
? 'bg-success-500'
|
|
: 'bg-neutral-400'
|
|
)}
|
|
/>
|
|
Active
|
|
</div>
|
|
</button>
|
|
<button
|
|
onClick={() => handleInputChange('is_active', false)}
|
|
className={cn(
|
|
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 cursor-pointer',
|
|
!formData.is_active
|
|
? 'bg-white text-neutral-700 shadow-sm ring-1 ring-neutral-200'
|
|
: 'text-neutral-600 hover:text-neutral-800'
|
|
)}
|
|
>
|
|
<div className="flex items-center justify-center gap-2">
|
|
<div
|
|
className={cn(
|
|
'w-2 h-2 rounded-full',
|
|
!formData.is_active
|
|
? 'bg-neutral-500'
|
|
: 'bg-neutral-400'
|
|
)}
|
|
/>
|
|
Inactive
|
|
</div>
|
|
</button>
|
|
</div>
|
|
<p className="text-xs text-neutral-500 mt-2">
|
|
{formData.is_active
|
|
? 'User can access their account and participate in activities'
|
|
: 'User account is suspended and cannot access services'}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Skills Section - Optional */}
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
|
Skills & Expertise{' '}
|
|
<span className="text-neutral-400 text-sm font-normal">
|
|
(Optional)
|
|
</span>
|
|
</h3>
|
|
<div className="space-y-3">
|
|
<div className="flex flex-wrap gap-2 min-h-10 p-3 border border-neutral-300 rounded-lg bg-neutral-50">
|
|
{formData.skills.length > 0 ? (
|
|
formData.skills.map((skill, index) => (
|
|
<span
|
|
key={index}
|
|
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-2xl text-sm font-medium bg-blue-100 text-blue-800"
|
|
>
|
|
{skill}
|
|
<button
|
|
onClick={() =>
|
|
handleSkillsChange(
|
|
formData.skills.filter((_, i) => i !== index)
|
|
)
|
|
}
|
|
className="text-blue-600 hover:text-blue-800 ml-1 cursor-pointer"
|
|
>
|
|
✕
|
|
</button>
|
|
</span>
|
|
))
|
|
) : (
|
|
<span className="text-neutral-400 text-sm">
|
|
No skills added yet
|
|
</span>
|
|
)}
|
|
</div>
|
|
<select
|
|
value=""
|
|
onChange={(e) => {
|
|
if (
|
|
e.target.value &&
|
|
!formData.skills.includes(e.target.value)
|
|
) {
|
|
handleSkillsChange([
|
|
...formData.skills,
|
|
e.target.value,
|
|
]);
|
|
}
|
|
}}
|
|
className="w-full border border-neutral-300 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white"
|
|
>
|
|
<option value="">Add a skill...</option>
|
|
{availableSkills
|
|
.filter((skill) => !formData.skills.includes(skill))
|
|
.map((skill) => (
|
|
<option key={skill} value={skill}>
|
|
{skill}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Account Details - Read Only - Only show for existing users */}
|
|
{user && (
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
|
Account Details
|
|
</h3>
|
|
<div className="space-y-3 bg-neutral-50 p-4 rounded-lg">
|
|
<div className="flex justify-between items-center py-1">
|
|
<span className="text-neutral-600 text-sm">
|
|
User ID
|
|
</span>
|
|
<span className="font-mono text-sm text-neutral-800">
|
|
{formData.id}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between items-center py-1">
|
|
<span className="text-neutral-600 text-sm">
|
|
Last Updated
|
|
</span>
|
|
<span className="text-sm text-neutral-800">
|
|
{new Date(formData.updated_at).toLocaleDateString(
|
|
'en-US',
|
|
{
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
}
|
|
)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer Actions */}
|
|
<div className="border-t border-neutral-200 px-8 py-6">
|
|
<div className="flex justify-between items-center">
|
|
<div className="flex items-center gap-4">
|
|
<div className="text-sm text-neutral-500">
|
|
{canSave
|
|
? 'Ready to save changes'
|
|
: hasChanges
|
|
? 'Please fill required fields'
|
|
: 'No changes made'}
|
|
</div>
|
|
{/* Delete Account Button - Only show for existing users */}
|
|
{user && (
|
|
<button
|
|
onClick={() => setShowDeleteConfirm(true)}
|
|
className="text-red-600 hover:text-red-700 text-sm font-medium transition-colors cursor-pointer"
|
|
>
|
|
Delete Account
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={handleCancel}
|
|
className="px-6"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={handleSave}
|
|
disabled={!canSave}
|
|
className={cn(
|
|
'flex items-center gap-2 px-6',
|
|
!canSave && 'opacity-50 cursor-not-allowed'
|
|
)}
|
|
>
|
|
<SaveOutlined className="text-sm" />
|
|
{user ? 'Save Changes' : 'Create User'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Delete Confirmation Modal */}
|
|
{showDeleteConfirm && (
|
|
<div className="fixed inset-0 z-60">
|
|
<div
|
|
className="fixed inset-0 bg-black/50"
|
|
onClick={() => setShowDeleteConfirm(false)}
|
|
/>
|
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
|
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md">
|
|
<div className="p-6">
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
|
|
<ExclamationOutlined className="text-red-600 text-lg" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-neutral-900">
|
|
Delete Account
|
|
</h3>
|
|
<p className="text-sm text-neutral-500">
|
|
This action cannot be undone
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<p className="text-neutral-700 mb-6">
|
|
Are you sure you want to permanently delete{' '}
|
|
<strong>{formData.fullname}</strong>'s account? This will
|
|
remove all their data and cannot be reversed.
|
|
</p>
|
|
<div className="flex gap-3 justify-end">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setShowDeleteConfirm(false)}
|
|
className="px-4"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={handleDeleteAccount}
|
|
className="px-4 bg-red-600 hover:bg-red-700 border-red-600"
|
|
>
|
|
Delete Account
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ModalUserDetail;
|