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