- Removed the "دکتر" prefix from doctor names in various components and API responses to ensure consistency and clarity. - Updated the AppointmentDetailPage, CommentsPage, DashboardPage, RatingsPage, SecretariesPage, and other relevant files to reflect the changes in doctor name formatting. - Adjusted API documentation to align with the new naming conventions. - Implemented validation to prevent the creation of clinics without a name and restricted users to a single clinic. - Added tests to verify that doctor names are stored without titles and that clinic creation adheres to the new validation rules.
1181 lines
58 KiB
TypeScript
1181 lines
58 KiB
TypeScript
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<string, string> = Object.fromEntries(
|
|
Object.entries(STATUS_META).map(([k, v]) => [k, v.label]),
|
|
);
|
|
const APPT_COLOR: Record<string, string> = Object.fromEntries(
|
|
Object.entries(STATUS_META).map(([k, v]) => [k, v.color]),
|
|
);
|
|
const APPT_CLS: Record<string, string> = {
|
|
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<string, string> = {
|
|
pending: 'در انتظار', received: 'موفق', canceled: 'لغو شده', refund: 'استرداد',
|
|
};
|
|
const PAY_COLOR: Record<string, string> = {
|
|
pending: '#f59e0b', received: '#22c55e', canceled: '#ef4444', refund: '#3b82f6',
|
|
};
|
|
const PAY_CLS: Record<string, string> = {
|
|
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 (
|
|
<svg viewBox={`0 0 ${w} ${h}`} width="100%" height={h} preserveAspectRatio="none" style={{ overflow: 'visible' }}>
|
|
<defs>
|
|
<linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor={color} stopOpacity={0.22} />
|
|
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
{[0.25, 0.5, 0.75, 1].map((g, i) => (
|
|
<line key={i} x1={pad} x2={w - pad} y1={(h - pad * 2) * g} y2={(h - pad * 2) * g}
|
|
stroke="var(--border)" strokeDasharray="4 6" strokeWidth={1} />
|
|
))}
|
|
<path d={area} fill={`url(#${gid})`} className="ln-area" />
|
|
<path d={d} fill="none" stroke={color} strokeWidth={2.6} strokeLinecap="round" className="ln-path" />
|
|
{pts.map((p, i) => i % 4 === 0 && (
|
|
<circle key={i} cx={p[0]} cy={p[1]} r={3.2} fill="var(--surface)" stroke={color} strokeWidth={2.2} />
|
|
))}
|
|
<style>{`.ln-path{stroke-dasharray:2400;stroke-dashoffset:2400;animation:draw 1.4s cubic-bezier(.22,.61,.36,1) forwards}.ln-area{opacity:0;animation:fadein .9s .5s forwards}@keyframes draw{to{stroke-dashoffset:0}}`}</style>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<svg viewBox={`0 0 ${size} ${size}`} width={size} height={size}>
|
|
<g transform={`rotate(-90 ${size / 2} ${size / 2})`}>
|
|
{data.map((d, i) => {
|
|
const frac = d.value / total;
|
|
const seg = (
|
|
<circle key={i} cx={size / 2} cy={size / 2} r={r} fill="none"
|
|
stroke={d.color} strokeWidth={20} strokeLinecap="round"
|
|
strokeDasharray={`${Math.max(frac * c - 4, 0)} ${c}`}
|
|
strokeDashoffset={-off * c} />
|
|
);
|
|
off += frac;
|
|
return seg;
|
|
})}
|
|
</g>
|
|
<text x="50%" y="46%" textAnchor="middle" fontSize="13" fill="var(--text-3)" fontFamily="Vazirmatn">مجموع</text>
|
|
<text x="50%" y="60%" textAnchor="middle" fontSize="22" fontWeight="800" fill="var(--text)" fontFamily="Vazirmatn">{formatNumber(total)}</text>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function SvgHBars({ data }: { data: { label: string; value: number }[] }) {
|
|
const max = Math.max(...data.map(d => d.value)) || 1;
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
{data.map((d, i) => (
|
|
<div key={i} style={{ display: 'grid', gridTemplateColumns: '160px 1fr 52px', alignItems: 'center', gap: 12 }}>
|
|
<div style={{ fontSize: 12.5, color: 'var(--text-2)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{d.label}</div>
|
|
<div className="bar" style={{ height: 12 }}>
|
|
<i style={{ width: `${(d.value / max) * 100}%`, animation: `growbar 1s ${i * 0.07}s cubic-bezier(.22,.61,.36,1) both` }} />
|
|
</div>
|
|
<div style={{ fontSize: 12.5, fontWeight: 700 }}>{formatNumber(d.value)}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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' }) {
|
|
const cls = 'avatar' + (size === 'sm' ? ' sm' : size === 'lg' ? ' lg' : '');
|
|
return (
|
|
<div className={cls} style={{ background: `linear-gradient(145deg, oklch(0.62 0.15 ${hue}), oklch(0.48 0.16 ${hue}))` }}>
|
|
{initials}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="card card-pad">
|
|
<div className="card-title-row">
|
|
<h3 style={{ fontSize: 16 }}>{title}</h3>
|
|
<Link to={to} className="link">همه</Link>
|
|
</div>
|
|
{loading ? (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<div key={i} style={{ display: 'flex', gap: 11, alignItems: 'center' }}>
|
|
<div className="skeleton" style={{ width: 32, height: 32, borderRadius: '50%', flexShrink: 0 }} />
|
|
<div style={{ flex: 1 }}>
|
|
<div className="skeleton" style={{ height: 13, borderRadius: 5, width: '70%', marginBottom: 5 }} />
|
|
<div className="skeleton" style={{ height: 11, borderRadius: 5, width: '50%' }} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : !rows.length ? (
|
|
<p className="muted" style={{ textAlign: 'center', padding: '32px 0', fontSize: 13.5 }}>موردی ثبت نشده</p>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
|
{rows.map((r, i) => (
|
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 11, padding: '10px 0', borderBottom: i < rows.length - 1 ? '1px solid var(--border)' : 'none' }}>
|
|
<AvatarEl initials={r.initials} hue={r.hue} size="sm" />
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<b style={{ fontSize: 13.5, display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</b>
|
|
<span className="muted" style={{ fontSize: 11.5 }}>{r.sub}</span>
|
|
</div>
|
|
<div style={{ textAlign: 'start', flexShrink: 0 }}>
|
|
<span className={`badge ${r.badgeCls}`}><span className="bdot" />{r.badgeLabel}</span>
|
|
<div className="muted" style={{ fontSize: 10.5, marginTop: 3 }}>{r.meta}</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function KpiSkeleton() {
|
|
return (
|
|
<div className="stat">
|
|
<div className="skeleton" style={{ width: 40, height: 40, borderRadius: 12, marginBottom: 14 }} />
|
|
<div className="skeleton" style={{ height: 13, borderRadius: 6, width: '60%', marginBottom: 6 }} />
|
|
<div className="skeleton" style={{ height: 26, borderRadius: 6, width: '45%', marginBottom: 4 }} />
|
|
<div className="skeleton" style={{ height: 12, borderRadius: 6, width: '75%' }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LoadingSkeleton() {
|
|
return (
|
|
<div className="fade-in">
|
|
<div className="stat-grid">
|
|
{Array.from({ length: 4 }).map((_, i) => <KpiSkeleton key={i} />)}
|
|
</div>
|
|
<div className="skeleton" style={{ height: 300, borderRadius: 'var(--r)', marginTop: 'var(--gap)' }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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 <div className="skeleton" style={{ height: 180, borderRadius: 'var(--r)' }} />;
|
|
if (!appts.length) return <p className="muted" style={{ textAlign: 'center', padding: '32px 0', fontSize: 13.5 }}>نوبتی برای امروز ثبت نشده</p>;
|
|
return (
|
|
<div style={{ overflowX: 'auto' }}>
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13.5 }}>
|
|
<thead>
|
|
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
|
<th style={{ textAlign: 'right', padding: '8px 12px', color: 'var(--text-3)', fontWeight: 500 }}>بیمار</th>
|
|
<th style={{ textAlign: 'right', padding: '8px 12px', color: 'var(--text-3)', fontWeight: 500 }}>ساعت</th>
|
|
<th style={{ textAlign: 'right', padding: '8px 12px', color: 'var(--text-3)', fontWeight: 500 }}>وضعیت</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{appts.map((a, i) => (
|
|
<tr key={a.uuid} style={{ borderBottom: i < appts.length - 1 ? '1px solid var(--border)' : 'none' }}>
|
|
<td style={{ padding: '10px 12px' }}>{a.patient_name || a.patient_mobile || '—'}</td>
|
|
<td style={{ padding: '10px 12px', direction: 'ltr', textAlign: 'left' }}>
|
|
{new Date(a.slot_start * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })}
|
|
</td>
|
|
<td style={{ padding: '10px 12px' }}>
|
|
<span className={`badge ${APPT_CLS[a.status] ?? 'gray'}`}>
|
|
<span className="bdot" />{APPT_LABEL[a.status] ?? a.status}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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<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', 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
|
|
const stats = useMemo<AdminStats | undefined>(() => (statsQ.data?.data as any)?.data ?? statsQ.data?.data, [statsQ.data]);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const charts = useMemo<AdminCharts | undefined>(() => (chartsQ.data?.data as any)?.data ?? chartsQ.data?.data, [chartsQ.data]);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const recent = useMemo<AdminRecent | undefined>(() => (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<MiniRow[]>(() =>
|
|
(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<MiniRow[]>(() =>
|
|
(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<MiniRow[]>(() =>
|
|
(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 (
|
|
<div className="fade-in">
|
|
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
|
<div>
|
|
<h1 className="section-title">داشبورد مدیریت</h1>
|
|
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · نمای کلی عملکرد مجموعه</div>
|
|
</div>
|
|
<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 }} />
|
|
بهروزرسانی
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="stat-grid">
|
|
{statsQ.isLoading
|
|
? Array.from({ length: 6 }).map((_, i) => <KpiSkeleton key={i} />)
|
|
: kpiCards.map(c => (
|
|
<div key={c.label} className="stat">
|
|
<div className="ico" style={{ background: c.bg, color: c.color }}>
|
|
<c.icon style={{ width: 21, height: 21 }} />
|
|
</div>
|
|
<div className="lbl">{c.label}</div>
|
|
<div className="val">{c.value}</div>
|
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'space-between' }}>
|
|
<span className="hint">{c.hint}</span>
|
|
</div>
|
|
</div>
|
|
))
|
|
}
|
|
</div>
|
|
|
|
<div className="dash-main">
|
|
<div className="card card-pad">
|
|
<div className="card-title-row"><h3 style={{ fontSize: 16 }}>وضعیت نوبتها</h3></div>
|
|
{chartsQ.isLoading ? (
|
|
<>
|
|
<div className="skeleton" style={{ width: 190, height: 190, borderRadius: '50%', margin: '6px auto 18px' }} />
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 11 }}>
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<div key={i} className="skeleton" style={{ height: 14, borderRadius: 5 }} />
|
|
))}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div style={{ display: 'flex', justifyContent: 'center', margin: '6px 0 18px' }}>
|
|
<SvgDonut data={donutData} />
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 11 }}>
|
|
{donutData.map((d, i) => (
|
|
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: 13 }}>
|
|
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<span style={{ width: 9, height: 9, borderRadius: 3, background: d.color, flexShrink: 0, display: 'inline-block' }} />
|
|
<span style={{ color: 'var(--text-2)' }}>{d.label}</span>
|
|
</span>
|
|
<b>{formatNumber(d.value)}</b>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="card card-pad">
|
|
<div className="card-title-row">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
{chartsQ.isLoading ? (
|
|
<div className="skeleton" style={{ height: 236, borderRadius: 'var(--r)' }} />
|
|
) : (
|
|
<SvgLineChart key={chartMode}
|
|
data={chartMode === 'appts' ? apptSeries : revSeries}
|
|
color={chartMode === 'appts' ? 'var(--primary)' : 'var(--success)'}
|
|
h={236} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
</div>
|
|
|
|
<div className="grid-2" style={{ marginBottom: 'var(--gap)' }}>
|
|
<div className="card card-pad">
|
|
<div className="card-title-row"><h3 style={{ fontSize: 16 }}>دسترسی سریع</h3></div>
|
|
<div className="quick-grid">
|
|
{quickActions.map(a => (
|
|
<Link key={a.to} to={a.to} className="quick">
|
|
<span className="qi" style={{ background: a.bg, color: a.color }}>
|
|
<a.icon style={{ width: 22, height: 22 }} />
|
|
</span>
|
|
<b>{a.label}</b>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="card card-pad">
|
|
<div className="card-title-row"><h3 style={{ fontSize: 16 }}>آخرین رویدادها</h3></div>
|
|
{recentQ.isLoading ? (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<div key={i} style={{ display: 'flex', gap: 13, alignItems: 'flex-start' }}>
|
|
<div className="skeleton" style={{ width: 9, height: 9, borderRadius: '50%', marginTop: 7, flexShrink: 0 }} />
|
|
<div style={{ flex: 1 }}>
|
|
<div className="skeleton" style={{ height: 13, borderRadius: 5, width: '70%', marginBottom: 5 }} />
|
|
<div className="skeleton" style={{ height: 11, borderRadius: 5, width: '50%' }} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : !timelineEvents.length ? (
|
|
<p className="muted" style={{ textAlign: 'center', padding: '32px 0', fontSize: 13.5 }}>رویدادی ثبت نشده</p>
|
|
) : (
|
|
<div>
|
|
{timelineEvents.map((e, i) => (
|
|
<div className="timeline-item" key={i}>
|
|
<span className="tl-dot" style={{ background: e.color }}></span>
|
|
<div className="tl-body">
|
|
<b>{e.title}</b>
|
|
{e.sub && <p>{e.sub}</p>}
|
|
<time>{formatDateTime(e.time)}</time>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="dash-3">
|
|
<MiniList title="آخرین نوبتها" to="/admin/appointments" rows={apptRows} loading={recentQ.isLoading} />
|
|
<MiniList title="آخرین پرداختها" to="/admin/payments" rows={payRows} loading={recentQ.isLoading} />
|
|
<MiniList title="کاربران جدید" to="/admin/users" rows={userRows} loading={recentQ.isLoading} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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<ApiResponse<ClinicDashboardData>>(`/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<ClinicDashboardData | undefined>(() => (q.data?.data as any)?.data ?? q.data?.data, [q.data]);
|
|
|
|
if (q.isLoading) return <LoadingSkeleton />;
|
|
|
|
return (
|
|
<div className="fade-in">
|
|
<TauriDashboardView
|
|
stats={{
|
|
totalPatients: d?.stats.total_patients ?? 0,
|
|
totalPaymentsRials: d?.stats.revenue_period_rials ?? 0,
|
|
todayPaymentsRials: d?.stats.today_payments_rials ?? 0,
|
|
todayAppointments: d?.stats.today_appointments ?? 0,
|
|
}}
|
|
patientBars={(d?.charts?.appointments_by_day ?? []).map(x => ({ 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}
|
|
/>
|
|
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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 (
|
|
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
|
<div className="card-title-row">
|
|
<h3 style={{ fontSize: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<BellAlertIcon style={{ width: 18, height: 18, color: 'var(--danger)' }} />
|
|
دعوتنامههای کلینیک
|
|
</h3>
|
|
<span className="badge red"><span className="bdot" />{invitations.length} در انتظار</span>
|
|
</div>
|
|
{isLoading ? (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
{[1, 2].map(i => (
|
|
<div key={i} className="skeleton" style={{ height: 60, borderRadius: 8 }} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
{invitations.map((inv) => (
|
|
<div key={inv.uuid} style={{
|
|
display: 'flex', alignItems: 'center', gap: 12,
|
|
padding: '12px 14px', borderRadius: 8,
|
|
background: 'var(--surface-2, var(--primary-soft))',
|
|
border: '1px solid var(--border)',
|
|
}}>
|
|
{inv.clinic.logo ? (
|
|
<img src={inv.clinic.logo} alt="" style={{ width: 40, height: 40, borderRadius: 8, objectFit: 'cover', flexShrink: 0 }} />
|
|
) : (
|
|
<div style={{ width: 40, height: 40, borderRadius: 8, background: 'var(--primary)', color: 'var(--on-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700, fontSize: 16, flexShrink: 0 }}>
|
|
{(inv.clinic.name ?? '?')[0]}
|
|
</div>
|
|
)}
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<b style={{ fontSize: 13.5, display: 'block' }}>{inv.clinic.name}</b>
|
|
{inv.invited_specialty && <span className="muted" style={{ fontSize: 12 }}>{inv.invited_specialty}</span>}
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
|
<button
|
|
className="btn primary sm"
|
|
style={{ padding: '5px 12px', fontSize: 12 }}
|
|
disabled={respondMut.isPending}
|
|
onClick={() => respondMut.mutate({ uuid: inv.uuid, action: 'accept' })}
|
|
>
|
|
<CheckIcon style={{ width: 14, height: 14 }} />
|
|
پذیرفتن
|
|
</button>
|
|
<button
|
|
className="btn ghost sm"
|
|
style={{ padding: '5px 12px', fontSize: 12 }}
|
|
disabled={respondMut.isPending}
|
|
onClick={() => respondMut.mutate({ uuid: inv.uuid, action: 'reject' })}
|
|
>
|
|
<XMarkIcon style={{ width: 14, height: 14 }} />
|
|
رد کردن
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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<ApiResponse<DoctorDashboardData>>(`/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<DoctorDashboardData | undefined>(() => (q.data?.data as any)?.data ?? q.data?.data, [q.data]);
|
|
|
|
if (q.isLoading) return <LoadingSkeleton />;
|
|
|
|
return (
|
|
<div className="fade-in">
|
|
<TauriDashboardView
|
|
stats={{
|
|
totalPatients: d?.stats.total_patients ?? 0,
|
|
totalPaymentsRials: d?.stats.revenue_period_rials ?? 0,
|
|
todayPaymentsRials: d?.stats.today_payments_rials ?? 0,
|
|
todayAppointments: d?.stats.today_appointments ?? 0,
|
|
}}
|
|
patientBars={(d?.charts?.appointments_by_day ?? []).map(x => ({ 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={<DoctorAppointmentsPanel doctorUuid={d?.doctor?.uuid} />}
|
|
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}
|
|
/>
|
|
|
|
<div className="grid-2" style={{ marginTop: 'var(--gap)' }}>
|
|
<DoctorClinicInvitationsCard />
|
|
<div className="card card-pad">
|
|
<div className="card-title-row">
|
|
<h3 style={{ fontSize: 16 }}>کلینیکهای من</h3>
|
|
</div>
|
|
{!d?.clinics.length ? (
|
|
<p className="muted" style={{ textAlign: 'center', padding: '32px 0', fontSize: 13.5 }}>عضو کلینیکی نیستید</p>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
|
{d.clinics.map((c, i) => (
|
|
<div key={c.uuid} style={{ display: 'flex', alignItems: 'center', gap: 11, padding: '10px 0', borderBottom: i < d.clinics.length - 1 ? '1px solid var(--border)' : 'none' }}>
|
|
<AvatarEl initials={c.name.slice(0, 1)} hue={205} size="sm" />
|
|
<div style={{ flex: 1 }}>
|
|
<b style={{ fontSize: 13.5 }}>{c.name}</b>
|
|
</div>
|
|
<span className="badge green"><span className="bdot" />فعال</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Secretary Dashboard ───────────────────────────────────────────────────
|
|
|
|
interface SecretaryDashboardData {
|
|
doctor: { uuid: string; name: string; degree: string | null };
|
|
permissions: Record<string, unknown>;
|
|
stats: { today_appointments: number; tomorrow_appointments: number };
|
|
today_appointments: ApptRow[];
|
|
}
|
|
|
|
function SecretaryDashboard() {
|
|
const q = useQuery({
|
|
queryKey: ['dashboard-secretary'],
|
|
queryFn: () => api.get<ApiResponse<SecretaryDashboardData>>('/api/v1/dashboard/secretary'),
|
|
staleTime: 60_000,
|
|
});
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const d = useMemo<SecretaryDashboardData | undefined>(() => (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 <LoadingSkeleton />;
|
|
|
|
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 (
|
|
<div className="fade-in">
|
|
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
|
<div>
|
|
<h1 className="section-title">داشبورد منشی</h1>
|
|
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · منشی دکتر {d?.doctor.name ?? ''}</div>
|
|
</div>
|
|
<button className="btn ghost sm" onClick={() => q.refetch()}>
|
|
<ArrowPathIcon style={{ width: 14, height: 14 }} />
|
|
بهروزرسانی
|
|
</button>
|
|
</div>
|
|
|
|
<div className="card card-pad" style={{ marginBottom: 'var(--gap)', display: 'flex', alignItems: 'center', gap: 16 }}>
|
|
<AvatarEl initials={(d?.doctor.name ?? 'D').slice(0, 1)} hue={256} size="lg" />
|
|
<div>
|
|
<div style={{ fontWeight: 700, fontSize: 16 }}>دکتر {d?.doctor.name ?? '—'}</div>
|
|
{d?.doctor.degree && <div className="muted" style={{ fontSize: 13, marginTop: 3 }}>{d.doctor.degree}</div>}
|
|
</div>
|
|
<div style={{ marginRight: 'auto', display: 'flex', gap: 8 }}>
|
|
<span className={`badge ${canViewAppts ? 'green' : 'gray'}`}>
|
|
<span className="bdot" />{canViewAppts ? 'دسترسی نوبتها: فعال' : 'دسترسی نوبتها: غیرفعال'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="stat-grid">
|
|
{kpiCards.map(c => (
|
|
<div key={c.label} className="stat">
|
|
<div className="ico" style={{ background: c.bg, color: c.color }}>
|
|
<c.icon style={{ width: 21, height: 21 }} />
|
|
</div>
|
|
<div className="lbl">{c.label}</div>
|
|
<div className="val">{c.value}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{canViewAppts && (
|
|
<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>
|
|
)}
|
|
|
|
{!canViewAppts && (
|
|
<div className="card card-pad" style={{ marginTop: 'var(--gap)', textAlign: 'center', padding: '2rem' }}>
|
|
<UserIcon style={{ width: 40, height: 40, color: 'var(--text-3)', margin: '0 auto 1rem' }} />
|
|
<p className="muted" style={{ fontSize: 13.5 }}>دسترسی مشاهده نوبتها برای این منشی فعال نیست.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<ApiResponse<{ data: { uuid: string; full_name: string } }>>('/api/v1/representation/me'),
|
|
staleTime: 300_000,
|
|
});
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const rep = useMemo<any>(() => (meQ.data?.data as any)?.data ?? meQ.data?.data, [meQ.data]);
|
|
|
|
const summaryQ = useQuery({
|
|
queryKey: ['representation-summary'],
|
|
queryFn: () => api.get<ApiResponse<RepSummary>>('/api/v1/representation/dashboard/summary'),
|
|
staleTime: 120_000,
|
|
});
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const summary = useMemo<RepSummary | undefined>(() => (summaryQ.data?.data as any)?.data ?? summaryQ.data?.data, [summaryQ.data]);
|
|
|
|
const perfQ = useQuery({
|
|
queryKey: ['representation-doctors-performance'],
|
|
queryFn: () => api.get<ApiResponse<RepDoctorPerf[]>>('/api/v1/representation/doctors/performance?limit=100'),
|
|
staleTime: 120_000,
|
|
});
|
|
const doctors: RepDoctorPerf[] = perfQ.data?.data ?? [];
|
|
|
|
if (meQ.isLoading) return <LoadingSkeleton />;
|
|
|
|
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<string, string> = { active: 'فعال', expired: 'منقضی', none: 'بدون اشتراک' };
|
|
|
|
return (
|
|
<div className="fade-in">
|
|
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
|
<div>
|
|
<h1 className="section-title">داشبورد نماینده</h1>
|
|
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · {rep?.full_name ?? ''}</div>
|
|
</div>
|
|
<button className="btn ghost sm" onClick={() => { summaryQ.refetch(); perfQ.refetch(); }}>
|
|
<ArrowPathIcon style={{ width: 14, height: 14 }} />
|
|
بهروزرسانی
|
|
</button>
|
|
</div>
|
|
|
|
<div className="stat-grid">
|
|
{apptCards.map(c => (
|
|
<div key={c.label} className="stat">
|
|
<div className="ico" style={{ background: c.bg, color: c.color }}>
|
|
<CalendarDaysIcon style={{ width: 21, height: 21 }} />
|
|
</div>
|
|
<div className="lbl">{c.label}</div>
|
|
<div className="val">{c.value}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
|
<div className="card-title-row" style={{ marginBottom: 12 }}>
|
|
<h3 style={{ fontSize: 16 }}>درآمد نماینده</h3>
|
|
</div>
|
|
<div className="stat-grid">
|
|
{incomeCards.map(c => (
|
|
<div key={c.label} className="stat" style={{ background: 'var(--surface-3)' }}>
|
|
<div className="lbl">{c.label}</div>
|
|
<div className="val" style={{ color: c.color, fontSize: 15 }}>{c.value}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
|
<div className="card-title-row" style={{ marginBottom: 12 }}>
|
|
<h3 style={{ fontSize: 16 }}>عملکرد پزشکان</h3>
|
|
</div>
|
|
<div style={{ overflowX: 'auto' }}>
|
|
<table className="tbl" style={{ width: '100%' }}>
|
|
<thead>
|
|
<tr>
|
|
<th>پزشک</th><th>امروز</th><th>هفته</th><th>ماه</th><th>کل</th>
|
|
<th>درآمد نماینده</th><th>اشتراک</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{doctors.length === 0 && (
|
|
<tr><td colSpan={7} className="muted" style={{ textAlign: 'center', padding: 16 }}>پزشکی یافت نشد</td></tr>
|
|
)}
|
|
{doctors.map(d => (
|
|
<tr key={d.uuid}>
|
|
<td>{d.name}</td>
|
|
<td>{formatNumber(d.appointments.today)}</td>
|
|
<td>{formatNumber(d.appointments.week)}</td>
|
|
<td>{formatNumber(d.appointments.month)}</td>
|
|
<td>{formatNumber(d.appointments.total)}</td>
|
|
<td>{formatRial(d.representation_income_rials)}</td>
|
|
<td>
|
|
<span className={`badge ${d.subscription_status === 'active' ? 'green' : 'gray'}`}>
|
|
{subLabel[d.subscription_status] ?? d.subscription_status}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
|
<div className="card-title-row">
|
|
<h3 style={{ fontSize: 16 }}>دسترسی سریع</h3>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
|
<Link to="/admin/doctors" className="btn sm">پزشکان من</Link>
|
|
<Link to="/admin/clinics" className="btn sm">کلینیکها</Link>
|
|
<Link to="/admin/appointments" className="btn sm">نوبتها</Link>
|
|
<Link to="/admin/representation-settlement" className="btn sm">تسویه حساب</Link>
|
|
<Link to="/admin/representation-finance" className="btn sm">گزارش مالی</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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<ApiResponse<DoctorDashboardData>>(
|
|
`/api/v1/dashboard/doctor${dbUuid ? `?clinic_uuid=${encodeURIComponent(dbUuid)}` : ''}`
|
|
),
|
|
staleTime: 60_000,
|
|
});
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const d = useMemo<DoctorDashboardData | undefined>(() => (q.data?.data as any)?.data ?? q.data?.data, [q.data]);
|
|
|
|
if (q.isLoading) return <LoadingSkeleton />;
|
|
|
|
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 (
|
|
<div className="fade-in">
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-[var(--gap)]">
|
|
{tiles.map(t => (
|
|
<div key={t.label} className="card card-pad">
|
|
<b style={{ fontSize: 22 }}>{t.value}</b>
|
|
<p className="muted" style={{ fontSize: 13.5, marginTop: 6 }}>{t.label}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{can('appointments', 'view') && (
|
|
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
|
<div className="card-title-row">
|
|
<h3 style={{ fontSize: 16 }}>لیست نوبتهای جدید</h3>
|
|
<Link to="/admin/appointments" className="muted" style={{ fontSize: 13 }}>نوبتها</Link>
|
|
</div>
|
|
<DoctorAppointmentsPanel doctorUuid={d?.doctor?.uuid} clinicUuid={dbUuid} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Main Dispatcher ───────────────────────────────────────────────────────
|
|
|
|
export default function DashboardPage() {
|
|
const primaryRole = useAuthStore(s => s.primaryRole);
|
|
const scope = useAuthStore(s => s.context?.scope ?? null);
|
|
|
|
if (!primaryRole) return <LoadingSkeleton />;
|
|
if (primaryRole === 'admin') return <AdminDashboard />;
|
|
if (primaryRole === 'clinic') return <ClinicDashboard />;
|
|
// پزشکِ دعوتشده داخل کلینیک، داشبورد شخصیاش را نمیبیند: نه درآمد، نه کیف پول،
|
|
// نه فهرست کلینیکها — فقط نوبتهای خودش در همان کلینیک.
|
|
if (primaryRole === 'doctor' && scope === 'clinic') return <InvitedDoctorDashboard />;
|
|
if (primaryRole === 'doctor') return <DoctorDashboard />;
|
|
if (primaryRole === 'secretary') return <SecretaryDashboard />;
|
|
if (primaryRole === 'representation') return <RepresentationDashboard />;
|
|
|
|
return <AdminDashboard />;
|
|
}
|