feat(appointments): implement multi-step wizard for appointment booking modal

This commit is contained in:
hamed
2026-08-09 08:53:55 +03:30
parent b49abee52f
commit 948f59827a
5 changed files with 369 additions and 46 deletions
@@ -16,6 +16,12 @@ const post = api.post as ReturnType<typeof vi.fn>;
const slot = { start: 1_800_000_000, end: 1_800_001_800, start_time: '15:00', end_time: '15:30', doctor_uuid: 'doc1', doctor_name: 'دکتر تست' };
/**
* فرم ویزارد شد: بیمار و «تأیید و ثبت» دو مرحلهٔ جدا هستند، پس ثبت یک کلیک جلوتر است.
* (نوبت اسلاتی مرحلهٔ «خدمت و زمان» ندارد؛ زمانش از خودِ اسلات می‌آید.)
*/
const goToConfirm = () => fireEvent.click(screen.getByRole('button', { name: 'مرحلهٔ بعد' }));
beforeEach(() => {
get.mockReset();
post.mockReset();
@@ -28,8 +34,9 @@ describe('NewAppointmentModal — جستجوی موبایل‌محور', () => {
renderWithProviders(<NewAppointmentModal slot={slot} onClose={() => {}} onSuccess={() => {}} />);
fireEvent.click(screen.getByRole('button', { name: 'شماره موبایل' }));
fireEvent.change(screen.getByPlaceholderText('مثال: 09123456789'), { target: { value: '09121234567' } });
// بدون جستجو، ثبت نوبت غیرفعال است
expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeDisabled();
// بدون جستجو، رفتن به مرحلهٔ بعد ممکن نیست
expect(screen.getByRole('button', { name: 'مرحلهٔ بعد' })).toBeDisabled();
expect(screen.queryByRole('button', { name: 'ثبت نوبت' })).not.toBeInTheDocument();
});
it('converts Persian digits to English and searches by the normalized mobile', async () => {
@@ -70,6 +77,7 @@ describe('NewAppointmentModal — جستجوی موبایل‌محور', () => {
// فیلد کد ملی برای بیمارِ یافت‌شده نمایش داده نمی‌شود
expect(screen.queryByPlaceholderText('کد ملی ۱۰ رقمی')).toBeNull();
goToConfirm();
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/my/appointment', expect.objectContaining({
doctor_uuid: 'doc1',
@@ -91,6 +99,7 @@ describe('NewAppointmentModal — جستجوی موبایل‌محور', () => {
fireEvent.change(screen.getByPlaceholderText('مثال: علی محمدی'), { target: { value: 'مریم خلیلی' } });
fireEvent.change(nc, { target: { value: '1234567891' } });
goToConfirm();
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/my/appointment', expect.objectContaining({
patient_mobile: '09990001122',
@@ -113,6 +122,7 @@ describe('NewAppointmentModal — جستجو با کد ملی', () => {
await waitFor(() => expect(get).toHaveBeenCalledWith('/api/v1/my/appointment/patient-lookup?national_code=0012345678'));
await screen.findByText('بیمار یافت شد');
goToConfirm();
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/my/appointment', expect.objectContaining({
patient_mobile: '09121112233',
@@ -135,6 +145,7 @@ describe('NewAppointmentModal — جستجو با کد ملی', () => {
fireEvent.change(screen.getByPlaceholderText('مثال: علی محمدی'), { target: { value: 'مریم خلیلی' } });
fireEvent.change(mob, { target: { value: '09990001122' } });
goToConfirm();
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/my/appointment', expect.objectContaining({
patient_mobile: '09990001122',
@@ -145,6 +156,17 @@ describe('NewAppointmentModal — جستجو با کد ملی', () => {
});
describe('NewAppointmentModal — هزینه ویزیت', () => {
/** بیمارِ یافت‌شده را رد می‌کند تا به مرحلهٔ «تأیید و ثبت» برسیم. */
async function bookableWithoutPatient() {
fireEvent.click(screen.getByRole('button', { name: 'شماره موبایل' }));
fireEvent.change(screen.getByPlaceholderText('مثال: 09123456789'), { target: { value: '09121234567' } });
fireEvent.click(screen.getByRole('button', { name: 'جستجو' }));
await screen.findByPlaceholderText('مثال: علی محمدی');
fireEvent.change(screen.getByPlaceholderText('مثال: علی محمدی'), { target: { value: 'علی محمدی' } });
fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } });
goToConfirm();
}
/** تعرفه را برای همان پزشکِ اسلات برمی‌گرداند؛ بقیهٔ GETها بیمارِ ناشناس. */
function mockPricing(rials: number, requireVisit = false) {
get.mockImplementation((url: string) =>
@@ -159,6 +181,9 @@ describe('NewAppointmentModal — هزینه ویزیت', () => {
await waitFor(() =>
expect(get).toHaveBeenCalledWith('/api/v1/insurance-pricing?doctor_uuid=doc1'));
// هزینه در مرحلهٔ تأیید است؛ بدون بیمار هم می‌شود جلو رفت چون دکمه فقط با
// نبودِ بیمار قفل است — پس اول بیمار را رد می‌کنیم.
await bookableWithoutPatient();
// اختیاری → کلپس بسته است؛ باز کردن آن تا فیلد دیده شود
fireEvent.click(screen.getByRole('button', { name: /هزینه ویزیت/ }));
// ۲٬۱۲۰٬۰۰۰ ریال = ۲۱۲٬۰۰۰ تومان
@@ -169,6 +194,7 @@ describe('NewAppointmentModal — هزینه ویزیت', () => {
mockPricing(0);
renderWithProviders(<NewAppointmentModal slot={slot} onClose={() => {}} onSuccess={() => {}} />);
await bookableWithoutPatient();
// برچسب اختیاری همیشه در سرِ کلپس هست، ولی فیلد در ابتدا پنهان است
expect(screen.getByText('(اختیاری)')).toBeInTheDocument();
expect(screen.queryByPlaceholderText('0')).toBeNull();
@@ -182,6 +208,7 @@ describe('NewAppointmentModal — هزینه ویزیت', () => {
mockPricing(0, true);
renderWithProviders(<NewAppointmentModal slot={slot} onClose={() => {}} onSuccess={() => {}} />);
await bookableWithoutPatient();
// الزامی → بدون کلیک، فیلد و خطا نمایش داده می‌شوند
await screen.findByText('هزینه ویزیت الزامی است');
expect(screen.getByPlaceholderText('0')).toBeInTheDocument();