feat: Implement Jalali calendar support for dashboard charts

- Added support for Jalali calendar in the dashboard, allowing charts to display data based on the current Jalali month and year.
- Updated the API to return `patients_year`, `patients_month`, and `revenue_year` parameters for the dashboard charts.
- Refactored the dashboard controller to handle Jalali date calculations and queries.
- Modified the frontend components to utilize the new Jalali date parameters and reflect changes in the UI.
- Removed the status column from the NewAppointmentsTable as status management is now handled on the appointments page.
- Added tests to ensure the correct functioning of the new Jalali chart period features.
This commit is contained in:
hamed
2026-07-18 21:44:01 +03:30
parent 7921407f33
commit 62a9fd87c3
7 changed files with 479 additions and 100 deletions
+176 -29
View File
@@ -140,15 +140,23 @@ class DashboardController extends BaseController
$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();
});
$chartPeriod = $this->chartPeriodParams($request);
$rev = $this->revenueDaily('clinic', $clinicId);
$revenueByMon = $this->revenueByJalaliYear('clinic', $clinicId, $chartPeriod['revenue_year']);
$apptByDay = $this->appointmentsByJalaliMonth(
$chartPeriod['patients_year'],
$chartPeriod['patients_month'],
function (int $s, int $e) use ($clinicId): array {
$rows = $this->em->createQuery('
SELECT a.slotStart 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' => $s, 'e' => $e])->getArrayResult();
return array_column($rows, 'slotStart');
}
);
return $this->success([
'clinic' => [
@@ -171,8 +179,10 @@ class DashboardController extends BaseController
],
'charts' => [
'revenue_by_day' => $rev['revenue'],
'revenue_by_month' => $revenueByMon,
'appointments_by_day' => $apptByDay,
],
'charts_period' => $chartPeriod,
'period' => ['from' => $from, 'to' => $to],
'today_appointments' => $todayAppts,
'doctors' => $doctors,
@@ -245,8 +255,11 @@ class DashboardController extends BaseController
WHERE d.id = :doctorId
')->setParameter('doctorId', $doctorId)->getArrayResult();
$apptByDay = $this->appointmentsDaily(
fn(int $ds, int $de): int => $this->countAppointments($doctor, $clinic, $ds, $de)
$chartPeriod = $this->chartPeriodParams($request);
$apptByDay = $this->appointmentsByJalaliMonth(
$chartPeriod['patients_year'],
$chartPeriod['patients_month'],
fn(int $s, int $e): array => $this->appointmentSlotStarts($doctor, $clinic, $s, $e)
);
$stats = [
@@ -272,7 +285,8 @@ class DashboardController extends BaseController
$stats['today_payments_rials'] = $rev['today_payments_rials'];
$stats['week_payments_rials'] = $rev['week_payments_rials'];
$charts['revenue_by_day'] = $rev['revenue'];
$charts['revenue_by_day'] = $rev['revenue'];
$charts['revenue_by_month'] = $this->revenueByJalaliYear('doctor', $doctorId, $chartPeriod['revenue_year']);
}
return $this->success([
@@ -288,6 +302,7 @@ class DashboardController extends BaseController
],
'stats' => $stats,
'charts' => $charts,
'charts_period' => $chartPeriod,
'period' => ['from' => $from, 'to' => $to],
'today_appointments' => $todayAppts,
// فهرست کلینیک‌ها فقط در محیط شخصی معنا دارد.
@@ -315,6 +330,28 @@ class DashboardController extends BaseController
return (int) $qb->getQuery()->getSingleScalarResult();
}
/**
* تایم‌استمپ شروع نوبت‌های یک پزشک در بازه — نسخه‌ی لیستی countAppointments
* برای سری‌های روزانه (یک کوئری به جای یکی به ازای هر روز).
*
* @return array<int, int>
*/
private function appointmentSlotStarts(\App\Doctor\Entity\Doctor $doctor, ?\App\Clinic\Entity\Clinic $clinic, int $from, int $to): array
{
$qb = $this->em->createQueryBuilder()
->select('a.slotStart')
->from(\App\Appointment\Entity\Appointment::class, 'a')
->where('a.doctor = :doctor')
->andWhere('a.slotStart >= :s AND a.slotStart <= :e')
->setParameter('doctor', $doctor)
->setParameter('s', $from)
->setParameter('e', $to);
$this->restrictToClinicAddresses($qb, $clinic);
return array_column($qb->getQuery()->getArrayResult(), 'slotStart');
}
private function restrictToClinicAddresses(\Doctrine\ORM\QueryBuilder $qb, ?\App\Clinic\Entity\Clinic $clinic): void
{
if ($clinic === null) {
@@ -341,6 +378,133 @@ class DashboardController extends BaseController
&& $this->permChecker->can($user, $clinic, 'payments', 'view');
}
// ── Jalali chart helpers ────────────────────────────────────────────────
/** تقویم شمسی تهران — پایه‌ی همه‌ی محاسبات بازه‌ی نمودارها. */
private function jalaliCalendar(): \IntlCalendar
{
return \IntlCalendar::createInstance(new \DateTimeZone('Asia/Tehran'), 'fa_IR@calendar=persian');
}
/**
* سال/ماه شمسی جاری.
* @return array{0:int, 1:int} [year, month] با ماه ۱..۱۲
*/
private function currentJalaliYearMonth(): array
{
$cal = $this->jalaliCalendar();
return [
$cal->get(\IntlCalendar::FIELD_YEAR),
$cal->get(\IntlCalendar::FIELD_MONTH) + 1,
];
}
/**
* بازه‌ی یونیکس یک ماه شمسی و تعداد روزهای آن.
* @return array{start:int, end:int, days:int}
*/
private function jalaliMonthRange(int $year, int $month): array
{
$cal = $this->jalaliCalendar();
$cal->set(\IntlCalendar::FIELD_YEAR, $year);
$cal->set(\IntlCalendar::FIELD_MONTH, $month - 1);
$cal->set(\IntlCalendar::FIELD_DAY_OF_MONTH, 1);
$cal->set(\IntlCalendar::FIELD_HOUR_OF_DAY, 0);
$cal->set(\IntlCalendar::FIELD_MINUTE, 0);
$cal->set(\IntlCalendar::FIELD_SECOND, 0);
$cal->set(\IntlCalendar::FIELD_MILLISECOND, 0);
$start = (int) ($cal->getTime() / 1000);
$days = $cal->getActualMaximum(\IntlCalendar::FIELD_DAY_OF_MONTH);
return ['start' => $start, 'end' => $start + $days * 86400 - 1, 'days' => $days];
}
/**
* سری تعداد نوبت به تفکیک روزهای یک ماه شمسی (نمودار «تعداد بیماران»).
* روزهای بدون نوبت صفر می‌مانند تا طول سری برابر طول ماه باشد.
*
* `$fetchSlotStarts` باید تایم‌استمپ شروع همه‌ی نوبت‌های بازه را برگرداند —
* یک کوئری برای کل ماه، نه یکی به ازای هر روز.
*
* @param callable(int $from, int $to): array<int, int> $fetchSlotStarts
* @return array<int, array{label:string, count:int}>
*/
private function appointmentsByJalaliMonth(int $year, int $month, callable $fetchSlotStarts): array
{
$range = $this->jalaliMonthRange($year, $month);
$buckets = array_fill(0, $range['days'], 0);
$dayFmt = new \IntlDateFormatter(
'fa_IR@calendar=persian',
\IntlDateFormatter::NONE,
\IntlDateFormatter::NONE,
'Asia/Tehran',
\IntlDateFormatter::TRADITIONAL,
'd'
);
foreach ($fetchSlotStarts($range['start'], $range['end']) as $slotStart) {
$day = intdiv($slotStart - $range['start'], 86400);
if ($day >= 0 && $day < $range['days']) {
$buckets[$day]++;
}
}
$series = [];
foreach ($buckets as $day => $count) {
$series[] = ['label' => $dayFmt->format($range['start'] + $day * 86400), 'count' => $count];
}
return $series;
}
/**
* سری درآمد به تفکیک ۱۲ ماه یک سال شمسی (نمودار «میزان درآمد»).
* @return array<int, array{label:string, amount_rials:int}>
*/
private function revenueByJalaliYear(string $entityType, int $entityId, int $year): array
{
$fmt = new \IntlDateFormatter(
'fa_IR@calendar=persian',
\IntlDateFormatter::NONE,
\IntlDateFormatter::NONE,
'Asia/Tehran',
\IntlDateFormatter::TRADITIONAL,
'MMMM'
);
$series = [];
for ($m = 1; $m <= 12; $m++) {
$range = $this->jalaliMonthRange($year, $m);
$series[] = [
'label' => $fmt->format($range['start']),
'amount_rials' => (int) $this->patientSessionRepo->sumRevenue($entityType, $entityId, $range['start'], $range['end']),
];
}
return $series;
}
/**
* پارامترهای بازه‌ی نمودارها از query string، با fallback به دوره‌ی جاری شمسی.
* @return array{patients_year:int, patients_month:int, revenue_year:int}
*/
private function chartPeriodParams(Request $request): array
{
[$curYear, $curMonth] = $this->currentJalaliYearMonth();
$patientsYear = (int) ($request->query->get('patients_year') ?: $curYear);
$patientsMonth = (int) ($request->query->get('patients_month') ?: $curMonth);
$revenueYear = (int) ($request->query->get('revenue_year') ?: $curYear);
return [
'patients_year' => max(1300, min(1500, $patientsYear)),
'patients_month' => max(1, min(12, $patientsMonth)),
'revenue_year' => max(1300, min(1500, $revenueYear)),
];
}
/**
* سری ۷ روز اخیر درآمد (بر اساس PatientSession) + پرداختی امروز و هفته.
* @return array{revenue: array<int, array{label:string, amount_rials:int}>, today_payments_rials:int, week_payments_rials:int}
@@ -362,23 +526,6 @@ class DashboardController extends BaseController
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'])]