feat: section-based service picker + per-appointment duration override (service mode)
Service-booking mode now selects services by section like slot mode: appointment-booking-services returns service_section per item; ServiceSlotPicker groups by section (SearchableSelect), accumulates picks across sections into a removable 'section -> service' chip list. Secretaries can override a service's duration for a single appointment without changing the service default: appointment-service-slots accepts durations[uuid] and both create endpoints accept service_durations; the override drives total duration and slot_end. Online (patient) booking is unaffected — it never sends overrides. Backend + frontend tests and docs updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../../test/utils';
|
||||
|
||||
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 ServiceSlotPicker, { type ServicePick } from './ServiceSlotPicker';
|
||||
import type { BookingService } from '../../hooks/useDoctorBookingServices';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
|
||||
const services: BookingService[] = [
|
||||
{ uuid: 'i1', name: 'بوتاکس', duration_minutes: 30, price_rials: 0, service_section: { uuid: 'sec1', name: 'زیبایی' } },
|
||||
{ uuid: 'i2', name: 'فیلر لب', duration_minutes: 20, price_rials: 0, service_section: { uuid: 'sec1', name: 'زیبایی' } },
|
||||
{ uuid: 'i3', name: 'لیزر موهای زائد', duration_minutes: 45, price_rials: 0, service_section: { uuid: 'sec2', name: 'لیزر' } },
|
||||
];
|
||||
|
||||
const pickSection = async (optionLabel: string) => {
|
||||
const input = document.getElementById('service-mode-section-select') as HTMLInputElement;
|
||||
fireEvent.focus(input);
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
||||
fireEvent.click(await screen.findByText(optionLabel));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
get.mockResolvedValue({ success: true, data: { total_duration_minutes: 50, buffer_minutes: 0, start_times: [] } });
|
||||
});
|
||||
|
||||
describe('ServiceSlotPicker — انتخاب سرویس بر اساس بخش + مدت قابلویرایش', () => {
|
||||
it('accumulates services across sections and reports section→service + durations', async () => {
|
||||
let last: ServicePick = { serviceUuids: [], durations: {}, slot: null };
|
||||
renderWithProviders(
|
||||
<ServiceSlotPicker doctorUuid="doc1" date="2026-07-20" services={services} onSelect={v => { last = v; }} />,
|
||||
);
|
||||
|
||||
// بخش زیبایی → دو سرویس
|
||||
await pickSection('زیبایی');
|
||||
fireEvent.click(await screen.findByText('بوتاکس'));
|
||||
fireEvent.click(await screen.findByText('فیلر لب'));
|
||||
|
||||
// بخش لیزر → یک سرویس؛ لیست انباشته حفظ میشود
|
||||
await pickSection('لیزر');
|
||||
fireEvent.click(await screen.findByText('لیزر موهای زائد'));
|
||||
|
||||
expect(screen.getByText('سرویسهای انتخابشده (3)')).toBeInTheDocument();
|
||||
// chip نام بخش را کنار سرویس نشان میدهد (دو chip از بخش زیبایی)
|
||||
expect(screen.getAllByText('زیبایی').length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
await waitFor(() => expect(last.serviceUuids).toEqual(['i1', 'i2', 'i3']));
|
||||
expect(last.durations).toEqual({ i1: 30, i2: 20, i3: 45 });
|
||||
});
|
||||
|
||||
it('lets the secretary override a service duration for this appointment only', async () => {
|
||||
let last: ServicePick = { serviceUuids: [], durations: {}, slot: null };
|
||||
renderWithProviders(
|
||||
<ServiceSlotPicker doctorUuid="doc1" date="2026-07-20" services={services} onSelect={v => { last = v; }} />,
|
||||
);
|
||||
|
||||
await pickSection('زیبایی');
|
||||
fireEvent.click(await screen.findByText('بوتاکس'));
|
||||
|
||||
fireEvent.change(screen.getByLabelText('مدت بوتاکس'), { target: { value: '90' } });
|
||||
await waitFor(() => expect(last.durations).toEqual({ i1: 90 }));
|
||||
});
|
||||
|
||||
it('removes a selected service from the accumulated list', async () => {
|
||||
let last: ServicePick = { serviceUuids: [], durations: {}, slot: null };
|
||||
renderWithProviders(
|
||||
<ServiceSlotPicker doctorUuid="doc1" date="2026-07-20" services={services} onSelect={v => { last = v; }} />,
|
||||
);
|
||||
|
||||
await pickSection('زیبایی');
|
||||
fireEvent.click(await screen.findByText('بوتاکس'));
|
||||
fireEvent.click(await screen.findByText('فیلر لب'));
|
||||
expect(screen.getByText('سرویسهای انتخابشده (2)')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('حذف بوتاکس'));
|
||||
expect(screen.getByText('سرویسهای انتخابشده (1)')).toBeInTheDocument();
|
||||
await waitFor(() => expect(last.serviceUuids).toEqual(['i2']));
|
||||
});
|
||||
});
|
||||
@@ -1,35 +1,64 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../../lib/api';
|
||||
import type { ApiResponse } from '../../lib/api';
|
||||
import type { BookingService } from '../../hooks/useDoctorBookingServices';
|
||||
import SearchableSelect from '../ui/SearchableSelect';
|
||||
import DigitInput from '../ui/DigitInput';
|
||||
|
||||
interface ServiceSlot { start: number; end: number; start_time: string }
|
||||
export interface PickedService { uuid: string; name: string; section: string; duration: number }
|
||||
export interface ServicePick { serviceUuids: string[]; durations: Record<string, number>; slot: ServiceSlot | null }
|
||||
|
||||
/**
|
||||
* انتخاب سرویس (یک/چند) + زمانهای خالیِ کافیِ پیشنهادی برای نوبتدهی سرویسی.
|
||||
* مدت نوبت از مجموع مدت سرویسها میآید؛ زمانها از `appointment-service-slots`.
|
||||
* انتخاب را از طریق onSelect بالا میفرستد تا فرمِ میزبان payload بسازد.
|
||||
* انتخاب سرویس بر اساس بخش (بخش → سرویس، انباشته از چند بخش) + زمانهای خالیِ پیشنهادی
|
||||
* برای نوبتدهی سرویسی. مدتِ هر سرویس در پنل قابل ویرایش است (فقط برای همین نوبت؛ پیشفرضِ
|
||||
* سرویس تغییر نمیکند). مدت کل = مجموع مدتها؛ زمانها از `appointment-service-slots`
|
||||
* با اعمال همان override محاسبه میشوند. انتخاب را از طریق onSelect بالا میفرستد.
|
||||
*/
|
||||
export default function ServiceSlotPicker({
|
||||
doctorUuid, date, services, onSelect,
|
||||
doctorUuid, date, services, onSelect, editableDuration = true,
|
||||
}: {
|
||||
doctorUuid: string;
|
||||
date: string;
|
||||
services: BookingService[];
|
||||
onSelect: (v: { serviceUuids: string[]; slot: ServiceSlot | null }) => void;
|
||||
onSelect: (v: ServicePick) => void;
|
||||
editableDuration?: boolean;
|
||||
}) {
|
||||
const [serviceUuids, setServiceUuids] = useState<string[]>([]);
|
||||
const [sectionUuid, setSectionUuid] = useState('');
|
||||
const [selected, setSelected] = useState<PickedService[]>([]);
|
||||
const [pickedSlot, setPickedSlot] = useState<ServiceSlot | null>(null);
|
||||
|
||||
useEffect(() => { setPickedSlot(null); }, [serviceUuids, date, doctorUuid]);
|
||||
useEffect(() => { onSelect({ serviceUuids, slot: pickedSlot }); }, [serviceUuids, pickedSlot]);
|
||||
// بخشهای یکتا از روی سرویسهای bookable (بدون endpoint اضافه — همه یکجا آمدهاند).
|
||||
const sections = useMemo(() => {
|
||||
const map = new Map<string, { uuid: string; name: string }>();
|
||||
services.forEach(s => { if (s.service_section) map.set(s.service_section.uuid, s.service_section); });
|
||||
return [...map.values()];
|
||||
}, [services]);
|
||||
const sectionServices = useMemo(
|
||||
() => services.filter(s => s.service_section?.uuid === sectionUuid),
|
||||
[services, sectionUuid],
|
||||
);
|
||||
|
||||
// تعویض پزشک ⇒ لیست سرویسها عوض میشود؛ انتخابها ریست شوند.
|
||||
useEffect(() => { setSelected([]); setSectionUuid(''); }, [doctorUuid]);
|
||||
useEffect(() => { setPickedSlot(null); }, [selected, date, doctorUuid]);
|
||||
|
||||
const serviceUuids = useMemo(() => selected.map(s => s.uuid), [selected]);
|
||||
const durations = useMemo(
|
||||
() => Object.fromEntries(selected.map(s => [s.uuid, s.duration])) as Record<string, number>,
|
||||
[selected],
|
||||
);
|
||||
|
||||
useEffect(() => { onSelect({ serviceUuids, durations, slot: pickedSlot }); }, [serviceUuids, durations, pickedSlot]);
|
||||
|
||||
const durationsQs = selected.map(s => `&durations[${encodeURIComponent(s.uuid)}]=${s.duration}`).join('');
|
||||
const slotsQ = useQuery<ApiResponse<any>>({
|
||||
queryKey: ['service-slots-picker', doctorUuid, date, serviceUuids],
|
||||
queryKey: ['service-slots-picker', doctorUuid, date, serviceUuids, durations],
|
||||
queryFn: () => api.get(
|
||||
`/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}`
|
||||
+ serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('')
|
||||
+ durationsQs,
|
||||
),
|
||||
enabled: !!doctorUuid && !!date && serviceUuids.length > 0,
|
||||
});
|
||||
@@ -38,51 +67,121 @@ export default function ServiceSlotPicker({
|
||||
|
||||
const label = { fontSize: 12.5, color: 'var(--text-3)', display: 'block' } as const;
|
||||
|
||||
const toggle = (uuid: string) =>
|
||||
setServiceUuids(prev => prev.includes(uuid) ? prev.filter(u => u !== uuid) : [...prev, uuid]);
|
||||
const toggle = (s: BookingService) =>
|
||||
setSelected(prev => prev.some(p => p.uuid === s.uuid)
|
||||
? prev.filter(p => p.uuid !== s.uuid)
|
||||
: [...prev, { uuid: s.uuid, name: s.name, section: s.service_section.name, duration: s.duration_minutes ?? 0 }]);
|
||||
const remove = (uuid: string) => setSelected(prev => prev.filter(p => p.uuid !== uuid));
|
||||
const setDuration = (uuid: string, minutes: number) =>
|
||||
setSelected(prev => prev.map(p => p.uuid === uuid ? { ...p, duration: minutes } : p));
|
||||
|
||||
if (services.length === 0) {
|
||||
return (
|
||||
<div style={{ fontSize: 12.5, color: 'var(--danger)', margin: '6px 0' }}>
|
||||
سرویسی با «نمایش در نوبتدهی» برای این پزشک تعریف نشده است.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label style={label}>سرویس (یک یا چند)</label>
|
||||
{services.length === 0 ? (
|
||||
<div style={{ fontSize: 12.5, color: 'var(--danger)', margin: '6px 0' }}>
|
||||
سرویسی با «نمایش در نوبتدهی» برای این پزشک تعریف نشده است.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, margin: '6px 0 10px' }}>
|
||||
{services.map(s => {
|
||||
const active = serviceUuids.includes(s.uuid);
|
||||
return (
|
||||
<button
|
||||
key={s.uuid}
|
||||
type="button"
|
||||
onClick={() => toggle(s.uuid)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
|
||||
padding: '8px 10px', 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={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{
|
||||
width: 15, height: 15, 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: 7, height: 7, background: '#fff', borderRadius: 2 }} />}
|
||||
</span>
|
||||
{s.name}
|
||||
{/* انتخاب بخش */}
|
||||
<label style={label}>بخش</label>
|
||||
<div style={{ margin: '6px 0 10px', maxWidth: 400 }}>
|
||||
<SearchableSelect
|
||||
inputId="service-mode-section-select"
|
||||
options={sections.map(s => ({ value: s.uuid, label: s.name }))}
|
||||
value={sectionUuid || null}
|
||||
onChange={v => setSectionUuid(v ? String(v) : '')}
|
||||
placeholder="ابتدا بخش را انتخاب کنید"
|
||||
isClearable
|
||||
height={44}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* سرویسهای بخشِ انتخابشده — چند انتخابی */}
|
||||
{sectionUuid && (
|
||||
<>
|
||||
<label style={label}>سرویسهای این بخش (یک یا چند)</label>
|
||||
{sectionServices.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' }}>
|
||||
{sectionServices.map(s => {
|
||||
const active = selected.some(p => p.uuid === s.uuid);
|
||||
return (
|
||||
<button key={s.uuid} type="button" onClick={() => toggle(s)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', 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={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
<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>
|
||||
{s.name}
|
||||
</span>
|
||||
{s.duration_minutes ? <span style={{ color: 'var(--text-3)', fontSize: 12 }}>{s.duration_minutes} دقیقه</span> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* لیستِ انباشتهٔ سرویسهای انتخابشده (از هر بخش) — «بخش → سرویس» + مدت قابلویرایش + حذف */}
|
||||
{selected.length > 0 && (
|
||||
<div style={{ margin: '4px 0 12px' }}>
|
||||
<label style={label}>سرویسهای انتخابشده ({selected.length})</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 6 }}>
|
||||
{selected.map(s => (
|
||||
<div key={s.uuid} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap',
|
||||
padding: '8px 10px', borderRadius: 'var(--r-sm)', fontSize: 13,
|
||||
background: 'var(--primary-soft)', border: '1px solid var(--primary)',
|
||||
}}>
|
||||
<span style={{ flex: 1, minWidth: 120, color: 'var(--primary-700)', fontWeight: 600 }}>
|
||||
<span style={{ color: 'var(--text-3)', fontWeight: 400 }}>{s.section}</span>
|
||||
{' ← '}{s.name}
|
||||
</span>
|
||||
{s.duration_minutes ? <span style={{ color: 'var(--text-3)', fontSize: 12 }}>{s.duration_minutes} دقیقه</span> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
{editableDuration ? (
|
||||
<span className="field" style={{ height: 34, width: 92, padding: '0 8px' }}>
|
||||
<DigitInput
|
||||
aria-label={`مدت ${s.name}`}
|
||||
value={String(s.duration || '')}
|
||||
onChange={v => setDuration(s.uuid, Number(v) || 0)}
|
||||
maxDigits={3}
|
||||
placeholder="دقیقه"
|
||||
/>
|
||||
<span style={{ color: 'var(--text-3)', fontSize: 11.5 }}>دقیقه</span>
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-3)', fontSize: 12 }}>{s.duration} دقیقه</span>
|
||||
)}
|
||||
<button type="button" aria-label={`حذف ${s.name}`} onClick={() => remove(s.uuid)}
|
||||
style={{
|
||||
display: 'grid', placeItems: 'center', width: 18, height: 18, borderRadius: 999,
|
||||
border: 'none', cursor: 'pointer', background: 'var(--primary)', color: '#fff',
|
||||
fontSize: 13, lineHeight: 1, fontFamily: 'inherit',
|
||||
}}>×</button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{serviceUuids.length > 0 && (
|
||||
{/* زمانهای خالی پیشنهادی */}
|
||||
{selected.length > 0 && (
|
||||
<>
|
||||
<label style={label}>زمانهای خالی پیشنهادی{totalMinutes != null ? ` (مدت کل: ${totalMinutes} دقیقه)` : ''}</label>
|
||||
{slotsQ.isLoading ? (
|
||||
@@ -96,17 +195,13 @@ export default function ServiceSlotPicker({
|
||||
{startTimes.map(s => {
|
||||
const active = pickedSlot?.start === s.start;
|
||||
return (
|
||||
<button
|
||||
key={s.start}
|
||||
type="button"
|
||||
dir="ltr"
|
||||
<button key={s.start} type="button" dir="ltr"
|
||||
onClick={() => setPickedSlot({ start: s.start, end: s.end, start_time: s.start_time })}
|
||||
style={{
|
||||
fontSize: 13, padding: '6px 12px', borderRadius: 'var(--r-sm)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
|
||||
background: active ? 'var(--primary)' : 'var(--surface)', color: active ? '#fff' : 'var(--text)',
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
{s.start_time}
|
||||
</button>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user