Files
clinicpro/src/Dashboard/Controller/DashboardController.php
T
hamed d9f96b68cd feat: port clinic dashboard components from clinic-pro-tauri
- Add NewAppointmentsTable for displaying today's appointments with status chips and formatted time.
- Implement TauriCharts for bar and line charts representing patient counts and revenue.
- Create TauriDashboardView to combine stat cards, charts, and new appointments list.
- Introduce TauriStatCards for displaying key statistics with icons.
- Add dashboardIcons for SVG icons used in stat cards.
- Implement tests for DashboardPage to ensure correct rendering and API calls.
- Create DashboardTodayAppointmentsTest to validate extended fields in today's appointments API response.
2026-07-14 13:54:50 +03:30

483 lines
22 KiB
PHP

<?php
namespace App\Dashboard\Controller;
use App\Auth\Entity\User;
use App\Auth\Repository\UserActiveContextRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Patient\Repository\PatientRecordRepository;
use App\Patient\Repository\PatientSessionRepository;
use App\Secretary\Entity\DoctorSecretary;
use App\Secretary\Repository\DoctorSecretaryRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Sms\Service\SmsWalletService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use OpenApi\Attributes as OA;
#[OA\Tag(name: 'Dashboard')]
class DashboardController extends BaseController
{
public function __construct(
private readonly ClinicRepository $clinicRepo,
private readonly DoctorRepository $doctorRepo,
private readonly DoctorSecretaryRepository $secretaryRepo,
private readonly EntityManagerInterface $em,
private readonly SmsWalletService $smsWalletService,
private readonly PatientRecordRepository $patientRecordRepo,
private readonly PatientSessionRepository $patientSessionRepo,
private readonly UserActiveContextRepository $contextRepo,
) {}
// ── Clinic Dashboard ────────────────────────────────────────────────────
#[Route('/api/v1/dashboard/clinic', methods: ['GET'])]
#[IsGranted('ROLE_CLINIC')]
public function clinic(Request $request, #[CurrentUser] User $user): JsonResponse
{
$clinic = $this->clinicRepo->findByUser($user);
if ($clinic === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کلینیک یافت نشد', 404);
}
$clinicId = $clinic->getId();
$todayStart = strtotime('today midnight');
$todayEnd = strtotime('tomorrow midnight') - 1;
$monthStart = strtotime('first day of this month midnight');
$from = $request->query->get('from') ? (int) $request->query->get('from') : $monthStart;
$to = $request->query->get('to') ? (int) $request->query->get('to') : time();
// آمار نوبت‌های امروز و این ماه
$stats = $this->em->createQuery('
SELECT
COUNT(a.id) AS today_appointments,
SUM(CASE WHEN a.slotStart >= :monthStart THEN 1 ELSE 0 END) AS this_month_appointments
FROM App\Appointment\Entity\Appointment a
JOIN App\Doctor\Entity\Doctor d WITH a.doctor = d
JOIN App\Clinic\Entity\Clinic c WITH d MEMBER OF c.doctors
WHERE c.id = :clinicId
AND a.slotStart >= :todayStart AND a.slotStart <= :todayEnd
')->setParameters([
'clinicId' => $clinicId,
'todayStart' => $todayStart,
'todayEnd' => $todayEnd,
'monthStart' => $monthStart,
])->getOneOrNullResult() ?? [];
// شمارش کل نوبت‌های این ماه (query جداگانه)
$monthCount = (int) $this->em->createQuery('
SELECT COUNT(a.id)
FROM App\Appointment\Entity\Appointment a
JOIN App\Doctor\Entity\Doctor d WITH a.doctor = d
JOIN App\Clinic\Entity\Clinic c WITH d MEMBER OF c.doctors
WHERE c.id = :clinicId
AND a.slotStart >= :monthStart
')->setParameters([
'clinicId' => $clinicId,
'monthStart' => $monthStart,
])->getSingleScalarResult();
// تعداد دعوتنامه‌های در انتظار
$pendingInvitations = (int) $this->em->createQuery('
SELECT COUNT(i.id)
FROM App\ClinicInvitation\Entity\ClinicDoctorInvitation i
WHERE i.clinic = :clinic AND i.status = :status
')->setParameters([
'clinic' => $clinic,
'status' => 'pending',
])->getSingleScalarResult();
// ۵ نوبت امروز این کلینیک
$todayAppts = $this->em->createQuery('
SELECT a.uuid, u.realName AS patient_name, u.mobileNumber AS patient_mobile,
d.name AS doctor_name, si.name AS service_name,
a.slotStart AS slot_start, a.slotEnd AS slot_end, a.status
FROM App\Appointment\Entity\Appointment a
JOIN a.doctor d
JOIN a.user u
LEFT JOIN a.serviceItem si
JOIN App\Clinic\Entity\Clinic c WITH d MEMBER OF c.doctors
WHERE c.id = :clinicId
AND a.slotStart >= :todayStart AND a.slotStart <= :todayEnd
ORDER BY a.slotStart ASC
')->setMaxResults(5)->setParameters([
'clinicId' => $clinicId,
'todayStart' => $todayStart,
'todayEnd' => $todayEnd,
])->getArrayResult();
// لیست پزشکان با شمارش نوبت امروز
$doctors = $this->em->createQuery('
SELECT d.uuid, d.name,
COUNT(a.id) AS today_count
FROM App\Doctor\Entity\Doctor d
JOIN App\Clinic\Entity\Clinic c WITH d MEMBER OF c.doctors
LEFT JOIN App\Appointment\Entity\Appointment a
WITH a.doctor = d
AND a.slotStart >= :todayStart
AND a.slotStart <= :todayEnd
WHERE c.id = :clinicId
GROUP BY d.id
')->setParameters([
'clinicId' => $clinicId,
'todayStart' => $todayStart,
'todayEnd' => $todayEnd,
])->getArrayResult();
$smsBalance = $this->smsWalletService->getBalance('clinic', $clinicId);
$uniquePatients = $this->patientRecordRepo->countUnique('clinic', $clinicId, $from, $to);
$revenuePeriod = $this->patientSessionRepo->sumRevenue('clinic', $clinicId, $from, $to);
$totalPatients = $this->patientRecordRepo->countUnique('clinic', $clinicId, 0, time());
$rev = $this->revenueDaily('clinic', $clinicId);
$apptByDay = $this->appointmentsDaily(function (int $ds, int $de) use ($clinicId): int {
return (int) $this->em->createQuery('
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
JOIN App\Doctor\Entity\Doctor d WITH a.doctor = d
JOIN App\Clinic\Entity\Clinic c WITH d MEMBER OF c.doctors
WHERE c.id = :clinicId AND a.slotStart >= :s AND a.slotStart <= :e
')->setParameters(['clinicId' => $clinicId, 's' => $ds, 'e' => $de])->getSingleScalarResult();
});
return $this->success([
'clinic' => [
'uuid' => $clinic->getUuid(),
'name' => $clinic->getName(),
'is_active' => $clinic->isActive(),
'logo' => $clinic->getClinicLogo(),
],
'stats' => [
'total_doctors' => count($doctors),
'today_appointments' => (int) ($stats['today_appointments'] ?? 0),
'this_month_appointments' => $monthCount,
'pending_invitations' => $pendingInvitations,
'sms_wallet_balance' => $smsBalance,
'unique_patients_count' => $uniquePatients,
'total_patients' => $totalPatients,
'revenue_period_rials' => $revenuePeriod,
'today_payments_rials' => $rev['today_payments_rials'],
'week_payments_rials' => $rev['week_payments_rials'],
],
'charts' => [
'revenue_by_day' => $rev['revenue'],
'appointments_by_day' => $apptByDay,
],
'period' => ['from' => $from, 'to' => $to],
'today_appointments' => $todayAppts,
'doctors' => $doctors,
]);
}
// ── Doctor Dashboard ─────────────────────────────────────────────────────
#[Route('/api/v1/dashboard/doctor', methods: ['GET'])]
#[IsGranted('ROLE_DOCTOR')]
public function doctor(Request $request, #[CurrentUser] User $user): JsonResponse
{
$doctor = $this->doctorRepo->findByUser($user);
if ($doctor === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404);
}
$doctorId = $doctor->getId();
$todayStart = strtotime('today midnight');
$todayEnd = strtotime('tomorrow midnight') - 1;
$tmrStart = strtotime('tomorrow midnight');
$tmrEnd = strtotime('tomorrow midnight') + 86399;
$monthStart = strtotime('first day of this month midnight');
$from = $request->query->get('from') ? (int) $request->query->get('from') : $monthStart;
$to = $request->query->get('to') ? (int) $request->query->get('to') : time();
// آمار
$todayCount = (int) $this->em->createQuery('
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e
')->setParameters(['doctor' => $doctor, 's' => $todayStart, 'e' => $todayEnd])
->getSingleScalarResult();
$tmrCount = (int) $this->em->createQuery('
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e
')->setParameters(['doctor' => $doctor, 's' => $tmrStart, 'e' => $tmrEnd])
->getSingleScalarResult();
$monthCount = (int) $this->em->createQuery('
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
WHERE a.doctor = :doctor AND a.slotStart >= :s
')->setParameters(['doctor' => $doctor, 's' => $monthStart])
->getSingleScalarResult();
// میانگین و تعداد امتیاز
$ratingRow = $this->em->createQuery('
SELECT AVG((r.waitingTimeAtClinic + r.accuracyOfDiagnosis + r.doctorBehavior + r.clinicCleanliness + r.doctorExpertise) / 5.0) AS avg_score,
COUNT(r.id) AS total
FROM App\Rating\Entity\Rate r WHERE r.doctor = :doctor
')->setParameter('doctor', $doctor)->getOneOrNullResult() ?? [];
// نوبت‌های امروز
$todayAppts = $this->em->createQuery('
SELECT a.uuid, u.realName AS patient_name, u.mobileNumber AS patient_mobile,
d.name AS doctor_name, si.name AS service_name,
a.slotStart AS slot_start, a.slotEnd AS slot_end, a.status
FROM App\Appointment\Entity\Appointment a
JOIN a.user u
JOIN a.doctor d
LEFT JOIN a.serviceItem si
WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e
ORDER BY a.slotStart ASC
')->setMaxResults(10)->setParameters([
'doctor' => $doctor,
's' => $todayStart,
'e' => $todayEnd,
])->getArrayResult();
// کلینیک‌های عضو
$clinics = $this->em->createQuery('
SELECT c.uuid, c.name, c.clinicLogo AS logo
FROM App\Clinic\Entity\Clinic c
JOIN c.doctors d
WHERE d.id = :doctorId
')->setParameter('doctorId', $doctorId)->getArrayResult();
$smsBalance = $this->smsWalletService->getBalance('doctor', $doctorId);
$uniquePatients = $this->patientRecordRepo->countUnique('doctor', $doctorId, $from, $to);
$revenuePeriod = $this->patientSessionRepo->sumRevenue('doctor', $doctorId, $from, $to);
$totalPatients = $this->patientRecordRepo->countUnique('doctor', $doctorId, 0, time());
$rev = $this->revenueDaily('doctor', $doctorId);
$apptByDay = $this->appointmentsDaily(function (int $ds, int $de) use ($doctor): int {
return (int) $this->em->createQuery('
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
WHERE a.doctor = :d AND a.slotStart >= :s AND a.slotStart <= :e
')->setParameters(['d' => $doctor, 's' => $ds, 'e' => $de])->getSingleScalarResult();
});
return $this->success([
'doctor' => [
'uuid' => $doctor->getUuid(),
'name' => $doctor->getName(),
'degree' => $doctor->getDegree(),
],
'stats' => [
'today_appointments' => $todayCount,
'tomorrow_appointments' => $tmrCount,
'this_month_appointments' => $monthCount,
'avg_rating' => $ratingRow['avg_score'] ? round((float) $ratingRow['avg_score'], 1) : null,
'total_ratings' => (int) ($ratingRow['total'] ?? 0),
'sms_wallet_balance' => $smsBalance,
'unique_patients_count' => $uniquePatients,
'total_patients' => $totalPatients,
'revenue_period_rials' => $revenuePeriod,
'today_payments_rials' => $rev['today_payments_rials'],
'week_payments_rials' => $rev['week_payments_rials'],
],
'charts' => [
'revenue_by_day' => $rev['revenue'],
'appointments_by_day' => $apptByDay,
],
'period' => ['from' => $from, 'to' => $to],
'today_appointments' => $todayAppts,
'clinics' => $clinics,
]);
}
/**
* سری ۷ روز اخیر درآمد (بر اساس PatientSession) + پرداختی امروز و هفته.
* @return array{revenue: array<int, array{label:string, amount_rials:int}>, today_payments_rials:int, week_payments_rials:int}
*/
private function revenueDaily(string $entityType, int $entityId): array
{
$fmt = new \IntlDateFormatter('fa_IR@calendar=persian', \IntlDateFormatter::NONE, \IntlDateFormatter::NONE, 'Asia/Tehran', \IntlDateFormatter::TRADITIONAL, 'd MMMM');
$series = [];
$todayPay = 0;
$weekPay = 0;
for ($i = 6; $i >= 0; $i--) {
$ds = strtotime('today midnight') - $i * 86400;
$de = $ds + 86399;
$rev = (int) $this->patientSessionRepo->sumRevenue($entityType, $entityId, $ds, $de);
$series[] = ['label' => $fmt->format($ds), 'amount_rials' => $rev];
$weekPay += $rev;
if ($i === 0) { $todayPay = $rev; }
}
return ['revenue' => $series, 'today_payments_rials' => $todayPay, 'week_payments_rials' => $weekPay];
}
/**
* سری ۷ روز اخیر تعداد نوبت با شمارنده‌ی دلخواه (doctor/clinic).
* @param callable(int $dayStart, int $dayEnd): int $counter
* @return array<int, array{label:string, count:int}>
*/
private function appointmentsDaily(callable $counter): array
{
$fmt = new \IntlDateFormatter('fa_IR@calendar=persian', \IntlDateFormatter::NONE, \IntlDateFormatter::NONE, 'Asia/Tehran', \IntlDateFormatter::TRADITIONAL, 'd MMMM');
$series = [];
for ($i = 6; $i >= 0; $i--) {
$ds = strtotime('today midnight') - $i * 86400;
$de = $ds + 86399;
$series[] = ['label' => $fmt->format($ds), 'count' => $counter($ds, $de)];
}
return $series;
}
// ── Secretary Dashboard ──────────────────────────────────────────────────
#[Route('/api/v1/dashboard/secretary', methods: ['GET'])]
#[IsGranted('ROLE_SECRETARY')]
public function secretary(#[CurrentUser] User $user): JsonResponse
{
$activeCtx = $this->contextRepo->findByUser($user);
$dbUuid = $activeCtx?->getDbUuid();
if ($dbUuid === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی منشی تنظیم نشده', 403);
}
// تعیین scope بر اساس db_uuid فعال
$clinic = $this->clinicRepo->findByUuid($dbUuid);
if ($clinic !== null) {
return $this->secretaryClinicDashboard($user, $clinic);
}
$doctor = $this->doctorRepo->findByUuid($dbUuid);
if ($doctor !== null) {
$rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor);
if ($rel === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی منشی تنظیم نشده', 403);
}
return $this->secretaryDoctorDashboard($user, $rel);
}
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'context نامعتبر است', 403);
}
private function secretaryDoctorDashboard(User $user, DoctorSecretary $rel): JsonResponse
{
$doctor = $rel->getDoctor();
$permissions = $rel->getPermissions();
$canView = (bool) ($permissions['resources']['appointments']['view'] ?? false);
$todayStart = strtotime('today midnight');
$todayEnd = strtotime('tomorrow midnight') - 1;
$tmrStart = strtotime('tomorrow midnight');
$tmrEnd = strtotime('tomorrow midnight') + 86399;
$todayCount = (int) $this->em->createQuery('
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e
')->setParameters(['doctor' => $doctor, 's' => $todayStart, 'e' => $todayEnd])
->getSingleScalarResult();
$tmrCount = (int) $this->em->createQuery('
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e
')->setParameters(['doctor' => $doctor, 's' => $tmrStart, 'e' => $tmrEnd])
->getSingleScalarResult();
$todayAppts = [];
if ($canView) {
$todayAppts = $this->em->createQuery('
SELECT a.uuid, u.realName AS patient_name, u.mobileNumber AS patient_mobile,
a.slotStart AS slot_start, a.status
FROM App\Appointment\Entity\Appointment a
JOIN a.user u
WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e
ORDER BY a.slotStart ASC
')->setMaxResults(10)->setParameters([
'doctor' => $doctor,
's' => $todayStart,
'e' => $todayEnd,
])->getArrayResult();
}
return $this->success([
'scope' => 'doctor',
'doctor' => [
'uuid' => $doctor->getUuid(),
'name' => $doctor->getName(),
'degree' => $doctor->getDegree(),
],
'permissions' => $permissions,
'stats' => [
'today_appointments' => $todayCount,
'tomorrow_appointments' => $tmrCount,
],
'today_appointments' => $todayAppts,
]);
}
private function secretaryClinicDashboard(User $user, \App\Clinic\Entity\Clinic $clinic): JsonResponse
{
$rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic);
if ($rel === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی منشی تنظیم نشده', 403);
}
$permissions = $rel->getPermissions();
$canView = (bool) ($permissions['resources']['appointments']['view'] ?? false);
$todayStart = strtotime('today midnight');
$todayEnd = strtotime('tomorrow midnight') - 1;
$tmrStart = strtotime('tomorrow midnight');
$tmrEnd = strtotime('tomorrow midnight') + 86399;
$doctors = $this->secretaryRepo->findDoctorsBySecretaryInClinic($user, $clinic);
$todayCount = 0;
$tmrCount = 0;
$todayAppts = [];
if (!empty($doctors)) {
$todayCount = (int) $this->em->createQuery('
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
WHERE a.doctor IN (:doctors) AND a.slotStart >= :s AND a.slotStart <= :e
')->setParameters(['doctors' => $doctors, 's' => $todayStart, 'e' => $todayEnd])
->getSingleScalarResult();
$tmrCount = (int) $this->em->createQuery('
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
WHERE a.doctor IN (:doctors) AND a.slotStart >= :s AND a.slotStart <= :e
')->setParameters(['doctors' => $doctors, 's' => $tmrStart, 'e' => $tmrEnd])
->getSingleScalarResult();
if ($canView) {
$todayAppts = $this->em->createQuery('
SELECT a.uuid, u.realName AS patient_name, u.mobileNumber AS patient_mobile,
a.slotStart AS slot_start, a.status, d.name AS doctor_name
FROM App\Appointment\Entity\Appointment a
JOIN a.user u
JOIN a.doctor d
WHERE a.doctor IN (:doctors) AND a.slotStart >= :s AND a.slotStart <= :e
ORDER BY a.slotStart ASC
')->setMaxResults(20)->setParameters([
'doctors' => $doctors,
's' => $todayStart,
'e' => $todayEnd,
])->getArrayResult();
}
}
return $this->success([
'scope' => 'clinic',
'clinic' => [
'uuid' => $clinic->getUuid(),
'name' => $clinic->getName(),
],
'permissions' => $permissions,
'stats' => [
'today_appointments' => $todayCount,
'tomorrow_appointments' => $tmrCount,
],
'today_appointments' => $todayAppts,
]);
}
}