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:
co-authored by
Claude Sonnet 4.6
parent
69df9947a9
commit
5a4116fee0
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user