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:
@@ -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'])]
|
||||
|
||||
Reference in New Issue
Block a user