feat(appointment): implement service mode functionality with service slot selection and validation

This commit is contained in:
hamed
2026-07-16 00:02:36 +03:30
parent ac4a430564
commit 4715649fd8
5 changed files with 339 additions and 49 deletions
@@ -0,0 +1,120 @@
import { useEffect, 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';
interface ServiceSlot { start: number; end: number; start_time: string }
/**
* انتخاب سرویس (یک/چند) + زمان‌های خالیِ کافیِ پیشنهادی برای نوبت‌دهی سرویسی.
* مدت نوبت از مجموع مدت سرویس‌ها می‌آید؛ زمان‌ها از `appointment-service-slots`.
* انتخاب را از طریق onSelect بالا می‌فرستد تا فرمِ میزبان payload بسازد.
*/
export default function ServiceSlotPicker({
doctorUuid, date, services, onSelect,
}: {
doctorUuid: string;
date: string;
services: BookingService[];
onSelect: (v: { serviceUuids: string[]; slot: ServiceSlot | null }) => void;
}) {
const [serviceUuids, setServiceUuids] = useState<string[]>([]);
const [pickedSlot, setPickedSlot] = useState<ServiceSlot | null>(null);
useEffect(() => { setPickedSlot(null); }, [serviceUuids, date, doctorUuid]);
useEffect(() => { onSelect({ serviceUuids, slot: pickedSlot }); }, [serviceUuids, pickedSlot]);
const slotsQ = useQuery<ApiResponse<any>>({
queryKey: ['service-slots-picker', doctorUuid, date, serviceUuids],
queryFn: () => api.get(
`/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}`
+ serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('')
),
enabled: !!doctorUuid && !!date && serviceUuids.length > 0,
});
const startTimes: ServiceSlot[] = (slotsQ.data?.data as any)?.start_times ?? [];
const totalMinutes = (slotsQ.data?.data as any)?.total_duration_minutes as number | undefined;
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]);
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}
</span>
{s.duration_minutes ? <span style={{ color: 'var(--text-3)', fontSize: 12 }}>{s.duration_minutes} دقیقه</span> : null}
</button>
);
})}
</div>
)}
{serviceUuids.length > 0 && (
<>
<label style={label}>زمانهای خالی پیشنهادی{totalMinutes != null ? ` (مدت کل: ${totalMinutes} دقیقه)` : ''}</label>
{slotsQ.isLoading ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '6px 0' }}>در حال محاسبه...</div>
) : startTimes.length === 0 ? (
<div style={{ fontSize: 12.5, color: 'var(--danger)', margin: '6px 0' }}>
برای این سرویس در این روز زمان خالی کافی نیست؛ روز دیگری انتخاب کنید.
</div>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, margin: '6px 0 4px' }}>
{startTimes.map(s => {
const active = pickedSlot?.start === s.start;
return (
<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>
);
})}
</div>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,36 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
export interface BookingService {
uuid: string;
name: string;
duration_minutes: number | null;
price_rials: number;
}
interface BookingServicesData {
booking_mode: 'slot' | 'service';
buffer_minutes: number;
services: BookingService[];
}
/**
* روش نوبت‌دهی و سرویس‌های قابل‌انتخابِ یک پزشک — از endpoint عمومیِ
* `appointment-booking-services`. برای سرویس‌محور کردن فرم‌های ثبت نوبت پنل.
*/
export function useDoctorBookingServices(doctorUuid: string | null | undefined) {
const q = useQuery<ApiResponse<BookingServicesData>>({
queryKey: ['booking-services', doctorUuid],
queryFn: () => api.get(`/api/v1/appointment-booking-services/${doctorUuid}`),
enabled: !!doctorUuid,
});
const data = q.data?.data as BookingServicesData | undefined;
return {
bookingMode: (data?.booking_mode === 'service' ? 'service' : 'slot') as 'slot' | 'service',
bufferMinutes: data?.buffer_minutes ?? 0,
services: data?.services ?? [],
isLoading: q.isLoading,
};
}
@@ -38,6 +38,33 @@ describe('AppointmentCreatePage — افزودن نوبت', () => {
expect(body).toMatchObject({ doctor_uuid: 'doc1', patient_name: 'علی محمدی', patient_mobile: '09121234567', patient_national_code: '1234567891' });
});
it('service mode: picks service + suggested time and posts service_item_uuids', async () => {
get.mockImplementation((url: string) => {
if (url.startsWith('/api/v1/appointment-booking-services/'))
return Promise.resolve({ success: true, data: { booking_mode: 'service', buffer_minutes: 5, services: [{ uuid: 'sv1', name: 'عصب‌کشی', duration_minutes: 30, price_rials: 500000 }] } });
if (url.startsWith('/api/v1/appointment-service-slots'))
return Promise.resolve({ success: true, data: { total_duration_minutes: 30, buffer_minutes: 5, start_times: [{ start: 1754000000, end: 1754001800, start_time: '15:00' }] } });
return Promise.resolve({ success: true, data: [] });
});
renderWithProviders(<AppointmentCreatePage />, { route: '/admin/appointments/new' });
fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی مراجعه کننده'), { target: { value: 'علی محمدی' } });
fireEvent.change(screen.getByPlaceholderText('شماره تماس مراجعه کننده'), { target: { value: '09121234567' } });
fireEvent.change(screen.getByPlaceholderText('کد ملی مراجعه کننده'), { target: { value: '1234567891' } });
// حالت سرویس: ورودی ساعت شروع نباید باشد
await waitFor(() => expect(screen.queryByLabelText('ساعت شروع')).toBeNull());
fireEvent.click(await screen.findByText('عصب‌کشی'));
fireEvent.click(await screen.findByRole('button', { name: '15:00' }));
fireEvent.click(screen.getByText('ثبت اطلاعات'));
await waitFor(() => expect(post).toHaveBeenCalled());
const [, body] = post.mock.calls[0];
expect(body).toMatchObject({ doctor_uuid: 'doc1', slot_start: 1754000000, slot_end: 1754001800, service_item_uuids: ['sv1'] });
});
it('keeps the submit button disabled until a valid patient is entered (boundary)', () => {
renderWithProviders(<AppointmentCreatePage />, { route: '/admin/appointments/new' });
const btn = screen.getByText('ثبت اطلاعات') as HTMLButtonElement;
+65 -34
View File
@@ -10,6 +10,8 @@ import PersianDateInput from '../components/ui/PersianDateInput';
import PriceInput from '../components/ui/PriceInput';
import SearchableSelect from '../components/ui/SearchableSelect';
import { WalletChargeLink } from '../components/AppointmentActions';
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
/**
* افزودن نوبت — صفحهٔ کامل (بازسازی `CreateTurn.jsx` طرح tauri). از همان endpointهای
@@ -76,6 +78,11 @@ export default function AppointmentCreatePage() {
});
const staffQ = useQuery<ApiResponse<Option[]>>({ queryKey: ['staff-list'], queryFn: () => api.get('/api/v1/staff') });
// ── روش نوبت‌دهی پزشک (سرویسی/اسلاتی)
const { bookingMode, services } = useDoctorBookingServices(doctorUuid);
const serviceMode = bookingMode === 'service';
const [servicePick, setServicePick] = useState<{ serviceUuids: string[]; slot: { start: number; end: number } | null }>({ serviceUuids: [], slot: null });
// ── زمان نوبت
const [date, setDate] = useState(params.get('date') || today);
const [duration, setDuration] = useState(40);
@@ -92,21 +99,28 @@ export default function AppointmentCreatePage() {
const effectiveName = picked?.user_name || name.trim();
const effectiveMobile = picked?.user_mobile || mobile.trim();
const effectiveNationalCode = (picked?.user_national_code || nationalCode).replace(/\D/g, '');
const timingValid = serviceMode
? (servicePick.serviceUuids.length > 0 && !!servicePick.slot)
: (!!start && !!end);
const valid = !!doctorUuid && !!date && effectiveName.length >= 2 && effectiveMobile.length >= 10
&& effectiveNationalCode.length === 10 && !!start && !!end;
&& effectiveNationalCode.length === 10 && timingValid;
const create = useMutation({
mutationFn: async () => {
const createEndpoint = primaryRole === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/my/appointment';
const payload: Record<string, unknown> = {
doctor_uuid: doctorUuid,
slot_start: toEpoch(date, start),
slot_end: toEpoch(date, end),
slot_start: serviceMode ? servicePick.slot!.start : toEpoch(date, start),
slot_end: serviceMode ? servicePick.slot!.end : toEpoch(date, end),
patient_name: effectiveName,
patient_mobile: effectiveMobile,
patient_national_code: effectiveNationalCode,
...(sectionUuid ? { service_section_uuid: sectionUuid } : {}),
...(itemUuid ? { service_item_uuid: itemUuid } : {}),
...(serviceMode
? { service_item_uuids: servicePick.serviceUuids }
: {
...(sectionUuid ? { service_section_uuid: sectionUuid } : {}),
...(itemUuid ? { service_item_uuid: itemUuid } : {}),
}),
...(staffUuid ? { staff_uuid: staffUuid } : {}),
...(depositRequired ? { deposit_required: true, deposit_amount_rials: depositRials } : {}),
...(note.trim() ? { note: note.trim() } : {}),
@@ -210,22 +224,24 @@ export default function AppointmentCreatePage() {
{/* مشخصات سرویس */}
<div style={sectionTitle}>مشخصات سرویس:</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 10 }}>
<div>
<label style={label}>بخش</label>
<select aria-label="بخش" style={{ ...sel, marginTop: 6 }} value={sectionUuid} onChange={e => { setSectionUuid(e.target.value); setItemUuid(''); }}>
<option value="">انتخاب بخش</option>
{(sectionsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
</select>
{!serviceMode && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 10 }}>
<div>
<label style={label}>بخش</label>
<select aria-label="بخش" style={{ ...sel, marginTop: 6 }} value={sectionUuid} onChange={e => { setSectionUuid(e.target.value); setItemUuid(''); }}>
<option value="">انتخاب بخش</option>
{(sectionsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
</select>
</div>
<div>
<label style={label}>سرویس</label>
<select aria-label="سرویس" style={{ ...sel, marginTop: 6 }} value={itemUuid} onChange={e => setItemUuid(e.target.value)} disabled={!sectionUuid}>
<option value="">انتخاب سرویس</option>
{(itemsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
</select>
</div>
</div>
<div>
<label style={label}>سرویس</label>
<select aria-label="سرویس" style={{ ...sel, marginTop: 6 }} value={itemUuid} onChange={e => setItemUuid(e.target.value)} disabled={!sectionUuid}>
<option value="">انتخاب سرویس</option>
{(itemsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
</select>
</div>
</div>
)}
<label style={label}>انتخاب پرسنل</label>
<select aria-label="پرسنل" style={{ ...sel, margin: '6px 0 4px' }} value={staffUuid} onChange={e => setStaffUuid(e.target.value)}>
<option value="">انتخاب...</option>
@@ -236,22 +252,37 @@ export default function AppointmentCreatePage() {
<div style={sectionTitle}>زمان نوبت:</div>
<label style={label}>انتخاب تاریخ</label>
<div style={{ margin: '6px 0 10px' }}><PersianDateInput value={date} onChange={setDate} /></div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 10, marginBottom: 12 }}>
<div>
<label style={label}>زمان پیش فرض (دقیقه)</label>
<div className="field" style={{ marginTop: 6 }}>
<input aria-label="زمان پیش فرض" type="number" min={5} value={duration} onChange={e => setDuration(Math.max(5, Number(e.target.value) || 0))} dir="ltr" />
{serviceMode ? (
<div style={{ marginBottom: 12 }}>
{doctorUuid ? (
<ServiceSlotPicker
doctorUuid={doctorUuid}
date={date}
services={services}
onSelect={setServicePick}
/>
) : (
<div style={{ fontSize: 12.5, color: 'var(--text-3)' }}>ابتدا پزشک را انتخاب کنید.</div>
)}
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 10, marginBottom: 12 }}>
<div>
<label style={label}>زمان پیش فرض (دقیقه)</label>
<div className="field" style={{ marginTop: 6 }}>
<input aria-label="زمان پیش فرض" type="number" min={5} value={duration} onChange={e => setDuration(Math.max(5, Number(e.target.value) || 0))} dir="ltr" />
</div>
</div>
<div>
<label style={label}>ساعت شروع</label>
<div className="field" style={{ marginTop: 6 }}><input aria-label="ساعت شروع" type="time" value={start} onChange={e => setStart(e.target.value)} dir="ltr" /></div>
</div>
<div>
<label style={label}>ساعت پایان</label>
<div className="field" style={{ marginTop: 6 }}><input aria-label="ساعت پایان" type="time" value={end} onChange={e => setEnd(e.target.value)} dir="ltr" /></div>
</div>
</div>
<div>
<label style={label}>ساعت شروع</label>
<div className="field" style={{ marginTop: 6 }}><input aria-label="ساعت شروع" type="time" value={start} onChange={e => setStart(e.target.value)} dir="ltr" /></div>
</div>
<div>
<label style={label}>ساعت پایان</label>
<div className="field" style={{ marginTop: 6 }}><input aria-label="ساعت پایان" type="time" value={end} onChange={e => setEnd(e.target.value)} dir="ltr" /></div>
</div>
</div>
)}
{/* بیعانه */}
<div style={sectionTitle}>بیعانه:</div>
+91 -15
View File
@@ -22,6 +22,8 @@ import type { TurnsViewMode } from '../components/appointments/TurnsViewToggle';
import DoctorTabs from '../components/appointments/DoctorTabs';
import TurnsTimeline from '../components/appointments/TurnsTimeline';
import TurnsTable from '../components/appointments/TurnsTable';
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
import { CANCELLED_STATUSES } from '../components/appointments/turnStatus';
import type { TimelineSlot } from '../components/appointments/types';
@@ -97,17 +99,26 @@ interface BookingSlot { start: number; end: number; start_time: string; end_time
interface PatientLookup { found: boolean; name?: string | null; mobile?: string; national_code?: string | null }
export function NewAppointmentModal({
slot, onClose, onSuccess,
}: { slot: BookingSlot; onClose: () => void; onSuccess: () => void }) {
slot, onClose, onSuccess, serviceMode = false, services = [], date,
}: {
slot: BookingSlot;
onClose: () => void;
onSuccess: () => void;
serviceMode?: boolean;
services?: import('../hooks/useDoctorBookingServices').BookingService[];
date?: string;
}) {
const [mobile, setMobile] = useState('');
const [lookup, setLookup] = useState<PatientLookup | null>(null);
const [patientName, setPatientName] = useState('');
const [nationalCode, setNationalCode] = useState('');
const [pick, setPick] = useState<{ serviceUuids: string[]; slot: { start: number; end: number } | null }>({ serviceUuids: [], slot: null });
const role = useAuthStore(s => s.primaryRole);
const createEndpoint = role === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/my/appointment';
const mobileValid = /^09\d{9}$/.test(mobile);
const serviceTimingValid = !serviceMode || (pick.serviceUuids.length > 0 && !!pick.slot);
// یک بیمارِ یافت‌شده که کد ملی دارد، بدون فرم اضافی قابل استفاده است.
const foundWithNationalCode = !!lookup?.found && !!lookup.national_code;
const needsDetails = lookup !== null && !foundWithNationalCode; // یافت‌نشده، یا یافت‌شده بدون کد ملی
@@ -115,7 +126,7 @@ export function NewAppointmentModal({
const effectiveName = foundWithNationalCode ? (lookup?.name ?? '') : patientName.trim();
const effectiveNationalCode = foundWithNationalCode ? (lookup?.national_code ?? '') : nationalCode;
const detailsValid = effectiveName.length >= 2 && effectiveNationalCode.length === 10;
const isValid = mobileValid && (foundWithNationalCode || (needsDetails && detailsValid));
const isValid = mobileValid && (foundWithNationalCode || (needsDetails && detailsValid)) && serviceTimingValid;
const search = useMutation({
mutationFn: () => api.get(`/api/v1/my/appointment/patient-lookup?mobile=${encodeURIComponent(mobile)}`),
@@ -131,11 +142,12 @@ export function NewAppointmentModal({
const mutation = useMutation({
mutationFn: () => api.post(createEndpoint, {
doctor_uuid: slot.doctor_uuid,
slot_start: slot.start,
slot_end: slot.end,
slot_start: serviceMode ? pick.slot!.start : slot.start,
slot_end: serviceMode ? pick.slot!.end : slot.end,
patient_mobile: mobile,
patient_name: effectiveName,
patient_national_code: effectiveNationalCode,
...(serviceMode ? { service_item_uuids: pick.serviceUuids } : {}),
}),
onSuccess: () => {
toast.success('نوبت با موفقیت ثبت شد');
@@ -180,9 +192,22 @@ export function NewAppointmentModal({
}} onClick={e => e.stopPropagation()}>
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 4 }}>ثبت نوبت</div>
<div style={{ fontSize: 13, color: 'var(--text-2)', marginBottom: 16 }}>
{slot.start_time} تا {slot.end_time} {slot.doctor_name}
{serviceMode
? slot.doctor_name
: `${slot.start_time} تا ${slot.end_time}${slot.doctor_name}`}
</div>
{serviceMode && date && (
<div style={{ marginBottom: 16 }}>
<ServiceSlotPicker
doctorUuid={slot.doctor_uuid}
date={date}
services={services}
onSelect={setPick}
/>
</div>
)}
<div style={{ marginBottom: 12 }}>
<label style={labelSx}>شماره موبایل بیمار *</label>
<div style={{ display: 'flex', gap: 8 }}>
@@ -374,10 +399,23 @@ export default function AppointmentsPage() {
enabled: viewMode === 'timeline' && !!selectedDoctorUuid,
});
// ── روش نوبت‌دهی پزشکِ انتخاب‌شده (سرویسی/اسلاتی)
const { bookingMode, services } = useDoctorBookingServices(selectedDoctorUuid);
const serviceMode = bookingMode === 'service';
// بازهٔ کاری پزشک در این روز (برای هدرِ تایم‌لاینِ سرویسی).
const workingRange = React.useMemo(() => {
const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? [];
if (!rawSessions.length) return null;
const starts = rawSessions.map(s => s.start_time).filter(Boolean).sort();
const ends = rawSessions.map(s => s.end_time).filter(Boolean).sort();
if (!starts.length || !ends.length) return null;
return { start: starts[0], end: ends[ends.length - 1] };
}, [slotsQuery.data]);
// ── Merge sessions + appointments → flat timeline slots
const timelineSlots: TimelineSlot[] = React.useMemo(() => {
if (viewMode !== 'timeline') return [];
const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? [];
const activeByStart = new Map<number, Appointment>();
const cancelledByStart = new Map<number, Appointment>();
@@ -391,6 +429,27 @@ export default function AppointmentsPage() {
}
});
// حالت سرویسی: اسلات ثابت وجود ندارد — تایم‌لاین از خودِ نوبت‌های رزروشده
// (با مدت واقعی) ساخته می‌شود، مرتب بر اساس زمان شروع.
if (serviceMode) {
const fmt = (ts: number) => new Date(ts * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' });
return [...activeByStart.values()]
.sort((a, b) => Number(a.slot_start) - Number(b.slot_start))
.map(a => {
const start = Number(a.slot_start);
const end = Number(a.slot_end);
return {
start, end,
start_time: fmt(start),
end_time: fmt(end),
is_available: false,
appointment: a,
cancelled_appointment: null,
} as TimelineSlot;
});
}
const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? [];
const out: TimelineSlot[] = [];
rawSessions.forEach((session: any) => {
(session.slots as any[]).forEach((s: any) => {
@@ -408,7 +467,7 @@ export default function AppointmentsPage() {
});
});
return out;
}, [viewMode, slotsQuery.data, appointments]);
}, [viewMode, slotsQuery.data, appointments, serviceMode]);
// ── Slot click → quick booking modal
function handleSlotClick(slot: TimelineSlot) {
@@ -513,13 +572,27 @@ export default function AppointmentsPage() {
برای نمایش زمانبندی، ابتدا یک پزشک انتخاب کنید
</div>
) : (
<TurnsTimeline
slots={timelineSlots}
loading={apptQuery.isLoading || slotsQuery.isLoading}
queryKey={apptQueryKey}
onView={openDetail}
onBook={handleSlotClick}
/>
<>
{serviceMode && (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
padding: '8px 12px', marginBottom: 12, borderRadius: 'var(--r-sm)',
background: 'var(--surface-2)', border: '1px solid var(--border)', fontSize: 12.5, color: 'var(--text-2)',
}}>
<span>نوبتدهی سرویسی نوبتها بر اساس مدت سرویس چیده میشوند.</span>
{workingRange && (
<span dir="ltr" style={{ color: 'var(--text-3)' }}>ساعت کاری: {workingRange.start} - {workingRange.end}</span>
)}
</div>
)}
<TurnsTimeline
slots={timelineSlots}
loading={apptQuery.isLoading || slotsQuery.isLoading}
queryKey={apptQueryKey}
onView={openDetail}
onBook={handleSlotClick}
/>
</>
)}
</>
)}
@@ -531,6 +604,9 @@ export default function AppointmentsPage() {
{bookingSlot && (
<NewAppointmentModal
slot={bookingSlot}
serviceMode={serviceMode}
services={services}
date={selectedDate}
onClose={() => setBookingSlot(null)}
onSuccess={() => {
qc.invalidateQueries({ queryKey: apptQueryKey });