From 3ffab2bbd00fceaa53889eddc23bb3875a97ab40 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 9 Aug 2026 09:12:00 +0330 Subject: [PATCH] feat(stepper): implement multi-step wizard for appointment confirmation process --- .../ConfirmAppointmentModal.test.tsx | 104 +++++++++++-- .../appointments/ConfirmAppointmentModal.tsx | 92 ++++++++++-- .../appointments/NewAppointmentModal.tsx | 61 +------- .../patient/PatientTreatmentTab.test.tsx | 139 ++++++++++++++++++ .../patient/PatientTreatmentTab.tsx | 119 ++++++++++++++- assets/admin/components/ui/Stepper.test.tsx | 42 ++++++ assets/admin/components/ui/Stepper.tsx | 68 +++++++++ 7 files changed, 544 insertions(+), 81 deletions(-) create mode 100644 assets/admin/components/patient/PatientTreatmentTab.test.tsx create mode 100644 assets/admin/components/ui/Stepper.test.tsx create mode 100644 assets/admin/components/ui/Stepper.tsx diff --git a/assets/admin/components/appointments/ConfirmAppointmentModal.test.tsx b/assets/admin/components/appointments/ConfirmAppointmentModal.test.tsx index 33525698..791ce62b 100644 --- a/assets/admin/components/appointments/ConfirmAppointmentModal.test.tsx +++ b/assets/admin/components/appointments/ConfirmAppointmentModal.test.tsx @@ -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(); + }); +}); diff --git a/assets/admin/components/appointments/ConfirmAppointmentModal.tsx b/assets/admin/components/appointments/ConfirmAppointmentModal.tsx index d0d9af3c..3375d224 100644 --- a/assets/admin/components/appointments/ConfirmAppointmentModal.tsx +++ b/assets/admin/components/appointments/ConfirmAppointmentModal.tsx @@ -11,6 +11,7 @@ import { useAppointmentInsurance } from '../../hooks/useAppointmentInsurance'; import Modal from '../ui/Modal'; import PriceInput from '../ui/PriceInput'; import SearchableSelect from '../ui/SearchableSelect'; +import Stepper from '../ui/Stepper'; /** همان چهار روشِ SessionPayment::METHODS در بک‌اند. */ const METHOD_OPTIONS = [ @@ -49,6 +50,20 @@ interface Props { 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 { id: number; @@ -99,6 +114,7 @@ export default function ConfirmAppointmentModal({ const [rows, setRows] = useState([makeRow()]); /** تا وقتی کاربر مبلغ را دست نزده، ردیفِ اول با کل مبلغ پر می‌ماند. */ const [touched, setTouched] = useState(false); + const [stepIdx, setStepIdx] = useState(0); // وقتی صفحه‌ی میزبان نوبت را ندارد (مثل ردیف لیست) خودمان جزئیات را می‌گیریم: // مبلغ ویزیت و قیمت سرویس‌ها فقط در detail هستند. @@ -225,6 +241,7 @@ export default function ConfirmAppointmentModal({ nextId.current = 1; setRows([makeRow()]); setTouched(false); + setStepIdx(0); } function patchRow(id: number, patch: Partial) { @@ -254,6 +271,8 @@ export default function ConfirmAppointmentModal({ } const loading = detailQuery.isLoading && !appointment; + const currentStep: ConfirmStepKey = CONFIRM_STEPS[Math.min(stepIdx, CONFIRM_STEPS.length - 1)].key; + const isLastStep = currentStep === 'review'; return ( + {/* بستنِ فرم همیشه یک کلیک است؛ «مرحلهٔ قبل» جایش را نمی‌گیرد. */} - + {stepIdx > 0 && ( + + )} + {isLastStep ? ( + + ) : ( + + )} } > @@ -295,6 +331,14 @@ export default function ConfirmAppointmentModal({ )} + ({ key: st.key, title: st.title }))} + current={currentStep} + ariaLabel="مراحل قطعی کردن نوبت" + /> + + {currentStep === 'cost' && ( + <> {/* بیمه — نوع خدمت فقط وقتی چند نوع فعال است پرسیده می‌شود. */}
{insurance.needsCategoryChoice && ( @@ -390,6 +434,11 @@ export default function ConfirmAppointmentModal({
+ + )} + + {currentStep === 'payment' && ( + <> {/* پرداخت‌ها — تقسیم بین چند روش */}
@@ -508,8 +557,31 @@ export default function ConfirmAppointmentModal({ مجموع پرداخت‌ها از مبلغ قابل پرداخت بیشتر است.

)} + + )} + + + {currentStep === 'review' && ( + <> + {/* خلاصهٔ همان چیزی که ثبت می‌شود */} +
+
+ مبلغ قابل پرداخت + {formatRial(payable)} +
+ {rows.filter(r => tomanToRial(r.amountToman) > 0).map(r => ( +
+ {METHOD_OPTIONS.find(m => m.value === r.method)?.label ?? r.method} + {formatRial(tomanToRial(r.amountToman))} +
+ ))} +
- {/* خلاصه */}
+ + )} )} diff --git a/assets/admin/components/appointments/NewAppointmentModal.tsx b/assets/admin/components/appointments/NewAppointmentModal.tsx index d8e7eaf6..8cbde3ba 100644 --- a/assets/admin/components/appointments/NewAppointmentModal.tsx +++ b/assets/admin/components/appointments/NewAppointmentModal.tsx @@ -11,6 +11,7 @@ import Modal from '../ui/Modal'; import PersianDateInput from '../ui/PersianDateInput'; import PriceInput from '../ui/PriceInput'; import SearchableSelect from '../ui/SearchableSelect'; +import Stepper from '../ui/Stepper'; import ServiceSlotPicker from './ServiceSlotPicker'; import type { ServicePick } from './ServiceSlotPicker'; import type { BookingService } from '../../hooks/useDoctorBookingServices'; @@ -379,7 +380,11 @@ export default function NewAppointmentModal({
{stepKeys.length > 1 && ( - + ({ key, title: STEP_TITLES[key] }))} + current={currentStep} + ariaLabel="مراحل ثبت نوبت" + /> )} {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 ( -
    - {keys.map((key, idx) => { - const done = idx < activeIdx; - const active = idx === activeIdx; - - return ( -
  1. - - {idx + 1} - - - {STEP_TITLES[key]} - - {idx < keys.length - 1 && ( - - )} -
  2. - ); - })} -
- ); -} diff --git a/assets/admin/components/patient/PatientTreatmentTab.test.tsx b/assets/admin/components/patient/PatientTreatmentTab.test.tsx new file mode 100644 index 00000000..21ac1252 --- /dev/null +++ b/assets/admin/components/patient/PatientTreatmentTab.test.tsx @@ -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; +const patch = api.patch as ReturnType; + +const CASE_UUID = 'case-1'; + +const summary = (over: Record = {}) => ({ + uuid: CASE_UUID, + status: 'active', + total_sessions: 5, + completed_sessions: 3, + service: { uuid: 'svc-1', name: 'لیزر ناحیه ۱' }, + ...over, +}); + +/** فهرست دوره‌ها، تقویم دوره، و بقیهٔ GETها. */ +function mockApi(caseOver: Record = {}) { + 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( + , + { 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(); + }); +}); diff --git a/assets/admin/components/patient/PatientTreatmentTab.tsx b/assets/admin/components/patient/PatientTreatmentTab.tsx index 02718819..90eb79c5 100644 --- a/assets/admin/components/patient/PatientTreatmentTab.tsx +++ b/assets/admin/components/patient/PatientTreatmentTab.tsx @@ -1,12 +1,14 @@ 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 { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ClipboardDocumentListIcon, - CpuChipIcon, MagnifyingGlassIcon, XMarkIcon, + CpuChipIcon, MagnifyingGlassIcon, MinusIcon, PencilSquareIcon, PlusIcon, XMarkIcon, } from '@heroicons/react/24/outline'; import { api } from '../../lib/api'; import type { ApiResponse } from '../../lib/api'; import StatusBadge from '../ui/StatusBadge'; +import TreatmentCaseEditModal from '../TreatmentCaseEditModal'; import Pagination from '../ui/Pagination'; import NewAppointmentModal from '../appointments/NewAppointmentModal'; import { useResourceBookingServices } from '../../hooks/useResourceBookingServices'; @@ -33,6 +35,15 @@ interface PlanResponse { sessions: PlanSession[]; } +/** + * آینهٔ `TreatmentProtocol::MIN_STEPS/MAX_STEPS` در بک‌اند. + * + * قفلِ واقعی سمت سرور است؛ این فقط جلوی درخواستی را می‌گیرد که جوابش از پیش معلوم + * است — دکمه‌ای که می‌دانیم ۴۲۲ می‌گیرد نباید فعال بماند. + */ +const MIN_SESSIONS = 2; +const MAX_SESSIONS = 60; + const CASE_STATUS_LABEL: Record = { active: 'در جریان', completed: 'تمام شده', @@ -208,8 +219,36 @@ function CaseDetail({ summary, view, onView, onBack }: { }) { const [term, setTerm] = useState(''); const [page, setPage] = useState(1); + const [editing, setEditing] = useState(false); + const qc = useQueryClient(); useEffect(() => setPage(1), [term, view]); + /** + * کم/زیاد کردن جلسات همین دوره. + * + * پیش از این فقط از صفحهٔ «دوره‌های درمان» و پشت یک مودال ممکن بود، در حالی که + * تصمیمش سرِ پروندهٔ بیمار گرفته می‌شود. سرور جلسهٔ انجام‌شده یا نوبت‌دار را حذف + * نمی‌کند و ۴۰۹ برمی‌گرداند؛ پیام همان خطا نشان داده می‌شود چون دلیلش را دقیق‌تر + * از هر متن ثابتی می‌گوید (چند جلسه قفل است). + */ + const setTotalSessions = useMutation({ + mutationFn: (total: number) => + api.patch>(`/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({ queryKey: ['treatment-case-plan', summary.uuid], queryFn: () => api.get>(`/api/v1/treatment-case/${summary.uuid}/plan`), @@ -234,9 +273,75 @@ function CaseDetail({ summary, view, onView, onBack }: { {CASE_STATUS_LABEL[summary.status]} - - {formatNumber(summary.completed_sessions)} از {formatNumber(summary.total_sessions)} جلسه - + + + + {/* تعداد جلسات — تصمیمی که سرِ همین صفحه گرفته می‌شود، پس همین‌جا هم تغییر می‌کند. */} +
+
+ تعداد جلسات + +
+ + + {formatNumber(total)} + + +
+ + + {formatNumber(completed)} از {formatNumber(total)} جلسه انجام شده + +
+ +
+
+
+ + {total <= minAllowed && ( + + {completed >= total + ? 'همهٔ جلسه‌ها انجام شده‌اند؛ کم کردن ممکن نیست.' + : `کمتر از ${formatNumber(MIN_SESSIONS)} جلسه ممکن نیست.`} + + )}
@@ -301,6 +406,10 @@ function CaseDetail({ summary, view, onView, onBack }: {
)} + + {editing && ( + setEditing(false)} /> + )}
); } diff --git a/assets/admin/components/ui/Stepper.test.tsx b/assets/admin/components/ui/Stepper.test.tsx new file mode 100644 index 00000000..a5e8c76b --- /dev/null +++ b/assets/admin/components/ui/Stepper.test.tsx @@ -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(); + + expect(items()).toHaveLength(3); + expect(items()[0].textContent).toBe('1بیمه و هزینه'); + expect(items()[2].textContent).toBe('3تأیید'); + }); + + it('مرحلهٔ جاری با aria-current اعلام می‌شود', () => { + render(); + + // فقط یکی؛ وگرنه صفحه‌خوان دو «مرحلهٔ فعلی» می‌خواند. + const marked = items().filter(li => li.getAttribute('aria-current') === 'step'); + expect(marked).toHaveLength(1); + expect(marked[0].textContent).toBe('2پرداخت'); + }); + + it('نوار با برچسبِ داده‌شده قابل پیدا شدن است', () => { + render(); + + expect(screen.getByRole('list', { name: 'مراحل قطعی کردن نوبت' })).toBeInTheDocument(); + }); + + it('مرحلهٔ ناشناخته هیچ‌کدام را فعال نمی‌کند و خطا نمی‌دهد', () => { + render(); + + expect(items().filter(li => li.getAttribute('aria-current') === 'step')).toHaveLength(0); + }); +}); diff --git a/assets/admin/components/ui/Stepper.tsx b/assets/admin/components/ui/Stepper.tsx new file mode 100644 index 00000000..e0e504fd --- /dev/null +++ b/assets/admin/components/ui/Stepper.tsx @@ -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 ( +
    + {steps.map((step, idx) => { + const done = idx < activeIdx; + const active = idx === activeIdx; + + return ( +
  1. + + {idx + 1} + + + {step.title} + + {idx < steps.length - 1 && ( + + )} +
  2. + ); + })} +
+ ); +}