Files
clinicpro/assets/admin/components/appointments/ResourceBookingModal.test.tsx
T
hamedandClaude Opus 5 103d913cd0 refactor(admin): restructure the new-appointment modal around its real steps
The booking modal presented one flat scroll of fields whose order did not
match the order of the decisions behind them, and gave no reason when the
submit button stayed grey.

- Group the form into numbered steps (service+time, patient) so the order of
  decisions is visible. The optional visit-price collapse stays unnumbered —
  numbering an optional step reads as required.
- Show the first blocking condition above the footer instead of leaving a
  disabled button unexplained.
- Label the header chip's facts ("device:", "supervising doctor:") and add the
  appointment's Jalali date, which the modal never displayed at all.
- Replace the hand-rolled primary/ghost button pair with the design system's
  `.seg` + `.on`, and announce state via aria-pressed.
- Move autoFocus off the patient search in picker mode; the first decision is
  the section select above it.
- Give every input an id and its label an htmlFor.
- Surface a distinct error state for the slot query. A failed request used to
  fall through to "not enough free time", which sent users to another day for
  no reason.
- Raise the service remove button (18px), the duration pill and the time chips
  to at least the 32px hit target; mark service rows role=checkbox.
- Modal close button gets an accessible name; `.field` controls stretch to the
  full 40px box so the whole frame is clickable.

Runtime probe on the open modal goes from 4 unnamed icon controls, 1 unlabelled
field and 2 sub-32px controls to clean, across light/dark/compact/mobile.

The redesign-page probe now names the offending elements instead of only
counting them.

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

