feat(admin): add user and doctor management APIs and frontend form

- Updated security configuration to include new API route for file uploads.
- Added new endpoints in AdminApiController for user statistics, toggling user status, updating user roles, and managing user details.
- Implemented doctor statistics and management endpoints, including toggling doctor status and creating new doctors.
- Enhanced user listing with filtering options for roles and status.
- Introduced DoctorFormPage component for adding new doctors with specialties selection.
- Integrated react-leaflet for mapping functionalities and added necessary dependencies.
- Updated package.json and package-lock.json to include new dependencies.
This commit is contained in:
hamed
2026-06-10 17:02:42 +03:30
parent 8ed3d41d27
commit 25701c09b7
13 changed files with 3914 additions and 419 deletions
+329 -10
View File
@@ -7,6 +7,7 @@ use App\Auth\Entity\User;
use App\Location\Entity\City;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Specialty\Entity\Specialty;
use App\Payment\Entity\Payment;
use App\Rating\Entity\Comment;
use App\Rating\Entity\Rate;
@@ -73,29 +74,168 @@ class AdminApiController extends BaseController
new OA\Response(response: 403, description: 'Forbidden ROLE_ADMIN required'),
]
)]
#[Route('/api/v1/admin/users/stats', methods: ['GET'])]
public function userStats(): JsonResponse
{
$conn = $this->em->getConnection();
$total = (int) $conn->fetchOne('SELECT COUNT(*) FROM users');
$active = (int) $conn->fetchOne('SELECT COUNT(*) FROM users WHERE status = 1');
$admins = (int) $conn->fetchOne("SELECT COUNT(*) FROM users WHERE roles LIKE '%ROLE_ADMIN%'");
$doctors = (int) $conn->fetchOne("SELECT COUNT(*) FROM users WHERE roles LIKE '%ROLE_DOCTOR%' AND roles NOT LIKE '%ROLE_ADMIN%'");
return $this->success([
'total' => $total,
'active' => $active,
'inactive' => $total - $active,
'admins' => $admins,
'doctors' => $doctors,
'patients' => max(0, $total - $admins - $doctors),
]);
}
#[Route('/api/v1/admin/users/{uuid}/status', methods: ['POST'])]
public function toggleUserStatus(string $uuid): JsonResponse
{
$user = $this->em->getRepository(User::class)->findOneBy(['uuid' => $uuid]);
if (!$user) return $this->error('USER_NOT_FOUND', 'کاربر یافت نشد', 404);
$user->setStatus($user->getStatus() === 1 ? 0 : 1);
$this->em->flush();
return $this->success(['is_active' => $user->getStatus() === 1, 'status' => $user->getStatus()]);
}
#[Route('/api/v1/admin/users/{uuid}/role', methods: ['PUT'])]
public function updateUserRole(string $uuid, Request $request): JsonResponse
{
$user = $this->em->getRepository(User::class)->findOneBy(['uuid' => $uuid]);
if (!$user) return $this->error('USER_NOT_FOUND', 'کاربر یافت نشد', 404);
$data = json_decode($request->getContent(), true) ?? [];
$role = (string) ($data['role'] ?? '');
$roleMap = [
'admin' => ['ROLE_USER', 'ROLE_ADMIN'],
'doctor' => ['ROLE_USER', 'ROLE_DOCTOR'],
'secretary' => ['ROLE_USER', 'ROLE_SECRETARY'],
'clinic' => ['ROLE_USER', 'ROLE_CLINIC'],
'patient' => ['ROLE_USER'],
];
if (!isset($roleMap[$role])) {
return $this->error('INVALID_ROLE', 'نقش نامعتبر است', 422);
}
$user->setRoles($roleMap[$role]);
$this->em->flush();
return $this->success(['roles' => $user->getRoles()]);
}
#[Route('/api/v1/admin/users/{uuid}', methods: ['GET'])]
public function userDetail(string $uuid): JsonResponse
{
$user = $this->em->getRepository(User::class)->findOneBy(['uuid' => $uuid]);
if (!$user) return $this->error('USER_NOT_FOUND', 'کاربر یافت نشد', 404);
return $this->success([
'uuid' => $user->getUuid(),
'id' => $user->getId(),
'mobile_number' => $user->getMobileNumber(),
'name' => $user->getRealName(),
'email' => $user->getEmail(),
'roles' => $user->getRoles(),
'is_active' => $user->getStatus() === 1,
'status' => $user->getStatus(),
'created_at' => date('c', $user->getCreatedAt()),
'updated_at' => date('c', $user->getCreatedAt()),
]);
}
#[Route('/api/v1/admin/users/{uuid}', methods: ['PUT'])]
public function updateUser(string $uuid, Request $request): JsonResponse
{
$user = $this->em->getRepository(User::class)->findOneBy(['uuid' => $uuid]);
if (!$user) return $this->error('USER_NOT_FOUND', 'کاربر یافت نشد', 404);
$data = json_decode($request->getContent(), true) ?? [];
if (array_key_exists('name', $data)) {
$user->setRealName($data['name'] !== '' ? (string) $data['name'] : null);
}
if (array_key_exists('email', $data)) {
$user->setEmail($data['email'] !== '' ? (string) $data['email'] : null);
}
if (!empty($data['password'])) {
$user->setPasswordHash(password_hash((string) $data['password'], PASSWORD_BCRYPT));
}
$this->em->flush();
return $this->success([
'uuid' => $user->getUuid(),
'name' => $user->getRealName(),
'email' => $user->getEmail(),
'is_active' => $user->getStatus() === 1,
]);
}
#[Route('/api/v1/admin/users/{uuid}', methods: ['DELETE'])]
public function deleteUser(string $uuid): JsonResponse
{
$user = $this->em->getRepository(User::class)->findOneBy(['uuid' => $uuid]);
if (!$user) return $this->error('USER_NOT_FOUND', 'کاربر یافت نشد', 404);
$this->em->remove($user);
$this->em->flush();
return $this->success(null);
}
#[Route('/api/v1/admin/users', methods: ['GET'])]
public function users(Request $request): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(100, max(5, (int) $request->query->get('limit', 25)));
$search = trim((string) $request->query->get('search', ''));
$role = trim((string) $request->query->get('role', ''));
$status = $request->query->get('status', '');
$sort = trim((string) $request->query->get('sort', 'newest'));
$qb = $this->em->createQueryBuilder()
->select('u.uuid, u.id, u.mobileNumber as mobile, u.realName as name, u.email, u.roles, u.status, u.createdAt')
->from(User::class, 'u')
->orderBy('u.createdAt', 'DESC');
->from(User::class, 'u');
if ($search !== '') {
$qb->andWhere('u.mobileNumber LIKE :s OR u.realName LIKE :s OR u.email LIKE :s')
->setParameter('s', '%' . $search . '%');
}
if ($role !== '') {
match ($role) {
'admin' => $qb->andWhere("u.roles LIKE :role")->setParameter('role', '%ROLE_ADMIN%'),
'doctor' => $qb->andWhere("u.roles LIKE :role AND u.roles NOT LIKE :notRole")
->setParameter('role', '%ROLE_DOCTOR%')->setParameter('notRole', '%ROLE_ADMIN%'),
'secretary' => $qb->andWhere("u.roles LIKE :role")->setParameter('role', '%ROLE_SECRETARY%'),
'clinic' => $qb->andWhere("u.roles LIKE :role AND u.roles NOT LIKE :notRole")
->setParameter('role', '%ROLE_CLINIC%')->setParameter('notRole', '%ROLE_ADMIN%'),
'patient' => $qb->andWhere("u.roles NOT LIKE :d AND u.roles NOT LIKE :a")
->setParameter('d', '%ROLE_DOCTOR%')->setParameter('a', '%ROLE_ADMIN%'),
default => null,
};
}
if ($status !== '') {
$qb->andWhere('u.status = :status')->setParameter('status', (int) $status);
}
$qb->orderBy('u.createdAt', $sort === 'oldest' ? 'ASC' : 'DESC');
$total = (clone $qb)->select('COUNT(u.id)')->getQuery()->getSingleScalarResult();
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->getQuery()->getArrayResult();
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
->getQuery()->getArrayResult();
$items = array_map(fn(array $u) => [
return $this->paginated(array_map(fn(array $u) => [
'uuid' => $u['uuid'],
'id' => $u['id'],
'mobile_number' => $u['mobile'],
@@ -103,10 +243,189 @@ class AdminApiController extends BaseController
'email' => $u['email'],
'roles' => $u['roles'],
'is_active' => $u['status'] === 1,
'status' => $u['status'],
'created_at' => date('c', (int) $u['createdAt']),
], $rows);
], $rows), (int) $total, $page, $limit);
}
return $this->paginated($items, (int) $total, $page, $limit);
// ── Doctors ───────────────────────────────────────────────────────────────
#[Route('/api/v1/admin/doctors/stats', methods: ['GET'])]
public function doctorStats(): JsonResponse
{
$conn = $this->em->getConnection();
$total = (int) $conn->fetchOne('SELECT COUNT(*) FROM doctors');
$active = (int) $conn->fetchOne('SELECT COUNT(*) FROM doctors WHERE active_doctor_appointment = 1');
$male = (int) $conn->fetchOne("SELECT COUNT(*) FROM doctors WHERE gender IN ('man','male')");
$female = (int) $conn->fetchOne("SELECT COUNT(*) FROM doctors WHERE gender IN ('woman','female')");
$topSpec = $conn->fetchAllAssociative(
'SELECT s.name, COUNT(*) as cnt FROM doctor_specialties ds
JOIN specialties s ON s.id = ds.specialty_id
GROUP BY s.id ORDER BY cnt DESC LIMIT 5'
);
return $this->success([
'total' => $total,
'active' => $active,
'inactive' => $total - $active,
'male' => $male,
'female' => $female,
'top_specialty' => $topSpec,
]);
}
#[Route('/api/v1/admin/doctors/{uuid}/status', methods: ['POST'])]
public function toggleDoctorStatus(string $uuid): JsonResponse
{
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
if (!$doctor) return $this->error('DOCTOR_NOT_FOUND', 'پزشک یافت نشد', 404);
$doctor->setActiveDoctorAppointment(!$doctor->isActiveDoctorAppointment());
$this->em->flush();
return $this->success(['is_active' => $doctor->isActiveDoctorAppointment()]);
}
#[Route('/api/v1/admin/doctors', methods: ['GET'])]
public function doctorsList(Request $request): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(100, max(5, (int) $request->query->get('limit', 25)));
$search = trim((string) $request->query->get('search', ''));
$status = $request->query->get('status', '');
$gender = trim((string) $request->query->get('gender', ''));
$specId = (int) $request->query->get('specialty_id', 0);
$sort = trim((string) $request->query->get('sort', 'newest'));
$conn = $this->em->getConnection();
$where = ['1=1'];
$params = [];
if ($search !== '') {
$where[] = '(d.name LIKE :s OR d.mobile_number LIKE :s OR u.mobile_number LIKE :s OR u.email LIKE :s)';
$params['s'] = '%' . $search . '%';
}
if ($status !== '') {
$where[] = 'd.active_doctor_appointment = :status';
$params['status'] = $status === '1' ? 1 : 0;
}
if ($gender !== '') {
$where[] = 'd.gender = :gender';
$params['gender'] = $gender;
}
if ($specId > 0) {
$where[] = 'EXISTS (SELECT 1 FROM doctor_specialties ds2 WHERE ds2.doctor_id = d.id AND ds2.specialty_id = :specId)';
$params['specId'] = $specId;
}
$whereStr = implode(' AND ', $where);
$orderBy = match ($sort) {
'oldest' => 'd.created_at ASC',
'rating' => 'd.doctor_rate DESC',
default => 'd.created_at DESC',
};
$total = (int) $conn->fetchOne(
"SELECT COUNT(*) FROM doctors d JOIN users u ON u.id = d.user_id WHERE $whereStr",
$params
);
$offset = ($page - 1) * $limit;
$rows = $conn->fetchAllAssociative(
"SELECT d.id, d.uuid, d.name, d.gender, d.degree, d.medical_system_code,
d.mobile_number as doctor_mobile, d.active_doctor_appointment,
d.doctor_rate, d.doctor_rate_percentage, d.images, d.created_at,
u.mobile_number as user_mobile, u.email
FROM doctors d JOIN users u ON u.id = d.user_id
WHERE $whereStr ORDER BY $orderBy LIMIT $limit OFFSET $offset",
$params
);
$doctorIds = array_column($rows, 'id');
$specMap = [];
if (!empty($doctorIds)) {
$specRows = $conn->fetchAllAssociative(
'SELECT ds.doctor_id, s.id, s.name FROM doctor_specialties ds
JOIN specialties s ON s.id = ds.specialty_id
WHERE ds.doctor_id IN (' . implode(',', array_map('intval', $doctorIds)) . ')'
);
foreach ($specRows as $sr) {
$specMap[(int) $sr['doctor_id']][] = ['id' => (int) $sr['id'], 'name' => $sr['name']];
}
}
$items = array_map(function (array $d) use ($specMap): array {
$images = $d['images'] ? (json_decode($d['images'], true) ?? []) : [];
return [
'uuid' => $d['uuid'],
'id' => (int) $d['id'],
'name' => $d['name'],
'gender' => $d['gender'],
'degree' => $d['degree'],
'medical_code' => $d['medical_system_code'],
'mobile' => $d['doctor_mobile'] ?: $d['user_mobile'],
'email' => $d['email'],
'is_active' => (bool) $d['active_doctor_appointment'],
'rate' => (float) $d['doctor_rate'],
'specialties' => $specMap[(int) $d['id']] ?? [],
'profile_image' => !empty($images) ? ($images[0]['url'] ?? null) : null,
'created_at' => date('c', (int) $d['created_at']),
];
}, $rows);
return $this->paginated($items, $total, $page, $limit);
}
#[Route('/api/v1/admin/doctors', methods: ['POST'])]
public function createDoctor(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$mobile = trim((string) ($data['mobile'] ?? ''));
$name = trim((string) ($data['name'] ?? ''));
if ($mobile === '' || $name === '') {
return $this->error('VALIDATION', 'موبایل و نام الزامی هستند', 422);
}
$user = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
if (!$user) {
$user = new User($mobile);
$user->setRealName($name);
$user->setPasswordHash(password_hash(bin2hex(random_bytes(8)), PASSWORD_BCRYPT));
$this->em->persist($user);
}
$existing = $this->em->getRepository(Doctor::class)->findOneBy(['user' => $user]);
if ($existing) {
return $this->error('DOCTOR_EXISTS', 'این کاربر قبلاً پروفایل پزشک دارد', 409);
}
$doctor = new Doctor($user, $name);
if (!empty($data['gender'])) $doctor->setGender($data['gender']);
if (!empty($data['degree'])) $doctor->setDegree($data['degree']);
if (!empty($data['medical_system_code'])) $doctor->setMedicalSystemCode($data['medical_system_code']);
if (!empty($data['info'])) $doctor->setInfo($data['info']);
if (!empty($data['mobile_number'])) $doctor->setMobileNumber($data['mobile_number']);
if (!empty($data['specialties']) && is_array($data['specialties'])) {
foreach ($data['specialties'] as $id) {
$s = $this->em->getRepository(Specialty::class)->find((int) $id);
if ($s !== null) $doctor->getSpecialties()->add($s);
}
}
$roles = $user->getRoles();
if (!in_array('ROLE_DOCTOR', $roles, true)) {
$roles[] = 'ROLE_DOCTOR';
$user->setRoles(array_values(array_unique($roles)));
}
$this->em->persist($doctor);
$this->em->flush();
return $this->success(['uuid' => $doctor->getUuid()], 201);
}
// ── Appointments ──────────────────────────────────────────────────────────