From 704075770b4973d9f4dbed6ed9a6e750362e7f4c Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 11 Jun 2026 14:32:10 +0330 Subject: [PATCH] feat(api): enhance appointment retrieval and add today stats endpoint --- assets/admin/pages/AppointmentsPage.tsx | 37 ++++-- .../Controller/MyAppointmentsController.php | 111 ++++++++++++++---- 2 files changed, 112 insertions(+), 36 deletions(-) diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index 701f668d..06ee8b53 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -13,6 +13,8 @@ import { useAuthStore } from '../stores/authStore'; import AppointmentStatusDropdown from '../components/ui/AppointmentStatusDropdown'; import PersianCalendar from '../components/ui/PersianCalendar'; +const EMPTY_ARR: Appointment[] = []; + // ───────────────────────────────────────────────────────────────────────────── // Status config // ───────────────────────────────────────────────────────────────────────────── @@ -36,12 +38,12 @@ function statusMeta(s: string) { // ───────────────────────────────────────────────────────────────────────────── function IconTotal() { + const dots: [number, number][] = []; + for (const x of [10, 16, 22, 28, 34]) for (const y of [10, 16, 22, 28, 34]) dots.push([x, y]); return ( - {[8,14,20,26].map(x => [8,14,20,26].map(y => ( - - )))} + {dots.map(([x, y]) => )} ); } @@ -82,10 +84,14 @@ function IconCancelled() { interface TodayStats { total: number; completed: number; waiting: number; cancelled: number; } -function StatsBar({ date }: { date: string }) { +function StatsBar({ date, isAdmin }: { date: string; isAdmin: boolean }) { const { data } = useQuery>({ - queryKey: ['appt-today-stats', date], - queryFn: () => api.get(`/api/v1/admin/appointments/today-stats?date=${date}`), + queryKey: ['appt-today-stats', date, isAdmin], + queryFn: () => api.get( + isAdmin + ? `/api/v1/admin/appointments/today-stats?date=${date}` + : `/api/v1/my/appointments/today-stats?date=${date}` + ), }); const s = data?.data ?? { total: 0, completed: 0, waiting: 0, cancelled: 0 }; const stats = [ @@ -337,8 +343,11 @@ function NewAppointmentModal({ const [mobile, setMobile] = useState(''); const qc = useQueryClient(); + const role = useAuthStore(s => s.primaryRole); + const createEndpoint = role === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/appointment'; + const mutation = useMutation({ - mutationFn: () => api.post('/api/v1/admin/appointment', { + mutationFn: () => api.post(createEndpoint, { doctor_uuid: slot.doctor_uuid, slot_start: slot.start, slot_end: slot.end, @@ -405,7 +414,8 @@ function NewAppointmentModal({ // ───────────────────────────────────────────────────────────────────────────── export default function AppointmentsPage() { - const { primaryRole, dbUuid } = useAuthStore(s => ({ primaryRole: s.primaryRole, dbUuid: s.dbUuid })); + const primaryRole = useAuthStore(s => s.primaryRole); + const dbUuid = useAuthStore(s => s.dbUuid); const isAdmin = primaryRole === 'admin'; const isClinic = primaryRole === 'clinic'; const isDoctor = primaryRole === 'doctor'; @@ -418,15 +428,16 @@ export default function AppointmentsPage() { const qc = useQueryClient(); // ── Appointments query - const apptQueryKey = ['admin-appointments', selectedDate, selectedDoctorUuid]; + const apptEndpoint = isAdmin ? '/api/v1/admin/appointments' : '/api/v1/my/appointments'; + const apptQueryKey = ['appointments', apptEndpoint, selectedDate, selectedDoctorUuid]; const apptParams = new URLSearchParams({ date: selectedDate, limit: '500' }); if (selectedDoctorUuid) apptParams.set('doctor_uuid', selectedDoctorUuid); const apptQuery = useQuery>({ queryKey: apptQueryKey, - queryFn: () => api.get(`/api/v1/admin/appointments?${apptParams}`), + queryFn: () => api.get(`${apptEndpoint}?${apptParams}`), }); - const appointments: Appointment[] = apptQuery.data?.data ?? []; + const appointments: Appointment[] = apptQuery.data?.data ?? EMPTY_ARR; // ── Unique doctors from results (for clinic tabs) const doctors = React.useMemo(() => { @@ -451,7 +462,7 @@ export default function AppointmentsPage() { // ── Merge slots + appointments for schedule view const mergedSlots: SlotItem[] = React.useMemo(() => { if (viewMode !== 'schedule') return []; - const rawSlots: any[] = slotsQuery.data?.data ?? []; + const rawSlots: any[] = (slotsQuery.data?.data as any)?.slots ?? []; const apptByStart = new Map(); appointments.forEach(a => apptByStart.set(a.slot_start, a)); @@ -484,7 +495,7 @@ export default function AppointmentsPage() { return (
{/* Stats Bar */} - {isAdmin && } + {/* Doctor tabs (clinic with ≥2 doctors) */} {showDoctorTabs && ( diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php index 60a0e683..96bb5032 100644 --- a/src/Appointment/Controller/MyAppointmentsController.php +++ b/src/Appointment/Controller/MyAppointmentsController.php @@ -28,35 +28,35 @@ class MyAppointmentsController extends BaseController #[IsGranted('IS_AUTHENTICATED_FULLY')] public function myAppointments(Request $request, #[CurrentUser] User $user): 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', '')); - $date = trim((string) $request->query->get('date', '')); + $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( - 'DISTINCT a.uuid, a.slotStart, a.slotEnd, a.status, a.createdAt', + 'DISTINCT 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', - 'c.name as clinic_name' + 'u.mobileNumber as patient_mobile, u.realName as patient_name' ) ->from(Appointment::class, 'a') ->join('a.doctor', 'd') ->join('a.user', 'u') - ->leftJoin('App\Clinic\Entity\Clinic', 'c', 'WITH', 'd MEMBER OF c.doctors') - ->orderBy('a.slotStart', 'DESC'); + ->orderBy('a.slotStart', 'ASC'); $roles = $user->getRoles(); if (in_array('ROLE_ADMIN', $roles, true)) { - // Admin voit tout + // Admin sees all } elseif (in_array('ROLE_CLINIC', $roles, true)) { $clinic = $this->clinicRepo->findByUser($user); if ($clinic === null) { return $this->paginated([], 0, $page, $limit); } - $qb->andWhere('c = :clinic') + $qb->join('App\Clinic\Entity\Clinic', 'c', 'WITH', 'd MEMBER OF c.doctors') + ->andWhere('c = :clinic') ->setParameter('clinic', $clinic); } elseif (in_array('ROLE_DOCTOR', $roles, true)) { $doctor = $this->doctorRepo->findByUser($user); @@ -87,14 +87,15 @@ class MyAppointmentsController extends BaseController if ($status !== '') { $qb->andWhere('a.status = :status')->setParameter('status', $status); } - if ($date !== '') { - $dayStart = strtotime($date . ' 00:00:00'); - $dayEnd = strtotime($date . ' 23:59:59'); - if ($dayStart && $dayEnd) { - $qb->andWhere('a.slotStart >= :dayStart AND a.slotStart <= :dayEnd') - ->setParameter('dayStart', $dayStart) - ->setParameter('dayEnd', $dayEnd); - } + 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(); @@ -106,16 +107,80 @@ class MyAppointmentsController 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'], - 'clinic_name' => $a['clinic_name'] ?? null, + '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']), - 'slot_start' => (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); return $this->paginated($items, (int) $total, $page, $limit); } + + #[Route('/api/v1/my/appointments/today-stats', methods: ['GET'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function todayStats(Request $request, #[CurrentUser] User $user): 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'); + + $qb = $this->em->createQueryBuilder() + ->select('a.status, COUNT(a.id) AS cnt') + ->from(Appointment::class, 'a') + ->join('a.doctor', 'd') + ->where('a.slotStart >= :dayStart AND a.slotStart <= :dayEnd') + ->setParameter('dayStart', $dayStart) + ->setParameter('dayEnd', $dayEnd) + ->groupBy('a.status'); + + $roles = $user->getRoles(); + if (in_array('ROLE_CLINIC', $roles, true)) { + $clinic = $this->clinicRepo->findByUser($user); + if ($clinic) { + $qb->join('App\Clinic\Entity\Clinic', 'c', 'WITH', 'd MEMBER OF c.doctors') + ->andWhere('c = :clinic') + ->setParameter('clinic', $clinic); + } + } elseif (in_array('ROLE_DOCTOR', $roles, true)) { + $doctor = $this->doctorRepo->findByUser($user); + if ($doctor) { + $qb->andWhere('a.doctor = :doctor')->setParameter('doctor', $doctor); + } + } elseif (in_array('ROLE_SECRETARY', $roles, true)) { + $rel = $this->secretaryRepo->findActiveBySecretary($user); + if ($rel) { + $qb->andWhere('a.doctor = :doctor')->setParameter('doctor', $rel->getDoctor()); + } + } + + $rows = $qb->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['no_show'] ?? 0) + + ($byStatus['expired'] ?? 0); + $waiting = $total - $completed - $cancelled; + + return $this->success([ + 'total' => $total, + 'completed' => $completed, + 'waiting' => max(0, $waiting), + 'cancelled' => $cancelled, + ]); + } }