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) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-16 10:18:10 +03:30
co-authored by Claude Opus 4.8
parent 08aa3a78b8
commit 029b5f632e
2 changed files with 117 additions and 38 deletions
@@ -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(<AppointmentCreatePage />, { 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?'))
+70 -38
View File
@@ -68,11 +68,16 @@ export default function AppointmentCreatePage() {
const patients = useMemo(() => patientsQ.data?.data ?? [], [patientsQ.data]);
// ── مشخصات سرویس
const [sectionUuid, setSectionUuid] = useState('');
const [serviceItemUuids, setServiceItemUuids] = useState<string[]>([]); // چند سرویس در حالت اسلاتی
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<ApiResponse<Option[]>>({ queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections') });
const itemsQ = useQuery<ApiResponse<Option[]>>({
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() {
<label style={label}>بخش</label>
<div style={{ marginTop: 6 }}>
<SearchableSelect
inputId="appt-section-select"
options={(sectionsQ.data?.data ?? []).map(o => ({ 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() {
</div>
</div>
{/* سرویس‌ها — چند انتخابی، بر اساس بخشِ انتخاب‌شده */}
<label style={label}>سرویس (یک یا چند)</label>
{!sectionUuid ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '6px 0 10px' }}>ابتدا بخش را انتخاب کنید.</div>
) : itemsQ.isLoading ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '6px 0 10px' }}>در حال بارگذاری...</div>
) : (itemsQ.data?.data ?? []).length === 0 ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '6px 0 10px' }}>سرویسی در این بخش تعریف نشده است.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, margin: '6px 0 12px' }}>
{(itemsQ.data?.data ?? []).map(o => {
const active = serviceItemUuids.includes(o.uuid);
return (
<button key={o.uuid} type="button" onClick={() => toggleServiceItem(o.uuid)}
style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '9px 12px', borderRadius: 'var(--r-sm)',
cursor: 'pointer', textAlign: 'right', fontFamily: 'inherit', fontSize: 13,
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
background: active ? 'var(--primary-soft)' : 'var(--surface)',
}}>
<span style={{
width: 16, height: 16, borderRadius: 4, display: 'grid', placeItems: 'center', flexShrink: 0,
border: active ? '1px solid var(--primary)' : '1px solid var(--border-2)',
background: active ? 'var(--primary)' : 'transparent',
}}>
{active && <span style={{ width: 8, height: 8, background: '#fff', borderRadius: 2 }} />}
</span>
{o.name}
</button>
);
})}
{/* سرویس‌های بخشِ انتخاب‌شده — چند انتخابی، به لیست انباشته اضافه می‌شوند */}
{sectionUuid && (
<>
<label style={label}>سرویسهای این بخش (یک یا چند)</label>
{itemsQ.isLoading ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '6px 0 10px' }}>در حال بارگذاری...</div>
) : (itemsQ.data?.data ?? []).length === 0 ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '6px 0 10px' }}>سرویسی در این بخش تعریف نشده است.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, margin: '6px 0 12px' }}>
{(itemsQ.data?.data ?? []).map(o => {
const active = selectedServices.some(s => s.uuid === o.uuid);
return (
<button key={o.uuid} type="button" onClick={() => toggleServiceItem(o.uuid, o.name ?? '')}
style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '9px 12px', borderRadius: 'var(--r-sm)',
cursor: 'pointer', textAlign: 'right', fontFamily: 'inherit', fontSize: 13,
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
background: active ? 'var(--primary-soft)' : 'var(--surface)',
}}>
<span style={{
width: 16, height: 16, borderRadius: 4, display: 'grid', placeItems: 'center', flexShrink: 0,
border: active ? '1px solid var(--primary)' : '1px solid var(--border-2)',
background: active ? 'var(--primary)' : 'transparent',
}}>
{active && <span style={{ width: 8, height: 8, background: '#fff', borderRadius: 2 }} />}
</span>
{o.name}
</button>
);
})}
</div>
)}
</>
)}
{/* لیستِ انباشتهٔ سرویس‌های انتخاب‌شده (از هر بخش) — قابل حذف */}
{selectedServices.length > 0 && (
<div style={{ margin: '4px 0 12px' }}>
<label style={label}>سرویسهای انتخابشده ({selectedServices.length})</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 6 }}>
{selectedServices.map(s => (
<span key={s.uuid} style={{
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '5px 6px 5px 10px',
borderRadius: 'var(--r-pill)', fontSize: 12.5, background: 'var(--primary-soft)',
color: 'var(--primary-700)', border: '1px solid var(--primary)',
}}>
{s.name}
<button type="button" aria-label={`حذف ${s.name}`} onClick={() => removeService(s.uuid)}
style={{
display: 'grid', placeItems: 'center', width: 16, height: 16, borderRadius: 999,
border: 'none', cursor: 'pointer', background: 'var(--primary)', color: '#fff',
fontSize: 12, lineHeight: 1, fontFamily: 'inherit',
}}>×</button>
</span>
))}
</div>
</div>
)}
</>