170 lines
7.8 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 {},
}));
import { api } from '../../lib/api';
import { useAuthStore } from '../../stores/authStore';
import NewAppointmentModal from './NewAppointmentModal';
const get = api.get as ReturnType<typeof vi.fn>;
const post = api.post as ReturnType<typeof vi.fn>;
const START = 1_900_000_000;
const slot = {
start: 0, end: 0, start_time: '', end_time: '',
doctor_uuid: 'doc1', doctor_name: 'مینا یوسفی',
};
const resource = { uuid: 'r-2', name: 'لیزر CO2' };
const services = [
{ uuid: 's-1', name: 'کرایوتراپی', duration_minutes: 25, price_rials: 1_000_000, service_section: { uuid: 'sec-1', name: 'خدمات درمانگاه' } },
{ uuid: 's-2', name: 'RF فرکشنال', duration_minutes: 50, price_rials: 2_000_000, service_section: { uuid: 'sec-1', name: 'خدمات درمانگاه' } },
];
/** بیمارِ یافت‌شده تا فرم معتبر شود؛ زمان‌های خالی از تقویم منبع. */
function mockApi() {
get.mockImplementation((url: string) => {
if (url.includes('patient-lookup')) {
return Promise.resolve({ success: true, data: { found: true, name: 'رضا رحیمی', mobile: '09120001307', national_code: '0012345678' } });
}
if (url.includes('/service-slots')) {
return Promise.resolve({ success: true, data: {
total_duration_minutes: 25,
start_times: [{ start: START, end: START + 1500, start_time: '12:00', end_time: '12:25' }],
} });
}
return Promise.resolve({ success: true, data: {} });
});
}
/** انتخاب بخش → سرویس → زمان، همان مسیرِ مودالِ طرح. */
async function pickServiceAndTime() {
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' });
fireEvent.click(await screen.findByText('خدمات درمانگاه'));
// ردیف سرویس یک checkbox است نه دکمه: انتخابش حالت دارد و باید برای screen reader
// «انتخاب‌شده/نشده» اعلام شود.
fireEvent.click(await screen.findByRole('checkbox', { name: /کرایوتراپی/ }));
fireEvent.click(await screen.findByRole('button', { name: '12:00' }));
}
beforeEach(() => {
get.mockReset();
post.mockReset();
useAuthStore.setState({ primaryRole: 'clinic' } as any);
post.mockResolvedValue({ success: true, data: { uuid: 'new1' } });
mockApi();
});
describe('مودال ثبت نوبتِ منبع', () => {
it('نام منبع و پزشک ناظر بالای فرم می‌آید و زمان‌ها از تقویم منبع خوانده می‌شوند', async () => {
renderWithProviders(
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
);
expect(screen.getByText('لیزر CO2')).toBeInTheDocument();
expect(screen.getByText('مینا یوسفی')).toBeInTheDocument();
await pickServiceAndTime();
await waitFor(() => expect(
get.mock.calls.some((c: any[]) => typeof c[0] === 'string' && c[0].includes('/api/v1/resource/r-2/service-slots')),
).toBe(true));
});
it('ثبت، نوبت را با resource_uuid و مدتِ سرویس‌ها می‌فرستد', async () => {
renderWithProviders(
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
);
await pickServiceAndTime();
fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } });
fireEvent.click(screen.getByRole('button', { name: 'جستجو' }));
await screen.findByText('بیمار یافت شد');
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
await waitFor(() => expect(post).toHaveBeenCalled());
const [url, body] = post.mock.calls[0];
expect(url).toBe('/api/v1/my/appointment');
expect(body).toMatchObject({
resource_uuid: 'r-2',
duration_from_services: true,
service_item_uuids: ['s-1'],
service_durations: { 's-1': 25 },
slot_start: START,
patient_national_code: '0012345678',
});
});
it('بدون انتخاب سرویس و زمان، ثبت غیرفعال می‌ماند', async () => {
renderWithProviders(
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
);
fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } });
fireEvent.click(screen.getByRole('button', { name: 'جستجو' }));
await screen.findByText('بیمار یافت شد');
expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeDisabled();
});
it('دلیلِ غیرفعال بودنِ ثبت را می‌گوید و با پیشرفتِ فرم عوض می‌شود', async () => {
renderWithProviders(
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
);
expect(await screen.findByText(/یک سرویس انتخاب کنید/)).toBeInTheDocument();
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' });
fireEvent.click(await screen.findByText('خدمات درمانگاه'));
fireEvent.click(await screen.findByRole('checkbox', { name: /کرایوتراپی/ }));
// سرویس هست، زمان نه — پیام باید مرحلهٔ بعد را نشان دهد نه همان قبلی.
expect(await screen.findByText(/ساعت شروع را انتخاب کنید/)).toBeInTheDocument();
fireEvent.click(await screen.findByRole('button', { name: '12:00' }));
expect(await screen.findByText(/ابتدا بیمار را جستجو کنید/)).toBeInTheDocument();
});
it('تاریخ نوبت به‌صورت جلالی بالای فرم می‌آید', () => {
renderWithProviders(
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
);
// بدون این، کاربر روزِ در حال رزرو را هیچ‌جای مودال نمی‌دید.
expect(screen.getByText('تاریخ:')).toBeInTheDocument();
expect(screen.getByText('۱۴۰۵/۰۵/۱۳')).toBeInTheDocument();
});
it('معیار جستجو یک seg با حالتِ اعلام‌شده است', () => {
renderWithProviders(
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
);
const national = screen.getByRole('button', { name: 'کد ملی' });
const mobile = screen.getByRole('button', { name: 'شماره موبایل' });
expect(national).toHaveAttribute('aria-pressed', 'true');
expect(mobile).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(mobile);
expect(screen.getByRole('button', { name: 'شماره موبایل' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByPlaceholderText('مثال: 09123456789')).toBeInTheDocument();
});
it('منبعِ بدون سرویس، پیام راهنما می‌دهد نه فهرست خالی', () => {
renderWithProviders(
<NewAppointmentModal slot={slot} resource={resource} services={[]} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
);
expect(screen.getByText(/برای این منبع سرویسی تعریف نشده است/)).toBeInTheDocument();
});
});