From 599382fae4bebf1357ee40484b1c42d8e5214272 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Mon, 13 Jul 2026 09:23:38 +0330 Subject: [PATCH] feat(dashboard): doctor/clinic dashboard matching Figma layout Restructure to Figma: 4 stat cards (today appts, today/week payments, total patients) -> two charts (revenue area + patient-count vertical bars) -> today appointments table. Add today/week payments, total patients, and 7-day revenue/appointments series to doctor + clinic dashboard endpoints. Add SvgVBars chart. Update dashboard.md. Co-Authored-By: Claude Opus 4.8 --- assets/admin/pages/DashboardPage.tsx | 107 ++++++++++++++---- docs/api/dashboard.md | 25 +++- .../Controller/DashboardController.php | 72 ++++++++++++ 3 files changed, 180 insertions(+), 24 deletions(-) diff --git a/assets/admin/pages/DashboardPage.tsx b/assets/admin/pages/DashboardPage.tsx index e3709f81..e4512fd8 100644 --- a/assets/admin/pages/DashboardPage.tsx +++ b/assets/admin/pages/DashboardPage.tsx @@ -4,7 +4,7 @@ import { Link } from 'react-router-dom'; import { UserGroupIcon, HeartIcon, BuildingOffice2Icon, CalendarDaysIcon, CreditCardIcon, ArrowPathIcon, BellAlertIcon, ChatBubbleLeftEllipsisIcon, - ClockIcon, StarIcon, UserIcon, CheckIcon, XMarkIcon, + ClockIcon, StarIcon, UserIcon, CheckIcon, XMarkIcon, BanknotesIcon, } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; @@ -128,6 +128,32 @@ function SvgHBars({ data }: { data: { label: string; value: number }[] }) { ); } +function SvgVBars({ data, color = 'var(--primary)' }: { data: { label: string; value: number }[]; color?: string }) { + if (!data.length) return
داده‌ای برای نمایش نیست
; + const max = Math.max(...data.map(d => d.value), 1); + return ( +
+ {data.map((d, i) => ( +
+
+
0 ? 6 : 0, + background: color, borderRadius: '6px 6px 0 0', + animation: `growcol 0.9s ${i * 0.06}s cubic-bezier(.22,.61,.36,1) both`, + }} + /> +
+
{d.label}
+
+ ))} + +
+ ); +} + // ── Shared UI Pieces ────────────────────────────────────────────────────── function AvatarEl({ initials, hue = 222, size }: { initials: string; hue?: number; size?: 'sm' | 'lg' }) { @@ -571,7 +597,9 @@ interface ClinicDashboardData { stats: { total_doctors: number; today_appointments: number; this_month_appointments: number; pending_invitations: number; sms_wallet_balance?: number; unique_patients_count?: number; revenue_period_rials?: number; + today_payments_rials?: number; week_payments_rials?: number; total_patients?: number; }; + charts?: DashboardCharts; today_appointments: ApptRow[]; doctors: { uuid: string; name: string; today_count: number }[]; } @@ -596,15 +624,14 @@ function ClinicDashboard() { const statCards: { tone: 'amber' | 'violet' | 'green' | 'pink'; label: string; value: React.ReactNode; icon: React.ReactNode }[] = [ { tone: 'amber', label: 'تعداد نوبت‌های امروز', value: `${formatNumber(d?.stats.today_appointments ?? 0)}+`, icon: }, - { tone: 'violet', label: 'نوبت‌های این ماه', value: formatNumber(d?.stats.this_month_appointments ?? 0), icon: }, - { tone: 'green', label: d?.stats.revenue_period_rials != null ? 'درآمد دوره' : 'پزشکان', - value: d?.stats.revenue_period_rials != null ? formatRial(d.stats.revenue_period_rials) : formatNumber(d?.stats.total_doctors ?? 0), - icon: d?.stats.revenue_period_rials != null ? : }, - { tone: 'pink', label: d?.stats.unique_patients_count != null ? 'بیماران یکتا' : 'دعوتنامه در انتظار', - value: d?.stats.unique_patients_count != null ? formatNumber(d.stats.unique_patients_count) : formatNumber(d?.stats.pending_invitations ?? 0), - icon: }, + { tone: 'violet', label: 'پرداختی‌های امروز', value: formatRial(d?.stats.today_payments_rials ?? 0), icon: }, + { tone: 'green', label: 'پرداختی‌های هفته', value: formatRial(d?.stats.week_payments_rials ?? 0), icon: }, + { tone: 'pink', label: 'تعداد کل مراجعین', value: `${formatNumber(d?.stats.total_patients ?? 0)}+`, icon: }, ]; + const revSeries = (d?.charts?.revenue_by_day ?? []).map(x => x.amount_rials); + const patientBars = (d?.charts?.appointments_by_day ?? []).map(x => ({ label: x.label, value: x.count })); + return (
@@ -638,12 +665,27 @@ function ClinicDashboard() {
-

نوبت‌های امروز

- همه نوبت‌ها +

نمودار تعداد مراجعین

- +
+
+
+

میزان درآمد

+
+ +
+
+
+
+

لیست نوبت‌های امروز

+ مشاهده همه +
+ +
+ +

پزشکان کلینیک

@@ -792,13 +834,20 @@ function DoctorClinicInvitationsCard() { // ── Doctor Dashboard ────────────────────────────────────────────────────── +interface DashboardCharts { + revenue_by_day: { label: string; amount_rials: number }[]; + appointments_by_day: { label: string; count: number }[]; +} + interface DoctorDashboardData { doctor: { uuid: string; name: string; degree: string | null }; stats: { today_appointments: number; tomorrow_appointments: number; this_month_appointments: number; avg_rating: number | null; total_ratings: number; unique_patients_count?: number; revenue_period_rials?: number; + today_payments_rials?: number; week_payments_rials?: number; total_patients?: number; }; + charts?: DashboardCharts; today_appointments: ApptRow[]; clinics: { uuid: string; name: string; logo: string | null }[]; } @@ -821,13 +870,14 @@ function DoctorDashboard() { const statCards: { tone: 'amber' | 'violet' | 'green' | 'pink'; label: string; value: React.ReactNode; icon: React.ReactNode }[] = [ { tone: 'amber', label: 'تعداد نوبت‌های امروز', value: `${formatNumber(d?.stats.today_appointments ?? 0)}+`, icon: }, - { tone: 'violet', label: 'نوبت‌های فردا', value: `${formatNumber(d?.stats.tomorrow_appointments ?? 0)}+`, icon: }, - { tone: 'green', label: 'نوبت‌های این ماه', value: formatNumber(d?.stats.this_month_appointments ?? 0), icon: }, - { tone: 'pink', label: d?.stats.revenue_period_rials != null ? 'درآمد دوره' : 'میانگین امتیاز', - value: d?.stats.revenue_period_rials != null ? formatRial(d.stats.revenue_period_rials) : (d?.stats.avg_rating != null ? String(d.stats.avg_rating) : '—'), - icon: d?.stats.revenue_period_rials != null ? : }, + { tone: 'violet', label: 'پرداختی‌های امروز', value: formatRial(d?.stats.today_payments_rials ?? 0), icon: }, + { tone: 'green', label: 'پرداختی‌های هفته', value: formatRial(d?.stats.week_payments_rials ?? 0), icon: }, + { tone: 'pink', label: 'تعداد کل مراجعین', value: `${formatNumber(d?.stats.total_patients ?? 0)}+`, icon: }, ]; + const revSeries = (d?.charts?.revenue_by_day ?? []).map(x => x.amount_rials); + const patientBars = (d?.charts?.appointments_by_day ?? []).map(x => ({ label: x.label, value: x.count })); + return (
@@ -854,18 +904,31 @@ function DoctorDashboard() { ))}
-
- -
-

