From 948f59827a62b13dc8fb6155809a1ac2a493daab Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 9 Aug 2026 08:53:55 +0330 Subject: [PATCH] feat(appointments): implement multi-step wizard for appointment booking modal --- .../appointments/NewAppointmentModal.test.tsx | 107 ++++++++++ .../appointments/NewAppointmentModal.tsx | 198 +++++++++++++++--- .../ResourceBookingModal.test.tsx | 36 +++- assets/admin/components/ui/Modal.test.tsx | 43 ++++ .../pages/AppointmentBookingModal.test.tsx | 31 ++- 5 files changed, 369 insertions(+), 46 deletions(-) create mode 100644 assets/admin/components/appointments/NewAppointmentModal.test.tsx create mode 100644 assets/admin/components/ui/Modal.test.tsx diff --git a/assets/admin/components/appointments/NewAppointmentModal.test.tsx b/assets/admin/components/appointments/NewAppointmentModal.test.tsx new file mode 100644 index 00000000..b252b7ba --- /dev/null +++ b/assets/admin/components/appointments/NewAppointmentModal.test.tsx @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen } from '@testing-library/react'; +import { renderWithProviders } from '../../test/utils'; + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +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 NewAppointmentModal from './NewAppointmentModal'; + +const get = api.get as ReturnType; + +const slot = { + start: 1_780_000_000, + end: 1_780_001_800, + start_time: '۰۹:۰۰', + end_time: '۰۹:۳۰', + doctor_uuid: 'doc-1', + doctor_name: 'دکتر رضایی', +}; + +beforeEach(() => { + get.mockReset(); + get.mockResolvedValue({ success: true, data: [] }); +}); + +const props = (overrides: Record = {}) => ({ + slot, + onClose: vi.fn(), + onSuccess: vi.fn(), + ...overrides, +}); + +/** عنوان مرحله‌های نوار بالای مودال. */ +const stepLabels = () => + Array.from(document.querySelectorAll('ol[aria-label="مراحل ثبت نوبت"] li')) + .map(li => li.textContent?.replace(/^\d+/, '').trim()); + +const pickerProps = () => props({ serviceMode: true, date: '1405-05-18', services: [] }); + +describe('NewAppointmentModal — ویزارد', () => { + /** + * فرم قبلاً یک صفحهٔ بلند بود: خدمت و زمان، بیمار و هزینه با هم. هر بار یک مرحله + * دیده می‌شود تا مودال از ارتفاع صفحه بلندتر نشود. + */ + it('حالت انتخابگر سه مرحله دارد و از مرحلهٔ اول شروع می‌شود', () => { + renderWithProviders(); + + expect(stepLabels()).toEqual(['خدمت و زمان', 'بیمار', 'تأیید و ثبت']); + expect(screen.getByRole('heading', { name: 'خدمت و زمان' })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'بیمار' })).not.toBeInTheDocument(); + }); + + it('مرحله‌ای که داده‌اش از قبل معلوم است ساخته نمی‌شود', () => { + // نوبت اسلاتی: زمان از خودِ اسلات می‌آید، پس مرحلهٔ «خدمت و زمان» بی‌معناست. + renderWithProviders(); + + expect(stepLabels()).toEqual(['بیمار', 'تأیید و ثبت']); + }); + + it('بیمارِ از پیش معلوم، مرحلهٔ بیمار را حذف می‌کند', () => { + renderWithProviders(); + + // تنها مرحلهٔ باقی‌مانده «تأیید و ثبت» است، پس نوار مراحل هم لازم نیست. + expect(document.querySelector('ol[aria-label="مراحل ثبت نوبت"]')).toBeNull(); + expect(screen.getByRole('heading', { name: 'تأیید و ثبت' })).toBeInTheDocument(); + }); + + it('تا وقتی مرحله کامل نشده، دکمهٔ بعدی غیرفعال است و دلیلش را می‌گوید', () => { + renderWithProviders(); + + expect(screen.getByRole('button', { name: 'مرحلهٔ بعد' })).toBeDisabled(); + expect(screen.getByText(/یک سرویس انتخاب کنید/)).toBeInTheDocument(); + // دکمهٔ ثبت فقط در مرحلهٔ آخر وجود دارد. + expect(screen.queryByRole('button', { name: 'ثبت نوبت' })).not.toBeInTheDocument(); + }); + + it('در مرحلهٔ اول دکمهٔ «مرحلهٔ قبل» نیست، ولی «انصراف» همیشه هست', () => { + renderWithProviders(); + + expect(screen.queryByRole('button', { name: 'مرحلهٔ قبل' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'انصراف' })).toBeInTheDocument(); + }); + + it('مرحلهٔ آخر خلاصهٔ نوبت را نشان می‌دهد', () => { + renderWithProviders(); + + expect(screen.getByText('علی محمدی')).toBeInTheDocument(); + expect(screen.getByText('09123456789')).toBeInTheDocument(); + }); + + it('دکمهٔ اصلی در فوتر ثابت است، نه داخل ناحیهٔ اسکرول', () => { + renderWithProviders(); + + const next = screen.getByRole('button', { name: 'مرحلهٔ بعد' }); + + expect(next.closest('.modal-foot')).not.toBeNull(); + expect(next.closest('.modal-body')).toBeNull(); + }); +}); diff --git a/assets/admin/components/appointments/NewAppointmentModal.tsx b/assets/admin/components/appointments/NewAppointmentModal.tsx index a65a344a..d8e7eaf6 100644 --- a/assets/admin/components/appointments/NewAppointmentModal.tsx +++ b/assets/admin/components/appointments/NewAppointmentModal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import type { ReactNode } from 'react'; import { useMutation, useQuery } from '@tanstack/react-query'; import { ChevronDownIcon, ClockIcon, MagnifyingGlassIcon, CheckCircleIcon, CpuChipIcon, ExclamationCircleIcon } from '@heroicons/react/24/outline'; @@ -24,6 +24,14 @@ export interface BookingSlot { doctor_name: string; } +type StepKey = 'service' | 'patient' | 'confirm'; + +const STEP_TITLES: Record = { + service: 'خدمت و زمان', + patient: 'بیمار', + confirm: 'تأیید و ثبت', +}; + interface PatientLookup { found: boolean; name?: string | null; mobile?: string; national_code?: string | null } /** منبعِ هدفِ نوبت — دستگاه/اتاق/پرسنلی که نوبت رویش می‌نشیند. */ @@ -160,6 +168,7 @@ export default function NewAppointmentModal({ // هزینه ویزیت اختیاری داخل کلپسِ بسته می‌نشیند؛ وقتی الزامی است کلپس همیشه باز است. const [visitPriceOpen, setVisitPriceOpen] = useState(false); const visitPriceExpanded = requireVisit || visitPriceOpen; + const [stepIdx, setStepIdx] = useState(0); const mobileValid = /^09\d{9}$/.test(mobile); const nationalCodeValid = /^\d{10}$/.test(nationalCode); @@ -264,19 +273,51 @@ export default function NewAppointmentModal({ ...(activeDate && !pickerMode ? [{ label: 'تاریخ', value: formatDate(activeDate) }] : []), ]; - // اولین چیزی که جلوی ثبت را گرفته، به ترتیبِ همان مراحلِ فرم. دکمهٔ خاکستریِ + /** + * مراحلِ ویزارد — فقط مراحلی که واقعاً تصمیمی دارند. + * + * فرم قبلاً یک صفحهٔ بلند بود: انتخاب سرویس و زمان، جستجوی بیمار و هزینه همه با هم. + * مرحله‌ای که داده‌اش از قبل معلوم است ساخته نمی‌شود — نوبتِ اسلاتی زمان دارد و + * فرمِ بازشده از پروندهٔ بیمار، بیمار. + */ + const stepKeys: StepKey[] = [ + ...(pickerMode && activeDate ? (['service'] as StepKey[]) : []), + ...(patient === null ? (['patient'] as StepKey[]) : []), + 'confirm', + ]; + const currentStep = stepKeys[Math.min(stepIdx, stepKeys.length - 1)]; + const isLastStep = currentStep === 'confirm'; + + // اولین چیزی که جلوی رفتن به مرحلهٔ بعد (یا ثبت) را گرفته. دکمهٔ خاکستریِ // بی‌توضیح یعنی کاربر باید حدس بزند چه چیزی کم است. - const blockReason = !serviceTimingValid - ? (pick.serviceUuids.length === 0 ? 'یک سرویس انتخاب کنید' : 'ساعت شروع را انتخاب کنید') - : lookup === null + const stepBlockReason: Record = { + service: !serviceTimingValid + ? (pick.serviceUuids.length === 0 ? 'یک سرویس انتخاب کنید' : 'ساعت شروع را انتخاب کنید') + : null, + patient: lookup === null ? 'ابتدا بیمار را جستجو کنید' : needsDetails && !detailsValid ? 'مشخصات بیمار را کامل کنید' : !mobileValid ? 'شماره موبایل بیمار معتبر نیست' - : !visitPriceValid - ? 'هزینه ویزیت الزامی است' - : null; + : null, + confirm: !visitPriceValid ? 'هزینه ویزیت الزامی است' : null, + }; + const blockReason = stepBlockReason[currentStep]; + + // خلاصهٔ مرحلهٔ آخر: همان چیزی که ثبت می‌شود، پیش از ثبت. + const pickedServiceNames = pick.serviceUuids + .map(uuid => services.find(sv => sv.uuid === uuid)?.name ?? uuid); + const staffName = (staffQ.data?.data ?? []).find(st => st.uuid === staffUuid)?.full_name ?? null; + const summaryRows: { label: string; value: string }[] = [ + ...targetFacts.map(f => ({ label: f.label, value: f.value })), + ...(pickerMode && activeDate ? [{ label: 'تاریخ', value: formatDate(activeDate) }] : []), + ...(pickerMode && pick.slot ? [{ label: 'ساعت', value: pick.slot.start_time }] : []), + ...(pickedServiceNames.length > 0 ? [{ label: 'سرویس', value: pickedServiceNames.join('، ') }] : []), + ...(staffName ? [{ label: 'پرسنل', value: staffName }] : []), + { label: 'بیمار', value: effectiveName || (patient?.name ?? '—') }, + { label: 'موبایل', value: mobile || (patient?.mobile ?? '—') }, + ]; const priceHint = pricingLoading ? 'در حال خواندن تعرفهٔ پزشک…' @@ -288,18 +329,36 @@ export default function NewAppointmentModal({ + {/* «قبلی» جای «انصراف» را نمی‌گیرد: بستنِ فرم همیشه باید یک کلیک باشد. */} - + {stepIdx > 0 && ( + + )} + {isLastStep ? ( + + ) : ( + + )} } > @@ -319,8 +378,12 @@ export default function NewAppointmentModal({ ))} - {pickerMode && activeDate && ( - + {stepKeys.length > 1 && ( + + )} + + {currentStep === 'service' && ( +
)} - + {currentStep === 'patient' && ( + {/* بیمارِ از پیش معلوم: فقط تأیید می‌شود، جستجو لازم نیست. */} {patient !== null && (
)} + )} - {/* هزینه ویزیت مرحلهٔ شماره‌دار نیست: در حالت اختیاری یک کلپسِ بسته است و - شماره دادن به آن، کاری اختیاری را اجباری نشان می‌داد. */} + {currentStep === 'confirm' && ( + + {/* خلاصهٔ همان چیزی که ثبت می‌شود — تنها جایی که کاربر پیش از ثبت، انتخاب + سرویس و زمان و بیمار را با هم می‌بیند. */} +
+ {summaryRows.map(row => ( + +
{row.label}
+
+ {row.value} +
+
+ ))} +
+ + {/* هزینه ویزیت در حالت اختیاری یک کلپسِ بسته است. */}
{requireVisit ? (
+
+ )} {blockReason && (
- برای ثبت نوبت: {blockReason} + {isLastStep ? 'برای ثبت نوبت' : 'برای مرحلهٔ بعد'}: {blockReason}
)} ); } -/** مرحلهٔ شماره‌دار فرم — ترتیب تصمیم‌ها را دیدنی می‌کند، نه فقط ترتیب فیلدها. */ -function Step({ n, title, children }: { n: number | null; title: string; children: ReactNode }) { +/** بدنهٔ یک مرحله — عنوانش در `Stepper` هم هست، اینجا سرِ همان بخش است. */ +function Step({ title, children }: { title: string; children: ReactNode }) { return (

- {/* حالت اسلاتی فقط یک مرحله دارد؛ شمارهٔ «۱» تنها، نویز است نه راهنما. */} - {n !== null && ( - {n} - )} {title}

{children}
); } + +/** + * نوار مراحل — «کجای کارم و چقدر مانده». + * + * فرم قبلاً همهٔ مراحل را با هم نشان می‌داد و مودال از ارتفاع صفحه بلندتر می‌شد؛ + * حالا هر بار یک مرحله دیده می‌شود و این نوار تنها چیزی است که کلِ مسیر را می‌گوید. + */ +function Stepper({ keys, current }: { keys: StepKey[]; current: StepKey }) { + const activeIdx = keys.indexOf(current); + + return ( +
    + {keys.map((key, idx) => { + const done = idx < activeIdx; + const active = idx === activeIdx; + + return ( +
  1. + + {idx + 1} + + + {STEP_TITLES[key]} + + {idx < keys.length - 1 && ( + + )} +
  2. + ); + })} +
+ ); +} diff --git a/assets/admin/components/appointments/ResourceBookingModal.test.tsx b/assets/admin/components/appointments/ResourceBookingModal.test.tsx index d7d4b37e..ec2b73d9 100644 --- a/assets/admin/components/appointments/ResourceBookingModal.test.tsx +++ b/assets/admin/components/appointments/ResourceBookingModal.test.tsx @@ -61,6 +61,15 @@ async function pickServiceAndTime() { fireEvent.click(await screen.findByRole('button', { name: '12:00' })); } +/** فرم ویزارد شد: هر «مرحلهٔ بعد» یک گام جلو می‌برد. */ +const nextStep = () => fireEvent.click(screen.getByRole('button', { name: 'مرحلهٔ بعد' })); + +/** خدمت و زمان را انتخاب می‌کند و به مرحلهٔ «بیمار» می‌رود. */ +async function pickServiceAndTimeThenNext() { + await pickServiceAndTime(); + nextStep(); +} + beforeEach(() => { get.mockReset(); post.mockReset(); @@ -90,12 +99,13 @@ describe('مودال ثبت نوبتِ منبع', () => { {}} onSuccess={() => {}} />, ); - await pickServiceAndTime(); + await pickServiceAndTimeThenNext(); fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } }); fireEvent.click(screen.getByRole('button', { name: 'جستجو' })); await screen.findByText('بیمار یافت شد'); + nextStep(); fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' })); await waitFor(() => expect(post).toHaveBeenCalled()); @@ -111,16 +121,14 @@ describe('مودال ثبت نوبتِ منبع', () => { }); }); - it('بدون انتخاب سرویس و زمان، ثبت غیرفعال می‌ماند', async () => { + it('بدون انتخاب سرویس و زمان، از مرحلهٔ اول رد نمی‌شود', () => { renderWithProviders( {}} onSuccess={() => {}} />, ); - fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } }); - fireEvent.click(screen.getByRole('button', { name: 'جستجو' })); - await screen.findByText('بیمار یافت شد'); - - expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeDisabled(); + // مرحلهٔ بیمار هنوز ساخته نشده، پس فیلد جستجو در DOM نیست. + expect(screen.queryByPlaceholderText('کد ملی ۱۰ رقمی')).toBeNull(); + expect(screen.getByRole('button', { name: 'مرحلهٔ بعد' })).toBeDisabled(); }); it('دلیلِ غیرفعال بودنِ ثبت را می‌گوید و با پیشرفتِ فرم عوض می‌شود', async () => { @@ -139,6 +147,7 @@ describe('مودال ثبت نوبتِ منبع', () => { expect(await screen.findByText(/ساعت شروع را انتخاب کنید/)).toBeInTheDocument(); fireEvent.click(await screen.findByRole('button', { name: '12:00' })); + nextStep(); expect(await screen.findByText(/ابتدا بیمار را جستجو کنید/)).toBeInTheDocument(); }); @@ -154,11 +163,13 @@ describe('مودال ثبت نوبتِ منبع', () => { expect(screen.queryByText('تاریخ:')).not.toBeInTheDocument(); }); - it('معیار جستجو یک seg با حالتِ اعلام‌شده است', () => { + it('معیار جستجو یک seg با حالتِ اعلام‌شده است', async () => { renderWithProviders( {}} onSuccess={() => {}} />, ); + await pickServiceAndTimeThenNext(); + const national = screen.getByRole('button', { name: 'کد ملی' }); const mobile = screen.getByRole('button', { name: 'شماره موبایل' }); expect(national).toHaveAttribute('aria-pressed', 'true'); @@ -176,13 +187,15 @@ describe('مودال ثبت نوبتِ منبع', () => { ); await pickServiceAndTime(); + // پرسنل در همان مرحلهٔ «خدمت و زمان» است. + await waitFor(() => expect(screen.getByLabelText(/پرسنل/)).toBeInTheDocument()); + nextStep(); fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } }); fireEvent.click(screen.getByRole('button', { name: 'جستجو' })); await screen.findByText('بیمار یافت شد'); - await waitFor(() => expect(screen.getByLabelText(/پرسنل/)).toBeInTheDocument()); - + nextStep(); fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' })); await waitFor(() => expect(post).toHaveBeenCalled()); expect(post.mock.calls[0][1]).toMatchObject({ staff_uuid: 'st-1' }); @@ -208,12 +221,13 @@ describe('مودال ثبت نوبتِ منبع', () => { {}} onSuccess={() => {}} />, ); - await pickServiceAndTime(); + await pickServiceAndTimeThenNext(); fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } }); fireEvent.click(screen.getByRole('button', { name: 'جستجو' })); await screen.findByText('بیمار یافت شد'); + nextStep(); fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' })); await waitFor(() => expect(post).toHaveBeenCalled()); expect(post.mock.calls[0][1]).not.toHaveProperty('staff_uuid'); diff --git a/assets/admin/components/ui/Modal.test.tsx b/assets/admin/components/ui/Modal.test.tsx new file mode 100644 index 00000000..0e6e579b --- /dev/null +++ b/assets/admin/components/ui/Modal.test.tsx @@ -0,0 +1,43 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import Modal from './Modal'; + +/** + * فرم‌های بلند (ثبت نوبت) کلِ کارت را اسکرول می‌کردند، پس عنوان و دکمهٔ اصلی از دید + * خارج می‌شد. حالا هدر و فوتر بیرون از ناحیهٔ اسکرول‌اند و فقط بدنه اسکرول می‌شود. + */ +describe('Modal', () => { + const renderModal = (size?: 'sm' | 'md' | 'lg' | 'xl') => + render( + ثبت}> +

