Files
clinicpro/assets/admin/pages/DashboardPage.test.tsx
T
hamedandClaude Opus 5 5b3e80f92a fix(dashboard): make the staff dashboard show the operator's actual work
Every number on it was zero. "نوبت‌های امروز من" counted appointments where
appointments.staff_id matches — a column no booking path fills by default, and
which is NULL on every row in the database. "سرویس‌های من" read only direct
service assignment, so an operator whose whole job comes from a treatment
protocol was told they had no services. The landing page of the only role that
has one data page said, in effect, that they had nothing to do — while they had
two sessions booked that day.

Today's work now comes from TreatmentSessionRepository::findTodayForStaff, the
same queue rule the sessions page uses, so there is one definition of "my work
today" rather than two that disagree. Services are the union of direct
assignment and protocol authorisation.

The two stat cards are links to the pages they name; a number with no
destination made the user hunt the sidebar for a page the card had just
mentioned. Each row of the work list opens that session. The avatar moves from
its own full-width card into the header — two lines of text were costing a card
and pushing the day's work below the fold on mobile. The assigned-appointments
table renders only when it has rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:16:00 +03:30

248 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent, waitFor } 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 {},
}));
const navigate = vi.fn();
vi.mock('react-router-dom', async () => ({
...(await vi.importActual<typeof import('react-router-dom')>('react-router-dom')),
useNavigate: () => navigate,
}));
import { api } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import DashboardPage from './DashboardPage';
import { JALALI_MONTHS } from '../components/dashboard/TauriDashboardView';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const jalaali = require('jalaali-js') as { toJalaali: (d: Date) => { jy: number; jm: number } };
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 },
],
revenue_by_month: [
{ 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 },
],
},
charts_period: { patients_year: 1405, patients_month: 4, revenue_year: 1405 },
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: 'completed',
},
],
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();
// ستون «وضعیت» هست، ولی در داشبورد فقط خواندنی (بدون queryKey → StatusPill نه dropdown)
expect(screen.getByText('وضعیت')).toBeInTheDocument();
expect(screen.getByText('ویزیت شده').closest('button')).toBeNull();
// کارت «پزشکان کلینیک» از داشبورد حذف شده
expect(screen.queryByText('پزشکان کلینیک')).not.toBeInTheDocument();
});
it('«مشاهده» نوبتِ قطعی‌شده، پروندهٔ بیمار را باز می‌کند', async () => {
// fixture وضعیت `completed` دارد — یعنی از قطعی‌شدن رد شده و پرونده دارد.
get.mockImplementation((url: string) => {
if (url.startsWith('/api/v1/patients?search='))
return Promise.resolve({ success: true, data: [{ uuid: 'rec-42' }] });
return Promise.resolve(clinicPayload);
});
renderWithProviders(<DashboardPage />, { route: '/admin/dashboard' });
await screen.findByText('دنیا خلیلی');
fireEvent.click(screen.getByText('مشاهده'));
await waitFor(() => expect(get).toHaveBeenCalledWith(expect.stringContaining('/api/v1/patients?search=09136549874')));
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/admin/patients/rec-42'));
});
it('«مشاهده» نوبتِ ثبت‌شده (قطعی‌نشده) به لیست نوبت‌های همان روز می‌رود', async () => {
get.mockResolvedValue({
...clinicPayload,
data: {
...clinicPayload.data,
today_appointments: [{ ...clinicPayload.data.today_appointments[0], status: 'pending' }],
},
});
renderWithProviders(<DashboardPage />, { route: '/admin/dashboard' });
await screen.findByText('دنیا خلیلی');
const d = new Date(1_718_000_000 * 1000);
const day = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
expect(screen.getByText('مشاهده').closest('a')).toHaveAttribute('href', `/admin/appointments?date=${day}`);
});
it('سلول‌های جدول با هدرهایشان هم‌تراز هستند (همه text-start جز ستون عملیات)', async () => {
const { container } = renderWithProviders(<DashboardPage />, { route: '/admin/dashboard' });
await screen.findByText('دنیا خلیلی');
const heads = Array.from(container.querySelectorAll('thead th'));
const cells = Array.from(container.querySelectorAll('tbody td'));
expect(heads).toHaveLength(cells.length);
heads.slice(0, -1).forEach(th => expect(th.className).toContain('text-start'));
cells.slice(0, -1).forEach(td => expect(td.className).toContain('text-start'));
// ستون آخر «عملیات» در هر دو سطر وسط‌چین است
expect(heads[heads.length - 1].className).toContain('text-center');
expect(cells[cells.length - 1].className).toContain('text-center');
// dir=ltr روی سلول نمی‌نشیند (جهت را برمی‌گرداند و هم‌ترازی را می‌شکند)
cells.forEach(td => expect(td.getAttribute('dir')).toBeNull());
});
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'));
});
it('نمودارها را روی ماه و سال شمسی جاری درخواست می‌کند', async () => {
renderWithProviders(<DashboardPage />, { route: '/admin/dashboard' });
await screen.findByText('تعداد کل مراجعین');
const url = get.mock.calls.map(c => String(c[0])).find(u => u.startsWith('/api/v1/dashboard/clinic'))!;
const params = new URLSearchParams(url.split('?')[1]);
const now = jalaali.toJalaali(new Date());
expect(params.get('patients_year')).toBe(String(now.jy));
expect(params.get('patients_month')).toBe(String(now.jm));
expect(params.get('revenue_year')).toBe(String(now.jy));
// سلکتورها همان دوره را نشان می‌دهند
expect(screen.getByText(JALALI_MONTHS[now.jm - 1])).toBeInTheDocument();
expect(
screen.getByText(`سال ${new Intl.NumberFormat('fa-IR', { useGrouping: false }).format(now.jy)}`)
).toBeInTheDocument();
});
});
describe('داشبورد پرسنل', () => {
const staffPayload = {
success: true,
data: {
scope: 'clinic',
staff: { uuid: 'st-1', full_name: 'پرسنل۱', job_title: 'پرسنل لیزر' },
owner: { name: 'مدیسا' },
stats: { today_sessions: 2, today_appointments: 0, services: 1 },
services: [{ uuid: 'svc-1', name: 'لیزر توتال', section_name: 'لیزر', price_rials: 10_000_000, duration_minutes: 40 }],
today_sessions: [
{ uuid: 'ses-1', session_number: 1, total_sessions: 3, status: 'in_progress', service_name: 'لیزر توتال', patient_name: 'محمد رسولی', slot_start: 1_786_084_200 },
{ uuid: 'ses-2', session_number: 1, total_sessions: 3, status: 'done', service_name: 'لیزر توتال', patient_name: 'محمد رستمی', slot_start: 1_786_080_600 },
],
today_appointments: [],
},
};
beforeEach(() => {
get.mockReset();
useAuthStore.setState({ primaryRole: 'staff' } as never);
get.mockResolvedValue(staffPayload);
});
/** کارِ اپراتور جلسهٔ درمان است؛ `appointments.staff_id` را هیچ مسیری پر نمی‌کند. */
it('کار امروز را از جلسات درمان می‌سازد، نه نوبت‌های اختصاص‌یافته', async () => {
renderWithProviders(<DashboardPage />);
expect(await screen.findByText('محمد رسولی')).toBeInTheDocument();
expect(screen.getByText('محمد رستمی')).toBeInTheDocument();
});
it('کارت‌های آمار به صفحهٔ کار خودشان لینک‌اند', async () => {
renderWithProviders(<DashboardPage />);
const sessions = (await screen.findByText('جلسات امروز من')).closest('a');
expect(sessions).toHaveAttribute('href', '/admin/my-sessions');
const services = screen.getByText('سرویس‌های من').closest('a');
expect(services).toHaveAttribute('href', '/admin/my-services');
});
it('هر ردیف کار به همان جلسه می‌رود', async () => {
renderWithProviders(<DashboardPage />);
const row = (await screen.findByText('محمد رسولی')).closest('a');
expect(row).toHaveAttribute('href', '/admin/my-sessions/ses-1');
});
/** جدولِ همیشه-خالی زیر کارِ واقعی فقط صفحه را بلند می‌کرد. */
it('نوبت‌های اختصاص‌یافته وقتی خالی است اصلاً نمی‌آید', async () => {
renderWithProviders(<DashboardPage />);
await screen.findByText('محمد رسولی');
expect(screen.queryByText('نوبت‌های اختصاص‌یافته به من')).not.toBeInTheDocument();
});
});