feat: update appointment management API and frontend components

- Added new endpoint to get today's appointment statistics with optional date filter.
- Enhanced appointment listing API to support filtering by date and doctor UUID.
- Updated Appointment model to include new fields and modified status values.
- Implemented AppointmentStatusDropdown component for status management with visual feedback.
- Created PersianCalendar component for date selection in Jalali format.
- Updated API documentation to reflect changes in appointment management.
This commit is contained in:
hamed
2026-06-11 14:13:42 +03:30
parent 92a258832b
commit 45ee725820
9 changed files with 1241 additions and 636 deletions
+99 -10
View File
@@ -510,15 +510,84 @@ class AdminApiController extends BaseController
// ── Appointments ──────────────────────────────────────────────────────────
#[OA\Get(
path: '/api/v1/admin/appointments/today-stats',
summary: 'Get today appointment stats',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'date', in: 'query', required: false, schema: new OA\Schema(type: 'string', format: 'date', example: '2025-06-15'), description: 'Date (YYYY-MM-DD), defaults to today'),
],
responses: [
new OA\Response(
response: 200,
description: 'Appointment stats for the given date',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', properties: [
new OA\Property(property: 'total', type: 'integer'),
new OA\Property(property: 'completed', type: 'integer'),
new OA\Property(property: 'waiting', type: 'integer'),
new OA\Property(property: 'cancelled', type: 'integer'),
], type: 'object'),
]
)
),
]
)]
#[Route('/api/v1/admin/appointments/today-stats', methods: ['GET'])]
public function appointmentsTodayStats(Request $request): JsonResponse
{
$date = trim((string) $request->query->get('date', date('Y-m-d')));
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$date = date('Y-m-d');
}
$dayStart = (int) strtotime($date . ' 00:00:00');
$dayEnd = (int) strtotime($date . ' 23:59:59');
$rows = $this->em->createQueryBuilder()
->select('a.status, COUNT(a.id) AS cnt')
->from(Appointment::class, 'a')
->where('a.slotStart >= :dayStart AND a.slotStart <= :dayEnd')
->setParameter('dayStart', $dayStart)
->setParameter('dayEnd', $dayEnd)
->groupBy('a.status')
->getQuery()
->getArrayResult();
$byStatus = [];
foreach ($rows as $row) {
$byStatus[$row['status']] = (int) $row['cnt'];
}
$total = array_sum($byStatus);
$completed = ($byStatus['completed'] ?? 0);
$cancelled = ($byStatus['cancelled_by_doctor'] ?? 0)
+ ($byStatus['cancelled_by_user'] ?? 0)
+ ($byStatus['cancelled_by_admin'] ?? 0)
+ ($byStatus['no_show'] ?? 0)
+ ($byStatus['expired'] ?? 0);
$waiting = $total - $completed - $cancelled;
return $this->success([
'total' => $total,
'completed' => $completed,
'waiting' => max(0, $waiting),
'cancelled' => $cancelled,
]);
}
#[OA\Get(
path: '/api/v1/admin/appointments',
summary: 'List all appointments (paginated)',
summary: 'List appointments (paginated, filterable by date and doctor)',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)),
new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'status', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'date', in: 'query', required: false, schema: new OA\Schema(type: 'string', format: 'date', example: '2025-06-15'), description: 'Filter by slot date (YYYY-MM-DD)'),
new OA\Parameter(name: 'doctor_uuid', in: 'query', required: false, schema: new OA\Schema(type: 'string'), description: 'Filter by doctor UUID'),
],
responses: [
new OA\Response(
@@ -532,11 +601,15 @@ class AdminApiController extends BaseController
new OA\Property(property: 'uuid', type: 'string'),
new OA\Property(property: 'patient_name', type: 'string'),
new OA\Property(property: 'patient_mobile', type: 'string'),
new OA\Property(property: 'doctor_uuid', type: 'string'),
new OA\Property(property: 'doctor_name', type: 'string'),
new OA\Property(property: 'slot_start', type: 'integer', description: 'Unix timestamp'),
new OA\Property(property: 'slot_end', type: 'integer', description: 'Unix timestamp'),
new OA\Property(property: 'appointment_date', type: 'string', format: 'date'),
new OA\Property(property: 'appointment_time', type: 'string', example: '14:30'),
new OA\Property(property: 'end_time', type: 'string', example: '14:50'),
new OA\Property(property: 'status', type: 'string'),
new OA\Property(property: 'amount', type: 'integer'),
new OA\Property(property: 'version', type: 'integer'),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
]
)),
@@ -555,29 +628,41 @@ class AdminApiController extends BaseController
#[Route('/api/v1/admin/appointments', methods: ['GET'])]
public function appointments(Request $request): JsonResponse
{
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
$search = trim((string) $request->query->get('search', ''));
$status = trim((string) $request->query->get('status', ''));
$page = max(1, (int) $request->query->get('page', 1));
$limit = min(500, max(1, (int) $request->query->get('limit', 15)));
$search = trim((string) $request->query->get('search', ''));
$status = trim((string) $request->query->get('status', ''));
$date = trim((string) $request->query->get('date', ''));
$doctorUuid = trim((string) $request->query->get('doctor_uuid', ''));
$qb = $this->em->createQueryBuilder()
->select(
'a.uuid, a.slotStart, a.slotEnd, a.status, a.createdAt',
'a.uuid, a.slotStart, a.slotEnd, a.status, a.createdAt, a.version',
'd.uuid as doctor_uuid, d.name as doctor_name',
'u.mobileNumber as patient_mobile, u.realName as patient_name',
)
->from(Appointment::class, 'a')
->join('a.doctor', 'd')
->join('a.user', 'u')
->orderBy('a.createdAt', 'DESC');
->orderBy('a.slotStart', 'ASC');
if ($search !== '') {
$qb->andWhere('u.mobileNumber LIKE :s OR d.name LIKE :s')
$qb->andWhere('u.mobileNumber LIKE :s OR d.name LIKE :s OR u.realName LIKE :s')
->setParameter('s', '%' . $search . '%');
}
if ($status !== '') {
$qb->andWhere('a.status = :status')->setParameter('status', $status);
}
if ($date !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$dayStart = (int) strtotime($date . ' 00:00:00');
$dayEnd = (int) strtotime($date . ' 23:59:59');
$qb->andWhere('a.slotStart >= :dayStart AND a.slotStart <= :dayEnd')
->setParameter('dayStart', $dayStart)
->setParameter('dayEnd', $dayEnd);
}
if ($doctorUuid !== '') {
$qb->andWhere('d.uuid = :doctorUuid')->setParameter('doctorUuid', $doctorUuid);
}
$total = (clone $qb)->select('COUNT(a.id)')->getQuery()->getSingleScalarResult();
@@ -588,11 +673,15 @@ class AdminApiController extends BaseController
'uuid' => $a['uuid'],
'patient_name' => $a['patient_name'] ?? '',
'patient_mobile' => $a['patient_mobile'],
'doctor_uuid' => $a['doctor_uuid'],
'doctor_name' => $a['doctor_name'],
'slot_start' => (int) $a['slotStart'],
'slot_end' => (int) $a['slotEnd'],
'appointment_date' => date('Y-m-d', (int) $a['slotStart']),
'appointment_time' => date('H:i', (int) $a['slotStart']),
'end_time' => date('H:i', (int) $a['slotEnd']),
'status' => $a['status'],
'amount' => 0,
'version' => (int) $a['version'],
'created_at' => date('c', (int) $a['createdAt']),
], $rows);