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>
|
||||
|
||||
Reference in New Issue
Block a user