نوبت‌های امروز

- همه نوبت‌ها +

نمودار تعداد مراجعین

- +
+
+
+

میزان درآمد

+
+ +
+
+
+
+

لیست نوبت‌های امروز

+ مشاهده همه +
+ +
+ +
+

کلینیک‌های من

diff --git a/docs/api/dashboard.md b/docs/api/dashboard.md index 30c314d7..8baac33b 100644 --- a/docs/api/dashboard.md +++ b/docs/api/dashboard.md @@ -36,7 +36,18 @@ Returns stats and today's schedule for the authenticated clinic owner. "pending_invitations": 2, "sms_wallet_balance": 50000, "unique_patients_count": 34, - "revenue_period_rials": 12500000 + "total_patients": 210, + "revenue_period_rials": 12500000, + "today_payments_rials": 5225000, + "week_payments_rials": 560000200 + }, + "charts": { + "revenue_by_day": [ + { "label": "۷ خرداد", "amount_rials": 3200000 } + ], + "appointments_by_day": [ + { "label": "۷ خرداد", "count": 9 } + ] }, "period": { "from": 1717200000, "to": 1719792000 }, "today_appointments": [ @@ -62,7 +73,10 @@ Returns stats and today's schedule for the authenticated clinic owner. **Field notes:** - `sms_wallet_balance` — current SMS wallet balance in Rials (0 if wallet not yet created) - `unique_patients_count` — distinct patients with at least one session in the `from`–`to` period +- `total_patients` — distinct patients ever (no period filter) - `revenue_period_rials` — sum of `final_price_rials` from all patient sessions in the period +- `today_payments_rials` / `week_payments_rials` — revenue for today / the last 7 days +- `charts.revenue_by_day` / `charts.appointments_by_day` — last 7 days series (Jalali day label); revenue drives the «میزان درآمد» area chart, appointments the «نمودار تعداد مراجعین» bar chart - `today_appointments` — up to 5 records, ordered by `slot_start ASC` - `doctors` — all doctors belonging to this clinic; each includes their appointment count for today @@ -106,7 +120,14 @@ Returns stats and today's schedule for the authenticated doctor. "total_ratings": 34, "sms_wallet_balance": 25000, "unique_patients_count": 18, - "revenue_period_rials": 6800000 + "total_patients": 140, + "revenue_period_rials": 6800000, + "today_payments_rials": 5225000, + "week_payments_rials": 42000000 + }, + "charts": { + "revenue_by_day": [ { "label": "۷ خرداد", "amount_rials": 3200000 } ], + "appointments_by_day": [ { "label": "۷ خرداد", "count": 4 } ] }, "period": { "from": 1717200000, "to": 1719792000 }, "today_appointments": [ diff --git a/src/Dashboard/Controller/DashboardController.php b/src/Dashboard/Controller/DashboardController.php index 5e889b1f..67543841 100644 --- a/src/Dashboard/Controller/DashboardController.php +++ b/src/Dashboard/Controller/DashboardController.php @@ -132,6 +132,17 @@ class DashboardController extends BaseController $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' => [ @@ -147,7 +158,14 @@ class DashboardController extends BaseController '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, @@ -227,6 +245,15 @@ class DashboardController extends BaseController $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' => [ @@ -242,7 +269,14 @@ class DashboardController extends BaseController '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, @@ -250,6 +284,44 @@ class DashboardController extends BaseController ]); } + /** + * سری ۷ روز اخیر درآمد (بر اساس PatientSession) + پرداختی امروز و هفته. + * @return array{revenue: array, 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 + */ + 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'])]