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>
248 lines
11 KiB
TypeScript
248 lines
11 KiB
TypeScript
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');
|
||
|
||
// صفحهٔ «سرویسهای من» حذف شد؛ کارتِ بدون مقصد نباید لینک باشد.
|
||
expect(screen.getByText('سرویسهای من').closest('a')).toBeNull();
|
||
});
|
||
|
||
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();
|
||
});
|
||
});
|