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
@@ -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<typeof vi.fn>;
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<string, unknown> = {}) => ({
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(<NewAppointmentModal {...pickerProps()} />);
expect(stepLabels()).toEqual(['خدمت و زمان', 'بیمار', 'تأیید و ثبت']);
expect(screen.getByRole('heading', { name: 'خدمت و زمان' })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'بیمار' })).not.toBeInTheDocument();
});
it('مرحله‌ای که داده‌اش از قبل معلوم است ساخته نمی‌شود', () => {
// نوبت اسلاتی: زمان از خودِ اسلات می‌آید، پس مرحلهٔ «خدمت و زمان» بی‌معناست.
renderWithProviders(<NewAppointmentModal {...props()} />);
expect(stepLabels()).toEqual(['بیمار', 'تأیید و ثبت']);
});
it('بیمارِ از پیش معلوم، مرحلهٔ بیمار را حذف می‌کند', () => {
renderWithProviders(<NewAppointmentModal {...props({
patient: { name: 'علی محمدی', mobile: '09123456789', national_code: '1234567890' },
})} />);
// تنها مرحلهٔ باقی‌مانده «تأیید و ثبت» است، پس نوار مراحل هم لازم نیست.
expect(document.querySelector('ol[aria-label="مراحل ثبت نوبت"]')).toBeNull();
expect(screen.getByRole('heading', { name: 'تأیید و ثبت' })).toBeInTheDocument();
});
it('تا وقتی مرحله کامل نشده، دکمهٔ بعدی غیرفعال است و دلیلش را می‌گوید', () => {
renderWithProviders(<NewAppointmentModal {...pickerProps()} />);
expect(screen.getByRole('button', { name: 'مرحلهٔ بعد' })).toBeDisabled();
expect(screen.getByText(/یک سرویس انتخاب کنید/)).toBeInTheDocument();
// دکمهٔ ثبت فقط در مرحلهٔ آخر وجود دارد.
expect(screen.queryByRole('button', { name: 'ثبت نوبت' })).not.toBeInTheDocument();
});
it('در مرحلهٔ اول دکمهٔ «مرحلهٔ قبل» نیست، ولی «انصراف» همیشه هست', () => {
renderWithProviders(<NewAppointmentModal {...pickerProps()} />);
expect(screen.queryByRole('button', { name: 'مرحلهٔ قبل' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'انصراف' })).toBeInTheDocument();
});
it('مرحلهٔ آخر خلاصهٔ نوبت را نشان می‌دهد', () => {
renderWithProviders(<NewAppointmentModal {...props({
patient: { name: 'علی محمدی', mobile: '09123456789', national_code: '1234567890' },
})} />);
expect(screen.getByText('علی محمدی')).toBeInTheDocument();
expect(screen.getByText('09123456789')).toBeInTheDocument();
});
it('دکمهٔ اصلی در فوتر ثابت است، نه داخل ناحیهٔ اسکرول', () => {
renderWithProviders(<NewAppointmentModal {...pickerProps()} />);
const next = screen.getByRole('button', { name: 'مرحلهٔ بعد' });
expect(next.closest('.modal-foot')).not.toBeNull();
expect(next.closest('.modal-body')).toBeNull();
});
});
@@ -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<StepKey, string> = {
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<StepKey, string | null> = {
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({
<Modal
open
title="ثبت نوبت"
size="sm"
// هر بار یک مرحله دیده می‌شود، پس ستون تکی کافی است؛ `md` عرضِ راحتِ
// انتخابگر سرویس و اسلات است بدون فضای خالی.
size="md"
onClose={onClose}
footer={
<>
{/* «قبلی» جای «انصراف» را نمی‌گیرد: بستنِ فرم همیشه باید یک کلیک باشد. */}
<button className="btn ghost" onClick={onClose}>انصراف</button>
<button
className="btn primary"
onClick={() => mutation.mutate()}
disabled={!isValid || mutation.isPending}
>
{mutation.isPending ? 'در حال ثبت…' : 'ثبت نوبت'}
</button>
{stepIdx > 0 && (
<button className="btn secondary" onClick={() => setStepIdx(i => i - 1)}>
مرحلهٔ قبل
</button>
)}
{isLastStep ? (
<button
className="btn primary"
onClick={() => mutation.mutate()}
disabled={!isValid || mutation.isPending}
>
{mutation.isPending ? 'در حال ثبت…' : 'ثبت نوبت'}
</button>
) : (
<button
className="btn primary"
onClick={() => setStepIdx(i => i + 1)}
disabled={blockReason !== null}
>
مرحلهٔ بعد
</button>
)}
</>
}
>
@@ -319,8 +378,12 @@ export default function NewAppointmentModal({
))}
</div>
{pickerMode && activeDate && (
<Step n={1} title="خدمت و زمان">
{stepKeys.length > 1 && (
<Stepper keys={stepKeys} current={currentStep} />
)}
{currentStep === 'service' && (
<Step title={STEP_TITLES.service}>
<div className="field-block" style={{ marginBottom: 12, maxWidth: 220 }}>
<label htmlFor="appt-date">تاریخ نوبت</label>
<PersianDateInput
@@ -357,7 +420,8 @@ export default function NewAppointmentModal({
</Step>
)}
<Step n={pickerMode ? 2 : null} title="بیمار">
{currentStep === 'patient' && (
<Step title={STEP_TITLES.patient}>
{/* بیمارِ از پیش معلوم: فقط تأیید می‌شود، جستجو لازم نیست. */}
{patient !== null && (
<div style={{
@@ -512,9 +576,28 @@ export default function NewAppointmentModal({
</>
)}
</Step>
)}
{/* هزینه ویزیت مرحلهٔ شماره‌دار نیست: در حالت اختیاری یک کلپسِ بسته است و
شماره دادن به آن، کاری اختیاری را اجباری نشان می‌داد. */}
{currentStep === 'confirm' && (
<Step title={STEP_TITLES.confirm}>
{/* خلاصهٔ همان چیزی که ثبت می‌شود — تنها جایی که کاربر پیش از ثبت، انتخاب
سرویس و زمان و بیمار را با هم می‌بیند. */}
<dl style={{
display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '8px 14px', margin: '0 0 18px',
padding: '12px 14px', borderRadius: 'var(--r-sm)', background: 'var(--surface-2)',
fontSize: 13,
}}>
{summaryRows.map(row => (
<React.Fragment key={row.label}>
<dt style={{ color: 'var(--text-3)' }}>{row.label}</dt>
<dd style={{ margin: 0, color: 'var(--text)', fontWeight: 600, minWidth: 0, overflowWrap: 'anywhere' }}>
{row.value}
</dd>
</React.Fragment>
))}
</dl>
{/* هزینه ویزیت در حالت اختیاری یک کلپسِ بسته است. */}
<div className="field-block">
{requireVisit ? (
<label>
@@ -572,6 +655,8 @@ export default function NewAppointmentModal({
</>
)}
</div>
</Step>
)}
{blockReason && (
<div style={{
@@ -579,31 +664,78 @@ export default function NewAppointmentModal({
fontSize: 12.5, color: 'var(--text-3)',
}}>
<ExclamationCircleIcon style={{ width: 15, height: 15, flexShrink: 0 }} />
برای ثبت نوبت: {blockReason}
{isLastStep ? 'برای ثبت نوبت' : 'برای مرحلهٔ بعد'}: {blockReason}
</div>
)}
</Modal>
);
}
/** مرحلهٔ شماره‌دار فرم — ترتیب تصمیم‌ها را دیدنی می‌کند، نه فقط ترتیب فیلدها. */
function Step({ n, title, children }: { n: number | null; title: string; children: ReactNode }) {
/** بدنهٔ یک مرحله — عنوانش در `Stepper` هم هست، اینجا سرِ همان بخش است. */
function Step({ title, children }: { title: string; children: ReactNode }) {
return (
<section style={{ marginBottom: 18 }}>
<h3 style={{
display: 'flex', alignItems: 'center', gap: 8, margin: '0 0 10px',
fontSize: 13.5, fontWeight: 700, color: 'var(--text)',
margin: '0 0 10px', fontSize: 13.5, fontWeight: 700, color: 'var(--text)',
}}>
{/* حالت اسلاتی فقط یک مرحله دارد؛ شمارهٔ «۱» تنها، نویز است نه راهنما. */}
{n !== null && (
<span style={{
display: 'grid', placeItems: 'center', width: 20, height: 20, borderRadius: 999,
background: 'var(--primary)', color: 'var(--on-primary)', fontSize: 11.5, flexShrink: 0,
}}>{n}</span>
)}
{title}
</h3>
{children}
</section>
);
}
/**
* نوار مراحل — «کجای کارم و چقدر مانده».
*
* فرم قبلاً همهٔ مراحل را با هم نشان می‌داد و مودال از ارتفاع صفحه بلندتر می‌شد؛
* حالا هر بار یک مرحله دیده می‌شود و این نوار تنها چیزی است که کلِ مسیر را می‌گوید.
*/
function Stepper({ keys, current }: { keys: StepKey[]; current: StepKey }) {
const activeIdx = keys.indexOf(current);
return (
<ol
aria-label="مراحل ثبت نوبت"
style={{
display: 'flex', alignItems: 'center', gap: 8, listStyle: 'none',
margin: '0 0 18px', padding: 0,
}}
>
{keys.map((key, idx) => {
const done = idx < activeIdx;
const active = idx === activeIdx;
return (
<li
key={key}
aria-current={active ? 'step' : undefined}
style={{ display: 'flex', alignItems: 'center', gap: 8, flex: idx === keys.length - 1 ? '0 0 auto' : 1 }}
>
<span style={{
display: 'grid', placeItems: 'center', width: 22, height: 22, borderRadius: 999,
flexShrink: 0, fontSize: 11.5, fontWeight: 700,
background: active || done ? 'var(--primary)' : 'var(--surface-3)',
color: active || done ? 'var(--on-primary)' : 'var(--text-3)',
}}>
{idx + 1}
</span>
<span style={{
fontSize: 12.5, whiteSpace: 'nowrap',
fontWeight: active ? 700 : 500,
color: active ? 'var(--text)' : 'var(--text-3)',
}}>
{STEP_TITLES[key]}
</span>
{idx < keys.length - 1 && (
<span style={{
flex: 1, height: 1, minWidth: 12,
background: done ? 'var(--primary)' : 'var(--border)',
}} />
)}
</li>
);
})}
</ol>
);
}
@@ -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('مودال ثبت نوبتِ منبع', () => {
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} 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(
<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();
// مرحلهٔ بیمار هنوز ساخته نشده، پس فیلد جستجو در 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(
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} 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('مودال ثبت نوبتِ منبع', () => {
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} 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');
+43
View File
@@ -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(
<Modal open title="ثبت نوبت" size={size} onClose={vi.fn()} footer={<button>ثبت</button>}>
<p>محتوای بلند</p>
</Modal>,
);
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(<Modal open={false} title="ثبت نوبت" onClose={vi.fn()}><p>محتوا</p></Modal>);
expect(document.querySelector('.modal')).toBeNull();
});
});
@@ -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();