feat(stepper): implement multi-step wizard for appointment confirmation process
This commit is contained in:
@@ -34,11 +34,28 @@ beforeEach(() => {
|
|||||||
post.mockResolvedValue({ success: true, data: {} });
|
post.mockResolvedValue({ success: true, data: {} });
|
||||||
});
|
});
|
||||||
|
|
||||||
/** همهٔ فیلدهای مبلغ (تومان) در ردیفهای پرداخت. */
|
/**
|
||||||
|
* فرم ویزارد شد: «بیمه و هزینه» → «پرداخت» → «تأیید».
|
||||||
|
* ردیفهای پرداخت در مرحلهٔ دوماند و وضعیت پرداخت در مرحلهٔ سوم.
|
||||||
|
*/
|
||||||
|
const nextStep = () => fireEvent.click(screen.getByRole('button', { name: 'مرحلهٔ بعد' }));
|
||||||
|
|
||||||
|
/** همهٔ فیلدهای مبلغ (تومان) در ردیفهای پرداخت — مرحلهٔ «پرداخت». */
|
||||||
function amountInputs() {
|
function amountInputs() {
|
||||||
return screen.getAllByPlaceholderText('0') as HTMLInputElement[];
|
return screen.getAllByPlaceholderText('0') as HTMLInputElement[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** از مرحلهٔ «بیمه و هزینه» به «پرداخت» میرود و ردیفهای مبلغ را برمیگرداند. */
|
||||||
|
function goToPayment() {
|
||||||
|
nextStep();
|
||||||
|
return amountInputs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** تا مرحلهٔ آخر جلو میرود؛ وضعیت پرداخت و دکمهٔ قطعی آنجا هستند. */
|
||||||
|
function goToReview() {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'مرحلهٔ بعد' }));
|
||||||
|
}
|
||||||
|
|
||||||
describe('ConfirmAppointmentModal', () => {
|
describe('ConfirmAppointmentModal', () => {
|
||||||
it('بیمار، اقلام هزینه و جمع کل را نشان میدهد', () => {
|
it('بیمار، اقلام هزینه و جمع کل را نشان میدهد', () => {
|
||||||
render();
|
render();
|
||||||
@@ -51,29 +68,36 @@ describe('ConfirmAppointmentModal', () => {
|
|||||||
it('ردیفِ اول پیشفرض برابر کل هزینه است و وضعیت «تسویه کامل» میشود', () => {
|
it('ردیفِ اول پیشفرض برابر کل هزینه است و وضعیت «تسویه کامل» میشود', () => {
|
||||||
render();
|
render();
|
||||||
// ۳٬۰۰۰٬۰۰۰ ریال = ۳۰۰٬۰۰۰ تومان
|
// ۳٬۰۰۰٬۰۰۰ ریال = ۳۰۰٬۰۰۰ تومان
|
||||||
expect(amountInputs()[0]).toHaveValue('۳۰۰٬۰۰۰');
|
expect(goToPayment()[0]).toHaveValue('۳۰۰٬۰۰۰');
|
||||||
|
goToReview();
|
||||||
expect(screen.getByText('تسویه کامل')).toBeInTheDocument();
|
expect(screen.getByText('تسویه کامل')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('با تغییر دستی مبلغ به کمتر از کل، وضعیت «پرداخت جزئی» میشود', () => {
|
it('با تغییر دستی مبلغ به کمتر از کل، وضعیت «پرداخت جزئی» میشود', () => {
|
||||||
render();
|
render();
|
||||||
fireEvent.change(amountInputs()[0], { target: { value: '100000' } });
|
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
|
||||||
|
goToReview();
|
||||||
expect(screen.getByText('پرداخت جزئی')).toBeInTheDocument();
|
expect(screen.getByText('پرداخت جزئی')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('دکمهٔ تأیید فقط با مجموعِ بیشتر از جمع کل غیرفعال میشود', () => {
|
it('دکمهٔ تأیید فقط با مجموعِ بیشتر از جمع کل غیرفعال میشود', () => {
|
||||||
render();
|
render();
|
||||||
const submit = screen.getByRole('button', { name: 'تأیید و قطعی کردن' });
|
const rows = goToPayment();
|
||||||
expect(submit).not.toBeDisabled();
|
|
||||||
|
|
||||||
fireEvent.change(amountInputs()[0], { target: { value: '9000000' } });
|
fireEvent.change(rows[0], { target: { value: '9000000' } });
|
||||||
expect(screen.getByText('مجموع پرداختها از مبلغ قابل پرداخت بیشتر است.')).toBeInTheDocument();
|
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 () => {
|
it('پرداخت جزئی مجاز است و همان یک روش را ثبت میکند', async () => {
|
||||||
render();
|
render();
|
||||||
fireEvent.change(amountInputs()[0], { target: { value: '100000' } });
|
fireEvent.change(goToPayment()[0], { target: { value: '100000' } });
|
||||||
|
goToReview();
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
|
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
|
||||||
|
|
||||||
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/appointment/a1/confirm', {
|
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/appointment/a1/confirm', {
|
||||||
@@ -85,7 +109,7 @@ describe('ConfirmAppointmentModal', () => {
|
|||||||
it('تقسیم پرداخت بین دو روش: مجموع ردیفها بهصورت آرایه ثبت میشود', async () => {
|
it('تقسیم پرداخت بین دو روش: مجموع ردیفها بهصورت آرایه ثبت میشود', async () => {
|
||||||
render();
|
render();
|
||||||
// ردیف اول را به ۲۰۰٬۰۰۰ تومان کم میکنیم
|
// ردیف اول را به ۲۰۰٬۰۰۰ تومان کم میکنیم
|
||||||
fireEvent.change(amountInputs()[0], { target: { value: '200000' } });
|
fireEvent.change(goToPayment()[0], { target: { value: '200000' } });
|
||||||
// افزودن روش دوم — پیشفرض با باقیماندهٔ ۱۰۰٬۰۰۰ تومان پر میشود
|
// افزودن روش دوم — پیشفرض با باقیماندهٔ ۱۰۰٬۰۰۰ تومان پر میشود
|
||||||
fireEvent.click(screen.getByRole('button', { name: /افزودن روش/ }));
|
fireEvent.click(screen.getByRole('button', { name: /افزودن روش/ }));
|
||||||
|
|
||||||
@@ -94,6 +118,7 @@ describe('ConfirmAppointmentModal', () => {
|
|||||||
expect(inputs[1]).toHaveValue('۱۰۰٬۰۰۰');
|
expect(inputs[1]).toHaveValue('۱۰۰٬۰۰۰');
|
||||||
|
|
||||||
// مجموع = ۳۰۰٬۰۰۰ تومان = کل ⇒ تسویه کامل
|
// مجموع = ۳۰۰٬۰۰۰ تومان = کل ⇒ تسویه کامل
|
||||||
|
goToReview();
|
||||||
expect(screen.getByText('تسویه کامل')).toBeInTheDocument();
|
expect(screen.getByText('تسویه کامل')).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
|
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
|
||||||
@@ -108,6 +133,7 @@ describe('ConfirmAppointmentModal', () => {
|
|||||||
|
|
||||||
it('حذف ردیف اضافهشده مجموع را دوباره محاسبه میکند', () => {
|
it('حذف ردیف اضافهشده مجموع را دوباره محاسبه میکند', () => {
|
||||||
render();
|
render();
|
||||||
|
goToPayment();
|
||||||
fireEvent.click(screen.getByRole('button', { name: /افزودن روش/ }));
|
fireEvent.click(screen.getByRole('button', { name: /افزودن روش/ }));
|
||||||
expect(amountInputs()).toHaveLength(2);
|
expect(amountInputs()).toHaveLength(2);
|
||||||
|
|
||||||
@@ -220,8 +246,9 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
|
|||||||
|
|
||||||
// ۵٬۹۵۲٬۰۰۰ × ۷۰٪ = ۴٬۱۶۶٬۴۰۰ سهم بیمه · ۱٬۷۸۵٬۶۰۰ سهم بیمار
|
// ۵٬۹۵۲٬۰۰۰ × ۷۰٪ = ۴٬۱۶۶٬۴۰۰ سهم بیمه · ۱٬۷۸۵٬۶۰۰ سهم بیمار
|
||||||
expect(await screen.findByText('سهم بیمار (قابل پرداخت)')).toBeInTheDocument();
|
expect(await screen.findByText('سهم بیمار (قابل پرداخت)')).toBeInTheDocument();
|
||||||
expect(amountInputs()[0]).toHaveValue('۱۷۸٬۵۶۰');
|
expect(goToPayment()[0]).toHaveValue('۱۷۸٬۵۶۰');
|
||||||
|
|
||||||
|
goToReview();
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
|
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
|
||||||
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/appointment/a1/confirm', {
|
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/appointment/a1/confirm', {
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -252,8 +279,10 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
|
|||||||
// تکمیلی ۹۰٪ منهای فرانشیز ۱۰٪ → ۱٬۴۲۸٬۴۸۰؛ سهم بیمار ۳۵۷٬۱۲۰ ریال = ۳۵٬۷۱۲ تومان.
|
// تکمیلی ۹۰٪ منهای فرانشیز ۱۰٪ → ۱٬۴۲۸٬۴۸۰؛ سهم بیمار ۳۵۷٬۱۲۰ ریال = ۳۵٬۷۱۲ تومان.
|
||||||
expect(await screen.findByText(/سهم بیمه پایه/)).toBeInTheDocument();
|
expect(await screen.findByText(/سهم بیمه پایه/)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/سهم بیمه تکمیلی/)).toBeInTheDocument();
|
expect(screen.getByText(/سهم بیمه تکمیلی/)).toBeInTheDocument();
|
||||||
|
nextStep();
|
||||||
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۳۵٬۷۱۲'));
|
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۳۵٬۷۱۲'));
|
||||||
|
|
||||||
|
goToReview();
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
|
fireEvent.click(screen.getByRole('button', { name: 'تأیید و قطعی کردن' }));
|
||||||
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/appointment/a1/confirm', {
|
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/appointment/a1/confirm', {
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -273,8 +302,9 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
|
|||||||
await pick('بدون بیمه تکمیلی', 'بیمه آسیا');
|
await pick('بدون بیمه تکمیلی', 'بیمه آسیا');
|
||||||
|
|
||||||
// بدون پایه: ۹۰٪ منهای فرانشیز ۱۰٪ از ۵٬۹۵۲٬۰۰۰ → ۴٬۷۶۱٬۶۰۰؛ بیمار ۱٬۱۹۰٬۴۰۰.
|
// بدون پایه: ۹۰٪ منهای فرانشیز ۱۰٪ از ۵٬۹۵۲٬۰۰۰ → ۴٬۷۶۱٬۶۰۰؛ بیمار ۱٬۱۹۰٬۴۰۰.
|
||||||
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۱۱۹٬۰۴۰'));
|
|
||||||
expect(screen.queryByText(/سهم بیمه پایه/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/سهم بیمه پایه/)).not.toBeInTheDocument();
|
||||||
|
nextStep();
|
||||||
|
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۱۱۹٬۰۴۰'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('نوبتِ بدون هزینهٔ ویزیت، «قیمت ویزیت آزاد» تنظیمات را نشان میدهد (نه صفر)', async () => {
|
it('نوبتِ بدون هزینهٔ ویزیت، «قیمت ویزیت آزاد» تنظیمات را نشان میدهد (نه صفر)', async () => {
|
||||||
@@ -288,15 +318,18 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText('مبلغ قابل پرداخت')).toBeInTheDocument();
|
||||||
// ۵٬۹۵۲٬۰۰۰ ریال = ۵۹۵٬۲۰۰ تومان — همان مبلغی که سرور روی مراجعه میگذارد.
|
// ۵٬۹۵۲٬۰۰۰ ریال = ۵۹۵٬۲۰۰ تومان — همان مبلغی که سرور روی مراجعه میگذارد.
|
||||||
|
nextStep();
|
||||||
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۵۹۵٬۲۰۰'));
|
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۵۹۵٬۲۰۰'));
|
||||||
expect(screen.getByText('مبلغ قابل پرداخت')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('هزینهٔ ویزیتِ خودِ نوبت بر «قیمت ویزیت آزاد» اولویت دارد', async () => {
|
it('هزینهٔ ویزیتِ خودِ نوبت بر «قیمت ویزیت آزاد» اولویت دارد', async () => {
|
||||||
mockInsurance(BOTH, 9_000_000);
|
mockInsurance(BOTH, 9_000_000);
|
||||||
renderReference(); // نوبت خودش ۵٬۹۵۲٬۰۰۰ دارد
|
renderReference(); // نوبت خودش ۵٬۹۵۲٬۰۰۰ دارد
|
||||||
|
|
||||||
|
await screen.findByText('مبلغ قابل پرداخت');
|
||||||
|
nextStep();
|
||||||
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۵۹۵٬۲۰۰'));
|
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۵۹۵٬۲۰۰'));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -309,6 +342,53 @@ describe('ConfirmAppointmentModal — انتخاب بیمه', () => {
|
|||||||
await pick('بدون بیمه', 'بیمه ایران');
|
await pick('بدون بیمه', 'بیمه ایران');
|
||||||
|
|
||||||
// ۵٬۹۵۲٬۰۰۰ × ۳۰٪ = ۱٬۷۸۵٬۶۰۰ سهم بیمه · ۴٬۱۶۶٬۴۰۰ سهم بیمار
|
// ۵٬۹۵۲٬۰۰۰ × ۳۰٪ = ۱٬۷۸۵٬۶۰۰ سهم بیمه · ۴٬۱۶۶٬۴۰۰ سهم بیمار
|
||||||
|
await screen.findByText('سهم بیمار (قابل پرداخت)');
|
||||||
|
nextStep();
|
||||||
await waitFor(() => expect(amountInputs()[0]).toHaveValue('۴۱۶٬۶۴۰'));
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useAppointmentInsurance } from '../../hooks/useAppointmentInsurance';
|
|||||||
import Modal from '../ui/Modal';
|
import Modal from '../ui/Modal';
|
||||||
import PriceInput from '../ui/PriceInput';
|
import PriceInput from '../ui/PriceInput';
|
||||||
import SearchableSelect from '../ui/SearchableSelect';
|
import SearchableSelect from '../ui/SearchableSelect';
|
||||||
|
import Stepper from '../ui/Stepper';
|
||||||
|
|
||||||
/** همان چهار روشِ SessionPayment::METHODS در بکاند. */
|
/** همان چهار روشِ SessionPayment::METHODS در بکاند. */
|
||||||
const METHOD_OPTIONS = [
|
const METHOD_OPTIONS = [
|
||||||
@@ -49,6 +50,20 @@ interface Props {
|
|||||||
queryKey?: unknown[];
|
queryKey?: unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* مراحلِ قطعی کردن: اول «چقدر»، بعد «چطور»، آخر «تأیید».
|
||||||
|
*
|
||||||
|
* فرم قبلاً یک صفحهٔ بلند بود — بیمه، جدول هزینه، ردیفهای پرداخت و خلاصه با هم — و
|
||||||
|
* با دو روش پرداخت از ارتفاع صفحه بلندتر میشد.
|
||||||
|
*/
|
||||||
|
const CONFIRM_STEPS = [
|
||||||
|
{ key: 'cost', title: 'بیمه و هزینه' },
|
||||||
|
{ key: 'payment', title: 'پرداخت' },
|
||||||
|
{ key: 'review', title: 'تأیید' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type ConfirmStepKey = (typeof CONFIRM_STEPS)[number]['key'];
|
||||||
|
|
||||||
/** یک ردیفِ پرداخت در تسویهٔ چندروشی. */
|
/** یک ردیفِ پرداخت در تسویهٔ چندروشی. */
|
||||||
interface PaymentRow {
|
interface PaymentRow {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -99,6 +114,7 @@ export default function ConfirmAppointmentModal({
|
|||||||
const [rows, setRows] = useState<PaymentRow[]>([makeRow()]);
|
const [rows, setRows] = useState<PaymentRow[]>([makeRow()]);
|
||||||
/** تا وقتی کاربر مبلغ را دست نزده، ردیفِ اول با کل مبلغ پر میماند. */
|
/** تا وقتی کاربر مبلغ را دست نزده، ردیفِ اول با کل مبلغ پر میماند. */
|
||||||
const [touched, setTouched] = useState(false);
|
const [touched, setTouched] = useState(false);
|
||||||
|
const [stepIdx, setStepIdx] = useState(0);
|
||||||
|
|
||||||
// وقتی صفحهی میزبان نوبت را ندارد (مثل ردیف لیست) خودمان جزئیات را میگیریم:
|
// وقتی صفحهی میزبان نوبت را ندارد (مثل ردیف لیست) خودمان جزئیات را میگیریم:
|
||||||
// مبلغ ویزیت و قیمت سرویسها فقط در detail هستند.
|
// مبلغ ویزیت و قیمت سرویسها فقط در detail هستند.
|
||||||
@@ -225,6 +241,7 @@ export default function ConfirmAppointmentModal({
|
|||||||
nextId.current = 1;
|
nextId.current = 1;
|
||||||
setRows([makeRow()]);
|
setRows([makeRow()]);
|
||||||
setTouched(false);
|
setTouched(false);
|
||||||
|
setStepIdx(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchRow(id: number, patch: Partial<PaymentRow>) {
|
function patchRow(id: number, patch: Partial<PaymentRow>) {
|
||||||
@@ -254,6 +271,8 @@ export default function ConfirmAppointmentModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loading = detailQuery.isLoading && !appointment;
|
const loading = detailQuery.isLoading && !appointment;
|
||||||
|
const currentStep: ConfirmStepKey = CONFIRM_STEPS[Math.min(stepIdx, CONFIRM_STEPS.length - 1)].key;
|
||||||
|
const isLastStep = currentStep === 'review';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -263,17 +282,34 @@ export default function ConfirmAppointmentModal({
|
|||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
|
{/* بستنِ فرم همیشه یک کلیک است؛ «مرحلهٔ قبل» جایش را نمیگیرد. */}
|
||||||
<button type="button" className="btn ghost" onClick={handleClose}>
|
<button type="button" className="btn ghost" onClick={handleClose}>
|
||||||
انصراف
|
انصراف
|
||||||
</button>
|
</button>
|
||||||
<button
|
{stepIdx > 0 && (
|
||||||
type="button"
|
<button type="button" className="btn secondary" onClick={() => setStepIdx(i => i - 1)}>
|
||||||
className="btn primary"
|
مرحلهٔ قبل
|
||||||
disabled={loading || overpaid || confirmMut.isPending}
|
</button>
|
||||||
onClick={() => confirmMut.mutate()}
|
)}
|
||||||
>
|
{isLastStep ? (
|
||||||
{confirmMut.isPending ? 'در حال ثبت…' : 'تأیید و قطعی کردن'}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
|
className="btn primary"
|
||||||
|
disabled={loading || overpaid || confirmMut.isPending}
|
||||||
|
onClick={() => confirmMut.mutate()}
|
||||||
|
>
|
||||||
|
{confirmMut.isPending ? 'در حال ثبت…' : 'تأیید و قطعی کردن'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary"
|
||||||
|
disabled={loading || overpaid}
|
||||||
|
onClick={() => setStepIdx(i => i + 1)}
|
||||||
|
>
|
||||||
|
مرحلهٔ بعد
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -295,6 +331,14 @@ export default function ConfirmAppointmentModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Stepper
|
||||||
|
steps={CONFIRM_STEPS.map(st => ({ key: st.key, title: st.title }))}
|
||||||
|
current={currentStep}
|
||||||
|
ariaLabel="مراحل قطعی کردن نوبت"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{currentStep === 'cost' && (
|
||||||
|
<>
|
||||||
{/* بیمه — نوع خدمت فقط وقتی چند نوع فعال است پرسیده میشود. */}
|
{/* بیمه — نوع خدمت فقط وقتی چند نوع فعال است پرسیده میشود. */}
|
||||||
<div style={{ display: 'flex', gap: 10, marginBottom: 16, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 10, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||||
{insurance.needsCategoryChoice && (
|
{insurance.needsCategoryChoice && (
|
||||||
@@ -390,6 +434,11 @@ export default function ConfirmAppointmentModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === 'payment' && (
|
||||||
|
<>
|
||||||
{/* پرداختها — تقسیم بین چند روش */}
|
{/* پرداختها — تقسیم بین چند روش */}
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||||
@@ -508,8 +557,31 @@ export default function ConfirmAppointmentModal({
|
|||||||
مجموع پرداختها از مبلغ قابل پرداخت بیشتر است.
|
مجموع پرداختها از مبلغ قابل پرداخت بیشتر است.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{currentStep === 'review' && (
|
||||||
|
<>
|
||||||
|
{/* خلاصهٔ همان چیزی که ثبت میشود */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--border)', borderRadius: 'var(--r)',
|
||||||
|
padding: '4px 14px 10px', marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={rowStyle}>
|
||||||
|
<span>مبلغ قابل پرداخت</span>
|
||||||
|
<strong style={{ color: 'var(--text)' }}>{formatRial(payable)}</strong>
|
||||||
|
</div>
|
||||||
|
{rows.filter(r => tomanToRial(r.amountToman) > 0).map(r => (
|
||||||
|
<div key={r.id} style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
|
||||||
|
<span>{METHOD_OPTIONS.find(m => m.value === r.method)?.label ?? r.method}</span>
|
||||||
|
<strong style={{ color: 'var(--text)' }}>{formatRial(tomanToRial(r.amountToman))}</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* خلاصه */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--surface-2)', border: '1px solid var(--border)',
|
background: 'var(--surface-2)', border: '1px solid var(--border)',
|
||||||
@@ -540,6 +612,8 @@ export default function ConfirmAppointmentModal({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import Modal from '../ui/Modal';
|
|||||||
import PersianDateInput from '../ui/PersianDateInput';
|
import PersianDateInput from '../ui/PersianDateInput';
|
||||||
import PriceInput from '../ui/PriceInput';
|
import PriceInput from '../ui/PriceInput';
|
||||||
import SearchableSelect from '../ui/SearchableSelect';
|
import SearchableSelect from '../ui/SearchableSelect';
|
||||||
|
import Stepper from '../ui/Stepper';
|
||||||
import ServiceSlotPicker from './ServiceSlotPicker';
|
import ServiceSlotPicker from './ServiceSlotPicker';
|
||||||
import type { ServicePick } from './ServiceSlotPicker';
|
import type { ServicePick } from './ServiceSlotPicker';
|
||||||
import type { BookingService } from '../../hooks/useDoctorBookingServices';
|
import type { BookingService } from '../../hooks/useDoctorBookingServices';
|
||||||
@@ -379,7 +380,11 @@ export default function NewAppointmentModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{stepKeys.length > 1 && (
|
{stepKeys.length > 1 && (
|
||||||
<Stepper keys={stepKeys} current={currentStep} />
|
<Stepper
|
||||||
|
steps={stepKeys.map(key => ({ key, title: STEP_TITLES[key] }))}
|
||||||
|
current={currentStep}
|
||||||
|
ariaLabel="مراحل ثبت نوبت"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentStep === 'service' && (
|
{currentStep === 'service' && (
|
||||||
@@ -685,57 +690,3 @@ function Step({ title, children }: { title: string; children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* نوار مراحل — «کجای کارم و چقدر مانده».
|
|
||||||
*
|
|
||||||
* فرم قبلاً همهٔ مراحل را با هم نشان میداد و مودال از ارتفاع صفحه بلندتر میشد؛
|
|
||||||
* حالا هر بار یک مرحله دیده میشود و این نوار تنها چیزی است که کلِ مسیر را میگوید.
|
|
||||||
*/
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { screen, fireEvent, waitFor } 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 { toast } from 'sonner';
|
||||||
|
import PatientTreatmentTab from './PatientTreatmentTab';
|
||||||
|
|
||||||
|
const get = api.get as ReturnType<typeof vi.fn>;
|
||||||
|
const patch = api.patch as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
const CASE_UUID = 'case-1';
|
||||||
|
|
||||||
|
const summary = (over: Record<string, unknown> = {}) => ({
|
||||||
|
uuid: CASE_UUID,
|
||||||
|
status: 'active',
|
||||||
|
total_sessions: 5,
|
||||||
|
completed_sessions: 3,
|
||||||
|
service: { uuid: 'svc-1', name: 'لیزر ناحیه ۱' },
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** فهرست دورهها، تقویم دوره، و بقیهٔ GETها. */
|
||||||
|
function mockApi(caseOver: Record<string, unknown> = {}) {
|
||||||
|
get.mockImplementation((url: string) => {
|
||||||
|
if (url.startsWith('/api/v1/treatment-cases')) {
|
||||||
|
return Promise.resolve({ success: true, data: [summary(caseOver)] });
|
||||||
|
}
|
||||||
|
if (url.includes('/plan')) {
|
||||||
|
return Promise.resolve({ success: true, data: {
|
||||||
|
case: { ...summary(caseOver), patient_national_code: null },
|
||||||
|
resource: null,
|
||||||
|
sessions: [],
|
||||||
|
} });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ success: true, data: [] });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
get.mockReset();
|
||||||
|
patch.mockReset();
|
||||||
|
patch.mockResolvedValue({ success: true, data: {} });
|
||||||
|
mockApi();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** دورهٔ انتخابشده در URL مینشیند؛ تست هم از همان مسیر شروع میکند. */
|
||||||
|
const render = () => renderWithProviders(
|
||||||
|
<PatientTreatmentTab recordUuid="rec-1" />,
|
||||||
|
{ route: `/admin/patients/p-1?case=${CASE_UUID}` },
|
||||||
|
);
|
||||||
|
|
||||||
|
const plusBtn = () => screen.getByRole('button', { name: 'افزودن یک جلسه' });
|
||||||
|
const minusBtn = () => screen.getByRole('button', { name: 'کم کردن یک جلسه' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* تعداد جلسات سرِ پروندهٔ بیمار تصمیم گرفته میشود، ولی تا این تغییر فقط از صفحهٔ
|
||||||
|
* «دورههای درمان» و پشت یک مودال قابل تغییر بود.
|
||||||
|
*/
|
||||||
|
describe('PatientTreatmentTab — تعداد جلسات', () => {
|
||||||
|
it('یک جلسه اضافه میکند', async () => {
|
||||||
|
render();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'افزودن یک جلسه' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(patch).toHaveBeenCalledWith(
|
||||||
|
`/api/v1/treatment-case/${CASE_UUID}`, { total_sessions: 6 },
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('یک جلسه کم میکند', async () => {
|
||||||
|
render();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'کم کردن یک جلسه' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(patch).toHaveBeenCalledWith(
|
||||||
|
`/api/v1/treatment-case/${CASE_UUID}`, { total_sessions: 4 },
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('وقتی همهٔ جلسهها انجام شده، کم کردن قفل است', async () => {
|
||||||
|
mockApi({ total_sessions: 4, completed_sessions: 4 });
|
||||||
|
render();
|
||||||
|
|
||||||
|
await waitFor(() => expect(minusBtn()).toBeDisabled());
|
||||||
|
expect(screen.getByText(/همهٔ جلسهها انجام شده/)).toBeInTheDocument();
|
||||||
|
// افزودن همچنان آزاد است.
|
||||||
|
expect(plusBtn()).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('کف دو جلسه رعایت میشود', async () => {
|
||||||
|
mockApi({ total_sessions: 2, completed_sessions: 0 });
|
||||||
|
render();
|
||||||
|
|
||||||
|
await waitFor(() => expect(minusBtn()).toBeDisabled());
|
||||||
|
expect(screen.getByText(/کمتر از ۲ جلسه ممکن نیست/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('سقف شصت جلسه رعایت میشود', async () => {
|
||||||
|
mockApi({ total_sessions: 60, completed_sessions: 0 });
|
||||||
|
render();
|
||||||
|
|
||||||
|
await waitFor(() => expect(plusBtn()).toBeDisabled());
|
||||||
|
expect(minusBtn()).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('خطای سرور را همانطور که آمده نشان میدهد', async () => {
|
||||||
|
// سرور دقیقتر از هر متن ثابتی میگوید چند جلسه قفل است.
|
||||||
|
patch.mockRejectedValue(new Error('۳ جلسه انجام شده یا نوبت دارد؛ تعداد کمتر از آن ممکن نیست'));
|
||||||
|
render();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'کم کردن یک جلسه' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(
|
||||||
|
'۳ جلسه انجام شده یا نوبت دارد؛ تعداد کمتر از آن ممکن نیست',
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('پیشرفت دوره با نسبت واقعی اعلام میشود', async () => {
|
||||||
|
render();
|
||||||
|
|
||||||
|
const bar = await screen.findByRole('progressbar', { name: /پیشرفت دوره/ });
|
||||||
|
|
||||||
|
expect(bar).toHaveAttribute('aria-valuenow', '3');
|
||||||
|
expect(bar).toHaveAttribute('aria-valuemax', '5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('دکمهٔ ویرایش دوره برای بقیهٔ فیلدها هست', async () => {
|
||||||
|
render();
|
||||||
|
|
||||||
|
expect(await screen.findByRole('button', { name: /ویرایش دوره/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ClipboardDocumentListIcon,
|
ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ClipboardDocumentListIcon,
|
||||||
CpuChipIcon, MagnifyingGlassIcon, XMarkIcon,
|
CpuChipIcon, MagnifyingGlassIcon, MinusIcon, PencilSquareIcon, PlusIcon, XMarkIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { api } from '../../lib/api';
|
import { api } from '../../lib/api';
|
||||||
import type { ApiResponse } from '../../lib/api';
|
import type { ApiResponse } from '../../lib/api';
|
||||||
import StatusBadge from '../ui/StatusBadge';
|
import StatusBadge from '../ui/StatusBadge';
|
||||||
|
import TreatmentCaseEditModal from '../TreatmentCaseEditModal';
|
||||||
import Pagination from '../ui/Pagination';
|
import Pagination from '../ui/Pagination';
|
||||||
import NewAppointmentModal from '../appointments/NewAppointmentModal';
|
import NewAppointmentModal from '../appointments/NewAppointmentModal';
|
||||||
import { useResourceBookingServices } from '../../hooks/useResourceBookingServices';
|
import { useResourceBookingServices } from '../../hooks/useResourceBookingServices';
|
||||||
@@ -33,6 +35,15 @@ interface PlanResponse {
|
|||||||
sessions: PlanSession[];
|
sessions: PlanSession[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* آینهٔ `TreatmentProtocol::MIN_STEPS/MAX_STEPS` در بکاند.
|
||||||
|
*
|
||||||
|
* قفلِ واقعی سمت سرور است؛ این فقط جلوی درخواستی را میگیرد که جوابش از پیش معلوم
|
||||||
|
* است — دکمهای که میدانیم ۴۲۲ میگیرد نباید فعال بماند.
|
||||||
|
*/
|
||||||
|
const MIN_SESSIONS = 2;
|
||||||
|
const MAX_SESSIONS = 60;
|
||||||
|
|
||||||
const CASE_STATUS_LABEL: Record<TreatmentCaseSummary['status'], string> = {
|
const CASE_STATUS_LABEL: Record<TreatmentCaseSummary['status'], string> = {
|
||||||
active: 'در جریان',
|
active: 'در جریان',
|
||||||
completed: 'تمام شده',
|
completed: 'تمام شده',
|
||||||
@@ -208,8 +219,36 @@ function CaseDetail({ summary, view, onView, onBack }: {
|
|||||||
}) {
|
}) {
|
||||||
const [term, setTerm] = useState('');
|
const [term, setTerm] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const qc = useQueryClient();
|
||||||
useEffect(() => setPage(1), [term, view]);
|
useEffect(() => setPage(1), [term, view]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* کم/زیاد کردن جلسات همین دوره.
|
||||||
|
*
|
||||||
|
* پیش از این فقط از صفحهٔ «دورههای درمان» و پشت یک مودال ممکن بود، در حالی که
|
||||||
|
* تصمیمش سرِ پروندهٔ بیمار گرفته میشود. سرور جلسهٔ انجامشده یا نوبتدار را حذف
|
||||||
|
* نمیکند و ۴۰۹ برمیگرداند؛ پیام همان خطا نشان داده میشود چون دلیلش را دقیقتر
|
||||||
|
* از هر متن ثابتی میگوید (چند جلسه قفل است).
|
||||||
|
*/
|
||||||
|
const setTotalSessions = useMutation({
|
||||||
|
mutationFn: (total: number) =>
|
||||||
|
api.patch<ApiResponse<unknown>>(`/api/v1/treatment-case/${summary.uuid}`, { total_sessions: total }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['patient-treatment-cases'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['treatment-case-plan', summary.uuid] });
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message || 'تغییر تعداد جلسات ناموفق بود'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = summary.total_sessions;
|
||||||
|
const completed = summary.completed_sessions;
|
||||||
|
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||||
|
const busy = setTotalSessions.isPending;
|
||||||
|
// کفِ واقعی، جلسات قفلشده است؛ سرور همان را با ۴۰۹ میگوید ولی دکمهٔ فعالِ
|
||||||
|
// بیاثر بدتر از دکمهٔ خاموش است.
|
||||||
|
const minAllowed = Math.max(MIN_SESSIONS, completed);
|
||||||
|
|
||||||
const { data, isLoading, isError, refetch } = useQuery({
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
queryKey: ['treatment-case-plan', summary.uuid],
|
queryKey: ['treatment-case-plan', summary.uuid],
|
||||||
queryFn: () => api.get<ApiResponse<PlanResponse>>(`/api/v1/treatment-case/${summary.uuid}/plan`),
|
queryFn: () => api.get<ApiResponse<PlanResponse>>(`/api/v1/treatment-case/${summary.uuid}/plan`),
|
||||||
@@ -234,9 +273,75 @@ function CaseDetail({ summary, view, onView, onBack }: {
|
|||||||
<span className={`badge ${summary.status === 'active' ? 'blue' : summary.status === 'completed' ? 'green' : 'gray'}`}>
|
<span className={`badge ${summary.status === 'active' ? 'blue' : summary.status === 'completed' ? 'green' : 'gray'}`}>
|
||||||
<span className="bdot" />{CASE_STATUS_LABEL[summary.status]}
|
<span className="bdot" />{CASE_STATUS_LABEL[summary.status]}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
|
<button
|
||||||
{formatNumber(summary.completed_sessions)} از {formatNumber(summary.total_sessions)} جلسه
|
type="button"
|
||||||
</span>
|
className="btn secondary sm"
|
||||||
|
style={{ marginInlineStart: 'auto' }}
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
>
|
||||||
|
<PencilSquareIcon style={{ width: 15, height: 15 }} />
|
||||||
|
ویرایش دوره
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* تعداد جلسات — تصمیمی که سرِ همین صفحه گرفته میشود، پس همینجا هم تغییر میکند. */}
|
||||||
|
<div className="card card-pad" style={{ display: 'grid', gap: 10 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700 }}>تعداد جلسات</span>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mini-btn"
|
||||||
|
aria-label="کم کردن یک جلسه"
|
||||||
|
disabled={busy || total <= minAllowed}
|
||||||
|
onClick={() => setTotalSessions.mutate(total - 1)}
|
||||||
|
>
|
||||||
|
<MinusIcon style={{ width: 15, height: 15 }} />
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
aria-live="polite"
|
||||||
|
style={{ minWidth: 32, textAlign: 'center', fontSize: 15, fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
{formatNumber(total)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mini-btn"
|
||||||
|
aria-label="افزودن یک جلسه"
|
||||||
|
disabled={busy || total >= MAX_SESSIONS}
|
||||||
|
onClick={() => setTotalSessions.mutate(total + 1)}
|
||||||
|
>
|
||||||
|
<PlusIcon style={{ width: 15, height: 15 }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>
|
||||||
|
{formatNumber(completed)} از {formatNumber(total)} جلسه انجام شده
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={completed}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={total}
|
||||||
|
aria-label={`پیشرفت دوره: ${completed} از ${total}`}
|
||||||
|
style={{ height: 6, borderRadius: 999, background: 'var(--surface-3)', overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: `${percent}%`, height: '100%',
|
||||||
|
background: summary.status === 'completed' ? 'var(--success)' : 'var(--primary)',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{total <= minAllowed && (
|
||||||
|
<span style={{ fontSize: 11.5, color: 'var(--text-3)' }}>
|
||||||
|
{completed >= total
|
||||||
|
? 'همهٔ جلسهها انجام شدهاند؛ کم کردن ممکن نیست.'
|
||||||
|
: `کمتر از ${formatNumber(MIN_SESSIONS)} جلسه ممکن نیست.`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="seg" style={{ alignSelf: 'start' }}>
|
<div className="seg" style={{ alignSelf: 'start' }}>
|
||||||
@@ -301,6 +406,10 @@ function CaseDetail({ summary, view, onView, onBack }: {
|
|||||||
<Pagination page={page} total={sessions.length} limit={PAGE_SIZE} onPageChange={setPage} />
|
<Pagination page={page} total={sessions.length} limit={PAGE_SIZE} onPageChange={setPage} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<TreatmentCaseEditModal caseUuid={summary.uuid} onClose={() => setEditing(false)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import Stepper from './Stepper';
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ key: 'cost', title: 'بیمه و هزینه' },
|
||||||
|
{ key: 'payment', title: 'پرداخت' },
|
||||||
|
{ key: 'review', title: 'تأیید' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const items = () => screen.getAllByRole('listitem');
|
||||||
|
|
||||||
|
describe('Stepper', () => {
|
||||||
|
it('همهٔ مراحل را با شمارهٔ خودشان نشان میدهد', () => {
|
||||||
|
render(<Stepper steps={STEPS} current="payment" ariaLabel="مراحل" />);
|
||||||
|
|
||||||
|
expect(items()).toHaveLength(3);
|
||||||
|
expect(items()[0].textContent).toBe('1بیمه و هزینه');
|
||||||
|
expect(items()[2].textContent).toBe('3تأیید');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('مرحلهٔ جاری با aria-current اعلام میشود', () => {
|
||||||
|
render(<Stepper steps={STEPS} current="payment" ariaLabel="مراحل" />);
|
||||||
|
|
||||||
|
// فقط یکی؛ وگرنه صفحهخوان دو «مرحلهٔ فعلی» میخواند.
|
||||||
|
const marked = items().filter(li => li.getAttribute('aria-current') === 'step');
|
||||||
|
expect(marked).toHaveLength(1);
|
||||||
|
expect(marked[0].textContent).toBe('2پرداخت');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('نوار با برچسبِ دادهشده قابل پیدا شدن است', () => {
|
||||||
|
render(<Stepper steps={STEPS} current="cost" ariaLabel="مراحل قطعی کردن نوبت" />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('list', { name: 'مراحل قطعی کردن نوبت' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('مرحلهٔ ناشناخته هیچکدام را فعال نمیکند و خطا نمیدهد', () => {
|
||||||
|
render(<Stepper steps={STEPS} current="unknown" ariaLabel="مراحل" />);
|
||||||
|
|
||||||
|
expect(items().filter(li => li.getAttribute('aria-current') === 'step')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
export interface Step {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* نوار مراحلِ یک فرمِ چندمرحلهای — «کجای کارم و چقدر مانده».
|
||||||
|
*
|
||||||
|
* فرمهای بلندِ مودال (ثبت نوبت، قطعی کردن نوبت) همهٔ تصمیمها را با هم نشان میدادند
|
||||||
|
* و از ارتفاع صفحه بلندتر میشدند. حالا هر بار یک مرحله دیده میشود و این نوار تنها
|
||||||
|
* چیزی است که کلِ مسیر را میگوید.
|
||||||
|
*
|
||||||
|
* فقط نمایش است: پیمایش با دکمههای فوتر انجام میشود، نه با کلیک روی مراحل — پرشِ
|
||||||
|
* دلخواه یعنی رسیدن به مرحلهای که پیشنیازش پر نشده.
|
||||||
|
*/
|
||||||
|
export default function Stepper({
|
||||||
|
steps, current, ariaLabel,
|
||||||
|
}: { steps: Step[]; current: string; ariaLabel: string }) {
|
||||||
|
const activeIdx = steps.findIndex(s => s.key === current);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8, listStyle: 'none',
|
||||||
|
margin: '0 0 18px', padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{steps.map((step, idx) => {
|
||||||
|
const done = idx < activeIdx;
|
||||||
|
const active = idx === activeIdx;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={step.key}
|
||||||
|
aria-current={active ? 'step' : undefined}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
flex: idx === steps.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.title}
|
||||||
|
</span>
|
||||||
|
{idx < steps.length - 1 && (
|
||||||
|
<span style={{
|
||||||
|
flex: 1, height: 1, minWidth: 12,
|
||||||
|
background: done ? 'var(--primary)' : 'var(--border)',
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user