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 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-13 09:23:38 +03:30
co-authored by Claude Opus 4.8
parent 9e491ae6b9
commit 599382fae4
3 changed files with 180 additions and 24 deletions
+85 -22
View File
@@ -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 <div className="muted" style={{ textAlign: 'center', padding: '48px 0', fontSize: 13 }}>دادهای برای نمایش نیست</div>;
const max = Math.max(...data.map(d => d.value), 1);
return (
<div style={{ display: 'flex', alignItems: 'stretch', gap: 8, height: 200, paddingTop: 8 }}>
{data.map((d, i) => (
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
<div style={{ flex: 1, width: '100%', display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }}>
<div
title={formatNumber(d.value)}
style={{
width: 24, maxWidth: '72%',
height: `${(d.value / max) * 100}%`, minHeight: d.value > 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`,
}}
/>
</div>
<div style={{ fontSize: 11, color: 'var(--text-3)', whiteSpace: 'nowrap' }}>{d.label}</div>
</div>
))}
<style>{`@keyframes growcol{from{height:0}}`}</style>
</div>
);
}
// ── 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: <CalendarDaysIcon style={{ width: 22, height: 22 }} /> },
{ tone: 'violet', label: 'نوبت‌های این ماه', value: formatNumber(d?.stats.this_month_appointments ?? 0), icon: <ClockIcon style={{ width: 22, height: 22 }} /> },
{ 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 ? <CreditCardIcon style={{ width: 22, height: 22 }} /> : <HeartIcon style={{ width: 22, height: 22 }} /> },
{ 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: <UserGroupIcon style={{ width: 22, height: 22 }} /> },
{ tone: 'violet', label: 'پرداختی‌های امروز', value: formatRial(d?.stats.today_payments_rials ?? 0), icon: <CreditCardIcon style={{ width: 22, height: 22 }} /> },
{ tone: 'green', label: 'پرداختی‌های هفته', value: formatRial(d?.stats.week_payments_rials ?? 0), icon: <BanknotesIcon style={{ width: 22, height: 22 }} /> },
{ tone: 'pink', label: 'تعداد کل مراجعین', value: `${formatNumber(d?.stats.total_patients ?? 0)}+`, icon: <UserGroupIcon style={{ width: 22, height: 22 }} /> },
];
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 (
<div className="fade-in">
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
@@ -638,12 +665,27 @@ function ClinicDashboard() {
<div className="grid-2" style={{ marginTop: 'var(--gap)' }}>
<div className="card card-pad">
<div className="card-title-row">
<h3 style={{ fontSize: 16 }}>نوبتهای امروز</h3>
<Link to="/admin/appointments" className="link">همه نوبتها</Link>
<h3 style={{ fontSize: 16 }}>نمودار تعداد مراجعین</h3>
</div>
<TodayAppointmentsTable appts={d?.today_appointments ?? []} loading={q.isLoading} />
<SvgVBars data={patientBars} />
</div>
<div className="card card-pad">
<div className="card-title-row">
<h3 style={{ fontSize: 16 }}>میزان درآمد</h3>
</div>
<SvgLineChart data={revSeries} color="var(--primary)" />
</div>
</div>
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
<div className="card-title-row">
<h3 style={{ fontSize: 16 }}>لیست نوبتهای امروز</h3>
<Link to="/admin/appointments" className="link">مشاهده همه</Link>
</div>
<TodayAppointmentsTable appts={d?.today_appointments ?? []} loading={q.isLoading} />
</div>
<div className="grid-2" style={{ marginTop: 'var(--gap)' }}>
<div className="card card-pad">
<div className="card-title-row">
<h3 style={{ fontSize: 16 }}>پزشکان کلینیک</h3>
@@ -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: <CalendarDaysIcon style={{ width: 22, height: 22 }} /> },
{ tone: 'violet', label: 'نوبت‌های فردا', value: `${formatNumber(d?.stats.tomorrow_appointments ?? 0)}+`, icon: <ClockIcon style={{ width: 22, height: 22 }} /> },
{ tone: 'green', label: 'نوبت‌های این ماه', value: formatNumber(d?.stats.this_month_appointments ?? 0), icon: <UserGroupIcon style={{ width: 22, height: 22 }} /> },
{ 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 ? <CreditCardIcon style={{ width: 22, height: 22 }} /> : <StarIcon style={{ width: 22, height: 22 }} /> },
{ tone: 'violet', label: 'پرداختی‌های امروز', value: formatRial(d?.stats.today_payments_rials ?? 0), icon: <CreditCardIcon style={{ width: 22, height: 22 }} /> },
{ tone: 'green', label: 'پرداختی‌های هفته', value: formatRial(d?.stats.week_payments_rials ?? 0), icon: <BanknotesIcon style={{ width: 22, height: 22 }} /> },
{ tone: 'pink', label: 'تعداد کل مراجعین', value: `${formatNumber(d?.stats.total_patients ?? 0)}+`, icon: <UserGroupIcon style={{ width: 22, height: 22 }} /> },
];
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 (
<div className="fade-in">
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
@@ -854,18 +904,31 @@ function DoctorDashboard() {
))}
</div>
<div style={{ height: 'var(--gap)' }} />
<DoctorClinicInvitationsCard />
<div className="grid-2" style={{ marginTop: 'var(--gap)' }}>
<div className="card card-pad">
<div className="card-title-row">
<h3 style={{ fontSize: 16 }}>نوبتهای امروز</h3>
<Link to="/admin/appointments" className="link">همه نوبتها</Link>
<h3 style={{ fontSize: 16 }}>نمودار تعداد مراجعین</h3>
</div>
<TodayAppointmentsTable appts={d?.today_appointments ?? []} loading={q.isLoading} />
<SvgVBars data={patientBars} />
</div>
<div className="card card-pad">
<div className="card-title-row">
<h3 style={{ fontSize: 16 }}>میزان درآمد</h3>
</div>
<SvgLineChart data={revSeries} color="var(--primary)" />
</div>
</div>
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
<div className="card-title-row">
<h3 style={{ fontSize: 16 }}>لیست نوبتهای امروز</h3>
<Link to="/admin/appointments" className="link">مشاهده همه</Link>
</div>
<TodayAppointmentsTable appts={d?.today_appointments ?? []} loading={q.isLoading} />
</div>
<div className="grid-2" style={{ marginTop: 'var(--gap)' }}>
<DoctorClinicInvitationsCard />
<div className="card card-pad">
<div className="card-title-row">
<h3 style={{ fontSize: 16 }}>کلینیکهای من</h3>
+23 -2
View File
@@ -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": [
@@ -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<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'])]