feat(stepper): implement multi-step wizard for appointment confirmation process

This commit is contained in:
hamed
2026-08-09 09:12:00 +03:30
parent 948f59827a
commit 3ffab2bbd0
7 changed files with 544 additions and 81 deletions
@@ -34,11 +34,28 @@ beforeEach(() => {
post.mockResolvedValue({ success: true, data: {} });
});
/** همهٔ فیلدهای مبلغ (تومان) در ردیف‌های پرداخت. */
/**
* فرم ویزارد شد: «بیمه و هزینه» → «پرداخت» → «تأیید».
* ردیف‌های پرداخت در مرحلهٔ دوم‌اند و وضعیت پرداخت در مرحلهٔ سوم.
*/
const nextStep = () => fireEvent.click(screen.getByRole('button', { name: 'مرحلهٔ بعد' }));
/** همهٔ فیلدهای مبلغ (تومان) در ردیف‌های پرداخت — مرحلهٔ «پرداخت». */
function amountInputs() {
return screen.getAllByPlaceholderText('0') as HTMLInputElement[];
}
/** از مرحلهٔ «بیمه و هزینه» به «پرداخت» می‌رود و ردیف‌های مبلغ را برمی‌گرداند. */
function goToPayment() {
nextStep();
return amountInputs();
}
/** تا مرحلهٔ آخر جلو می‌رود؛ وضعیت پرداخت و دکمهٔ قطعی آنجا هستند. */
function goToReview() {
fireEvent.click(screen.getByRole('button', { name: 'مرحلهٔ بعد' }));
}
describe('ConfirmAppointmentModal', () => {
it('بیمار، اقلام هزینه و جمع کل را نشان می‌دهد', () => {
render();
@@ -51,29 +68,36 @@ describe('ConfirmAppointmentModal', () => {
it('ردیفِ اول پیش‌فرض برابر کل هزینه است و وضعیت «تسویه کامل» می‌شود', () => {
render();
// ۳٬۰۰۰٬۰۰۰ ریال = ۳۰۰٬۰۰۰ تومان
expect(amountInputs()[0]).toHaveValue('۳۰۰٬۰۰۰');
expect(goToPayment()[0]).toHaveValue('۳۰۰٬۰۰۰');
goToReview();
expect(screen.getByText('تسویه کامل')).toBeInTheDocument();
});
it('با تغییر دستی مبلغ به کمتر از کل، وضعیت «پرداخت جزئی» می‌شود', () => {
render();
fireEvent.change(amountInputs()[0], { target: { value: '100000' } });
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
goToReview();
expect(screen.getByText('پرداخت جزئی')).toBeInTheDocument();
});
it('دکمهٔ تأیید فقط با مجموعِ بیشتر از جمع کل غیرفعال می‌شود', () => {
render();
const submit = screen.getByRole('button', { name: 'تأیید و قطعی کردن' });
expect(submit).not.toBeDisabled();
const rows = goToPayment();
fireEvent.change(amountInputs()[0], { target: { value: '9000000' } });
fireEvent.change(rows[0], { target: { value: '9000000' } });
expect(screen.getByText('مجموع پرداخت‌ها از مبلغ قابل پرداخت بیشتر است.')).toBeInTheDocument();
expect(submit).toBeDisabled();
// پرداختِ بیشتر از مبلغ، حتی جلوی رفتن به مرحلهٔ تأیید را می‌گیرد.
expect(screen.getByRole('button', { name: 'مرحلهٔ بعد' })).toBeDisabled();
fireEvent.change(rows[0], { target: { value: '100000' } });
goToReview();
expect(screen.getByRole('button', { name: 'تأیید و قطعی کردن' })).not.toBeDisabled();
});
it('پرداخت جزئی مجاز است و همان یک روش را ثبت می‌کند', async () => {
render();
fireEvent.change(amountInputs()[0], { target: { value: '100000' } });
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
goToReview();
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/appointment/a1/confirm', {
@@ -85,7 +109,7 @@ describe('ConfirmAppointmentModal', () => {
it('تقسیم پرداخت بین دو روش: مجموع ردیف‌ها به‌صورت آرایه ثبت می‌شود', async () => {
render();
// ردیف اول را به ۲۰۰٬۰۰۰ تومان کم می‌کنیم
fireEvent.change(amountInputs()[0], { target: { value: '200000' } });
fireEvent.change(goToPayment()[0], { target: { value: '200000' } });
// افزودن روش دوم — پیش‌فرض با باقی‌ماندهٔ ۱۰۰٬۰۰۰ تومان پر می‌شود
fireEvent.click(screen.getByRole('button', { name: /افزودن روش/ }));
@@ -94,6 +118,7 @@ describe('ConfirmAppointmentModal', () => {
expect(inputs[1]).toHaveValue('۱۰۰٬۰۰۰');
// مجموع = ۳۰۰٬۰۰۰ تومان = کل ⇒ تسویه کامل
goToReview();
expect(screen.getByText('تسویه کامل')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
@@ -108,6 +133,7 @@ describe('ConfirmAppointmentModal', () => {
it('حذف ردیف اضافه‌شده مجموع را دوباره محاسبه می‌کند', () => {
render();
goToPayment();
fireEvent.click(screen.getByRole('button', { name: /افزودن روش/ }));
expect(amountInputs()).toHaveLength(2);
@@ -220,8 +246,9 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
// ۵٬۹۵۲٬۰۰۰ × ۷۰٪ = ۴٬۱۶۶٬۴۰۰ سهم بیمه · ۱٬۷۸۵٬۶۰۰ سهم بیمار
expect(await screen.findByText('سهم بیمار (قابل پرداخت)')).toBeInTheDocument();
expect(amountInputs()[0]).toHaveValue('۱۷۸٬۵۶۰');
expect(goToPayment()[0]).toHaveValue('۱۷۸٬۵۶۰');
goToReview();
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/appointment/a1/confirm', {
version: 1,
@@ -252,8 +279,10 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
// تکمیلی ۹۰٪ منهای فرانشیز ۱۰٪ → ۱٬۴۲۸٬۴۸۰؛ سهم بیمار ۳۵۷٬۱۲۰ ریال = ۳۵٬۷۱۲ تومان.
expect(await screen.findByText(/سهم بیمه پایه/)).toBeInTheDocument();
expect(screen.getByText(/سهم بیمه تکمیلی/)).toBeInTheDocument();
nextStep();
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۳۵٬۷۱۲'));
goToReview();
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/appointment/a1/confirm', {
version: 1,
@@ -273,8 +302,9 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
await pick('بدون بیمه تکمیلی', 'بیمه آسیا');
// بدون پایه: ۹۰٪ منهای فرانشیز ۱۰٪ از ۵٬۹۵۲٬۰۰۰ → ۴٬۷۶۱٬۶۰۰؛ بیمار ۱٬۱۹۰٬۴۰۰.
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۱۱۹٬۰۴۰'));
expect(screen.queryByText(/سهم بیمه پایه/)).not.toBeInTheDocument();
nextStep();
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۱۱۹٬۰۴۰'));
});
it('نوبتِ بدون هزینهٔ ویزیت، «قیمت ویزیت آزاد» تنظیمات را نشان می‌دهد (نه صفر)', async () => {
@@ -288,15 +318,18 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
/>,
);
expect(await screen.findByText('مبلغ قابل پرداخت')).toBeInTheDocument();
// ۵٬۹۵۲٬۰۰۰ ریال = ۵۹۵٬۲۰۰ تومان — همان مبلغی که سرور روی مراجعه می‌گذارد.
nextStep();
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۵۹۵٬۲۰۰'));
expect(screen.getByText('مبلغ قابل پرداخت')).toBeInTheDocument();
});
it('هزینهٔ ویزیتِ خودِ نوبت بر «قیمت ویزیت آزاد» اولویت دارد', async () => {
mockInsurance(BOTH, 9_000_000);
renderReference(); // نوبت خودش ۵٬۹۵۲٬۰۰۰ دارد
await screen.findByText('مبلغ قابل پرداخت');
nextStep();
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۵۹۵٬۲۰۰'));
});
@@ -309,6 +342,53 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
await pick('بدون بیمه', 'بیمه ایران');
// ۵٬۹۵۲٬۰۰۰ × ۳۰٪ = ۱٬۷۸۵٬۶۰۰ سهم بیمه · ۴٬۱۶۶٬۴۰۰ سهم بیمار
await screen.findByText('سهم بیمار (قابل پرداخت)');
nextStep();
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۴۱۶٬۶۴۰'));
});
});
describe('ConfirmAppointmentModal — ویزارد', () => {
const stepLabels = () =>
Array.from(document.querySelectorAll('ol[aria-label="مراحل قطعی کردن نوبت"] li'))
.map(li => li.textContent?.replace(/^\d+/, '').trim());
it('سه مرحله دارد و از «بیمه و هزینه» شروع می‌شود', () => {
render();
expect(stepLabels()).toEqual(['بیمه و هزینه', 'پرداخت', 'تأیید']);
// جدول هزینه در مرحلهٔ اول است، ردیف پرداخت هنوز نه.
expect(screen.getByText('جمع کل')).toBeInTheDocument();
expect(screen.queryByPlaceholderText('0')).toBeNull();
});
it('در مرحلهٔ اول دکمهٔ قطعی وجود ندارد و «مرحلهٔ قبل» هم نیست', () => {
render();
expect(screen.queryByRole('button', { name: 'تأیید و قطعی کردن' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'مرحلهٔ قبل' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'انصراف' })).toBeInTheDocument();
});
it('مرحلهٔ قبل مقادیر واردشده را نگه می‌دارد', () => {
render();
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
fireEvent.click(screen.getByRole('button', { name: 'مرحلهٔ قبل' }));
expect(screen.getByText('جمع کل')).toBeInTheDocument();
// برگشت به پرداخت: مبلغ همان است، نه پیش‌فرضِ کل.
nextStep();
expect(amountInputs()[0]).toHaveValue('۱۰۰٬۰۰۰');
});
it('مرحلهٔ آخر روش‌های پرداخت را قبل از ثبت خلاصه می‌کند', () => {
render();
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
goToReview();
expect(screen.getByText('پرداخت نقدی')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'تأیید و قطعی کردن' })).toBeInTheDocument();
});
});