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:
@@ -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?'))
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user