import React, { useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; import { UserGroupIcon, HeartIcon, BuildingOffice2Icon, CalendarDaysIcon, CreditCardIcon, ArrowPathIcon, BellAlertIcon, ChatBubbleLeftEllipsisIcon, } from '@heroicons/react/24/outline'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import { formatNumber, formatRial, formatDateTime } from '../lib/utils'; // ── Types ───────────────────────────────────────────────────────────────── interface DashboardStats { total_users: number; active_doctors: number; total_doctors: number; total_clinics: number; today_appointments: number; total_appointments: number; today_payments_count: number; today_payments_amount: number; total_payments_amount: number; pending_comments: number; pending_settlements: number; this_month_revenue: number; this_month_appointments: number; } interface ChartData { appointments_30d: { date: string; count: number }[]; revenue_30d: { date: string; amount: number }[]; appointment_status: { status: string; count: number }[]; top_specialties: { name: string; count: number }[]; } interface RecentAppointment { uuid: string; slot_start: string; status: string; doctor_name: string; user_mobile: string; user_name: string | null; created_at: string; } interface RecentPayment { uuid: string; amount: number; status: string; gateway: string; user_mobile: string; user_name: string | null; created_at: string; } interface RecentUser { uuid: string; mobile: string; name: string | null; email: string | null; created_at: string; } interface RecentData { appointments: RecentAppointment[]; payments: RecentPayment[]; users: RecentUser[]; } // ── Status maps ─────────────────────────────────────────────────────────── const APPT_LABEL: Record = { waiting_for_payment: 'انتظار پرداخت', reserved: 'رزرو شده', checked_in: 'ورود به مطب', waiting: 'صف انتظار', in_progress: 'در حال ویزیت', visited: 'ویزیت شده', completed: 'تکمیل شده', cancelled_by_doctor: 'لغو پزشک', cancelled_by_user: 'لغو بیمار', auto_cancel_unpaid: 'لغو خودکار', no_show: 'غیبت', }; const APPT_COLOR: Record = { waiting_for_payment: '#f59e0b', reserved: '#3b82f6', checked_in: '#6366f1', waiting: '#f97316', in_progress: '#8b5cf6', visited: '#10b981', completed: '#22c55e', cancelled_by_doctor: '#ef4444', cancelled_by_user: '#f43f5e', auto_cancel_unpaid: '#94a3b8', no_show: '#64748b', }; const APPT_CLS: Record = { waiting_for_payment: 'amber', reserved: 'blue', checked_in: 'violet', waiting: 'amber', in_progress: 'violet', visited: 'green', completed: 'green', cancelled_by_doctor: 'red', cancelled_by_user: 'red', auto_cancel_unpaid: 'gray', no_show: 'gray', }; const PAY_LABEL: Record = { pending: 'در انتظار', received: 'موفق', canceled: 'لغو شده', refund: 'استرداد', }; const PAY_COLOR: Record = { pending: '#f59e0b', received: '#22c55e', canceled: '#ef4444', refund: '#3b82f6', }; const PAY_CLS: Record = { pending: 'amber', received: 'green', canceled: 'red', refund: 'blue', }; // ── SVG Chart Components ────────────────────────────────────────────────── function SvgLineChart({ data, color, h = 220 }: { data: number[]; color: string; h?: number }) { if (data.length < 2) return null; const w = 760, pad = 8; const max = Math.max(...data) * 1.15 || 1; const stepX = (w - pad * 2) / (data.length - 1); const pts: [number, number][] = data.map((v, i) => [ pad + i * stepX, h - pad - (v / max) * (h - pad * 2 - 18), ]); const d = pts.map((p, i) => { if (i === 0) return `M${p[0]},${p[1]}`; const prev = pts[i - 1]; const cx = (prev[0] + p[0]) / 2; return `C${cx},${prev[1]} ${cx},${p[1]} ${p[0]},${p[1]}`; }).join(' '); const area = `${d} L${pts[pts.length - 1][0]},${h - pad} L${pts[0][0]},${h - pad} Z`; const gid = 'lg' + color.replace(/[^a-z0-9]/gi, ''); return ( {[0.25, 0.5, 0.75, 1].map((g, i) => ( ))} {pts.map((p, i) => i % 4 === 0 && ( ))} ); } function SvgDonut({ data, size = 190 }: { data: { value: number; color: string; label: string }[]; size?: number }) { const total = data.reduce((s, d) => s + d.value, 0); if (!total) return null; const r = size / 2 - 16; const c = 2 * Math.PI * r; let off = 0; return ( {data.map((d, i) => { const frac = d.value / total; const seg = ( ); off += frac; return seg; })} مجموع {formatNumber(total)} ); } function SvgHBars({ data }: { data: { label: string; value: number }[] }) { const max = Math.max(...data.map(d => d.value)) || 1; return (
{data.map((d, i) => (
{d.label}
{formatNumber(d.value)}
))}
); } // ── Colored Avatar ──────────────────────────────────────────────────────── function AvatarEl({ initials, hue = 222, size }: { initials: string; hue?: number; size?: 'sm' | 'lg' }) { const cls = 'avatar' + (size === 'sm' ? ' sm' : size === 'lg' ? ' lg' : ''); return (
{initials}
); } // ── MiniList ────────────────────────────────────────────────────────────── interface MiniRow { title: string; sub: string; meta: string; badgeLabel: string; badgeCls: string; initials: string; hue: number; } function MiniList({ title, to, rows, loading }: { title: string; to: string; rows: MiniRow[]; loading?: boolean }) { return (

