From 029b5f632e7993f64361fa0dff3e091fc34182b4 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 16 Jul 2026 10:18:10 +0330 Subject: [PATCH] feat: section-then-service picker with cross-section accumulation Rework slot-mode service selection: pick a section, its services show as a checkbox list, and chosen services collect into a removable 'selected' chip list that persists when switching to another section (services from multiple sections accumulate). Changing the section no longer clears the selection. Add a test covering multi-section accumulation and chip removal. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../pages/AppointmentCreatePage.test.tsx | 47 ++++++++ assets/admin/pages/AppointmentCreatePage.tsx | 108 ++++++++++++------ 2 files changed, 117 insertions(+), 38 deletions(-) diff --git a/assets/admin/pages/AppointmentCreatePage.test.tsx b/assets/admin/pages/AppointmentCreatePage.test.tsx index 54a4d306..60ce8137 100644 --- a/assets/admin/pages/AppointmentCreatePage.test.tsx +++ b/assets/admin/pages/AppointmentCreatePage.test.tsx @@ -108,6 +108,53 @@ describe('AppointmentCreatePage — افزودن نوبت', () => { expect(body).toMatchObject({ patient_name: 'حامد حسینی', patient_mobile: '09210671745', patient_national_code: '0012345675' }); }); + it('slot mode: accumulates services across multiple sections and removes them (happy path)', async () => { + get.mockImplementation((url: string) => { + if (url === '/api/v1/service-sections') + return Promise.resolve({ success: true, data: [{ uuid: 'sec1', name: 'زیبایی' }, { uuid: 'sec2', name: 'پوست' }] }); + if (url === '/api/v1/service-items/sec1') + return Promise.resolve({ success: true, data: [{ uuid: 'i1', name: 'لیزر' }, { uuid: 'i2', name: 'ماساژ' }] }); + if (url === '/api/v1/service-items/sec2') + return Promise.resolve({ success: true, data: [{ uuid: 'i3', name: 'پاکسازی' }] }); + return Promise.resolve({ success: true, data: [] }); + }); + + const pickSection = async (optionLabel: string) => { + const input = document.getElementById('appt-section-select') as HTMLInputElement; + fireEvent.focus(input); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + fireEvent.click(await screen.findByText(optionLabel)); + }; + + renderWithProviders(, { route: '/admin/appointments/new' }); + + fireEvent.click(screen.getByText('مراجعه کننده جدید')); + fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی مراجعه کننده'), { target: { value: 'علی محمدی' } }); + fireEvent.change(screen.getByPlaceholderText('شماره تماس مراجعه کننده'), { target: { value: '09121234567' } }); + fireEvent.change(screen.getByPlaceholderText('کد ملی مراجعه کننده'), { target: { value: '1234567891' } }); + + // بخش اول → دو سرویس انتخاب + await pickSection('زیبایی'); + fireEvent.click(await screen.findByText('لیزر')); + fireEvent.click(await screen.findByText('ماساژ')); + + // بخش دوم → یک سرویس؛ لیستِ انباشته باید سرویس‌های بخش قبل را نگه دارد + await pickSection('پوست'); + fireEvent.click(await screen.findByText('پاکسازی')); + + expect(screen.getByText('سرویس‌های انتخاب‌شده (3)')).toBeInTheDocument(); + + // حذف یک سرویس از لیست + fireEvent.click(screen.getByLabelText('حذف ماساژ')); + expect(screen.getByText('سرویس‌های انتخاب‌شده (2)')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('ثبت اطلاعات')); + await waitFor(() => expect(post).toHaveBeenCalled()); + const [, body] = post.mock.calls[0]; + expect(body.service_item_uuids).toEqual(['i1', 'i3']); + expect(body.duration_from_services).toBeUndefined(); // حالت اسلاتی: ساعت پایانِ دستی حفظ + }); + it('picking an existing patient WITHOUT a national code keeps submit disabled until one is entered (boundary)', async () => { get.mockImplementation((url: string) => { if (url.startsWith('/api/v1/patients?')) diff --git a/assets/admin/pages/AppointmentCreatePage.tsx b/assets/admin/pages/AppointmentCreatePage.tsx index 156e42cb..5c04f323 100644 --- a/assets/admin/pages/AppointmentCreatePage.tsx +++ b/assets/admin/pages/AppointmentCreatePage.tsx @@ -68,11 +68,16 @@ export default function AppointmentCreatePage() { const patients = useMemo(() => patientsQ.data?.data ?? [], [patientsQ.data]); // ── مشخصات سرویس - const [sectionUuid, setSectionUuid] = useState(''); - const [serviceItemUuids, setServiceItemUuids] = useState([]); // چند سرویس در حالت اسلاتی + const [sectionUuid, setSectionUuid] = useState(''); // بخشِ در حال مرور (برای دیدن سرویس‌هایش) + // سرویس‌های انتخاب‌شده — انباشته از چند بخش؛ هر کدام قابل حذف. نام را نگه می‌داریم تا در chip نشان دهیم. + const [selectedServices, setSelectedServices] = useState<{ uuid: string; name: string }[]>([]); const [staffUuid, setStaffUuid] = useState(''); - const toggleServiceItem = (uuid: string) => - setServiceItemUuids(prev => prev.includes(uuid) ? prev.filter(u => u !== uuid) : [...prev, uuid]); + const toggleServiceItem = (uuid: string, name: string) => + setSelectedServices(prev => prev.some(s => s.uuid === uuid) + ? prev.filter(s => s.uuid !== uuid) + : [...prev, { uuid, name }]); + const removeService = (uuid: string) => + setSelectedServices(prev => prev.filter(s => s.uuid !== uuid)); const sectionsQ = useQuery>({ queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections') }); const itemsQ = useQuery>({ queryKey: ['service-items', sectionUuid], @@ -122,7 +127,7 @@ export default function AppointmentCreatePage() { ? { service_item_uuids: servicePick.serviceUuids, duration_from_services: true } : { ...(sectionUuid ? { service_section_uuid: sectionUuid } : {}), - ...(serviceItemUuids.length ? { service_item_uuids: serviceItemUuids } : {}), + ...(selectedServices.length ? { service_item_uuids: selectedServices.map(s => s.uuid) } : {}), }), ...(staffUuid ? { staff_uuid: staffUuid } : {}), ...(depositRequired ? { deposit_required: true, deposit_amount_rials: depositRials } : {}), @@ -281,10 +286,11 @@ export default function AppointmentCreatePage() {
({ value: o.uuid, label: o.name ?? '' }))} value={sectionUuid || null} - onChange={v => { setSectionUuid(v ? String(v) : ''); setServiceItemUuids([]); }} - placeholder="انتخاب بخش" + onChange={v => setSectionUuid(v ? String(v) : '')} + placeholder="ابتدا بخش را انتخاب کنید" isLoading={sectionsQ.isLoading} isClearable height={44} @@ -307,37 +313,63 @@ export default function AppointmentCreatePage() {
- {/* سرویس‌ها — چند انتخابی، بر اساس بخشِ انتخاب‌شده */} - - {!sectionUuid ? ( -
ابتدا بخش را انتخاب کنید.
- ) : itemsQ.isLoading ? ( -
در حال بارگذاری...
- ) : (itemsQ.data?.data ?? []).length === 0 ? ( -
سرویسی در این بخش تعریف نشده است.
- ) : ( -
- {(itemsQ.data?.data ?? []).map(o => { - const active = serviceItemUuids.includes(o.uuid); - return ( - - ); - })} + {/* سرویس‌های بخشِ انتخاب‌شده — چند انتخابی، به لیست انباشته اضافه می‌شوند */} + {sectionUuid && ( + <> + + {itemsQ.isLoading ? ( +
در حال بارگذاری...
+ ) : (itemsQ.data?.data ?? []).length === 0 ? ( +
سرویسی در این بخش تعریف نشده است.
+ ) : ( +
+ {(itemsQ.data?.data ?? []).map(o => { + const active = selectedServices.some(s => s.uuid === o.uuid); + return ( + + ); + })} +
+ )} + + )} + + {/* لیستِ انباشتهٔ سرویس‌های انتخاب‌شده (از هر بخش) — قابل حذف */} + {selectedServices.length > 0 && ( +
+ +
+ {selectedServices.map(s => ( + + {s.name} + + + ))} +
)}