محتوای بلند

+
, + ); + + it('هدر و فوتر بیرون از ناحیهٔ اسکرول می‌مانند', () => { + renderModal(); + + const body = document.querySelector('.modal-body'); + expect(body).not.toBeNull(); + expect(body!.textContent).toBe('محتوای بلند'); + // نه عنوان و نه دکمه نباید داخل بدنه باشند، وگرنه با محتوا اسکرول می‌شوند. + expect(body!.querySelector('.modal-head')).toBeNull(); + expect(body!.querySelector('.modal-foot')).toBeNull(); + expect(screen.getByRole('button', { name: 'ثبت' }).closest('.modal-foot')).not.toBeNull(); + }); + + it('عرض از size می‌آید', () => { + const { unmount } = renderModal('sm'); + expect((document.querySelector('.modal') as HTMLElement).style.maxWidth).toBe('420px'); + unmount(); + + renderModal('lg'); + expect((document.querySelector('.modal') as HTMLElement).style.maxWidth).toBe('720px'); + }); + + it('بسته، هیچ چیزی رندر نمی‌کند', () => { + render(

محتوا

); + + expect(document.querySelector('.modal')).toBeNull(); + }); +}); diff --git a/assets/admin/pages/AppointmentBookingModal.test.tsx b/assets/admin/pages/AppointmentBookingModal.test.tsx index 08d43023..d8fa047d 100644 --- a/assets/admin/pages/AppointmentBookingModal.test.tsx +++ b/assets/admin/pages/AppointmentBookingModal.test.tsx @@ -16,6 +16,12 @@ const post = api.post as ReturnType; 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( {}} 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( {}} onSuccess={() => {}} />); + await bookableWithoutPatient(); // برچسب اختیاری همیشه در سرِ کلپس هست، ولی فیلد در ابتدا پنهان است expect(screen.getByText('(اختیاری)')).toBeInTheDocument(); expect(screen.queryByPlaceholderText('0')).toBeNull(); @@ -182,6 +208,7 @@ describe('NewAppointmentModal — هزینه ویزیت', () => { mockPricing(0, true); renderWithProviders( {}} onSuccess={() => {}} />); + await bookableWithoutPatient(); // الزامی → بدون کلیک، فیلد و خطا نمایش داده می‌شوند await screen.findByText('هزینه ویزیت الزامی است'); expect(screen.getByPlaceholderText('0')).toBeInTheDocument();