feat: add doctor invitation modal and appointment creation API

- Implemented InviteDoctorModal component for inviting doctors to clinics.
- Updated ClinicDashboard to include a button for inviting doctors and handle modal state.
- Added createAppointment API endpoint in AdminApiController for scheduling appointments.
- Enhanced ClinicInvitationController to check user access when inviting doctors.
- Updated MyAppointmentsController to ensure unique appointment records.
- Added seed_test_data.php for populating test data including doctors, clinics, and appointments.
- Refactored styles to include new appointment status badges and updated font imports.
This commit is contained in:
hamed
2026-06-11 13:36:54 +03:30
parent 04b51273e3
commit 82e1c264a1
18 changed files with 1095 additions and 274 deletions
@@ -641,6 +641,52 @@ class AdminApiController extends BaseController
new OA\Response(response: 403, description: 'Forbidden ROLE_ADMIN required'),
]
)]
#[Route('/api/v1/admin/appointment', methods: ['POST'])]
public function createAppointment(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$doctorUuid = trim($data['doctor_uuid'] ?? '');
$slotStart = (int) ($data['slot_start'] ?? 0);
$slotEnd = (int) ($data['slot_end'] ?? 0);
$mobile = trim($data['patient_mobile'] ?? '');
if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart || empty($mobile)) {
return $this->error('VALIDATION', 'doctor_uuid، slot_start، slot_end و patient_mobile الزامی است', 422);
}
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $doctorUuid]);
if (!$doctor) return $this->error('DOCTOR_NOT_FOUND', 'پزشک یافت نشد', 404);
$patient = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
if (!$patient) return $this->error('USER_NOT_FOUND', 'بیمار با این شماره یافت نشد', 404);
$conflict = $this->em->createQueryBuilder()
->select('COUNT(a.id)')
->from(Appointment::class, 'a')
->where('a.doctor = :doctor')
->andWhere('a.slotStart < :end AND a.slotEnd > :start')
->andWhere("a.status NOT IN ('cancelled_by_doctor','cancelled_by_user','cancelled_by_admin','auto_cancel_unpaid')")
->setParameter('doctor', $doctor)
->setParameter('start', $slotStart)
->setParameter('end', $slotEnd)
->getQuery()->getSingleScalarResult();
if ($conflict > 0) return $this->error('SLOT_TAKEN', 'این نوبت قبلاً رزرو شده است', 409);
$appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd);
if (!empty($data['note'])) $appointment->setNote($data['note']);
$this->em->persist($appointment);
$this->em->flush();
return $this->success([
'uuid' => $appointment->getUuid(),
'slot_start' => $slotStart,
'slot_end' => $slotEnd,
'status' => $appointment->getStatus(),
], 201);
}
#[Route('/api/v1/admin/payments', methods: ['GET'])]
public function payments(Request $request): JsonResponse
{
@@ -36,7 +36,7 @@ class MyAppointmentsController extends BaseController
$qb = $this->em->createQueryBuilder()
->select(
'a.uuid, a.slotStart, a.slotEnd, a.status, a.createdAt',
'DISTINCT a.uuid, a.slotStart, a.slotEnd, a.status, a.createdAt',
'd.uuid as doctor_uuid, d.name as doctor_name',
'u.mobileNumber as patient_mobile, u.realName as patient_name',
'c.name as clinic_name'
@@ -56,7 +56,7 @@ class MyAppointmentsController extends BaseController
if ($clinic === null) {
return $this->paginated([], 0, $page, $limit);
}
$qb->andWhere(':clinic MEMBER OF d.clinics')
$qb->andWhere('c = :clinic')
->setParameter('clinic', $clinic);
} elseif (in_array('ROLE_DOCTOR', $roles, true)) {
$doctor = $this->doctorRepo->findByUser($user);
@@ -2,6 +2,7 @@
namespace App\ClinicInvitation\Controller;
use App\Auth\Entity\User;
use App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository;
use App\ClinicInvitation\Service\ClinicInvitationService;
use App\Clinic\Repository\ClinicRepository;
@@ -10,6 +11,7 @@ use App\Shared\Exception\AppException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ClinicInvitationController extends BaseController
@@ -23,14 +25,16 @@ class ClinicInvitationController extends BaseController
// ── Admin endpoints ──────────────────────────────────────────────────────
#[Route('/api/v1/admin/clinic/{uuid}/invite-doctor', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function inviteDoctor(string $uuid, Request $request): JsonResponse
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function inviteDoctor(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$clinic = $this->clinicRepo->findByUuid($uuid);
if (!$clinic) {
throw new AppException('ERR_NOT_FOUND_001', 'کلینیک یافت نشد', 404);
}
$this->assertClinicAccess($clinic, $user);
$body = json_decode($request->getContent(), true) ?? [];
$mobile = trim($body['mobile'] ?? '');
$name = !empty($body['name']) ? trim($body['name']) : null;
@@ -40,20 +44,22 @@ class ClinicInvitationController extends BaseController
throw new AppException('ERR_VALIDATION_001', 'شماره موبایل نامعتبر است', 422, 'mobile');
}
$inv = $this->invitationService->invite($clinic, $this->getUser(), $mobile, $name, $specialty);
$inv = $this->invitationService->invite($clinic, $user, $mobile, $name, $specialty);
return $this->success($inv->toArray(), 201);
}
#[Route('/api/v1/admin/clinic/{uuid}/invitations', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function listInvitations(string $uuid, Request $request): JsonResponse
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function listInvitations(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$clinic = $this->clinicRepo->findByUuid($uuid);
if (!$clinic) {
throw new AppException('ERR_NOT_FOUND_001', 'کلینیک یافت نشد', 404);
}
$this->assertClinicAccess($clinic, $user);
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(50, max(1, (int) $request->query->get('limit', 20)));
@@ -76,28 +82,31 @@ class ClinicInvitationController extends BaseController
}
#[Route('/api/v1/admin/clinic/invitation/{invUuid}/resend', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function resendInvitation(string $invUuid): JsonResponse
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function resendInvitation(string $invUuid, #[CurrentUser] User $user): JsonResponse
{
$inv = $this->invRepo->findOneBy(['uuid' => $invUuid]);
if (!$inv) {
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
}
$this->assertClinicAccess($inv->getClinic(), $user);
$this->invitationService->resend($inv);
return $this->success(['message' => 'پیامک مجدداً ارسال شد']);
}
#[Route('/api/v1/admin/clinic/invitation/{invUuid}/status', methods: ['PATCH'])]
#[IsGranted('ROLE_ADMIN')]
public function changeInvitationStatus(string $invUuid, Request $request): JsonResponse
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function changeInvitationStatus(string $invUuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$inv = $this->invRepo->findOneBy(['uuid' => $invUuid]);
if (!$inv) {
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
}
$this->assertClinicAccess($inv->getClinic(), $user);
$body = json_decode($request->getContent(), true) ?? [];
$status = $body['status'] ?? '';
@@ -107,19 +116,31 @@ class ClinicInvitationController extends BaseController
}
#[Route('/api/v1/admin/clinic/invitation/{invUuid}', methods: ['DELETE'])]
#[IsGranted('ROLE_ADMIN')]
public function deleteInvitation(string $invUuid): JsonResponse
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function deleteInvitation(string $invUuid, #[CurrentUser] User $user): JsonResponse
{
$inv = $this->invRepo->findOneBy(['uuid' => $invUuid]);
if (!$inv) {
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
}
$this->assertClinicAccess($inv->getClinic(), $user);
$this->invitationService->delete($inv);
return $this->success(null, 204);
}
private function assertClinicAccess(\App\Clinic\Entity\Clinic $clinic, User $user): void
{
if ($user->hasRole('ROLE_ADMIN')) {
return;
}
if ($user->hasRole('ROLE_CLINIC') && $clinic->getUser()->getId() === $user->getId()) {
return;
}
throw new AppException('ERR_ACCESS_DENIED', 'دسترسی ندارید', 403);
}
// ── Public endpoints ──────────────────────────────────────────────────────
#[Route('/api/v1/clinic-invitation/{token}', methods: ['GET'])]