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:
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user