feat(appointment): implement service mode functionality with service slot selection and validation
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user