{title}

همه
{loading ? (
{Array.from({ length: 5 }).map((_, i) => (
))}
) : !rows.length ? (

موردی ثبت نشده

) : (
{rows.map((r, i) => (
{r.title} {r.sub}
{r.badgeLabel}
{r.meta}
))}
)}
); } // ── Skeleton ────────────────────────────────────────────────────────────── function KpiSkeleton() { return (
); } // ── Main Component ──────────────────────────────────────────────────────── export default function DashboardPage() { const [chartMode, setChartMode] = useState<'appts' | 'rev'>('appts'); const statsQ = useQuery({ queryKey: ['dashboard-stats'], queryFn: () => api.get>('/api/v1/admin/dashboard/stats'), staleTime: 60_000, }); const chartsQ = useQuery({ queryKey: ['dashboard-charts'], queryFn: () => api.get>('/api/v1/admin/dashboard/charts'), staleTime: 120_000, }); const recentQ = useQuery({ queryKey: ['dashboard-recent'], queryFn: () => api.get>('/api/v1/admin/dashboard/recent'), staleTime: 30_000, }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const stats = useMemo(() => (statsQ.data?.data as any)?.data ?? statsQ.data?.data, [statsQ.data]); // eslint-disable-next-line @typescript-eslint/no-explicit-any const charts = useMemo( () => (chartsQ.data?.data as any)?.data ?? chartsQ.data?.data, [chartsQ.data]); // eslint-disable-next-line @typescript-eslint/no-explicit-any const recent = useMemo( () => (recentQ.data?.data as any)?.data ?? recentQ.data?.data, [recentQ.data]); const fn = (n?: number) => n !== undefined ? formatNumber(n) : '—'; const fr = (n?: number) => n !== undefined ? formatRial(n) : '—'; const today = new Date().toLocaleDateString('fa-IR', { year: 'numeric', month: 'long', day: 'numeric' }); const isFetching = statsQ.isFetching || chartsQ.isFetching || recentQ.isFetching; // Chart data transformations const apptSeries = useMemo(() => charts?.appointments_30d?.map(d => d.count) ?? [], [charts]); const revSeries = useMemo(() => charts?.revenue_30d?.map(d => d.amount) ?? [], [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', })), [charts]); const hbarsData = useMemo(() => (charts?.top_specialties ?? []).map(s => ({ label: s.name, value: s.count })), [charts]); // KPI cards const kpiCards = [ { label: 'کل کاربران', value: fn(stats?.total_users), hint: 'رشد نسبت به ماه قبل', icon: UserGroupIcon, color: 'var(--violet)', bg: 'var(--violet-bg)' }, { label: 'پزشکان فعال', value: fn(stats?.active_doctors), hint: stats ? `از ${fn(stats.total_doctors)} پزشک` : '', icon: HeartIcon, color: 'var(--success)', bg: 'var(--success-bg)' }, { label: 'کلینیک‌ها', value: fn(stats?.total_clinics), hint: '', icon: BuildingOffice2Icon, color: 'var(--info)', bg: 'var(--info-bg)' }, { label: 'نوبت‌های امروز', value: fn(stats?.today_appointments), hint: stats ? `ماه جاری: ${fn(stats.this_month_appointments)}` : '', icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' }, { label: 'درآمد این ماه', value: fr(stats?.this_month_revenue), hint: 'تومان', icon: CreditCardIcon, color: 'var(--primary)', bg: 'var(--primary-soft)' }, { label: 'در انتظار بررسی', value: fn(stats ? stats.pending_comments + stats.pending_settlements : undefined), hint: stats ? `${fn(stats.pending_comments)} نظر · ${fn(stats.pending_settlements)} تسویه` : '', icon: BellAlertIcon, color: 'var(--danger)', bg: 'var(--danger-bg)' }, ]; // Quick actions const quickActions = [ { label: 'پزشکان', to: '/admin/doctors', icon: HeartIcon, color: 'var(--success)', bg: 'var(--success-bg)' }, { label: 'کلینیک‌ها', to: '/admin/clinics', icon: BuildingOffice2Icon, color: 'var(--info)', bg: 'var(--info-bg)' }, { label: 'نوبت‌ها', to: '/admin/appointments', icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' }, { label: 'پرداخت‌ها', to: '/admin/payments', icon: CreditCardIcon, color: 'var(--danger)', bg: 'var(--danger-bg)' }, { label: 'نظرات', to: '/admin/comments', icon: ChatBubbleLeftEllipsisIcon, color: 'var(--violet)', bg: 'var(--violet-bg)' }, { label: 'کاربران', to: '/admin/users', icon: UserGroupIcon, color: 'var(--primary)', bg: 'var(--primary-soft)' }, ]; // Timeline events const timelineEvents = useMemo(() => { if (!recent) return []; const evs: { title: string; sub: string; time: string; color: string }[] = []; (recent.appointments ?? []).slice(0, 4).forEach(a => { evs.push({ title: `نوبت ${APPT_LABEL[a.status] ?? a.status}`, sub: `${a.user_name || a.user_mobile} · دکتر ${a.doctor_name}`, time: a.created_at, color: APPT_COLOR[a.status] ?? '#94a3b8' }); }); (recent.payments ?? []).slice(0, 3).forEach(p => { evs.push({ title: `پرداخت ${PAY_LABEL[p.status] ?? p.status}`, sub: `${p.user_name || p.user_mobile} · ${formatRial(p.amount)}`, time: p.created_at, color: PAY_COLOR[p.status] ?? '#94a3b8' }); }); (recent.users ?? []).slice(0, 3).forEach(u => { evs.push({ title: 'ثبت‌نام کاربر جدید', sub: u.name || u.mobile, time: u.created_at, color: '#8b5cf6' }); }); return evs.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime()).slice(0, 8); }, [recent]); // MiniList rows const apptRows = useMemo(() => (recent?.appointments ?? []).slice(0, 5).map(a => ({ title: a.user_name || a.user_mobile, sub: `دکتر ${a.doctor_name}`, meta: formatDateTime(a.slot_start), badgeLabel: APPT_LABEL[a.status] ?? a.status, badgeCls: APPT_CLS[a.status] ?? 'gray', initials: (a.user_name || a.user_mobile).slice(0, 2), hue: 222, })), [recent]); const payRows = useMemo(() => (recent?.payments ?? []).slice(0, 5).map(p => ({ title: p.user_name || p.user_mobile, sub: formatRial(p.amount), meta: formatDateTime(p.created_at), badgeLabel: PAY_LABEL[p.status] ?? p.status, badgeCls: PAY_CLS[p.status] ?? 'gray', initials: (p.user_name || p.user_mobile).slice(0, 2), hue: 162, })), [recent]); const userRows = useMemo(() => (recent?.users ?? []).slice(0, 5).map(u => ({ title: u.name || u.mobile, sub: u.name ? u.mobile : '', meta: formatDateTime(u.created_at), badgeLabel: 'فعال', badgeCls: 'green', initials: (u.name || u.mobile).slice(0, 2), hue: 256, })), [recent]); return (
{/* Page header */}

