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
+95
View File
@@ -0,0 +1,95 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import { api } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import DashboardPage from './DashboardPage';
const get = api.get as ReturnType<typeof vi.fn>;
const clinicPayload = {
success: true,
data: {
clinic: { uuid: 'clinic-1', name: 'کلینیک نمونه', is_active: true, logo: null },
stats: {
total_doctors: 2,
today_appointments: 15,
this_month_appointments: 40,
pending_invitations: 0,
total_patients: 151,
revenue_period_rials: 5_600_002_000,
today_payments_rials: 5_225_000,
week_payments_rials: 12_000_000,
},
charts: {
revenue_by_day: [
{ label: '۷ خرداد', amount_rials: 60_000 },
{ label: '۸ خرداد', amount_rials: 160_000 },
{ label: '۹ خرداد', amount_rials: 90_000 },
],
appointments_by_day: [
{ label: '۷ خرداد', count: 15 },
{ label: '۸ خرداد', count: 40 },
{ label: '۹ خرداد', count: 48 },
],
},
today_appointments: [
{
uuid: 'appt-1',
patient_name: 'دنیا خلیلی',
patient_mobile: '09136549874',
doctor_name: 'حمیدی',
service_name: 'ویزیت عمومی',
slot_start: 1_718_000_000,
slot_end: 1_718_001_800,
status: 'visited',
},
],
doctors: [],
period: { from: 0, to: 0 },
},
};
describe('DashboardPage (ported clinic dashboard)', () => {
beforeEach(() => {
get.mockReset();
useAuthStore.setState({ primaryRole: 'clinic', dbUuid: 'clinic-1', context: null } as never);
get.mockImplementation((url: string) => {
if (url.startsWith('/api/v1/dashboard/clinic')) return Promise.resolve(clinicPayload);
return Promise.resolve({ success: true, data: [] });
});
});
it('renders the ported stat cards, charts and new-appointments list', async () => {
renderWithProviders(<DashboardPage />, { route: '/admin/dashboard' });
// stat card labels (ported 1:1)
expect(await screen.findByText('تعداد کل مراجعین')).toBeInTheDocument();
expect(screen.getByText('کل پرداختی‌ها')).toBeInTheDocument();
expect(screen.getByText('پرداختی‌های امروز')).toBeInTheDocument();
expect(screen.getByText('تعداد نوبت‌های امروز')).toBeInTheDocument();
// chart titles
expect(screen.getByText('نمودار تعداد بیماران')).toBeInTheDocument();
expect(screen.getByText('میزان درآمد')).toBeInTheDocument();
// new-appointments list + row data (mobile/service columns come from the extended API)
expect(screen.getByText('لیست نوبت‌های جدید')).toBeInTheDocument();
expect(screen.getByText('دنیا خلیلی')).toBeInTheDocument();
expect(screen.getByText('09136549874')).toBeInTheDocument();
expect(screen.getByText('ویزیت عمومی')).toBeInTheDocument();
expect(screen.getByText('ویزیت شده')).toBeInTheDocument();
});
it('calls the real clinic dashboard endpoint', async () => {
renderWithProviders(<DashboardPage />, { route: '/admin/dashboard' });
await screen.findByText('تعداد کل مراجعین');
expect(get).toHaveBeenCalledWith(expect.stringContaining('/api/v1/dashboard/clinic'));
});
});
+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 />