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>