داشبورد

{today} · نمای کلی عملکرد مجموعه
{/* Stat cards (6-col grid) */}
{statsQ.isLoading ? Array.from({ length: 6 }).map((_, i) => ) : kpiCards.map(c => (
{c.label}
{c.value}
{c.hint}
)) }
{/* dash-main: Donut (360px) + LineChart (1fr) */}

وضعیت نوبت‌ها

{chartsQ.isLoading ? ( <>
{Array.from({ length: 5 }).map((_, i) => (
))}
) : ( <>
{donutData.map((d, i) => (
{d.label} {formatNumber(d.value)}
))}
)}

{chartMode === 'appts' ? 'نوبت‌ها' : 'درآمد'} — ۳۰ روز اخیر

{chartsQ.isLoading ? (
) : ( )}
{/* Top specialties (HBars) */}

پرتکرارترین تخصص‌ها

بر اساس تعداد نوبت
{chartsQ.isLoading ? (
) : !hbarsData.length ? (

داده‌ای موجود نیست

) : ( )}
{/* grid-2: Quick access + Timeline */}

دسترسی سریع

{quickActions.map(a => ( {a.label} ))}

آخرین رویدادها

{recentQ.isLoading ? (
{Array.from({ length: 5 }).map((_, i) => (
))}
) : !timelineEvents.length ? (

رویدادی ثبت نشده

) : (
{timelineEvents.map((e, i) => (
{e.title} {e.sub &&

{e.sub}

}
))}
)}
{/* dash-3: Three MiniLists */}
); }