feat: add date range filter and new stats to dashboard (TASK-16)

- AdminDashboard: adds this_week/this_month/3_months preset buttons; charts query now passes from/to params; renamed appointments_30d→appointments_by_day and revenue_30d→revenue_by_day; adds subscription_sales_by_plan table
- ClinicDashboard: adds preset filter; query passes from/to; adds sms_wallet_balance, unique_patients_count, revenue_period_rials KPI cards
- DoctorDashboard: adds preset filter; query passes from/to; adds unique_patients_count, revenue_period_rials KPI cards
- Shared: getRange() helper and DatePreset type extracted at module level

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-14 22:33:56 +03:30
co-authored by Claude Sonnet 4.6
parent 69df9947a9
commit 5a4116fee0
+115 -30
View File
@@ -259,10 +259,11 @@ interface AdminStats {
pending_settlements: number; this_month_revenue: number; this_month_appointments: number;
}
interface AdminCharts {
appointments_30d: { date: string; count: number }[];
revenue_30d: { date: string; amount: number }[];
appointments_by_day: { date: number; count: number }[];
revenue_by_day: { date: number; amount_rials: number }[];
appointment_status: { status: string; count: number }[];
top_specialties: { name: string; count: number }[];
subscription_sales_by_plan?: { plan: string; count: number; total_rials: number }[];
}
interface AdminRecent {
appointments: { uuid: string; slot_start: string; status: string; doctor_name: string; user_mobile: string; user_name: string | null; created_at: string }[];
@@ -270,11 +271,31 @@ interface AdminRecent {
users: { uuid: string; mobile: string; name: string | null; email: string | null; created_at: string }[];
}
type DatePreset = 'this_week' | 'this_month' | '3_months';
function getRange(preset: DatePreset): { from: number; to: number } {
const now = Math.floor(Date.now() / 1000);
if (preset === 'this_week') {
const d = new Date(); d.setHours(0, 0, 0, 0);
const day = d.getDay(); // 0=sun, 6=sat
const daysToSat = (day + 1) % 7;
const satMs = d.getTime() - daysToSat * 86400000;
return { from: Math.floor(satMs / 1000), to: now };
}
if (preset === 'this_month') {
const d = new Date(); d.setDate(1); d.setHours(0, 0, 0, 0);
return { from: Math.floor(d.getTime() / 1000), to: now };
}
return { from: now - 90 * 86400, to: now };
}
function AdminDashboard() {
const [chartMode, setChartMode] = useState<'appts' | 'rev'>('appts');
const [preset, setPreset] = useState<DatePreset>('this_month');
const range = getRange(preset);
const statsQ = useQuery({ queryKey: ['dashboard-stats'], queryFn: () => api.get<ApiResponse<AdminStats>>('/api/v1/admin/dashboard/stats'), staleTime: 60_000 });
const chartsQ = useQuery({ queryKey: ['dashboard-charts'], queryFn: () => api.get<ApiResponse<AdminCharts>>('/api/v1/admin/dashboard/charts'), staleTime: 120_000 });
const chartsQ = useQuery({ queryKey: ['dashboard-charts', preset], queryFn: () => api.get<ApiResponse<AdminCharts>>(`/api/v1/admin/dashboard/charts?from=${range.from}&to=${range.to}`), staleTime: 120_000 });
const recentQ = useQuery({ queryKey: ['dashboard-recent'], queryFn: () => api.get<ApiResponse<AdminRecent>>('/api/v1/admin/dashboard/recent'), staleTime: 30_000 });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -289,8 +310,8 @@ function AdminDashboard() {
const today = new Date().toLocaleDateString('fa-IR', { year: 'numeric', month: 'long', day: 'numeric' });
const isFetching = statsQ.isFetching || chartsQ.isFetching || recentQ.isFetching;
const apptSeries = useMemo(() => charts?.appointments_30d?.map(d => d.count) ?? [], [charts]);
const revSeries = useMemo(() => charts?.revenue_30d?.map(d => d.amount) ?? [], [charts]);
const apptSeries = useMemo(() => charts?.appointments_by_day?.map(d => d.count) ?? [], [charts]);
const revSeries = useMemo(() => charts?.revenue_by_day?.map(d => d.amount_rials) ?? [], [charts]);
const donutData = useMemo(() =>
(charts?.appointment_status ?? []).slice(0, 7).map(s => ({
label: APPT_LABEL[s.status] ?? s.status, value: s.count, color: APPT_COLOR[s.status] ?? '#94a3b8',
@@ -358,8 +379,12 @@ function AdminDashboard() {
<h1 className="section-title">داشبورد مدیریت</h1>
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · نمای کلی عملکرد مجموعه</div>
</div>
<div style={{ display: 'flex', gap: 10 }}>
<button className="btn ghost sm">گزارش</button>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<div className="seg">
<button className={preset === 'this_week' ? 'on' : ''} onClick={() => setPreset('this_week')}>این هفته</button>
<button className={preset === 'this_month' ? 'on' : ''} onClick={() => setPreset('this_month')}>این ماه</button>
<button className={preset === '3_months' ? 'on' : ''} onClick={() => setPreset('3_months')}>۳ ماه</button>
</div>
<button className="btn primary sm" disabled={isFetching}
onClick={() => { statsQ.refetch(); chartsQ.refetch(); recentQ.refetch(); }}>
<ArrowPathIcon style={{ width: 14, height: 14 }} />
@@ -419,7 +444,7 @@ function AdminDashboard() {
</div>
<div className="card card-pad">
<div className="card-title-row">
<h3 style={{ fontSize: 16 }}>{chartMode === 'appts' ? 'نوبت‌ها' : 'درآمد'} ۳۰ روز اخیر</h3>
<h3 style={{ fontSize: 16 }}>{chartMode === 'appts' ? 'نوبت‌ها' : 'درآمد'} {preset === 'this_week' ? 'این هفته' : preset === 'this_month' ? 'این ماه' : '۳ ماه اخیر'}</h3>
<div className="seg">
<button className={chartMode === 'appts' ? 'on' : ''} onClick={() => setChartMode('appts')}>نوبتها</button>
<button className={chartMode === 'rev' ? 'on' : ''} onClick={() => setChartMode('rev')}>درآمد</button>
@@ -436,18 +461,50 @@ function AdminDashboard() {
</div>
</div>
<div className="card card-pad" style={{ marginBottom: 'var(--gap)' }}>
<div className="card-title-row">
<h3 style={{ fontSize: 16 }}>پرتکرارترین تخصصها</h3>
<span className="muted" style={{ fontSize: 12 }}>بر اساس تعداد نوبت</span>
<div className="grid-2" style={{ marginBottom: 'var(--gap)' }}>
<div className="card card-pad">
<div className="card-title-row">
<h3 style={{ fontSize: 16 }}>پرتکرارترین تخصصها</h3>
<span className="muted" style={{ fontSize: 12 }}>بر اساس تعداد نوبت</span>
</div>
{chartsQ.isLoading ? (
<div className="skeleton" style={{ height: 200, borderRadius: 'var(--r)' }} />
) : !hbarsData.length ? (
<p className="muted" style={{ textAlign: 'center', padding: '40px 0', fontSize: 13.5 }}>دادهای موجود نیست</p>
) : (
<SvgHBars data={hbarsData} />
)}
</div>
<div className="card card-pad">
<div className="card-title-row">
<h3 style={{ fontSize: 16 }}>فروش اشتراکها</h3>
<span className="muted" style={{ fontSize: 12 }}>{preset === 'this_week' ? 'این هفته' : preset === 'this_month' ? 'این ماه' : '۳ ماه اخیر'}</span>
</div>
{chartsQ.isLoading ? (
<div className="skeleton" style={{ height: 120, borderRadius: 'var(--r)' }} />
) : !(charts?.subscription_sales_by_plan ?? []).length ? (
<p className="muted" style={{ textAlign: 'center', padding: '40px 0', fontSize: 13.5 }}>فروشی ثبت نشده</p>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<th style={{ textAlign: 'right', padding: '8px 4px', color: 'var(--text-3)', fontWeight: 500 }}>پنل</th>
<th style={{ textAlign: 'right', padding: '8px 4px', color: 'var(--text-3)', fontWeight: 500 }}>تعداد</th>
<th style={{ textAlign: 'right', padding: '8px 4px', color: 'var(--text-3)', fontWeight: 500 }}>درآمد</th>
</tr>
</thead>
<tbody>
{(charts?.subscription_sales_by_plan ?? []).map((row) => (
<tr key={row.plan} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 4px' }}>{row.plan === 'basic' ? 'پایه' : row.plan === 'professional' ? 'حرفه‌ای' : row.plan}</td>
<td style={{ padding: '8px 4px' }}>{formatNumber(row.count)}</td>
<td style={{ padding: '8px 4px' }}>{formatRial(row.total_rials)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{chartsQ.isLoading ? (
<div className="skeleton" style={{ height: 200, borderRadius: 'var(--r)' }} />
) : !hbarsData.length ? (
<p className="muted" style={{ textAlign: 'center', padding: '40px 0', fontSize: 13.5 }}>دادهای موجود نیست</p>
) : (
<SvgHBars data={hbarsData} />
)}
</div>
<div className="grid-2" style={{ marginBottom: 'var(--gap)' }}>
@@ -510,7 +567,10 @@ function AdminDashboard() {
interface ClinicDashboardData {
clinic: { uuid: string; name: string; is_active: boolean; logo: string | null };
stats: { total_doctors: number; today_appointments: number; this_month_appointments: number; pending_invitations: number };
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_appointments: ApptRow[];
doctors: { uuid: string; name: string; today_count: number }[];
}
@@ -518,9 +578,11 @@ interface ClinicDashboardData {
function ClinicDashboard() {
const { context, dbUuid } = useAuthStore();
const [inviteOpen, setInviteOpen] = useState(false);
const [preset, setPreset] = useState<DatePreset>('this_month');
const range = getRange(preset);
const q = useQuery({
queryKey: ['dashboard-clinic'],
queryFn: () => api.get<ApiResponse<ClinicDashboardData>>('/api/v1/dashboard/clinic'),
queryKey: ['dashboard-clinic', preset],
queryFn: () => api.get<ApiResponse<ClinicDashboardData>>(`/api/v1/dashboard/clinic?from=${range.from}&to=${range.to}`),
staleTime: 60_000,
});
@@ -536,6 +598,9 @@ function ClinicDashboard() {
{ label: 'نوبت‌های امروز', value: formatNumber(d?.stats.today_appointments ?? 0), icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' },
{ label: 'نوبت‌های این ماه', value: formatNumber(d?.stats.this_month_appointments ?? 0), icon: ClockIcon, color: 'var(--info)', bg: 'var(--info-bg)' },
{ label: 'دعوتنامه در انتظار', value: formatNumber(d?.stats.pending_invitations ?? 0), icon: BellAlertIcon, color: 'var(--danger)', bg: 'var(--danger-bg)' },
...(d?.stats.sms_wallet_balance != null ? [{ label: 'موجودی پیامک', value: formatRial(d.stats.sms_wallet_balance), icon: CreditCardIcon, color: 'var(--primary)', bg: 'var(--primary-soft)' }] : []),
...(d?.stats.unique_patients_count != null ? [{ label: 'بیماران یکتا', value: formatNumber(d.stats.unique_patients_count), icon: UserGroupIcon, color: 'var(--violet)', bg: 'var(--violet-bg)' }] : []),
...(d?.stats.revenue_period_rials != null ? [{ label: 'درآمد دوره', value: formatRial(d.stats.revenue_period_rials), icon: CreditCardIcon, color: 'var(--success)', bg: 'var(--success-bg)' }] : []),
];
return (
@@ -545,7 +610,12 @@ function ClinicDashboard() {
<h1 className="section-title">داشبورد کلینیک</h1>
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · {d?.clinic.name ?? context?.name ?? ''}</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<div className="seg">
<button className={preset === 'this_week' ? 'on' : ''} onClick={() => setPreset('this_week')}>این هفته</button>
<button className={preset === 'this_month' ? 'on' : ''} onClick={() => setPreset('this_month')}>این ماه</button>
<button className={preset === '3_months' ? 'on' : ''} onClick={() => setPreset('3_months')}>۳ ماه</button>
</div>
<button className="btn primary sm" onClick={() => setInviteOpen(true)}>
<UserIcon style={{ width: 14, height: 14 }} />
دعوت پزشک
@@ -728,16 +798,22 @@ function DoctorClinicInvitationsCard() {
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 };
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_appointments: ApptRow[];
clinics: { uuid: string; name: string; logo: string | null }[];
}
function DoctorDashboard() {
const { context } = useAuthStore();
const [preset, setPreset] = useState<DatePreset>('this_month');
const range = getRange(preset);
const q = useQuery({
queryKey: ['dashboard-doctor'],
queryFn: () => api.get<ApiResponse<DoctorDashboardData>>('/api/v1/dashboard/doctor'),
queryKey: ['dashboard-doctor', preset],
queryFn: () => api.get<ApiResponse<DoctorDashboardData>>(`/api/v1/dashboard/doctor?from=${range.from}&to=${range.to}`),
staleTime: 60_000,
});
@@ -752,6 +828,8 @@ function DoctorDashboard() {
{ label: 'نوبت‌های فردا', value: formatNumber(d?.stats.tomorrow_appointments ?? 0), icon: ClockIcon, color: 'var(--info)', bg: 'var(--info-bg)' },
{ label: 'نوبت‌های این ماه', value: formatNumber(d?.stats.this_month_appointments ?? 0), icon: CalendarDaysIcon, color: 'var(--success)', bg: 'var(--success-bg)' },
{ label: 'میانگین امتیاز', value: d?.stats.avg_rating != null ? String(d.stats.avg_rating) : '—', icon: StarIcon, color: 'var(--primary)', bg: 'var(--primary-soft)' },
...(d?.stats.unique_patients_count != null ? [{ label: 'بیماران یکتا', value: formatNumber(d.stats.unique_patients_count), icon: UserGroupIcon, color: 'var(--violet)', bg: 'var(--violet-bg)' }] : []),
...(d?.stats.revenue_period_rials != null ? [{ label: 'درآمد دوره', value: formatRial(d.stats.revenue_period_rials), icon: CreditCardIcon, color: 'var(--success)', bg: 'var(--success-bg)' }] : []),
];
return (
@@ -761,10 +839,17 @@ function DoctorDashboard() {
<h1 className="section-title">داشبورد پزشک</h1>
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · دکتر {d?.doctor.name ?? context?.name ?? ''}</div>
</div>
<button className="btn ghost sm" onClick={() => q.refetch()}>
<ArrowPathIcon style={{ width: 14, height: 14 }} />
بهروزرسانی
</button>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<div className="seg">
<button className={preset === 'this_week' ? 'on' : ''} onClick={() => setPreset('this_week')}>این هفته</button>
<button className={preset === 'this_month' ? 'on' : ''} onClick={() => setPreset('this_month')}>این ماه</button>
<button className={preset === '3_months' ? 'on' : ''} onClick={() => setPreset('3_months')}>۳ ماه</button>
</div>
<button className="btn ghost sm" onClick={() => q.refetch()}>
<ArrowPathIcon style={{ width: 14, height: 14 }} />
بهروزرسانی
</button>
</div>
</div>
<div className="stat-grid">