feat(appointments): implement multi-step wizard for appointment booking modal
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user