>(
- `/api/v1/users?page=${page}&limit=${limit}${search ? `&search=${encodeURIComponent(search)}` : ''}`,
+ `/api/v1/admin/users?page=${page}&limit=${limit}${search ? `&search=${encodeURIComponent(search)}` : ''}`,
),
});
@@ -45,9 +45,7 @@ export default function UsersPage() {
header: 'نام',
render: (u) => (
- {u.first_name || u.last_name
- ? `${u.first_name ?? ''} ${u.last_name ?? ''}`.trim()
- : '—'}
+ {u.name || (u.first_name || u.last_name ? `${u.first_name ?? ''} ${u.last_name ?? ''}`.trim() : '—')}
),
},
@@ -81,8 +79,8 @@ export default function UsersPage() {
},
];
- const items = data?.data?.items ?? [];
- const total = data?.data?.total ?? 0;
+ const items = data?.data ?? [];
+ const total = data?.meta?.totalRecords ?? 0;
return (
@@ -132,7 +130,7 @@ export default function UsersPage() {
query->get('page', 1));
+ $limit = min(100, max(1, (int) $request->query->get('limit', 15)));
+ $search = trim((string) $request->query->get('search', ''));
+
+ $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');
+
+ if ($search !== '') {
+ $qb->andWhere('u.mobileNumber LIKE :s OR u.realName LIKE :s OR u.email LIKE :s')
+ ->setParameter('s', '%' . $search . '%');
+ }
+
+ $total = (clone $qb)->select('COUNT(u.id)')->getQuery()->getSingleScalarResult();
+
+ $rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
+ ->getQuery()->getArrayResult();
+
+ $items = array_map(fn(array $u) => [
+ 'uuid' => $u['uuid'],
+ 'id' => $u['id'],
+ 'mobile_number' => $u['mobile'],
+ 'name' => $u['name'],
+ 'email' => $u['email'],
+ 'roles' => $u['roles'],
+ 'is_active' => $u['status'] === 1,
+ 'created_at' => date('c', (int) $u['createdAt']),
+ ], $rows);
+
+ return $this->paginated($items, (int) $total, $page, $limit);
+ }
+
+ // ── Appointments ──────────────────────────────────────────────────────────
+
+ #[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', ''));
+
+ $qb = $this->em->createQueryBuilder()
+ ->select(
+ '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',
+ )
+ ->from(Appointment::class, 'a')
+ ->join('a.doctor', 'd')
+ ->join('a.user', 'u')
+ ->orderBy('a.createdAt', 'DESC');
+
+ if ($search !== '') {
+ $qb->andWhere('u.mobileNumber LIKE :s OR d.name LIKE :s')
+ ->setParameter('s', '%' . $search . '%');
+ }
+ if ($status !== '') {
+ $qb->andWhere('a.status = :status')->setParameter('status', $status);
+ }
+
+ $total = (clone $qb)->select('COUNT(a.id)')->getQuery()->getSingleScalarResult();
+
+ $rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
+ ->getQuery()->getArrayResult();
+
+ $items = array_map(fn(array $a) => [
+ 'uuid' => $a['uuid'],
+ 'patient_name' => $a['patient_name'] ?? '',
+ 'patient_mobile' => $a['patient_mobile'],
+ 'doctor_name' => $a['doctor_name'],
+ 'appointment_date' => date('Y-m-d', (int) $a['slotStart']),
+ 'appointment_time' => date('H:i', (int) $a['slotStart']),
+ 'status' => $a['status'],
+ 'amount' => 0,
+ 'created_at' => date('c', (int) $a['createdAt']),
+ ], $rows);
+
+ return $this->paginated($items, (int) $total, $page, $limit);
+ }
+
+ // ── Payments ──────────────────────────────────────────────────────────────
+
+ #[Route('/api/v1/admin/payments', methods: ['GET'])]
+ public function payments(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', ''));
+
+ $qb = $this->em->createQueryBuilder()
+ ->select(
+ 'p.uuid, p.amountRials, p.status, p.gateway, p.referenceId, p.createdAt, p.updatedAt',
+ 'u.mobileNumber as patient_mobile',
+ )
+ ->from(Payment::class, 'p')
+ ->join('p.user', 'u')
+ ->orderBy('p.createdAt', 'DESC');
+
+ if ($search !== '') {
+ $qb->andWhere('u.mobileNumber LIKE :s OR p.referenceId LIKE :s OR p.orderId LIKE :s')
+ ->setParameter('s', '%' . $search . '%');
+ }
+ if ($status !== '') {
+ $qb->andWhere('p.status = :status')->setParameter('status', $status);
+ }
+
+ $total = (clone $qb)->select('COUNT(p.id)')->getQuery()->getSingleScalarResult();
+
+ $rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
+ ->getQuery()->getArrayResult();
+
+ $items = array_map(fn(array $p) => [
+ 'uuid' => $p['uuid'],
+ 'amount' => (int) $p['amountRials'],
+ 'status' => $p['status'],
+ 'gateway' => $p['gateway'],
+ 'ref_id' => $p['referenceId'],
+ 'patient_mobile' => $p['patient_mobile'],
+ 'paid_at' => $p['status'] === 'success' ? date('c', (int) $p['updatedAt']) : null,
+ 'created_at' => date('c', (int) $p['createdAt']),
+ ], $rows);
+
+ return $this->paginated($items, (int) $total, $page, $limit);
+ }
+
+ // ── Representations ───────────────────────────────────────────────────────
+
+ #[Route('/api/v1/admin/representations', methods: ['GET'])]
+ public function representations(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', ''));
+
+ $qb = $this->em->createQueryBuilder()
+ ->select('r.uuid, r.fullName, r.mobileNumber, r.city, r.commissionPercent, r.active, r.createdAt')
+ ->from(Representation::class, 'r')
+ ->orderBy('r.createdAt', 'DESC');
+
+ if ($search !== '') {
+ $qb->andWhere('r.fullName LIKE :s OR r.city LIKE :s')
+ ->setParameter('s', '%' . $search . '%');
+ }
+
+ $total = (clone $qb)->select('COUNT(r.id)')->getQuery()->getSingleScalarResult();
+
+ $rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
+ ->getQuery()->getArrayResult();
+
+ $items = array_map(fn(array $r) => [
+ 'uuid' => $r['uuid'],
+ 'domain' => $r['fullName'],
+ 'full_name' => $r['fullName'],
+ 'mobile_number' => $r['mobileNumber'],
+ 'city' => $r['city'] ?? '',
+ 'commission_percent' => (float) $r['commissionPercent'],
+ 'wallet_balance' => 0,
+ 'is_active' => (bool) $r['active'],
+ 'created_at' => date('c', (int) $r['createdAt']),
+ ], $rows);
+
+ return $this->paginated($items, (int) $total, $page, $limit);
+ }
+
+ // ── Secretaries ───────────────────────────────────────────────────────────
+
+ #[Route('/api/v1/admin/secretaries', methods: ['GET'])]
+ public function secretaries(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', ''));
+
+ $qb = $this->em->createQueryBuilder()
+ ->select(
+ 'ds.uuid, ds.permissions, ds.active, ds.createdAt',
+ 'u.mobileNumber as mobile, u.realName as user_name',
+ 'd.name as doctor_name, d.uuid as doctor_uuid',
+ )
+ ->from(DoctorSecretary::class, 'ds')
+ ->join('ds.doctor', 'd')
+ ->join('ds.secretary', 'u')
+ ->orderBy('ds.createdAt', 'DESC');
+
+ if ($search !== '') {
+ $qb->andWhere('u.mobileNumber LIKE :s OR u.realName LIKE :s OR d.name LIKE :s')
+ ->setParameter('s', '%' . $search . '%');
+ }
+
+ $total = (clone $qb)->select('COUNT(ds.id)')->getQuery()->getSingleScalarResult();
+
+ $rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
+ ->getQuery()->getArrayResult();
+
+ $items = array_map(fn(array $ds) => [
+ 'uuid' => $ds['uuid'],
+ 'user_name' => $ds['user_name'] ?? $ds['mobile'],
+ 'mobile_number' => $ds['mobile'],
+ 'doctor_name' => $ds['doctor_name'],
+ 'doctor_uuid' => $ds['doctor_uuid'],
+ 'is_active' => (bool) $ds['active'],
+ 'permissions' => $ds['permissions'] ?? DoctorSecretary::DEFAULT_PERMISSIONS,
+ 'created_at' => date('c', (int) $ds['createdAt']),
+ ], $rows);
+
+ return $this->paginated($items, (int) $total, $page, $limit);
+ }
+
+ // ── Ratings ───────────────────────────────────────────────────────────────
+
+ #[Route('/api/v1/admin/rates', methods: ['GET'])]
+ public function rates(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', ''));
+
+ $qb = $this->em->createQueryBuilder()
+ ->select(
+ 'r.uuid, r.score, r.createdAt',
+ 'u.realName as patient_name, u.mobileNumber as patient_mobile',
+ 'd.name as doctor_name',
+ )
+ ->from(Rate::class, 'r')
+ ->join('r.doctor', 'd')
+ ->join('r.user', 'u')
+ ->orderBy('r.createdAt', 'DESC');
+
+ if ($search !== '') {
+ $qb->andWhere('d.name LIKE :s OR u.realName LIKE :s')
+ ->setParameter('s', '%' . $search . '%');
+ }
+
+ $total = (clone $qb)->select('COUNT(r.id)')->getQuery()->getSingleScalarResult();
+
+ $rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
+ ->getQuery()->getArrayResult();
+
+ $items = array_map(fn(array $r) => [
+ 'uuid' => $r['uuid'],
+ 'patient_name' => $r['patient_name'] ?? $r['patient_mobile'],
+ 'doctor_name' => $r['doctor_name'],
+ 'overall' => (int) $r['score'],
+ 'score' => (int) $r['score'],
+ 'created_at' => date('c', (int) $r['createdAt']),
+ ], $rows);
+
+ return $this->paginated($items, (int) $total, $page, $limit);
+ }
+
+ // ── Comments ──────────────────────────────────────────────────────────────
+
+ #[Route('/api/v1/admin/comments', methods: ['GET'])]
+ public function comments(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', ''));
+
+ $qb = $this->em->createQueryBuilder()
+ ->select(
+ 'c.uuid, c.body, c.status, c.createdAt',
+ 'u.realName as patient_name',
+ 'd.name as doctor_name',
+ )
+ ->from(Comment::class, 'c')
+ ->join('c.doctor', 'd')
+ ->join('c.user', 'u')
+ ->orderBy('c.createdAt', 'DESC');
+
+ if ($search !== '') {
+ $qb->andWhere('d.name LIKE :s OR c.body LIKE :s OR u.realName LIKE :s')
+ ->setParameter('s', '%' . $search . '%');
+ }
+ if ($status !== '') {
+ $qb->andWhere('c.status = :status')->setParameter('status', $status);
+ }
+
+ $total = (clone $qb)->select('COUNT(c.id)')->getQuery()->getSingleScalarResult();
+
+ $rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
+ ->getQuery()->getArrayResult();
+
+ $items = array_map(fn(array $c) => [
+ 'uuid' => $c['uuid'],
+ 'patient_name' => $c['patient_name'] ?? '',
+ 'doctor_name' => $c['doctor_name'],
+ 'title' => mb_substr($c['body'], 0, 50),
+ 'body' => $c['body'],
+ 'is_approved' => $c['status'] === 'approved',
+ 'status' => $c['status'],
+ 'created_at' => date('c', (int) $c['createdAt']),
+ ], $rows);
+
+ return $this->paginated($items, (int) $total, $page, $limit);
+ }
+
+ // ── SMS Logs ──────────────────────────────────────────────────────────────
+
+ #[Route('/api/v1/admin/sms/logs', methods: ['GET'])]
+ public function smsLogs(Request $request): JsonResponse
+ {
+ $page = max(1, (int) $request->query->get('page', 1));
+ $limit = min(100, max(1, (int) $request->query->get('limit', 15)));
+
+ $qb = $this->em->createQueryBuilder()
+ ->select('s.uuid, s.mobile, s.message, s.provider, s.success, s.createdAt')
+ ->from(SmsLog::class, 's')
+ ->orderBy('s.createdAt', 'DESC');
+
+ $total = (clone $qb)->select('COUNT(s.id)')->getQuery()->getSingleScalarResult();
+
+ $rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
+ ->getQuery()->getArrayResult();
+
+ $items = array_map(fn(array $l) => [
+ 'uuid' => $l['uuid'],
+ 'recipient' => $l['mobile'],
+ 'message' => $l['message'],
+ 'status' => $l['success'] ? 'sent' : 'failed',
+ 'provider' => $l['provider'],
+ 'sent_at' => date('c', (int) $l['createdAt']),
+ 'created_at' => date('c', (int) $l['createdAt']),
+ ], $rows);
+
+ return $this->paginated($items, (int) $total, $page, $limit);
+ }
+
+ // ── Settlements ───────────────────────────────────────────────────────────
+
+ #[Route('/api/v1/admin/settlements', methods: ['GET'])]
+ public function settlements(Request $request): JsonResponse
+ {
+ $page = max(1, (int) $request->query->get('page', 1));
+ $limit = min(100, max(1, (int) $request->query->get('limit', 15)));
+ $status = trim((string) $request->query->get('status', ''));
+
+ $qb = $this->em->createQueryBuilder()
+ ->select(
+ 's.uuid, s.amountRials, s.status, s.bankAccount, s.adminNote, s.reviewedAt, s.createdAt',
+ 'u.realName as user_name, u.mobileNumber as user_mobile',
+ )
+ ->from(Settlement::class, 's')
+ ->join('s.user', 'u')
+ ->orderBy('s.createdAt', 'DESC');
+
+ if ($status !== '') {
+ $qb->andWhere('s.status = :status')->setParameter('status', $status);
+ }
+
+ $total = (clone $qb)->select('COUNT(s.id)')->getQuery()->getSingleScalarResult();
+
+ $rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
+ ->getQuery()->getArrayResult();
+
+ $items = array_map(fn(array $s) => [
+ 'uuid' => $s['uuid'],
+ 'representation_name' => $s['user_name'] ?? $s['user_mobile'],
+ 'amount' => (int) $s['amountRials'],
+ 'status' => $s['status'],
+ 'bank_card' => $s['bankAccount']['card'] ?? null,
+ 'bank_name' => $s['bankAccount']['bank_name'] ?? null,
+ 'reject_reason' => $s['adminNote'],
+ 'requested_at' => date('c', (int) $s['createdAt']),
+ 'processed_at' => $s['reviewedAt'] ? date('c', (int) $s['reviewedAt']) : null,
+ ], $rows);
+
+ return $this->paginated($items, (int) $total, $page, $limit);
+ }
+
+ // ── SMS Templates (paginated list with optional status filter) ────────────
+
+ #[Route('/api/v1/admin/sms/sample-templates', methods: ['GET'])]
+ public function smsSampleTemplates(Request $request): JsonResponse
+ {
+ $page = max(1, (int) $request->query->get('page', 1));
+ $limit = min(100, max(1, (int) $request->query->get('limit', 15)));
+ $status = trim((string) $request->query->get('status', 'approved'));
+
+ $qb = $this->em->createQueryBuilder()
+ ->select('t.uuid, t.name, t.body, t.providerCode, t.status, t.adminNote, t.createdAt')
+ ->from(SmsTemplate::class, 't')
+ ->orderBy('t.createdAt', 'DESC');
+
+ if ($status !== '') {
+ $qb->where('t.status = :status')->setParameter('status', $status);
+ }
+
+ $total = (clone $qb)->select('COUNT(t.id)')->getQuery()->getSingleScalarResult();
+
+ $rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
+ ->getQuery()->getArrayResult();
+
+ $items = array_map(fn(array $t) => [
+ 'uuid' => $t['uuid'],
+ 'name' => $t['name'],
+ 'body' => $t['body'],
+ 'provider_code' => $t['providerCode'],
+ 'status' => $t['status'],
+ 'admin_note' => $t['adminNote'],
+ 'created_at' => date('c', (int) $t['createdAt']),
+ ], $rows);
+
+ return $this->paginated($items, (int) $total, $page, $limit);
+ }
+
+ // ── Dashboard Stats ───────────────────────────────────────────────────────
+
+ #[Route('/api/v1/admin/dashboard/stats', methods: ['GET'])]
+ public function dashboardStats(): JsonResponse
+ {
+ $em = $this->em;
+
+ $todayStart = mktime(0, 0, 0);
+ $todayEnd = mktime(23, 59, 59);
+
+ $totalUsers = (int) $em->createQueryBuilder()
+ ->select('COUNT(u.id)')->from(User::class, 'u')
+ ->getQuery()->getSingleScalarResult();
+
+ $activeDoctors = (int) $em->createQueryBuilder()
+ ->select('COUNT(d.id)')->from(Doctor::class, 'd')
+ ->where('d.activeDoctorAppointment = true')
+ ->getQuery()->getSingleScalarResult();
+
+ $totalDoctors = (int) $em->createQueryBuilder()
+ ->select('COUNT(d.id)')->from(Doctor::class, 'd')
+ ->getQuery()->getSingleScalarResult();
+
+ $totalClinics = (int) $em->createQueryBuilder()
+ ->select('COUNT(c.id)')->from(Clinic::class, 'c')
+ ->getQuery()->getSingleScalarResult();
+
+ $todayAppointments = (int) $em->createQueryBuilder()
+ ->select('COUNT(a.id)')->from(Appointment::class, 'a')
+ ->where('a.slotStart BETWEEN :s AND :e')
+ ->setParameter('s', $todayStart)->setParameter('e', $todayEnd)
+ ->getQuery()->getSingleScalarResult();
+
+ $totalAppointments = (int) $em->createQueryBuilder()
+ ->select('COUNT(a.id)')->from(Appointment::class, 'a')
+ ->getQuery()->getSingleScalarResult();
+
+ $todayPaymentsResult = $em->createQueryBuilder()
+ ->select('COUNT(p.id) as cnt, COALESCE(SUM(p.amountRials), 0) as total')
+ ->from(Payment::class, 'p')
+ ->where('p.status = :s AND p.createdAt BETWEEN :ts AND :te')
+ ->setParameter('s', Payment::STATUS_SUCCESS)
+ ->setParameter('ts', $todayStart)->setParameter('te', $todayEnd)
+ ->getQuery()->getSingleResult();
+
+ $totalPaymentsResult = $em->createQueryBuilder()
+ ->select('COUNT(p.id) as cnt, COALESCE(SUM(p.amountRials), 0) as total')
+ ->from(Payment::class, 'p')
+ ->where('p.status = :s')
+ ->setParameter('s', Payment::STATUS_SUCCESS)
+ ->getQuery()->getSingleResult();
+
+ $pendingComments = (int) $em->createQueryBuilder()
+ ->select('COUNT(c.id)')->from(Comment::class, 'c')
+ ->where('c.status = :s')->setParameter('s', Comment::STATUS_PENDING)
+ ->getQuery()->getSingleScalarResult();
+
+ $pendingSettlements = (int) $em->createQueryBuilder()
+ ->select('COUNT(s.id)')->from(Settlement::class, 's')
+ ->where('s.status = :s')->setParameter('s', Settlement::STATUS_PENDING)
+ ->getQuery()->getSingleScalarResult();
+
+ return $this->success([
+ 'total_users' => $totalUsers,
+ 'active_doctors' => $activeDoctors,
+ 'total_doctors' => $totalDoctors,
+ 'total_clinics' => $totalClinics,
+ 'today_appointments' => $todayAppointments,
+ 'total_appointments' => $totalAppointments,
+ 'today_payments_count' => (int) $todayPaymentsResult['cnt'],
+ 'today_payments_amount' => (int) $todayPaymentsResult['total'],
+ 'total_payments_amount' => (int) $totalPaymentsResult['total'],
+ 'pending_comments' => $pendingComments,
+ 'pending_settlements' => $pendingSettlements,
+ ]);
+ }
+}