feat: port clinic dashboard components from clinic-pro-tauri

- Add NewAppointmentsTable for displaying today's appointments with status chips and formatted time.
- Implement TauriCharts for bar and line charts representing patient counts and revenue.
- Create TauriDashboardView to combine stat cards, charts, and new appointments list.
- Introduce TauriStatCards for displaying key statistics with icons.
- Add dashboardIcons for SVG icons used in stat cards.
- Implement tests for DashboardPage to ensure correct rendering and API calls.
- Create DashboardTodayAppointmentsTest to validate extended fields in today's appointments API response.
This commit is contained in:
hamed
2026-07-14 13:54:50 +03:30
parent 3b9c5056a3
commit d9f96b68cd
10 changed files with 883 additions and 127 deletions
+39 -122
View File
@@ -12,7 +12,7 @@ import type { ApiResponse } from '../lib/api';
import { formatNumber, formatRial, formatDateTime } from '../lib/utils';
import { useAuthStore } from '../stores/authStore';
import InviteDoctorModal from '../components/ui/InviteDoctorModal';
import StatCard from '../components/ui/StatCard';
import { TauriDashboardView } from '../components/dashboard/TauriDashboardView';
// ── Shared Status Maps ────────────────────────────────────────────────────
@@ -240,7 +240,10 @@ 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;
}
@@ -607,7 +610,7 @@ interface ClinicDashboardData {
function ClinicDashboard() {
const { context, dbUuid } = useAuthStore();
const [inviteOpen, setInviteOpen] = useState(false);
const [preset, setPreset] = useState<DatePreset>('this_month');
const preset: DatePreset = 'this_month';
const range = getRange(preset);
const q = useQuery({
queryKey: ['dashboard-clinic', preset],
@@ -617,79 +620,37 @@ function ClinicDashboard() {
// 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]);
const today = new Date().toLocaleDateString('fa-IR', { year: 'numeric', month: 'long', day: 'numeric' });
const clinicUuid = d?.clinic.uuid ?? dbUuid ?? '';
if (q.isLoading) return <LoadingSkeleton />;
const statCards: { tone: 'amber' | 'violet' | 'green' | 'pink'; label: string; value: React.ReactNode; icon: React.ReactNode }[] = [
{ tone: 'amber', label: 'تعداد نوبت‌های امروز', value: `${formatNumber(d?.stats.today_appointments ?? 0)}+`, icon: <CalendarDaysIcon style={{ width: 22, height: 22 }} /> },
{ tone: 'violet', label: 'پرداختی‌های امروز', value: formatRial(d?.stats.today_payments_rials ?? 0), icon: <CreditCardIcon style={{ width: 22, height: 22 }} /> },
{ tone: 'green', label: 'پرداختی‌های هفته', value: formatRial(d?.stats.week_payments_rials ?? 0), icon: <BanknotesIcon style={{ width: 22, height: 22 }} /> },
{ tone: 'pink', label: 'تعداد کل مراجعین', value: `${formatNumber(d?.stats.total_patients ?? 0)}+`, icon: <UserGroupIcon style={{ width: 22, height: 22 }} /> },
];
const revSeries = (d?.charts?.revenue_by_day ?? []).map(x => x.amount_rials);
const patientBars = (d?.charts?.appointments_by_day ?? []).map(x => ({ label: x.label, value: x.count }));
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?.clinic.name ?? context?.name ?? ''}</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" onClick={() => setInviteOpen(true)}>
<UserIcon style={{ width: 14, height: 14 }} />
دعوت پزشک
</button>
<button className="btn ghost sm" onClick={() => q.refetch()}>
<ArrowPathIcon style={{ width: 14, height: 14 }} />
بهروزرسانی
</button>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
{statCards.map(c => (
<StatCard key={c.label} tone={c.tone} label={c.label} value={c.value} icon={c.icon} />
))}
</div>
<div className="grid-2" style={{ marginTop: 'var(--gap)' }}>
<div className="card card-pad">
<div className="card-title-row">
<h3 style={{ fontSize: 16 }}>نمودار تعداد مراجعین</h3>
</div>
<SvgVBars data={patientBars} />
</div>
<div className="card card-pad">
<div className="card-title-row">
<h3 style={{ fontSize: 16 }}>میزان درآمد</h3>
</div>
<SvgLineChart data={revSeries} color="var(--primary)" />
</div>
</div>
<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>
<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_day ?? []).map(x => ({ label: x.label, value: x.amount_rials }))}
appointments={d?.today_appointments ?? []}
loading={q.isFetching}
formatNumber={formatNumber}
formatRial={formatRial}
/>
{/* پزشکان کلینیک — بخش عملکردی موجود clinicpro، حفظ‌شده زیر نمای منتقل‌شده */}
<div className="grid-2" style={{ marginTop: 'var(--gap)' }}>
<div className="card card-pad">
<div className="card-title-row">
<h3 style={{ fontSize: 16 }}>پزشکان کلینیک</h3>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button className="btn ghost sm" onClick={() => q.refetch()}>
<ArrowPathIcon style={{ width: 14, height: 14 }} />
بهروزرسانی
</button>
<button className="btn ghost sm" style={{ fontSize: 11, padding: '3px 10px' }} onClick={() => setInviteOpen(true)}>
+ دعوت جدید
</button>
@@ -853,8 +814,7 @@ interface DoctorDashboardData {
}
function DoctorDashboard() {
const { context } = useAuthStore();
const [preset, setPreset] = useState<DatePreset>('this_month');
const preset: DatePreset = 'this_month';
const range = getRange(preset);
const q = useQuery({
queryKey: ['dashboard-doctor', preset],
@@ -864,68 +824,25 @@ function DoctorDashboard() {
// 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]);
const today = new Date().toLocaleDateString('fa-IR', { year: 'numeric', month: 'long', day: 'numeric' });
if (q.isLoading) return <LoadingSkeleton />;
const statCards: { tone: 'amber' | 'violet' | 'green' | 'pink'; label: string; value: React.ReactNode; icon: React.ReactNode }[] = [
{ tone: 'amber', label: 'تعداد نوبت‌های امروز', value: `${formatNumber(d?.stats.today_appointments ?? 0)}+`, icon: <CalendarDaysIcon style={{ width: 22, height: 22 }} /> },
{ tone: 'violet', label: 'پرداختی‌های امروز', value: formatRial(d?.stats.today_payments_rials ?? 0), icon: <CreditCardIcon style={{ width: 22, height: 22 }} /> },
{ tone: 'green', label: 'پرداختی‌های هفته', value: formatRial(d?.stats.week_payments_rials ?? 0), icon: <BanknotesIcon style={{ width: 22, height: 22 }} /> },
{ tone: 'pink', label: 'تعداد کل مراجعین', value: `${formatNumber(d?.stats.total_patients ?? 0)}+`, icon: <UserGroupIcon style={{ width: 22, height: 22 }} /> },
];
const revSeries = (d?.charts?.revenue_by_day ?? []).map(x => x.amount_rials);
const patientBars = (d?.charts?.appointments_by_day ?? []).map(x => ({ label: x.label, value: x.count }));
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 ?? context?.name ?? ''}</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 ghost sm" onClick={() => q.refetch()}>
<ArrowPathIcon style={{ width: 14, height: 14 }} />
بهروزرسانی
</button>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
{statCards.map(c => (
<StatCard key={c.label} tone={c.tone} label={c.label} value={c.value} icon={c.icon} />
))}
</div>
<div className="grid-2" style={{ marginTop: 'var(--gap)' }}>
<div className="card card-pad">
<div className="card-title-row">
<h3 style={{ fontSize: 16 }}>نمودار تعداد مراجعین</h3>
</div>
<SvgVBars data={patientBars} />
</div>
<div className="card card-pad">
<div className="card-title-row">
<h3 style={{ fontSize: 16 }}>میزان درآمد</h3>
</div>
<SvgLineChart data={revSeries} color="var(--primary)" />
</div>
</div>
<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>
<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_day ?? []).map(x => ({ label: x.label, value: x.amount_rials }))}
appointments={d?.today_appointments ?? []}
loading={q.isFetching}
formatNumber={formatNumber}
formatRial={formatRial}
/>
<div className="grid-2" style={{ marginTop: 'var(--gap)' }}>
<DoctorClinicInvitationsCard />