Appointments now persist address_id resolved from the weekly-schedule session (location_id) across all booking paths (online, secretary, admin). On confirm, the patient is added to the clinic owning that address, or to the doctor's single clinic as fallback. Weekly-schedule create/update now requires location_id on every active session. PatientSession exposes doctor_uuid/doctor_name so clinic records show which doctor each visit is for. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1783 lines
87 KiB
PHP
1783 lines
87 KiB
PHP
<?php
|
||
|
||
namespace App\Admin\Controller;
|
||
|
||
use App\Appointment\Entity\Appointment;
|
||
use App\Auth\Entity\User;
|
||
use App\Shared\Service\InputValidator;
|
||
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;
|
||
use App\Representation\Entity\Representation;
|
||
use App\Secretary\Entity\DoctorSecretary;
|
||
use App\Settlement\Entity\Settlement;
|
||
use App\Sms\Entity\SmsLog;
|
||
use App\Sms\Entity\SmsTemplate;
|
||
use App\Shared\Controller\BaseController;
|
||
use Doctrine\ORM\EntityManagerInterface;
|
||
use OpenApi\Attributes as OA;
|
||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||
use Symfony\Component\HttpFoundation\Request;
|
||
use Symfony\Component\Routing\Attribute\Route;
|
||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||
|
||
#[OA\Tag(name: 'Admin')]
|
||
#[IsGranted('ROLE_ADMIN')]
|
||
class AdminApiController extends BaseController
|
||
{
|
||
public function __construct(
|
||
private readonly EntityManagerInterface $em,
|
||
private readonly \App\Appointment\Service\SlotCalculatorService $slotCalculator,
|
||
) {}
|
||
|
||
// ── Users ─────────────────────────────────────────────────────────────────
|
||
|
||
#[OA\Get(
|
||
path: '/api/v1/admin/users',
|
||
summary: 'List all users (paginated)',
|
||
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')),
|
||
],
|
||
responses: [
|
||
new OA\Response(
|
||
response: 200,
|
||
description: 'Paginated list of users',
|
||
content: new OA\JsonContent(
|
||
properties: [
|
||
new OA\Property(property: 'success', type: 'boolean', example: true),
|
||
new OA\Property(property: 'data', type: 'array', items: new OA\Items(
|
||
properties: [
|
||
new OA\Property(property: 'uuid', type: 'string'),
|
||
new OA\Property(property: 'id', type: 'integer'),
|
||
new OA\Property(property: 'mobile_number', type: 'string'),
|
||
new OA\Property(property: 'name', type: 'string'),
|
||
new OA\Property(property: 'email', type: 'string'),
|
||
new OA\Property(property: 'roles', type: 'array', items: new OA\Items(type: 'string')),
|
||
new OA\Property(property: 'is_active', type: 'boolean'),
|
||
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
|
||
]
|
||
)),
|
||
new OA\Property(property: 'meta', properties: [
|
||
new OA\Property(property: 'totalRecords', type: 'integer'),
|
||
new OA\Property(property: 'totalPages', type: 'integer'),
|
||
new OA\Property(property: 'currentPage', type: 'integer'),
|
||
], type: 'object'),
|
||
]
|
||
)
|
||
),
|
||
new OA\Response(response: 401, description: 'Unauthorized'),
|
||
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(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');
|
||
|
||
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();
|
||
|
||
return $this->paginated(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,
|
||
'status' => $u['status'],
|
||
'created_at' => date('c', (int) $u['createdAt']),
|
||
], $rows), (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 = InputValidator::toEnglishDigits(trim((string) ($data['mobile'] ?? '')));
|
||
$name = trim((string) ($data['name'] ?? ''));
|
||
|
||
if ($mobile === '' || $name === '') {
|
||
return $this->error('VALIDATION', 'موبایل و نام الزامی هستند', 422);
|
||
}
|
||
if (!InputValidator::isValidIranMobile($mobile)) {
|
||
return $this->error('VALIDATION', 'شماره موبایل نامعتبر است', 422, 'mobile');
|
||
}
|
||
|
||
$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);
|
||
}
|
||
|
||
// ── Clinics ───────────────────────────────────────────────────────────────
|
||
|
||
#[Route('/api/v1/admin/clinics', methods: ['GET'])]
|
||
public function clinicsList(Request $request): JsonResponse
|
||
{
|
||
$page = max(1, (int) $request->query->get('page', 1));
|
||
$limit = min(100, max(5, (int) $request->query->get('limit', 15)));
|
||
$search = trim((string) $request->query->get('search', ''));
|
||
$status = $request->query->get('status', '');
|
||
|
||
$conn = $this->em->getConnection();
|
||
$where = ['1=1'];
|
||
$params = [];
|
||
|
||
if ($search !== '') {
|
||
$where[] = '(c.name LIKE :s OR c.telephone LIKE :s)';
|
||
$params['s'] = '%' . $search . '%';
|
||
}
|
||
if ($status !== '') {
|
||
$where[] = 'c.is_active = :active';
|
||
$params['active'] = $status === '1' ? 1 : 0;
|
||
}
|
||
|
||
$whereStr = implode(' AND ', $where);
|
||
|
||
$total = (int) $conn->fetchOne(
|
||
"SELECT COUNT(*) FROM clinics c WHERE $whereStr",
|
||
$params
|
||
);
|
||
|
||
$offset = ($page - 1) * $limit;
|
||
$rows = $conn->fetchAllAssociative(
|
||
"SELECT c.uuid, c.name, c.telephone, c.clinic_logo, c.is_active, c.created_at,
|
||
COUNT(DISTINCT cd.doctor_id) as doctors_count,
|
||
u.mobile_number as owner_mobile
|
||
FROM clinics c
|
||
LEFT JOIN clinic_doctors cd ON cd.clinic_id = c.id
|
||
LEFT JOIN users u ON u.id = c.user_id
|
||
WHERE $whereStr
|
||
GROUP BY c.id, u.mobile_number
|
||
ORDER BY c.created_at DESC
|
||
LIMIT $limit OFFSET $offset",
|
||
$params
|
||
);
|
||
|
||
$items = array_map(fn(array $c) => [
|
||
'uuid' => $c['uuid'],
|
||
'name' => $c['name'],
|
||
'phone' => $c['telephone'],
|
||
'logo' => $c['clinic_logo'],
|
||
'is_active' => (bool) $c['is_active'],
|
||
'doctors_count' => (int) $c['doctors_count'],
|
||
'created_at' => (int) $c['created_at'],
|
||
'owner_mobile' => $c['owner_mobile'],
|
||
], $rows);
|
||
|
||
return $this->paginated($items, $total, $page, $limit);
|
||
}
|
||
|
||
#[Route('/api/v1/admin/clinic', methods: ['POST'])]
|
||
public function createClinic(Request $request): JsonResponse
|
||
{
|
||
$data = json_decode($request->getContent(), true) ?? [];
|
||
$mobile = InputValidator::toEnglishDigits(trim((string) ($data['owner_mobile'] ?? '')));
|
||
$name = trim((string) ($data['name'] ?? ''));
|
||
|
||
if ($mobile === '') {
|
||
return $this->error('VALIDATION', 'شماره موبایل الزامی است', 422);
|
||
}
|
||
if (!InputValidator::isValidIranMobile($mobile)) {
|
||
return $this->error('VALIDATION', 'شماره موبایل نامعتبر است', 422, 'owner_mobile');
|
||
}
|
||
if ($name === '') {
|
||
return $this->error('VALIDATION', 'نام کلینیک الزامی است', 422);
|
||
}
|
||
|
||
$user = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
|
||
if (!$user) {
|
||
$user = new User($mobile);
|
||
$this->em->persist($user);
|
||
}
|
||
|
||
$roles = $user->getRoles();
|
||
if (!in_array('ROLE_CLINIC', $roles, true)) {
|
||
$roles[] = 'ROLE_CLINIC';
|
||
$user->setRoles(array_values(array_unique($roles)));
|
||
}
|
||
|
||
$clinic = new Clinic($user);
|
||
$clinic->setName($name);
|
||
if (!empty($data['telephone'])) $clinic->setTelephone($data['telephone']);
|
||
if (!empty($data['address'])) $clinic->setAddress($data['address']);
|
||
if (!empty($data['info'])) $clinic->setInfo($data['info']);
|
||
|
||
$this->em->persist($clinic);
|
||
$this->em->flush();
|
||
|
||
return $this->success(['uuid' => $clinic->getUuid(), 'name' => $clinic->getName(), 'is_active' => $clinic->isActive()]);
|
||
}
|
||
|
||
#[Route('/api/v1/admin/clinic/{uuid}/status', methods: ['PATCH'])]
|
||
public function toggleClinicStatus(string $uuid): JsonResponse
|
||
{
|
||
$clinic = $this->em->getRepository(Clinic::class)->findOneBy(['uuid' => $uuid]);
|
||
if (!$clinic) return $this->error('CLINIC_NOT_FOUND', 'کلینیک یافت نشد', 404);
|
||
|
||
$clinic->setIsActive(!$clinic->isActive());
|
||
$this->em->flush();
|
||
|
||
return $this->success(['is_active' => $clinic->isActive()]);
|
||
}
|
||
|
||
#[Route('/api/v1/admin/clinic/{uuid}', methods: ['DELETE'])]
|
||
public function deleteClinic(string $uuid): JsonResponse
|
||
{
|
||
$clinic = $this->em->getRepository(Clinic::class)->findOneBy(['uuid' => $uuid]);
|
||
if (!$clinic) return $this->error('CLINIC_NOT_FOUND', 'کلینیک یافت نشد', 404);
|
||
|
||
$this->em->remove($clinic);
|
||
$this->em->flush();
|
||
|
||
return $this->success(null);
|
||
}
|
||
|
||
// ── 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 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(
|
||
response: 200,
|
||
description: 'Paginated list of appointments',
|
||
content: new OA\JsonContent(
|
||
properties: [
|
||
new OA\Property(property: 'success', type: 'boolean', example: true),
|
||
new OA\Property(property: 'data', type: 'array', items: new OA\Items(
|
||
properties: [
|
||
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: 'version', type: 'integer'),
|
||
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
|
||
]
|
||
)),
|
||
new OA\Property(property: 'meta', properties: [
|
||
new OA\Property(property: 'totalRecords', type: 'integer'),
|
||
new OA\Property(property: 'totalPages', type: 'integer'),
|
||
new OA\Property(property: 'currentPage', type: 'integer'),
|
||
], type: 'object'),
|
||
]
|
||
)
|
||
),
|
||
new OA\Response(response: 401, description: 'Unauthorized'),
|
||
new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'),
|
||
]
|
||
)]
|
||
#[Route('/api/v1/admin/appointments', methods: ['GET'])]
|
||
public function appointments(Request $request): JsonResponse
|
||
{
|
||
$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.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.slotStart', 'ASC');
|
||
|
||
if ($search !== '') {
|
||
$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();
|
||
|
||
$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_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'],
|
||
'version' => (int) $a['version'],
|
||
'created_at' => date('c', (int) $a['createdAt']),
|
||
], $rows);
|
||
|
||
return $this->paginated($items, (int) $total, $page, $limit);
|
||
}
|
||
|
||
// ── Payments ──────────────────────────────────────────────────────────────
|
||
|
||
#[OA\Get(
|
||
path: '/api/v1/admin/payments',
|
||
summary: 'List all payments (paginated)',
|
||
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: 'status', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['pending', 'success', 'failed'])),
|
||
],
|
||
responses: [
|
||
new OA\Response(
|
||
response: 200,
|
||
description: 'Paginated list of payments',
|
||
content: new OA\JsonContent(
|
||
properties: [
|
||
new OA\Property(property: 'success', type: 'boolean', example: true),
|
||
new OA\Property(property: 'data', type: 'array', items: new OA\Items(
|
||
properties: [
|
||
new OA\Property(property: 'uuid', type: 'string'),
|
||
new OA\Property(property: 'amount', type: 'integer'),
|
||
new OA\Property(property: 'status', type: 'string'),
|
||
new OA\Property(property: 'gateway', type: 'string'),
|
||
new OA\Property(property: 'ref_id', type: 'string', nullable: true),
|
||
new OA\Property(property: 'patient_mobile', type: 'string'),
|
||
new OA\Property(property: 'paid_at', type: 'string', format: 'date-time', nullable: true),
|
||
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
|
||
]
|
||
)),
|
||
new OA\Property(property: 'meta', properties: [
|
||
new OA\Property(property: 'totalRecords', type: 'integer'),
|
||
new OA\Property(property: 'totalPages', type: 'integer'),
|
||
new OA\Property(property: 'currentPage', type: 'integer'),
|
||
], type: 'object'),
|
||
]
|
||
)
|
||
),
|
||
new OA\Response(response: 401, description: 'Unauthorized'),
|
||
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'] ?? '');
|
||
$patientName = trim($data['patient_name'] ?? '');
|
||
|
||
if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart || empty($mobile) || empty($patientName)) {
|
||
return $this->error('VALIDATION', 'doctor_uuid، slot_start، slot_end، patient_mobile و patient_name الزامی است', 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) {
|
||
$patient = new User($mobile);
|
||
$patient->setRealName($patientName);
|
||
$patient->setRoles(['ROLE_USER']);
|
||
$this->em->persist($patient);
|
||
}
|
||
|
||
$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']);
|
||
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart);
|
||
if ($locationId !== null) $appointment->setAddressId($locationId);
|
||
|
||
$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
|
||
{
|
||
$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 ───────────────────────────────────────────────────────
|
||
|
||
#[OA\Get(
|
||
path: '/api/v1/admin/representations',
|
||
summary: 'List all representations (paginated)',
|
||
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: 'city_id', in: 'query', required: false, schema: new OA\Schema(type: 'integer')),
|
||
],
|
||
responses: [
|
||
new OA\Response(
|
||
response: 200,
|
||
description: 'Paginated list of representations',
|
||
content: new OA\JsonContent(
|
||
properties: [
|
||
new OA\Property(property: 'success', type: 'boolean', example: true),
|
||
new OA\Property(property: 'data', type: 'array', items: new OA\Items(
|
||
properties: [
|
||
new OA\Property(property: 'uuid', type: 'string'),
|
||
new OA\Property(property: 'domain', type: 'string'),
|
||
new OA\Property(property: 'full_name', type: 'string'),
|
||
new OA\Property(property: 'mobile_number', type: 'string'),
|
||
new OA\Property(property: 'city_id', type: 'integer', nullable: true),
|
||
new OA\Property(property: 'city', type: 'string', nullable: true),
|
||
new OA\Property(property: 'commission_percent', type: 'number', format: 'float'),
|
||
new OA\Property(property: 'wallet_balance', type: 'integer'),
|
||
new OA\Property(property: 'is_active', type: 'boolean'),
|
||
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
|
||
]
|
||
)),
|
||
new OA\Property(property: 'meta', properties: [
|
||
new OA\Property(property: 'totalRecords', type: 'integer'),
|
||
new OA\Property(property: 'totalPages', type: 'integer'),
|
||
new OA\Property(property: 'currentPage', type: 'integer'),
|
||
], type: 'object'),
|
||
]
|
||
)
|
||
),
|
||
new OA\Response(response: 401, description: 'Unauthorized'),
|
||
new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'),
|
||
]
|
||
)]
|
||
#[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', ''));
|
||
$cityId = $request->query->get('city_id');
|
||
|
||
$qb = $this->em->createQueryBuilder()
|
||
->select('r.id, r.uuid, r.fullName, r.mobileNumber, u.mobileNumber as user_mobile, r.cityId, r.commissionPercent, r.active, r.createdAt, c.name as city_name')
|
||
->from(Representation::class, 'r')
|
||
->join('r.user', 'u')
|
||
->leftJoin(City::class, 'c', 'WITH', 'c.id = r.cityId')
|
||
->orderBy('r.createdAt', 'DESC');
|
||
|
||
if ($search !== '') {
|
||
$qb->andWhere('r.fullName LIKE :s OR r.mobileNumber LIKE :s OR u.mobileNumber LIKE :s')
|
||
->setParameter('s', '%' . $search . '%');
|
||
}
|
||
|
||
if ($cityId !== null && $cityId !== '') {
|
||
$qb->andWhere('r.cityId = :cityId')
|
||
->setParameter('cityId', (int) $cityId);
|
||
}
|
||
|
||
$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) => [
|
||
'id' => (int) $r['id'],
|
||
'uuid' => $r['uuid'],
|
||
'domain' => $r['fullName'],
|
||
'full_name' => $r['fullName'],
|
||
'mobile_number' => $r['mobileNumber'] ?: ($r['user_mobile'] ?? null),
|
||
'city_id' => $r['cityId'],
|
||
'city' => $r['city_name'] ?? null,
|
||
'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 ───────────────────────────────────────────────────────────
|
||
|
||
#[OA\Get(
|
||
path: '/api/v1/admin/secretaries',
|
||
summary: 'List all secretaries (paginated)',
|
||
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')),
|
||
],
|
||
responses: [
|
||
new OA\Response(
|
||
response: 200,
|
||
description: 'Paginated list of secretaries',
|
||
content: new OA\JsonContent(
|
||
properties: [
|
||
new OA\Property(property: 'success', type: 'boolean', example: true),
|
||
new OA\Property(property: 'data', type: 'array', items: new OA\Items(
|
||
properties: [
|
||
new OA\Property(property: 'uuid', type: 'string'),
|
||
new OA\Property(property: 'user_name', type: 'string'),
|
||
new OA\Property(property: 'mobile_number', type: 'string'),
|
||
new OA\Property(property: 'doctor_name', type: 'string'),
|
||
new OA\Property(property: 'doctor_uuid', type: 'string'),
|
||
new OA\Property(property: 'is_active', type: 'boolean'),
|
||
new OA\Property(property: 'permissions', type: 'array', items: new OA\Items(type: 'string')),
|
||
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
|
||
]
|
||
)),
|
||
new OA\Property(property: 'meta', properties: [
|
||
new OA\Property(property: 'totalRecords', type: 'integer'),
|
||
new OA\Property(property: 'totalPages', type: 'integer'),
|
||
new OA\Property(property: 'currentPage', type: 'integer'),
|
||
], type: 'object'),
|
||
]
|
||
)
|
||
),
|
||
new OA\Response(response: 401, description: 'Unauthorized'),
|
||
new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'),
|
||
]
|
||
)]
|
||
#[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 ───────────────────────────────────────────────────────────────
|
||
|
||
#[OA\Get(
|
||
path: '/api/v1/admin/rates',
|
||
summary: 'List all ratings (paginated)',
|
||
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')),
|
||
],
|
||
responses: [
|
||
new OA\Response(
|
||
response: 200,
|
||
description: 'Paginated list of ratings',
|
||
content: new OA\JsonContent(
|
||
properties: [
|
||
new OA\Property(property: 'success', type: 'boolean', example: true),
|
||
new OA\Property(property: 'data', type: 'array', items: new OA\Items(
|
||
properties: [
|
||
new OA\Property(property: 'uuid', type: 'string'),
|
||
new OA\Property(property: 'patient_name', type: 'string'),
|
||
new OA\Property(property: 'doctor_name', type: 'string'),
|
||
new OA\Property(property: 'overall', type: 'integer'),
|
||
new OA\Property(property: 'score', type: 'integer'),
|
||
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
|
||
]
|
||
)),
|
||
new OA\Property(property: 'meta', properties: [
|
||
new OA\Property(property: 'totalRecords', type: 'integer'),
|
||
new OA\Property(property: 'totalPages', type: 'integer'),
|
||
new OA\Property(property: 'currentPage', type: 'integer'),
|
||
], type: 'object'),
|
||
]
|
||
)
|
||
),
|
||
new OA\Response(response: 401, description: 'Unauthorized'),
|
||
new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'),
|
||
]
|
||
)]
|
||
#[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.createdAt',
|
||
'r.waitingTimeAtClinic, r.accuracyOfDiagnosis, r.doctorBehavior, r.clinicCleanliness, r.doctorExpertise',
|
||
'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(function (array $r) {
|
||
$overall = (int) round((
|
||
$r['waitingTimeAtClinic'] + $r['accuracyOfDiagnosis'] + $r['doctorBehavior']
|
||
+ $r['clinicCleanliness'] + $r['doctorExpertise']
|
||
) / 5);
|
||
return [
|
||
'uuid' => $r['uuid'],
|
||
'patient_name' => $r['patient_name'] ?? $r['patient_mobile'],
|
||
'doctor_name' => $r['doctor_name'],
|
||
'overall' => $overall,
|
||
'score' => (int) round($overall / 20),
|
||
'created_at' => date('c', (int) $r['createdAt']),
|
||
];
|
||
}, $rows);
|
||
|
||
return $this->paginated($items, (int) $total, $page, $limit);
|
||
}
|
||
|
||
// ── Comments ──────────────────────────────────────────────────────────────
|
||
|
||
#[OA\Get(
|
||
path: '/api/v1/admin/comments',
|
||
summary: 'List all comments (paginated)',
|
||
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', enum: ['pending', 'approved', 'rejected'])),
|
||
],
|
||
responses: [
|
||
new OA\Response(
|
||
response: 200,
|
||
description: 'Paginated list of comments',
|
||
content: new OA\JsonContent(
|
||
properties: [
|
||
new OA\Property(property: 'success', type: 'boolean', example: true),
|
||
new OA\Property(property: 'data', type: 'array', items: new OA\Items(
|
||
properties: [
|
||
new OA\Property(property: 'uuid', type: 'string'),
|
||
new OA\Property(property: 'patient_name', type: 'string'),
|
||
new OA\Property(property: 'doctor_name', type: 'string'),
|
||
new OA\Property(property: 'title', type: 'string'),
|
||
new OA\Property(property: 'body', type: 'string'),
|
||
new OA\Property(property: 'is_approved', type: 'boolean'),
|
||
new OA\Property(property: 'status', type: 'string'),
|
||
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
|
||
]
|
||
)),
|
||
new OA\Property(property: 'meta', properties: [
|
||
new OA\Property(property: 'totalRecords', type: 'integer'),
|
||
new OA\Property(property: 'totalPages', type: 'integer'),
|
||
new OA\Property(property: 'currentPage', type: 'integer'),
|
||
], type: 'object'),
|
||
]
|
||
)
|
||
),
|
||
new OA\Response(response: 401, description: 'Unauthorized'),
|
||
new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'),
|
||
]
|
||
)]
|
||
#[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 ──────────────────────────────────────────────────────────────
|
||
|
||
#[OA\Get(
|
||
path: '/api/v1/admin/sms/logs',
|
||
summary: 'List SMS send logs (paginated)',
|
||
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)),
|
||
],
|
||
responses: [
|
||
new OA\Response(
|
||
response: 200,
|
||
description: 'Paginated list of SMS logs',
|
||
content: new OA\JsonContent(
|
||
properties: [
|
||
new OA\Property(property: 'success', type: 'boolean', example: true),
|
||
new OA\Property(property: 'data', type: 'array', items: new OA\Items(
|
||
properties: [
|
||
new OA\Property(property: 'uuid', type: 'string'),
|
||
new OA\Property(property: 'recipient', type: 'string'),
|
||
new OA\Property(property: 'message', type: 'string'),
|
||
new OA\Property(property: 'status', type: 'string', enum: ['sent', 'failed']),
|
||
new OA\Property(property: 'provider', type: 'string'),
|
||
new OA\Property(property: 'sent_at', type: 'string', format: 'date-time'),
|
||
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
|
||
]
|
||
)),
|
||
new OA\Property(property: 'meta', properties: [
|
||
new OA\Property(property: 'totalRecords', type: 'integer'),
|
||
new OA\Property(property: 'totalPages', type: 'integer'),
|
||
new OA\Property(property: 'currentPage', type: 'integer'),
|
||
], type: 'object'),
|
||
]
|
||
)
|
||
),
|
||
new OA\Response(response: 401, description: 'Unauthorized'),
|
||
new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'),
|
||
]
|
||
)]
|
||
#[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)));
|
||
$tag = trim((string) $request->query->get('tag', ''));
|
||
|
||
$qb = $this->em->createQueryBuilder()
|
||
->select('s.uuid, s.mobile, s.message, s.provider, s.success, s.tag, s.createdAt')
|
||
->from(SmsLog::class, 's')
|
||
->orderBy('s.createdAt', 'DESC');
|
||
|
||
if ($tag !== '') {
|
||
$qb->andWhere('s.tag = :tag')->setParameter('tag', $tag);
|
||
}
|
||
|
||
$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'],
|
||
'tag' => $l['tag'],
|
||
'sent_at' => date('c', (int) $l['createdAt']),
|
||
'created_at' => date('c', (int) $l['createdAt']),
|
||
], $rows);
|
||
|
||
return $this->paginated($items, (int) $total, $page, $limit);
|
||
}
|
||
|
||
// ── Settlements ───────────────────────────────────────────────────────────
|
||
|
||
#[OA\Get(
|
||
path: '/api/v1/admin/settlements',
|
||
summary: 'List all settlement requests (paginated)',
|
||
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: 'status', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['pending', 'approved', 'rejected'])),
|
||
],
|
||
responses: [
|
||
new OA\Response(
|
||
response: 200,
|
||
description: 'Paginated list of settlement requests',
|
||
content: new OA\JsonContent(
|
||
properties: [
|
||
new OA\Property(property: 'success', type: 'boolean', example: true),
|
||
new OA\Property(property: 'data', type: 'array', items: new OA\Items(
|
||
properties: [
|
||
new OA\Property(property: 'uuid', type: 'string'),
|
||
new OA\Property(property: 'representation_name', type: 'string'),
|
||
new OA\Property(property: 'amount', type: 'integer'),
|
||
new OA\Property(property: 'status', type: 'string'),
|
||
new OA\Property(property: 'bank_card', type: 'string', nullable: true),
|
||
new OA\Property(property: 'bank_name', type: 'string', nullable: true),
|
||
new OA\Property(property: 'reject_reason', type: 'string', nullable: true),
|
||
new OA\Property(property: 'requested_at', type: 'string', format: 'date-time'),
|
||
new OA\Property(property: 'processed_at', type: 'string', format: 'date-time', nullable: true),
|
||
]
|
||
)),
|
||
new OA\Property(property: 'meta', properties: [
|
||
new OA\Property(property: 'totalRecords', type: 'integer'),
|
||
new OA\Property(property: 'totalPages', type: 'integer'),
|
||
new OA\Property(property: 'currentPage', type: 'integer'),
|
||
], type: 'object'),
|
||
]
|
||
)
|
||
),
|
||
new OA\Response(response: 401, description: 'Unauthorized'),
|
||
new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'),
|
||
]
|
||
)]
|
||
#[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) ────────────
|
||
|
||
#[OA\Get(
|
||
path: '/api/v1/admin/sms/sample-templates',
|
||
summary: 'List SMS sample templates (paginated)',
|
||
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: 'status', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['approved', 'pending'], default: 'approved')),
|
||
],
|
||
responses: [
|
||
new OA\Response(
|
||
response: 200,
|
||
description: 'Paginated list of SMS templates',
|
||
content: new OA\JsonContent(
|
||
properties: [
|
||
new OA\Property(property: 'success', type: 'boolean', example: true),
|
||
new OA\Property(property: 'data', type: 'array', items: new OA\Items(
|
||
properties: [
|
||
new OA\Property(property: 'uuid', type: 'string'),
|
||
new OA\Property(property: 'name', type: 'string'),
|
||
new OA\Property(property: 'body', type: 'string'),
|
||
new OA\Property(property: 'provider_code', type: 'string', nullable: true),
|
||
new OA\Property(property: 'status', type: 'string'),
|
||
new OA\Property(property: 'admin_note', type: 'string', nullable: true),
|
||
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
|
||
]
|
||
)),
|
||
new OA\Property(property: 'meta', properties: [
|
||
new OA\Property(property: 'totalRecords', type: 'integer'),
|
||
new OA\Property(property: 'totalPages', type: 'integer'),
|
||
new OA\Property(property: 'currentPage', type: 'integer'),
|
||
], type: 'object'),
|
||
]
|
||
)
|
||
),
|
||
new OA\Response(response: 401, description: 'Unauthorized'),
|
||
new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'),
|
||
]
|
||
)]
|
||
#[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 Recent ──────────────────────────────────────────────────────
|
||
|
||
#[OA\Get(
|
||
path: '/api/v1/admin/dashboard/recent',
|
||
summary: 'Get recent dashboard data (appointments, payments, users)',
|
||
security: [['bearerAuth' => []]],
|
||
responses: [
|
||
new OA\Response(
|
||
response: 200,
|
||
description: 'Recent dashboard data',
|
||
content: new OA\JsonContent(
|
||
properties: [
|
||
new OA\Property(property: 'success', type: 'boolean', example: true),
|
||
new OA\Property(property: 'data', properties: [
|
||
new OA\Property(property: 'appointments', type: 'array', items: new OA\Items(
|
||
properties: [
|
||
new OA\Property(property: 'uuid', type: 'string'),
|
||
new OA\Property(property: 'slot_start', type: 'string', format: 'date-time'),
|
||
new OA\Property(property: 'status', type: 'string'),
|
||
new OA\Property(property: 'doctor_name', type: 'string'),
|
||
new OA\Property(property: 'user_mobile', type: 'string'),
|
||
new OA\Property(property: 'user_name', type: 'string'),
|
||
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
|
||
]
|
||
)),
|
||
new OA\Property(property: 'payments', type: 'array', items: new OA\Items(
|
||
properties: [
|
||
new OA\Property(property: 'uuid', type: 'string'),
|
||
new OA\Property(property: 'amount', type: 'integer'),
|
||
new OA\Property(property: 'status', type: 'string'),
|
||
new OA\Property(property: 'gateway', type: 'string'),
|
||
new OA\Property(property: 'user_mobile', type: 'string'),
|
||
new OA\Property(property: 'user_name', type: 'string'),
|
||
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
|
||
]
|
||
)),
|
||
new OA\Property(property: 'users', type: 'array', items: new OA\Items(
|
||
properties: [
|
||
new OA\Property(property: 'uuid', type: 'string'),
|
||
new OA\Property(property: 'mobile', type: 'string'),
|
||
new OA\Property(property: 'name', type: 'string'),
|
||
new OA\Property(property: 'email', type: 'string', nullable: true),
|
||
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
|
||
]
|
||
)),
|
||
], type: 'object'),
|
||
]
|
||
)
|
||
),
|
||
new OA\Response(response: 401, description: 'Unauthorized'),
|
||
new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'),
|
||
]
|
||
)]
|
||
#[Route('/api/v1/admin/dashboard/recent', methods: ['GET'])]
|
||
public function dashboardRecent(): JsonResponse
|
||
{
|
||
$em = $this->em;
|
||
|
||
$recentAppointments = $em->createQueryBuilder()
|
||
->select(
|
||
'a.uuid, a.slotStart, a.status, a.createdAt',
|
||
'd.name as doctor_name',
|
||
'u.mobileNumber as user_mobile, u.realName as user_name'
|
||
)
|
||
->from(Appointment::class, 'a')
|
||
->join('a.doctor', 'd')
|
||
->join('a.user', 'u')
|
||
->orderBy('a.createdAt', 'DESC')
|
||
->setMaxResults(6)
|
||
->getQuery()->getArrayResult();
|
||
|
||
$recentPayments = $em->createQueryBuilder()
|
||
->select(
|
||
'p.uuid, p.amountRials, p.status, p.gateway, p.createdAt',
|
||
'u.mobileNumber as user_mobile, u.realName as user_name'
|
||
)
|
||
->from(Payment::class, 'p')
|
||
->join('p.user', 'u')
|
||
->orderBy('p.createdAt', 'DESC')
|
||
->setMaxResults(6)
|
||
->getQuery()->getArrayResult();
|
||
|
||
$recentUsers = $em->createQueryBuilder()
|
||
->select('u.uuid, u.mobileNumber, u.realName, u.email, u.createdAt')
|
||
->from(User::class, 'u')
|
||
->orderBy('u.createdAt', 'DESC')
|
||
->setMaxResults(6)
|
||
->getQuery()->getArrayResult();
|
||
|
||
return $this->success([
|
||
'appointments' => array_map(fn(array $a) => [
|
||
'uuid' => $a['uuid'],
|
||
'slot_start' => date('c', (int) $a['slotStart']),
|
||
'status' => $a['status'],
|
||
'doctor_name' => $a['doctor_name'],
|
||
'user_mobile' => $a['user_mobile'],
|
||
'user_name' => $a['user_name'],
|
||
'created_at' => date('c', (int) $a['createdAt']),
|
||
], $recentAppointments),
|
||
'payments' => array_map(fn(array $p) => [
|
||
'uuid' => $p['uuid'],
|
||
'amount' => (int) $p['amountRials'],
|
||
'status' => $p['status'],
|
||
'gateway' => $p['gateway'],
|
||
'user_mobile' => $p['user_mobile'],
|
||
'user_name' => $p['user_name'],
|
||
'created_at' => date('c', (int) $p['createdAt']),
|
||
], $recentPayments),
|
||
'users' => array_map(fn(array $u) => [
|
||
'uuid' => $u['uuid'],
|
||
'mobile' => $u['mobileNumber'],
|
||
'name' => $u['realName'],
|
||
'email' => $u['email'],
|
||
'created_at' => date('c', (int) $u['createdAt']),
|
||
], $recentUsers),
|
||
]);
|
||
}
|
||
|
||
// ── Dashboard Stats ───────────────────────────────────────────────────────
|
||
|
||
#[OA\Get(
|
||
path: '/api/v1/admin/dashboard/stats',
|
||
summary: 'Get aggregated dashboard statistics',
|
||
security: [['bearerAuth' => []]],
|
||
responses: [
|
||
new OA\Response(
|
||
response: 200,
|
||
description: 'Dashboard statistics',
|
||
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_users', type: 'integer'),
|
||
new OA\Property(property: 'active_doctors', type: 'integer'),
|
||
new OA\Property(property: 'total_doctors', type: 'integer'),
|
||
new OA\Property(property: 'total_clinics', type: 'integer'),
|
||
new OA\Property(property: 'today_appointments', type: 'integer'),
|
||
new OA\Property(property: 'total_appointments', type: 'integer'),
|
||
new OA\Property(property: 'today_payments_count', type: 'integer'),
|
||
new OA\Property(property: 'today_payments_amount', type: 'integer'),
|
||
new OA\Property(property: 'total_payments_amount', type: 'integer'),
|
||
new OA\Property(property: 'pending_comments', type: 'integer'),
|
||
new OA\Property(property: 'pending_settlements', type: 'integer'),
|
||
], type: 'object'),
|
||
]
|
||
)
|
||
),
|
||
new OA\Response(response: 401, description: 'Unauthorized'),
|
||
new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'),
|
||
]
|
||
)]
|
||
#[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();
|
||
|
||
$monthStart = mktime(0, 0, 0, (int) date('n'), 1);
|
||
$monthEnd = time();
|
||
|
||
$thisMonthRevenue = (int) $em->createQueryBuilder()
|
||
->select('COALESCE(SUM(p.amountRials), 0)')
|
||
->from(Payment::class, 'p')
|
||
->where('p.status = :s AND p.createdAt BETWEEN :ts AND :te')
|
||
->setParameter('s', Payment::STATUS_SUCCESS)
|
||
->setParameter('ts', $monthStart)->setParameter('te', $monthEnd)
|
||
->getQuery()->getSingleScalarResult();
|
||
|
||
$thisMonthAppointments = (int) $em->createQueryBuilder()
|
||
->select('COUNT(a.id)')
|
||
->from(Appointment::class, 'a')
|
||
->where('a.createdAt BETWEEN :s AND :e')
|
||
->setParameter('s', $monthStart)->setParameter('e', $monthEnd)
|
||
->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,
|
||
'this_month_revenue' => $thisMonthRevenue,
|
||
'this_month_appointments' => $thisMonthAppointments,
|
||
]);
|
||
}
|
||
|
||
// ── Dashboard Charts ──────────────────────────────────────────────────────
|
||
|
||
#[Route('/api/v1/admin/dashboard/charts', methods: ['GET'])]
|
||
public function dashboardCharts(Request $request): JsonResponse
|
||
{
|
||
$conn = $this->em->getConnection();
|
||
$now = time();
|
||
|
||
$from = $request->query->get('from') ? (int) $request->query->get('from') : $now - (30 * 86400);
|
||
$to = $request->query->get('to') ? (int) $request->query->get('to') : $now;
|
||
|
||
$apptRows = $conn->fetchAllAssociative(
|
||
'SELECT DATE(FROM_UNIXTIME(slot_start)) AS d, COUNT(*) AS cnt
|
||
FROM appointments WHERE slot_start BETWEEN :s AND :e GROUP BY d ORDER BY d',
|
||
['s' => $from, 'e' => $to]
|
||
);
|
||
$apptMap = array_column($apptRows, 'cnt', 'd');
|
||
|
||
$revRows = $conn->fetchAllAssociative(
|
||
'SELECT DATE(FROM_UNIXTIME(created_at)) AS d, COALESCE(SUM(amount_rials), 0) AS total
|
||
FROM payments WHERE status = :st AND created_at BETWEEN :s AND :e GROUP BY d ORDER BY d',
|
||
['st' => 'received', 's' => $from, 'e' => $to]
|
||
);
|
||
$revMap = array_column($revRows, 'total', 'd');
|
||
|
||
$days = max(1, (int) ceil(($to - $from) / 86400));
|
||
$apptByDay = [];
|
||
$revByDay = [];
|
||
for ($i = 0; $i < $days; $i++) {
|
||
$ts = $from + $i * 86400;
|
||
$date = date('Y-m-d', $ts);
|
||
$shortDate = date('m/d', $ts);
|
||
$apptByDay[] = ['date' => $shortDate, 'count' => (int)($apptMap[$date] ?? 0)];
|
||
$revByDay[] = ['date' => $shortDate, 'amount' => (int)($revMap[$date] ?? 0)];
|
||
}
|
||
|
||
$statusRows = $conn->fetchAllAssociative(
|
||
'SELECT status, COUNT(*) AS cnt FROM appointments GROUP BY status ORDER BY cnt DESC'
|
||
);
|
||
|
||
$specRows = $conn->fetchAllAssociative(
|
||
'SELECT s.name, COUNT(a.id) AS cnt
|
||
FROM appointments a
|
||
JOIN doctor_specialties ds ON ds.doctor_id = a.doctor_id
|
||
JOIN specialties s ON s.id = ds.specialty_id
|
||
GROUP BY s.id, s.name ORDER BY cnt DESC LIMIT 8'
|
||
);
|
||
|
||
$subRows = $conn->fetchAllAssociative(
|
||
'SELECT sp.name AS plan_name, COUNT(cs.id) AS cnt, COALESCE(SUM(p.amount_rials), 0) AS revenue
|
||
FROM clinic_subscriptions cs
|
||
JOIN subscription_plans sp ON sp.id = cs.plan_id
|
||
LEFT JOIN payments p ON p.id = cs.payment_id AND p.status = :st
|
||
WHERE cs.created_at BETWEEN :s AND :e
|
||
GROUP BY sp.id, sp.name ORDER BY cnt DESC',
|
||
['st' => 'received', 's' => $from, 'e' => $to]
|
||
);
|
||
|
||
return $this->success([
|
||
'appointments_by_day' => $apptByDay,
|
||
'revenue_by_day' => $revByDay,
|
||
'appointment_status' => array_map(fn($r) => ['status' => $r['status'], 'count' => (int) $r['cnt']], $statusRows),
|
||
'top_specialties' => array_map(fn($r) => ['name' => $r['name'], 'count' => (int) $r['cnt']], $specRows),
|
||
'subscription_sales_by_plan' => array_map(fn($r) => [
|
||
'plan' => $r['plan_name'],
|
||
'count' => (int) $r['cnt'],
|
||
'revenue' => (int) $r['revenue'],
|
||
], $subRows),
|
||
'period' => ['from' => $from, 'to' => $to],
|
||
]);
|
||
}
|
||
}
|