import React, { useMemo, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; import { UserGroupIcon, HeartIcon, BuildingOffice2Icon, CalendarDaysIcon, CreditCardIcon, ArrowPathIcon, BellAlertIcon, ChatBubbleLeftEllipsisIcon, ClockIcon, StarIcon, UserIcon, CheckIcon, XMarkIcon, BanknotesIcon, } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import { formatNumber, formatRial, formatDateTime } from '../lib/utils'; import { useAuthStore } from '../stores/authStore'; import { TauriDashboardView } from '../components/dashboard/TauriDashboardView'; // eslint-disable-next-line @typescript-eslint/no-var-requires const jalaali = require('jalaali-js') as { toJalaali: (date: Date) => { jy: number; jm: number; jd: number }; }; import { NewAppointmentsTable } from '../components/dashboard/NewAppointmentsTable'; import DoctorAppointmentsPanel from '../components/dashboard/DoctorAppointmentsPanel'; import { usePermissions } from '../hooks/usePermissions'; import { STATUS_META } from '../components/ui/AppointmentStatusDropdown'; // ── Chart period (Jalali) ───────────────────────────────────────────────── /** * دوره‌ی نمودارهای داشبورد: نمودار بیماران روی یک ماه شمسی و نمودار درآمد روی یک * سال شمسی. پیش‌فرض = ماه/سال جاری؛ تغییر سلکتور باعث fetch مجدد می‌شود. */ function useJalaliChartPeriod() { const now = useMemo(() => jalaali.toJalaali(new Date()), []); const [patientsMonth, setPatientsMonth] = useState(now.jm); const [revenueYear, setRevenueYear] = useState(now.jy); return { currentJalaliYear: now.jy, currentJalaliMonth: now.jm, patientsYear: now.jy, patientsMonth, setPatientsMonth, revenueYear, setRevenueYear, /** query string پارامترهای دوره‌ی نمودار */ query: `patients_year=${now.jy}&patients_month=${patientsMonth}&revenue_year=${revenueYear}`, }; } // ── Shared Status Maps ──────────────────────────────────────────────────── // Derived from the canonical STATUS_META rather than kept as a second copy — // the local map had drifted off the backend's Appointment::STATUS_* set, so // `confirmed` and `expired` rendered as raw English on the dashboard. const APPT_LABEL: Record = Object.fromEntries( Object.entries(STATUS_META).map(([k, v]) => [k, v.label]), ); const APPT_COLOR: Record = Object.fromEntries( Object.entries(STATUS_META).map(([k, v]) => [k, v.color]), ); 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', }; // ── Shared SVG Charts ───────────────────────────────────────────────────── 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)}
))}
); } function SvgVBars({ data, color = 'var(--primary)' }: { data: { label: string; value: number }[]; color?: string }) { if (!data.length) return
داده‌ای برای نمایش نیست
; const max = Math.max(...data.map(d => d.value), 1); return (
{data.map((d, i) => (
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`, }} />
{d.label}
))}
); } // ── Shared UI Pieces ────────────────────────────────────────────────────── function AvatarEl({ initials, hue = 222, size }: { initials: string; hue?: number; size?: 'sm' | 'lg' }) { const cls = 'avatar' + (size === 'sm' ? ' sm' : size === 'lg' ? ' lg' : ''); return (
{initials}
); } 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}
))}
)}
); } function KpiSkeleton() { return (
); } function LoadingSkeleton() { return (
{Array.from({ length: 4 }).map((_, i) => )}
); } // ── Appointments Table (shared) ──────────────────────────────────────────── interface ApptRow { uuid: string; patient_name: string | null; patient_mobile?: string; doctor_name?: string | null; service_name?: string | null; slot_start: number; slot_end?: number | null; status: string; } function TodayAppointmentsTable({ appts, loading }: { appts: ApptRow[]; loading: boolean }) { if (loading) return
; if (!appts.length) return

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

; return (
{appts.map((a, i) => ( ))}
بیمار ساعت وضعیت
{a.patient_name || a.patient_mobile || '—'} {new Date(a.slot_start * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })} {APPT_LABEL[a.status] ?? a.status}
); } // ── Admin Dashboard ─────────────────────────────────────────────────────── interface AdminStats { 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 AdminCharts { 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 }[]; payments: { uuid: string; amount: number; status: string; gateway: string; user_mobile: string; user_name: string | null; created_at: string }[]; 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('this_month'); const range = getRange(preset); const statsQ = useQuery({ queryKey: ['dashboard-stats'], queryFn: () => api.get>('/api/v1/admin/dashboard/stats'), staleTime: 60_000 }); const chartsQ = useQuery({ queryKey: ['dashboard-charts', preset], queryFn: () => api.get>(`/api/v1/admin/dashboard/charts?from=${range.from}&to=${range.to}`), 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; 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', })), [charts]); const hbarsData = useMemo(() => (charts?.top_specialties ?? []).map(s => ({ label: s.name, value: s.count })), [charts]); 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)' }, // formatRial already suffixes « تومان» — a 'تومان' hint here renders it twice. { 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)' }, ]; 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)' }, ]; 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]); 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 (

داشبورد مدیریت

{today} · نمای کلی عملکرد مجموعه
{statsQ.isLoading ? Array.from({ length: 6 }).map((_, i) => ) : kpiCards.map(c => (
{c.label}
{c.value}
{c.hint}
)) }

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

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

{chartMode === 'appts' ? 'نوبت‌ها' : 'درآمد'} — {preset === 'this_week' ? 'این هفته' : preset === 'this_month' ? 'این ماه' : '۳ ماه اخیر'}

{chartsQ.isLoading ? (
) : ( )}

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

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

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

) : ( )}

فروش اشتراک‌ها

{preset === 'this_week' ? 'این هفته' : preset === 'this_month' ? 'این ماه' : '۳ ماه اخیر'}
{chartsQ.isLoading ? (
) : !(charts?.subscription_sales_by_plan ?? []).length ? (

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

) : ( {(charts?.subscription_sales_by_plan ?? []).map((row) => ( ))}
پنل تعداد درآمد
{row.plan === 'basic' ? 'پایه' : row.plan === 'professional' ? 'حرفه‌ای' : row.plan} {formatNumber(row.count)} {formatRial(row.total_rials)}
)}

دسترسی سریع

{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}

}
))}
)}
); } // ── Clinic Dashboard ────────────────────────────────────────────────────── 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; 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 }[]; } function ClinicDashboard() { const preset: DatePreset = 'this_month'; const range = getRange(preset); const chartPeriod = useJalaliChartPeriod(); const q = useQuery({ queryKey: ['dashboard-clinic', preset, chartPeriod.query], queryFn: () => api.get>(`/api/v1/dashboard/clinic?from=${range.from}&to=${range.to}&${chartPeriod.query}`), staleTime: 60_000, }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const d = useMemo(() => (q.data?.data as any)?.data ?? q.data?.data, [q.data]); if (q.isLoading) return ; return (
({ label: x.label, value: x.count }))} incomeLine={(d?.charts?.revenue_by_month ?? []).map(x => ({ label: x.label, value: x.amount_rials }))} appointments={d?.today_appointments ?? []} loading={q.isFetching} formatNumber={formatNumber} formatRial={formatRial} patientsMonth={chartPeriod.patientsMonth} onPatientsMonthChange={chartPeriod.setPatientsMonth} revenueYear={chartPeriod.revenueYear} onRevenueYearChange={chartPeriod.setRevenueYear} currentJalaliYear={chartPeriod.currentJalaliYear} currentJalaliMonth={chartPeriod.currentJalaliMonth} />
); } // ── Doctor Clinic Invitations Card ─────────────────────────────────────── interface ClinicInvitation { uuid: string; invited_specialty: string | null; invited_at: number; expires_at: number; clinic: { uuid: string; name: string; logo: string | null }; } function DoctorClinicInvitationsCard() { const qc = useQueryClient(); const { data, isLoading } = useQuery({ queryKey: ['doctor-my-invitations'], queryFn: () => api.get<{ data: { data: ClinicInvitation[] } }>('/api/v1/doctor/invitations'), staleTime: 30_000, }); const respondMut = useMutation({ mutationFn: ({ uuid, action }: { uuid: string; action: 'accept' | 'reject' }) => api.post(`/api/v1/doctor/invitation/${uuid}/respond`, { action }), onSuccess: (_res, { action }) => { toast.success(action === 'accept' ? 'دعوتنامه پذیرفته شد' : 'دعوتنامه رد شد'); qc.invalidateQueries({ queryKey: ['doctor-my-invitations'] }); qc.invalidateQueries({ queryKey: ['dashboard-doctor'] }); }, onError: (err: Error) => toast.error(err.message), }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const invitations: ClinicInvitation[] = (data?.data as any) ?? []; if (!isLoading && invitations.length === 0) return null; return (

دعوتنامه‌های کلینیک

{invitations.length} در انتظار
{isLoading ? (
{[1, 2].map(i => (
))}
) : (
{invitations.map((inv) => (
{inv.clinic.logo ? ( ) : (
{(inv.clinic.name ?? '?')[0]}
)}
{inv.clinic.name} {inv.invited_specialty && {inv.invited_specialty}}
))}
)}
); } // ── Doctor Dashboard ────────────────────────────────────────────────────── interface DashboardCharts { /** ۷ روز اخیر — فقط برای کارت‌های آماری */ revenue_by_day: { label: string; amount_rials: number }[]; /** ۱۲ ماه سال شمسی انتخاب‌شده — نمودار «میزان درآمد» */ revenue_by_month?: { 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 }[]; } function DoctorDashboard() { const preset: DatePreset = 'this_month'; const range = getRange(preset); const chartPeriod = useJalaliChartPeriod(); const q = useQuery({ queryKey: ['dashboard-doctor', preset, chartPeriod.query], queryFn: () => api.get>(`/api/v1/dashboard/doctor?from=${range.from}&to=${range.to}&${chartPeriod.query}`), staleTime: 60_000, }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const d = useMemo(() => (q.data?.data as any)?.data ?? q.data?.data, [q.data]); if (q.isLoading) return ; return (
({ label: x.label, value: x.count }))} incomeLine={(d?.charts?.revenue_by_month ?? []).map(x => ({ label: x.label, value: x.amount_rials }))} appointments={d?.today_appointments ?? []} appointmentsSlot={} loading={q.isFetching} formatNumber={formatNumber} formatRial={formatRial} patientsMonth={chartPeriod.patientsMonth} onPatientsMonthChange={chartPeriod.setPatientsMonth} revenueYear={chartPeriod.revenueYear} onRevenueYearChange={chartPeriod.setRevenueYear} currentJalaliYear={chartPeriod.currentJalaliYear} currentJalaliMonth={chartPeriod.currentJalaliMonth} />

کلینیک‌های من

{!d?.clinics.length ? (

عضو کلینیکی نیستید

) : (
{d.clinics.map((c, i) => (
{c.name}
فعال
))}
)}
); } // ── Secretary Dashboard ─────────────────────────────────────────────────── interface SecretaryDashboardData { doctor: { uuid: string; name: string; degree: string | null }; permissions: Record; stats: { today_appointments: number; tomorrow_appointments: number }; today_appointments: ApptRow[]; } function SecretaryDashboard() { const q = useQuery({ queryKey: ['dashboard-secretary'], queryFn: () => api.get>('/api/v1/dashboard/secretary'), staleTime: 60_000, }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const d = useMemo(() => (q.data?.data as any)?.data ?? q.data?.data, [q.data]); const today = new Date().toLocaleDateString('fa-IR', { year: 'numeric', month: 'long', day: 'numeric' }); if (q.isLoading) return ; const kpiCards = [ { label: 'نوبت‌های امروز', value: formatNumber(d?.stats.today_appointments ?? 0), icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' }, { label: 'نوبت‌های فردا', value: formatNumber(d?.stats.tomorrow_appointments ?? 0), icon: ClockIcon, color: 'var(--info)', bg: 'var(--info-bg)' }, ]; // eslint-disable-next-line @typescript-eslint/no-explicit-any const canViewAppts = (d?.permissions as any)?.resources?.appointments?.view ?? false; return (

داشبورد منشی

{today} · منشی دکتر {d?.doctor.name ?? ''}
دکتر {d?.doctor.name ?? '—'}
{d?.doctor.degree &&
{d.doctor.degree}
}
{canViewAppts ? 'دسترسی نوبت‌ها: فعال' : 'دسترسی نوبت‌ها: غیرفعال'}
{kpiCards.map(c => (
{c.label}
{c.value}
))}
{canViewAppts && (

نوبت‌های امروز

همه نوبت‌ها
)} {!canViewAppts && (

دسترسی مشاهده نوبت‌ها برای این منشی فعال نیست.

)}
); } interface RepSummary { appointments: { today: number; week: number; month: number; total: number }; income: { today: number; week: number; month: number; total: number; settlable_rials: number; settled_rials: number; pending_rials: number; }; } interface RepDoctorPerf { uuid: string; name: string; appointments: { today: number; week: number; month: number; total: number }; representation_income_rials: number; subscription_status: 'active' | 'expired' | 'none'; } function RepresentationDashboard() { const meQ = useQuery({ queryKey: ['representation-me'], queryFn: () => api.get>('/api/v1/representation/me'), staleTime: 300_000, }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const rep = useMemo(() => (meQ.data?.data as any)?.data ?? meQ.data?.data, [meQ.data]); const summaryQ = useQuery({ queryKey: ['representation-summary'], queryFn: () => api.get>('/api/v1/representation/dashboard/summary'), staleTime: 120_000, }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const summary = useMemo(() => (summaryQ.data?.data as any)?.data ?? summaryQ.data?.data, [summaryQ.data]); const perfQ = useQuery({ queryKey: ['representation-doctors-performance'], queryFn: () => api.get>('/api/v1/representation/doctors/performance?limit=100'), staleTime: 120_000, }); const doctors: RepDoctorPerf[] = perfQ.data?.data ?? []; if (meQ.isLoading) return ; const today = new Date().toLocaleDateString('fa-IR', { year: 'numeric', month: 'long', day: 'numeric' }); const a = summary?.appointments; const inc = summary?.income; const apptCards = [ { label: 'نوبت‌های امروز', value: formatNumber(a?.today ?? 0), color: 'var(--warning)', bg: 'var(--warning-bg)' }, { label: 'این هفته', value: formatNumber(a?.week ?? 0), color: 'var(--info)', bg: 'var(--info-bg)' }, { label: 'این ماه', value: formatNumber(a?.month ?? 0), color: 'var(--success)', bg: 'var(--success-bg)' }, { label: 'کل نوبت‌ها', value: formatNumber(a?.total ?? 0), color: 'var(--violet)', bg: 'var(--violet-bg)' }, ]; const incomeCards = [ { label: 'درآمد امروز', value: formatRial(inc?.today ?? 0), color: 'var(--warning)' }, { label: 'درآمد این هفته', value: formatRial(inc?.week ?? 0), color: 'var(--info)' }, { label: 'درآمد این ماه', value: formatRial(inc?.month ?? 0), color: 'var(--success)' }, { label: 'درآمد کل', value: formatRial(inc?.total ?? 0), color: 'var(--violet)' }, { label: 'قابل تسویه', value: formatRial(inc?.settlable_rials ?? 0),color: 'var(--primary)' }, { label: 'تسویه‌شده', value: formatRial(inc?.settled_rials ?? 0), color: 'var(--text-2)' }, { label: 'در انتظار تسویه', value: formatRial(inc?.pending_rials ?? 0), color: 'var(--text-3)' }, ]; const subLabel: Record = { active: 'فعال', expired: 'منقضی', none: 'بدون اشتراک' }; return (

داشبورد نماینده

{today} · {rep?.full_name ?? ''}
{apptCards.map(c => (
{c.label}
{c.value}
))}

درآمد نماینده

{incomeCards.map(c => (
{c.label}
{c.value}
))}

عملکرد پزشکان

{doctors.length === 0 && ( )} {doctors.map(d => ( ))}
پزشکامروزهفتهماهکل درآمد نمایندهاشتراک
پزشکی یافت نشد
{d.name} {formatNumber(d.appointments.today)} {formatNumber(d.appointments.week)} {formatNumber(d.appointments.month)} {formatNumber(d.appointments.total)} {formatRial(d.representation_income_rials)} {subLabel[d.subscription_status] ?? d.subscription_status}

دسترسی سریع

پزشکان من کلینیک‌ها نوبت‌ها تسویه حساب گزارش مالی
); } // ── Invited-Doctor Dashboard (doctor working inside a clinic) ───────────── /** * پزشکی که با دعوت وارد یک کلینیک شده، در محیط آن کلینیک فقط کار خودش را می‌بیند. * ارقام مالی اینجا نمایش داده نمی‌شوند و backend هم آن‌ها را برنمی‌گرداند؛ این * کامپوننت لایهٔ دوم است، نه تنها محافظ. */ function InvitedDoctorDashboard() { const dbUuid = useAuthStore(s => s.dbUuid); const { can } = usePermissions(); const q = useQuery({ queryKey: ['dashboard-doctor-clinic', dbUuid], queryFn: () => api.get>( `/api/v1/dashboard/doctor${dbUuid ? `?clinic_uuid=${encodeURIComponent(dbUuid)}` : ''}` ), staleTime: 60_000, }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const d = useMemo(() => (q.data?.data as any)?.data ?? q.data?.data, [q.data]); if (q.isLoading) return ; const tiles: Array<{ label: string; value: string }> = [ { label: 'نوبت‌های امروز', value: formatNumber(d?.stats.today_appointments ?? 0) }, { label: 'نوبت‌های فردا', value: formatNumber(d?.stats.tomorrow_appointments ?? 0) }, { label: 'نوبت‌های این ماه', value: formatNumber(d?.stats.this_month_appointments ?? 0) }, ]; return (
{tiles.map(t => (
{t.value}

{t.label}

))}
{can('appointments', 'view') && (

لیست نوبت‌های جدید

نوبت‌ها
)}
); } // ── Main Dispatcher ─────────────────────────────────────────────────────── export default function DashboardPage() { const primaryRole = useAuthStore(s => s.primaryRole); const scope = useAuthStore(s => s.context?.scope ?? null); if (!primaryRole) return ; if (primaryRole === 'admin') return ; if (primaryRole === 'clinic') return ; // پزشکِ دعوت‌شده داخل کلینیک، داشبورد شخصی‌اش را نمی‌بیند: نه درآمد، نه کیف پول، // نه فهرست کلینیک‌ها — فقط نوبت‌های خودش در همان کلینیک. if (primaryRole === 'doctor' && scope === 'clinic') return ; if (primaryRole === 'doctor') return ; if (primaryRole === 'secretary') return ; if (primaryRole === 'representation') return ; return ; }