Skipping an area recorded only that it was skipped. Why it was skipped is clinical history — the next session needs to read it — so `skip` now takes an optional note, the same way completing an area already did, and the panel asks for it inline instead of firing on the first click. An operator finds out mid-laser that they closed the wrong area, and until now had to carry that mistake to the end of the session. `reopen` puts a settled area — completed or skipped — back to in_progress and clears finished_at, keeping the recorded parameters and note so they can be seen and overwritten. It stops at the same boundary everything else in this domain stops at: once the session is finished the record is history, and reopening it is 409. Also drops /admin/my-services. The staff role has one job — today's sessions — and the dashboard already lists the services they may perform, so the page was a second place to read the same list. Route, page, sidebar entry and the two links to it are gone; the services stat card is no longer a link because it no longer has a destination. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1372 lines
67 KiB
TypeScript
1372 lines
67 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, displayDoctorName } 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';
|
|
import StatusBadge from '../components/ui/StatusBadge';
|
|
|
|
// ── 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: 'var(--warning)', received: 'var(--success)', canceled: 'var(--danger)', refund: 'var(--info)',
|
|
};
|
|
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] ?? 'var(--text-3)',
|
|
})), [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] ?? 'var(--text-3)' });
|
|
});
|
|
(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] ?? 'var(--text-3)' });
|
|
});
|
|
(recent.users ?? []).slice(0, 3).forEach(u => {
|
|
evs.push({ title: 'ثبتنام کاربر جدید', sub: u.name || u.mobile, time: u.created_at, color: 'var(--violet)' });
|
|
});
|
|
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 style={{ marginTop: 'var(--gap)' }}>
|
|
<DoctorClinicInvitationsCard />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Secretary Dashboard ───────────────────────────────────────────────────
|
|
|
|
interface SecretaryDashboardData {
|
|
scope: 'doctor' | 'clinic';
|
|
doctor?: { uuid: string; name: string; degree: string | null };
|
|
clinic?: { uuid: string; name: string };
|
|
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 />;
|
|
|
|
if (q.isError || !d) {
|
|
return (
|
|
<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>
|
|
<button className="btn ghost sm" style={{ marginTop: 12 }} onClick={() => q.refetch()}>
|
|
<ArrowPathIcon style={{ width: 14, height: 14 }} />
|
|
تلاش دوباره
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// منشیِ scope=clinic فیلد doctor ندارد و scope=doctor فیلد clinic؛ نام محیط بر اساس scope.
|
|
const scopeName = d.scope === 'clinic' ? (d.clinic?.name ?? '') : displayDoctorName(d.doctor?.name);
|
|
const scopeLabel = d.scope === 'clinic' ? 'کلینیک' : 'مطب';
|
|
|
|
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} · منشی {scopeLabel} {scopeName}</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={(scopeName || 'D').slice(0, 1)} hue={256} size="lg" />
|
|
<div>
|
|
<div style={{ fontWeight: 700, fontSize: 16 }}>{scopeName || '—'}</div>
|
|
{d.scope === 'doctor' && 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>
|
|
);
|
|
}
|
|
|
|
// ── Staff Dashboard ───────────────────────────────────────────────────────
|
|
|
|
interface StaffDashboardData {
|
|
scope: 'doctor' | 'clinic';
|
|
staff: { uuid: string; full_name: string; job_title: string | null };
|
|
owner: { name: string };
|
|
stats: { today_sessions: number; today_appointments: number; services: number };
|
|
services: { uuid: string; name: string; section_name: string; price_rials: number; duration_minutes: number | null }[];
|
|
/** کارِ امروزِ اپراتور. نوبتِ اختصاصیافته زیرمجموعهٔ همین صف است. */
|
|
today_sessions: {
|
|
uuid: string;
|
|
session_number: number;
|
|
total_sessions: number;
|
|
status: string;
|
|
service_name: string;
|
|
patient_name: string | null;
|
|
slot_start: number | null;
|
|
}[];
|
|
today_appointments: ApptRow[];
|
|
}
|
|
|
|
function StaffDashboard() {
|
|
const q = useQuery({
|
|
queryKey: ['dashboard-staff'],
|
|
queryFn: () => api.get<ApiResponse<StaffDashboardData>>('/api/v1/dashboard/staff'),
|
|
staleTime: 60_000,
|
|
});
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const d = useMemo<StaffDashboardData | 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 />;
|
|
|
|
if (q.isError || !d) {
|
|
return (
|
|
<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>
|
|
<button className="btn ghost sm" style={{ marginTop: 12 }} onClick={() => q.refetch()}>
|
|
<ArrowPathIcon style={{ width: 14, height: 14 }} />
|
|
تلاش دوباره
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const scopeLabel = d.scope === 'clinic' ? 'کلینیک' : 'مطب';
|
|
const sessions = d.today_sessions ?? [];
|
|
const settled = sessions.filter(s => ['done', 'cancelled', 'no_show'].includes(s.status)).length;
|
|
|
|
// هر کارت به صفحهٔ کارِ خودش میرود. عددِ بیمقصد یعنی کاربر باید در سایدبار
|
|
// دنبال صفحهای بگردد که کارت همین حالا اسمش را برده است.
|
|
const kpiCards = [
|
|
{
|
|
to: '/admin/my-sessions',
|
|
label: 'جلسات امروز من',
|
|
value: formatNumber(d.stats?.today_sessions ?? 0),
|
|
hint: sessions.length > 0 ? `${formatNumber(settled)} انجام شده` : 'کاری برای امروز نیست',
|
|
icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)',
|
|
},
|
|
{
|
|
to: null,
|
|
label: 'سرویسهای من',
|
|
value: formatNumber(d.stats?.services ?? 0),
|
|
hint: 'سرویسهایی که مجاز به انجامشان هستید',
|
|
icon: ClockIcon, color: 'var(--info)', bg: 'var(--info-bg)',
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="fade-in">
|
|
{/* نام و سمت به هدر آمدند: یک کارتِ تمامعرض برای دو خط متن، روی موبایل
|
|
کارِ روز را زیر خط تا میبرد. */}
|
|
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 0 }}>
|
|
<AvatarEl initials={(d.staff?.full_name || 'P').slice(0, 1)} hue={162} />
|
|
<div style={{ minWidth: 0 }}>
|
|
<h1 className="section-title" style={{ margin: 0 }}>{d.staff?.full_name ?? 'داشبورد پرسنل'}</h1>
|
|
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>
|
|
{d.staff?.job_title ? `${d.staff.job_title} · ` : ''}{scopeLabel} {d.owner?.name ?? ''} · {today}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button className="btn ghost sm" onClick={() => q.refetch()}>
|
|
<ArrowPathIcon style={{ width: 14, height: 14 }} />
|
|
بهروزرسانی
|
|
</button>
|
|
</div>
|
|
|
|
<div className="stat-grid">
|
|
{kpiCards.map(c => {
|
|
const body = (
|
|
<>
|
|
<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 className="hint">{c.hint}</div>
|
|
</>
|
|
);
|
|
|
|
// فقط کارتی لینک میشود که صفحهای پشتش باشد؛ سرویسها صفحهٔ جدا ندارند و
|
|
// همینجا پایینتر فهرست میشوند.
|
|
return c.to === null
|
|
? <div key={c.label} className="stat">{body}</div>
|
|
: <Link key={c.label} to={c.to} className="stat" style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}>{body}</Link>;
|
|
})}
|
|
</div>
|
|
|
|
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
|
<div className="card-title-row">
|
|
<h3 style={{ fontSize: 16 }}>کار امروز من</h3>
|
|
<Link to="/admin/my-sessions" className="link">همهٔ جلسات</Link>
|
|
</div>
|
|
{sessions.length === 0 ? (
|
|
<p className="muted" style={{ fontSize: 13.5, padding: '1.5rem 0', textAlign: 'center' }}>
|
|
امروز جلسهای برای شما ثبت نشده است.
|
|
</p>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
|
|
{sessions.map(s => (
|
|
<Link
|
|
key={s.uuid}
|
|
to={`/admin/my-sessions/${s.uuid}`}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 12px',
|
|
borderRadius: 'var(--r-sm)', background: 'var(--surface-2)',
|
|
color: 'inherit', textDecoration: 'none',
|
|
}}
|
|
>
|
|
<span style={{
|
|
flexShrink: 0, minWidth: 52, fontWeight: 700, fontSize: 14,
|
|
color: 'var(--primary-700)', fontVariantNumeric: 'tabular-nums',
|
|
}}>
|
|
{s.slot_start === null
|
|
? '—'
|
|
: new Date(s.slot_start * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })}
|
|
</span>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontWeight: 600, fontSize: 14 }}>{s.patient_name || 'بیمار نامشخص'}</div>
|
|
<div className="muted" style={{ fontSize: 12, marginTop: 2 }}>
|
|
{s.service_name} · جلسهٔ {formatNumber(s.session_number)} از {formatNumber(s.total_sessions)}
|
|
</div>
|
|
</div>
|
|
<StatusBadge type="treatment-session" value={s.status} />
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
|
<div className="card-title-row">
|
|
<h3 style={{ fontSize: 16 }}>سرویسهای تخصیصیافته</h3>
|
|
</div>
|
|
{d.services.length === 0 ? (
|
|
<p className="muted" style={{ fontSize: 13.5, padding: '1.5rem 0', textAlign: 'center' }}>
|
|
هنوز سرویسی به شما تخصیص نیافته است.
|
|
</p>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
|
|
{d.services.slice(0, 5).map(s => (
|
|
<div key={s.uuid} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 12px', borderRadius: 'var(--r-sm)', background: 'var(--surface-2)' }}>
|
|
<div>
|
|
<div style={{ fontWeight: 600, fontSize: 14 }}>{s.name}</div>
|
|
<div className="muted" style={{ fontSize: 12, marginTop: 2 }}>{s.section_name}</div>
|
|
</div>
|
|
<div style={{ fontSize: 13 }}>{formatRial(s.price_rials)}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* نوبتِ مستقیمِ اختصاصیافته فقط وقتی نشان داده میشود که وجود داشته باشد؛
|
|
یک جدول همیشه-خالی زیر کارِ واقعی، فقط صفحه را بلند میکرد. */}
|
|
{(d.today_appointments?.length ?? 0) > 0 && (
|
|
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
|
<div className="card-title-row">
|
|
<h3 style={{ fontSize: 16 }}>نوبتهای اختصاصیافته به من</h3>
|
|
</div>
|
|
<TodayAppointmentsTable appts={d.today_appointments} loading={false} />
|
|
</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 === 'staff') return <StaffDashboard />;
|
|
if (primaryRole === 'representation') return <RepresentationDashboard />;
|
|
|
|
return <AdminDashboard />;
|
|
}
|