feat: Implement resource booking functionality

- Add service timeline builder for appointments to manage available slots.
- Create a hook to fetch resource booking services with effective durations.
- Develop ResourceBookingSlotController to handle API requests for resource booking slots.
- Implement ResourceBookingSlotService to calculate available time slots based on resource occupancy and service durations.
- Add tests for resource appointment creation and booking slot functionality to ensure correct behavior and edge cases.
This commit is contained in:
hamed
2026-08-03 14:34:23 +03:30
parent 981261ed3a
commit 4f69bc9044
21 changed files with 2045 additions and 514 deletions
@@ -0,0 +1,429 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { ChevronDownIcon, ClockIcon, MagnifyingGlassIcon, CheckCircleIcon, CpuChipIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../../lib/api';
import { digitsOnly, sanitizeMobileInput, rialToToman, tomanToRial, formatRial } from '../../lib/utils';
import { useAuthStore } from '../../stores/authStore';
import Modal from '../ui/Modal';
import PriceInput from '../ui/PriceInput';
import ServiceSlotPicker from './ServiceSlotPicker';
import type { ServicePick } from './ServiceSlotPicker';
import type { BookingService } from '../../hooks/useDoctorBookingServices';
export interface BookingSlot {
start: number;
end: number;
start_time: string;
end_time: string;
doctor_uuid: string;
doctor_name: string;
}
interface PatientLookup { found: boolean; name?: string | null; mobile?: string; national_code?: string | null }
/** منبعِ هدفِ نوبت — دستگاه/اتاق/پرسنلی که نوبت رویش می‌نشیند. */
export interface BookingResource {
uuid: string;
name: string;
}
/**
* ثبت نوبت از روی یک اسلات/منبع: جستجوی بیمار، انتخاب سرویس‌ها و زمان، هزینهٔ ویزیت.
*
* سه حالت دارد و هر سه یک فرم‌اند — سه مودال یعنی سه رفتار:
* - اسلاتی: زمان همان اسلاتِ کلیک‌شده است.
* - سرویسی (پزشک): زمان از `ServiceSlotPicker` و برنامهٔ هفتگیِ پزشک می‌آید.
* - منبع: همان انتخابگر، ولی زمان‌ها از تقویم خودِ منبع و ثبت با `resource_uuid`.
*/
export default function NewAppointmentModal({
slot, onClose, onSuccess, serviceMode = false, services = [], date, clinicUuid = null, resource = null,
}: {
slot: BookingSlot;
onClose: () => void;
onSuccess: () => void;
serviceMode?: boolean;
services?: BookingService[];
date?: string;
/** محل نوبت — بدون آن backend نوبت را به مطب شخصی نسبت می‌دهد. */
clinicUuid?: string | null;
resource?: BookingResource | null;
}) {
const [mobile, setMobile] = useState('');
const [lookup, setLookup] = useState<PatientLookup | null>(null);
const [patientName, setPatientName] = useState('');
const [nationalCode, setNationalCode] = useState('');
// معیار جستجوی بیمار: کد ملی (پیش‌فرض) یا موبایل.
const [searchBy, setSearchBy] = useState<'mobile' | 'national'>('national');
const [pick, setPick] = useState<ServicePick>({ serviceUuids: [], durations: {}, slot: null });
const role = useAuthStore(s => s.primaryRole);
// نوبتِ منبع فقط مسیر پنل را دارد؛ اندپوینت ادمین `resource_uuid` نمی‌شناسد.
const createEndpoint = role === 'admin' && resource === null
? '/api/v1/admin/appointment'
: '/api/v1/my/appointment';
// منبع تقویم خودش را دارد، پس نوبتش همیشه سرویسی است — اسلات ثابتی وجود ندارد
// که بشود رویش نشست.
const pickerMode = serviceMode || resource !== null;
// هزینه ویزیت — الزامی بودن از تنظیمات «الزامی کردن هزینه ویزیت». فیلد UI تومان،
// API ریالی (visit_price_rials). بدون این مقدار، وقتی فلگ فعال است backend خطای ۴۲۲ می‌دهد.
//
// قیمت باید از تنظیمات نوبت‌دهیِ *پزشکِ همین اسلات* بیاید، نه از entity کاربر جاری؛
// منشی/کلینیک قیمت خودشان را ندارند و فیلد صفر می‌ماند. اگر دسترسی به تنظیمات آن
// پزشک نبود (۴۰۳)، به تنظیمات خودِ کاربر برمی‌گردیم تا فلگ الزامی‌بودن از دست نرود.
type Pricing = { data: { free_visit_price_rials: number; require_visit_price: boolean } };
const doctorPricingQ = useQuery<Pricing>({
queryKey: ['insurance-pricing', slot.doctor_uuid],
queryFn: () => api.get(`/api/v1/insurance-pricing?doctor_uuid=${encodeURIComponent(slot.doctor_uuid)}`),
enabled: !!slot.doctor_uuid,
retry: false,
});
const selfPricingQ = useQuery<Pricing>({
queryKey: ['insurance-pricing'],
queryFn: () => api.get('/api/v1/insurance-pricing'),
enabled: doctorPricingQ.isError || !slot.doctor_uuid,
});
const pricing = (doctorPricingQ.data ?? selfPricingQ.data) as any;
const freeVisit = pricing?.data?.free_visit_price_rials ?? 0;
const requireVisit = pricing?.data?.require_visit_price ?? false;
const pricingLoading = doctorPricingQ.isLoading || selfPricingQ.isLoading;
const [visitPriceToman, setVisitPriceToman] = useState(0);
const [visitPriceTouched, setVisitPriceTouched] = useState(false);
useEffect(() => {
if (!visitPriceTouched && freeVisit > 0) setVisitPriceToman(rialToToman(freeVisit));
}, [freeVisit, visitPriceTouched]);
// هزینه ویزیت اختیاری داخل کلپسِ بسته می‌نشیند؛ وقتی الزامی است کلپس همیشه باز است.
const [visitPriceOpen, setVisitPriceOpen] = useState(false);
const visitPriceExpanded = requireVisit || visitPriceOpen;
const mobileValid = /^09\d{9}$/.test(mobile);
const nationalCodeValid = /^\d{10}$/.test(nationalCode);
// اعتبار کلید جستجو بسته به معیار انتخاب‌شده.
const searchValid = searchBy === 'mobile' ? mobileValid : nationalCodeValid;
const serviceTimingValid = !pickerMode || (pick.serviceUuids.length > 0 && !!pick.slot);
// یک بیمارِ یافت‌شده که کد ملی دارد، بدون فرم اضافی قابل استفاده است.
const foundWithNationalCode = !!lookup?.found && !!lookup.national_code;
const needsDetails = lookup !== null && !foundWithNationalCode; // یافت‌نشده، یا یافت‌شده بدون کد ملی
const effectiveName = foundWithNationalCode ? (lookup?.name ?? '') : patientName.trim();
const effectiveNationalCode = foundWithNationalCode ? (lookup?.national_code ?? '') : nationalCode;
const detailsValid = effectiveName.length >= 2 && effectiveNationalCode.length === 10;
const visitPriceValid = !requireVisit || visitPriceToman > 0;
const isValid = mobileValid && (foundWithNationalCode || (needsDetails && detailsValid)) && serviceTimingValid && visitPriceValid;
const search = useMutation({
mutationFn: () => {
const q = searchBy === 'mobile'
? `mobile=${encodeURIComponent(mobile)}`
: `national_code=${encodeURIComponent(nationalCode)}`;
return api.get(`/api/v1/my/appointment/patient-lookup?${q}`);
},
onSuccess: (res: any) => {
const data: PatientLookup = res?.data ?? { found: false };
setLookup(data);
setPatientName(data.found ? (data.name ?? '') : '');
// موبایل و کد ملیِ بیمارِ یافت‌شده را پر می‌کنیم تا ثبت مستقل از معیار جستجو کار کند.
if (data.found) {
if (data.mobile) setMobile(data.mobile);
setNationalCode(data.national_code ?? '');
} else if (searchBy === 'mobile') {
setNationalCode('');
}
},
onError: (e: any) => toast.error(e?.response?.data?.errors?.[0]?.message ?? e?.message ?? 'خطا در جستجو'),
});
const mutation = useMutation({
mutationFn: () => api.post(createEndpoint, {
...(slot.doctor_uuid ? { doctor_uuid: slot.doctor_uuid } : {}),
slot_start: pickerMode ? pick.slot!.start : slot.start,
slot_end: pickerMode ? pick.slot!.end : slot.end,
patient_mobile: mobile,
patient_name: effectiveName,
patient_national_code: effectiveNationalCode,
...(clinicUuid ? { clinic_uuid: clinicUuid } : {}),
...(pickerMode ? { service_item_uuids: pick.serviceUuids } : {}),
// منبع: مدت را سرور از سرویس‌های همین منبع می‌سازد، پس ساعت پایان حدس نیست.
...(resource ? {
resource_uuid: resource.uuid,
duration_from_services: true,
service_durations: pick.durations,
} : {}),
...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}),
}),
onSuccess: () => {
toast.success('نوبت با موفقیت ثبت شد');
onSuccess();
onClose();
},
onError: (e: any) => {
const msg = e?.response?.data?.errors?.[0]?.message ?? e?.message ?? 'خطا در ثبت نوبت';
toast.error(msg);
},
});
// تغییر کلید جستجو نتیجه‌ی جستجوی قبلی را باطل می‌کند تا کاربر دوباره جستجو کند.
function invalidateLookup() {
if (lookup !== null) { setLookup(null); setPatientName(''); }
}
function onMobileChange(v: string) {
// ارقام فارسی/عربی → انگلیسی، فقط رقم، حداکثر ۱۱ رقم (کیبورد فارسی هم پذیرفته می‌شود).
setMobile(sanitizeMobileInput(v));
if (lookup !== null) { setLookup(null); setPatientName(''); if (searchBy === 'mobile') setNationalCode(''); }
}
function onNationalSearchChange(v: string) {
setNationalCode(digitsOnly(v, 10));
invalidateLookup();
}
// جابه‌جایی معیار جستجو همه‌چیز را از نو شروع می‌کند.
function onSwitchSearchBy(mode: 'mobile' | 'national') {
setSearchBy(mode);
setLookup(null); setPatientName('');
setMobile(''); setNationalCode('');
}
const priceHint = pricingLoading
? 'در حال خواندن تعرفهٔ پزشک…'
: freeVisit > 0
? `تعرفهٔ نوبت‌دهی ${slot.doctor_name}: ${formatRial(freeVisit)} — در صورت نیاز تغییر دهید`
: 'برای این پزشک تعرفه‌ای ثبت نشده — در صورت نیاز مبلغ را وارد کنید';
return (
<Modal
open
title="ثبت نوبت"
size="sm"
onClose={onClose}
footer={
<>
<button className="btn ghost" onClick={onClose}>انصراف</button>
<button
className="btn primary"
onClick={() => mutation.mutate()}
disabled={!isValid || mutation.isPending}
>
{mutation.isPending ? 'در حال ثبت…' : 'ثبت نوبت'}
</button>
</>
}
>
{/* هدف نوبت: منبع، یا اسلات/پزشک */}
<div style={{
display: 'flex', alignItems: 'center', gap: 10, marginBottom: 18,
padding: '10px 14px', borderRadius: 'var(--r-sm)', background: 'var(--primary-soft)',
}}>
{resource
? <CpuChipIcon style={{ width: 18, height: 18, color: 'var(--primary-700)', flexShrink: 0 }} />
: <ClockIcon style={{ width: 18, height: 18, color: 'var(--primary-700)', flexShrink: 0 }} />}
<span style={{ fontSize: 13.5, color: 'var(--text)' }}>
{resource ? resource.name : pickerMode ? slot.doctor_name : `${slot.start_time} تا ${slot.end_time}`}
</span>
{(resource || !pickerMode) && slot.doctor_name && (
<span style={{ fontSize: 12.5, color: 'var(--text-2)', marginInlineStart: 'auto' }}>
{slot.doctor_name}
</span>
)}
</div>
{pickerMode && date && (
<div style={{ marginBottom: 18 }}>
<ServiceSlotPicker
doctorUuid={slot.doctor_uuid}
resourceUuid={resource?.uuid}
date={date}
services={services}
onSelect={setPick}
clinicUuidOverride={clinicUuid}
/>
</div>
)}
<div className="field-block" style={{ marginBottom: 14 }}>
<label>جستجوی بیمار <span className="req">*</span></label>
{/* انتخاب معیار جستجو: موبایل یا کد ملی */}
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
{(['national', 'mobile'] as const).map(mode => (
<button
key={mode}
type="button"
className={`btn sm ${searchBy === mode ? 'primary' : 'ghost'}`}
onClick={() => onSwitchSearchBy(mode)}
style={{ flex: 1 }}
>
{mode === 'mobile' ? 'شماره موبایل' : 'کد ملی'}
</button>
))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<div className="field" style={{ flex: 1 }}>
{searchBy === 'mobile' ? (
<input
type="tel"
inputMode="numeric"
maxLength={11}
value={mobile}
onChange={e => onMobileChange(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }}
placeholder="مثال: 09123456789"
style={{ direction: 'ltr' }}
autoFocus
/>
) : (
<input
type="text"
inputMode="numeric"
lang="en"
maxLength={10}
value={nationalCode}
onChange={e => onNationalSearchChange(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }}
placeholder="کد ملی ۱۰ رقمی"
style={{ direction: 'ltr' }}
autoFocus
/>
)}
</div>
<button
className="btn soft"
onClick={() => search.mutate()}
disabled={!searchValid || search.isPending}
style={{ whiteSpace: 'nowrap' }}
>
<MagnifyingGlassIcon style={{ width: 16, height: 16 }} />
{search.isPending ? '...' : 'جستجو'}
</button>
</div>
</div>
{foundWithNationalCode && (
<div style={{
marginBottom: 16, padding: '10px 14px', borderRadius: 'var(--r-sm)',
background: 'var(--success-bg)', fontSize: 13,
display: 'flex', alignItems: 'center', gap: 10,
}}>
<CheckCircleIcon style={{ width: 20, height: 20, color: 'var(--success)', flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 700, color: 'var(--success)', fontSize: 12 }}>بیمار یافت شد</div>
<div style={{ fontWeight: 700, color: 'var(--text)' }}>{lookup?.name}</div>
<div style={{ color: 'var(--text-2)', fontSize: 12 }}>کد ملی: {lookup?.national_code}</div>
</div>
</div>
)}
{needsDetails && (
<>
<div style={{
fontSize: 12.5, color: 'var(--text-2)', marginBottom: 12,
padding: '9px 12px', borderRadius: 'var(--r-sm)', background: 'var(--warning-bg)',
}}>
{lookup?.found ? 'برای این بیمار کد ملی ثبت نشده — لطفاً تکمیل کنید:' : 'بیماری با این مشخصات یافت نشد — بیمار جدید:'}
</div>
<div className="field-block" style={{ marginBottom: 14 }}>
<label>نام و نام خانوادگی بیمار <span className="req">*</span></label>
<div className="field">
<input
type="text"
value={patientName}
onChange={e => setPatientName(e.target.value)}
placeholder="مثال: علی محمدی"
/>
</div>
</div>
{/* در جستجو با کد ملی، موبایل هنوز نامعلوم است و برای ثبت لازم می‌شود. */}
{searchBy === 'national' && (
<div className="field-block" style={{ marginBottom: 14 }}>
<label>شماره موبایل بیمار <span className="req">*</span></label>
<div className="field">
<input
type="tel"
inputMode="numeric"
maxLength={11}
value={mobile}
onChange={e => setMobile(sanitizeMobileInput(e.target.value))}
placeholder="مثال: 09123456789"
style={{ direction: 'ltr' }}
/>
</div>
</div>
)}
{/* در جستجو با کد ملی، همان مقدار کلیدِ جستجو استفاده می‌شود و فیلد تکراری لازم نیست. */}
{searchBy === 'mobile' && (
<div className="field-block" style={{ marginBottom: 14 }}>
<label>کد ملی بیمار <span className="req">*</span></label>
<div className="field">
<input
type="text"
inputMode="numeric"
lang="en"
maxLength={10}
value={nationalCode}
onChange={e => setNationalCode(digitsOnly(e.target.value, 10))}
placeholder="کد ملی ۱۰ رقمی"
style={{ direction: 'ltr' }}
/>
</div>
</div>
)}
</>
)}
<div className="field-block">
{requireVisit ? (
<label>
هزینه ویزیت (تومان)<span className="req"> *</span>
</label>
) : (
// سرِ کلپس — با کلیک باز/بسته می‌شود (فقط وقتی اختیاری است).
<button
type="button"
onClick={() => setVisitPriceOpen(o => !o)}
aria-expanded={visitPriceExpanded}
style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
background: 'none', border: 'none', cursor: 'pointer', font: 'inherit',
padding: 0, color: 'var(--text)',
}}
>
<span>هزینه ویزیت (تومان) <span className="opt">(اختیاری)</span></span>
<ChevronDownIcon
style={{
width: 15, height: 15, marginInlineStart: 'auto', flexShrink: 0,
transition: 'transform .2s var(--ease)',
transform: visitPriceExpanded ? 'rotate(180deg)' : 'none',
}}
/>
</button>
)}
{visitPriceExpanded && (
<>
<div
className="field"
style={requireVisit && visitPriceToman <= 0 ? { borderColor: 'var(--danger)' } : undefined}
>
<PriceInput
value={visitPriceToman}
onChange={(v) => { setVisitPriceToman(v); setVisitPriceTouched(true); }}
suffix="تومان"
/>
</div>
{requireVisit && visitPriceToman <= 0
? <span className="field-err">هزینه ویزیت الزامی است</span>
: <span className="field-hint">{priceHint}</span>}
{freeVisit > 0 && visitPriceToman !== rialToToman(freeVisit) && (
<button
type="button"
className="btn ghost sm"
style={{ marginTop: 8, alignSelf: 'flex-start' }}
onClick={() => { setVisitPriceToman(rialToToman(freeVisit)); setVisitPriceTouched(true); }}
>
استفاده از تعرفهٔ پزشک
</button>
)}
</>
)}
</div>
</Modal>
);
}
@@ -0,0 +1,124 @@
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 { useAuthStore } from '../../stores/authStore';
import NewAppointmentModal from './NewAppointmentModal';
const get = api.get as ReturnType<typeof vi.fn>;
const post = api.post as ReturnType<typeof vi.fn>;
const START = 1_900_000_000;
const slot = {
start: 0, end: 0, start_time: '', end_time: '',
doctor_uuid: 'doc1', doctor_name: 'مینا یوسفی',
};
const resource = { uuid: 'r-2', name: 'لیزر CO2' };
const services = [
{ uuid: 's-1', name: 'کرایوتراپی', duration_minutes: 25, price_rials: 1_000_000, service_section: { uuid: 'sec-1', name: 'خدمات درمانگاه' } },
{ uuid: 's-2', name: 'RF فرکشنال', duration_minutes: 50, price_rials: 2_000_000, service_section: { uuid: 'sec-1', name: 'خدمات درمانگاه' } },
];
/** بیمارِ یافت‌شده تا فرم معتبر شود؛ زمان‌های خالی از تقویم منبع. */
function mockApi() {
get.mockImplementation((url: string) => {
if (url.includes('patient-lookup')) {
return Promise.resolve({ success: true, data: { found: true, name: 'رضا رحیمی', mobile: '09120001307', national_code: '0012345678' } });
}
if (url.includes('/service-slots')) {
return Promise.resolve({ success: true, data: {
total_duration_minutes: 25,
start_times: [{ start: START, end: START + 1500, start_time: '12:00', end_time: '12:25' }],
} });
}
return Promise.resolve({ success: true, data: {} });
});
}
/** انتخاب بخش → سرویس → زمان، همان مسیرِ مودالِ طرح. */
async function pickServiceAndTime() {
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' });
fireEvent.click(await screen.findByText('خدمات درمانگاه'));
fireEvent.click(await screen.findByRole('button', { name: /کرایوتراپی/ }));
fireEvent.click(await screen.findByRole('button', { name: '12:00' }));
}
beforeEach(() => {
get.mockReset();
post.mockReset();
useAuthStore.setState({ primaryRole: 'clinic' } as any);
post.mockResolvedValue({ success: true, data: { uuid: 'new1' } });
mockApi();
});
describe('مودال ثبت نوبتِ منبع', () => {
it('نام منبع و پزشک ناظر بالای فرم می‌آید و زمان‌ها از تقویم منبع خوانده می‌شوند', async () => {
renderWithProviders(
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
);
expect(screen.getByText('لیزر CO2')).toBeInTheDocument();
expect(screen.getByText('مینا یوسفی')).toBeInTheDocument();
await pickServiceAndTime();
await waitFor(() => expect(
get.mock.calls.some((c: any[]) => typeof c[0] === 'string' && c[0].includes('/api/v1/resource/r-2/service-slots')),
).toBe(true));
});
it('ثبت، نوبت را با resource_uuid و مدتِ سرویس‌ها می‌فرستد', async () => {
renderWithProviders(
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
);
await pickServiceAndTime();
fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } });
fireEvent.click(screen.getByRole('button', { name: 'جستجو' }));
await screen.findByText('بیمار یافت شد');
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
await waitFor(() => expect(post).toHaveBeenCalled());
const [url, body] = post.mock.calls[0];
expect(url).toBe('/api/v1/my/appointment');
expect(body).toMatchObject({
resource_uuid: 'r-2',
duration_from_services: true,
service_item_uuids: ['s-1'],
service_durations: { 's-1': 25 },
slot_start: START,
patient_national_code: '0012345678',
});
});
it('بدون انتخاب سرویس و زمان، ثبت غیرفعال می‌ماند', async () => {
renderWithProviders(
<NewAppointmentModal slot={slot} resource={resource} services={services} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
);
fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } });
fireEvent.click(screen.getByRole('button', { name: 'جستجو' }));
await screen.findByText('بیمار یافت شد');
expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeDisabled();
});
it('منبعِ بدون سرویس، پیام راهنما می‌دهد نه فهرست خالی', () => {
renderWithProviders(
<NewAppointmentModal slot={slot} resource={resource} services={[]} date="2026-08-04" onClose={() => {}} onSuccess={() => {}} />,
);
expect(screen.getByText(/برای این منبع سرویسی تعریف نشده است/)).toBeInTheDocument();
});
});
@@ -0,0 +1,83 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen } 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 ResourceDayPanel from './ResourceDayPanel';
import type { Appointment, ClinicResource } from '../../types';
const get = api.get as ReturnType<typeof vi.fn>;
const DAY = Math.floor(Date.now() / 1000) + 7 * 86400;
const resource = {
uuid: 'r-2', name: 'لیزر CO2',
supervisor: { uuid: 'd1', name: 'مینا یوسفی' },
} as unknown as ClinicResource;
const appointment = {
uuid: 'a-1', patient_name: 'رضا رحیمی', patient_mobile: '09120001307',
doctor_uuid: 'd1', doctor_name: 'مینا یوسفی',
slot_start: DAY + 3600, slot_end: DAY + 3600 + 3000,
appointment_date: '', appointment_time: '10:35', end_time: '11:25',
status: 'pending', version: 1, created_at: '',
service_item: { uuid: 's-2', name: 'RF فرکشنال' },
} as unknown as Appointment;
function render(appointments: Appointment[] = []) {
return renderWithProviders(
<ResourceDayPanel
resource={resource}
date="2026-08-04"
appointments={appointments}
loading={false}
canCreate
queryKey={['appointments']}
onBook={() => {}}
onView={() => {}}
/>,
);
}
beforeEach(() => get.mockReset());
describe('ResourceDayPanel', () => {
it('بازهٔ کاری منبع و ردیف‌های خالیِ قابل رزرو را نشان می‌دهد', async () => {
get.mockResolvedValue({ success: true, data: {
windows: [{ start: DAY, end: DAY + 6 * 3600, start_time: '08:00', end_time: '14:00' }],
empty_reason: null,
} });
render();
expect(await screen.findByText(/ساعت کاری: 08:00 - 14:00/)).toBeInTheDocument();
expect(screen.getByText(/نوبت‌ها بر اساس مدت سرویس چیده می‌شوند/)).toBeInTheDocument();
expect(await screen.findByText('افزودن نوبت سریع')).toBeInTheDocument();
});
it('نوبتِ رزروشده کارت خودش را می‌گیرد و خالی‌ها دو طرفش می‌مانند', async () => {
get.mockResolvedValue({ success: true, data: {
windows: [{ start: DAY, end: DAY + 6 * 3600, start_time: '08:00', end_time: '14:00' }],
empty_reason: null,
} });
render([appointment]);
expect(await screen.findByText('رضا رحیمی')).toBeInTheDocument();
expect(screen.getByText('سرویس: RF فرکشنال')).toBeInTheDocument();
expect(screen.getAllByText('افزودن نوبت سریع')).toHaveLength(2);
});
it('روزِ بدون شیفت، دلیلش را می‌گوید نه فهرست خالی', async () => {
get.mockResolvedValue({ success: true, data: { windows: [], empty_reason: 'no_shift' } });
render();
expect(await screen.findByText('این روز شیفت کاری ندارد')).toBeInTheDocument();
});
});
@@ -1,34 +1,53 @@
import React from 'react';
import { PlusIcon } from '@heroicons/react/24/outline';
import { formatNumber } from '../../lib/utils';
import StatusBadge from '../ui/StatusBadge';
import { useQuery } from '@tanstack/react-query';
import { api } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import TurnsTimeline from './TurnsTimeline';
import { buildServiceTimeline } from './serviceTimeline';
import type { TimelineSlot } from './types';
import type { Appointment, ClinicResource } from '../../types';
function hhmm(ts: number): string {
const d = new Date(ts * 1000);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
interface DaySlotsData {
windows: { start: number; end: number; start_time: string; end_time: string }[];
empty_reason: string | null;
}
/**
* نمای روزِ یک منبع.
* نمای روزِ یک منبع — همان تایم‌لاینِ نوبت‌دهی سرویسیِ پزشک، با تقویم خودِ منبع.
*
* عمداً تایم‌لاین اسلاتی نیست: منبع برنامهٔ هفتگیِ اسلات‌شده ندارد، تقویمش از مدتِ
* سرویس‌ها و اشغالِ واقعی ساخته می‌شود. نشان دادن شبکهٔ اسلات برای منبع یعنی وعدهٔ
* زمان‌هایی که موتور رزرو اصلاً نمی‌شناسد.
*
* پس فقط دو چیز: آنچه امروز روی این منبع رزرو شده، و یک راه برای افزودن نوبتِ سرویسی.
* منبع اسلاتِ ثابت ندارد: بازهٔ کاری از شیفت خودش می‌آید و طول هر نوبت از سرویس‌هایش،
* پس ردیف‌ها «نوبت‌های رزروشده + بازه‌های خالیِ بینشان»اند. کامپوننت تایم‌لاین یکی
* است تا کارت، وضعیت و عملیاتِ نوبت در هر دو نما یک چیز باشند.
*/
export default function ResourceDayPanel({
resource, appointments, loading, canCreate, onBook, onView,
resource, date, appointments, loading, canCreate, queryKey, onBook, onView,
}: {
resource: ClinicResource;
/** روزِ نمایش، ISO `Y-m-d`. */
date: string;
appointments: Appointment[];
loading: boolean;
canCreate: boolean;
onBook: () => void;
queryKey: unknown[];
onBook: (slot: TimelineSlot | null) => void;
onView: (appointment: Appointment) => void;
}) {
const rows = [...appointments].sort((a, b) => a.slot_start - b.slot_start);
const dayQuery = useQuery<ApiResponse<DaySlotsData>>({
queryKey: ['resource-day-slots', resource.uuid, date],
queryFn: () => api.get(`/api/v1/resource/${resource.uuid}/day-slots?date=${date}`),
});
const data = dayQuery.data?.data;
const windows = data?.windows ?? [];
const slots = React.useMemo(
() => buildServiceTimeline(windows, appointments),
[windows, appointments],
);
const workingRange = windows.length > 0
? { start: windows[0].start_time, end: windows[windows.length - 1].end_time }
: null;
return (
<div>
@@ -38,62 +57,23 @@ export default function ResourceDayPanel({
background: 'var(--surface-2)', border: '1px solid var(--border)',
fontSize: 12.5, color: 'var(--text-2)',
}}>
<span>
نوبتدهی این منبع سرویسی است زمان از مدت سرویس و آزادبودن خودِ منبع میآید، نه از اسلات ثابت.
</span>
{resource.supervisor && (
<span style={{ color: 'var(--text-3)', flexShrink: 0 }}>
زیر نظر {resource.supervisor.name}
<span>نوبتدهی سرویسی نوبتها بر اساس مدت سرویس چیده میشوند.</span>
{workingRange && (
<span dir="ltr" style={{ color: 'var(--text-3)', flexShrink: 0 }}>
ساعت کاری: {workingRange.start} - {workingRange.end}
</span>
)}
</div>
{canCreate && (
<button
type="button"
className="btn primary"
onClick={onBook}
style={{ marginBottom: 14 }}
>
<PlusIcon style={{ width: 16 }} /> افزودن نوبت سرویس
</button>
)}
{loading ? (
<div className="skeleton" style={{ height: 90, borderRadius: 'var(--r-sm)' }} />
) : rows.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0, lineHeight: 2 }}>
برای «{resource.name}» در این روز نوبتی ثبت نشده است.
</p>
) : (
<div style={{ display: 'grid', gap: 8 }}>
{rows.map((a) => (
<button
key={a.uuid}
type="button"
onClick={() => onView(a)}
style={{
display: 'flex', alignItems: 'center', gap: 12, width: '100%', textAlign: 'right',
padding: '10px 12px', borderRadius: 'var(--r-sm)', cursor: 'pointer',
border: '1px solid var(--border)', background: 'var(--surface)', fontFamily: 'inherit',
}}
>
<span dir="ltr" style={{ fontSize: 13, fontWeight: 700, color: 'var(--text)', flexShrink: 0 }}>
{hhmm(a.slot_start)} {hhmm(a.slot_end)}
</span>
<span style={{ flex: 1, minWidth: 0, fontSize: 13, color: 'var(--text-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{a.patient_name || '—'}
{a.service_item?.name ? ` · ${a.service_item.name}` : ''}
</span>
<StatusBadge type="appointment" value={a.status} />
</button>
))}
</div>
)}
{rows.length > 0 && (
<p className="field-hint">{formatNumber(rows.length)} نوبت روی این منبع در این روز</p>
)}
<TurnsTimeline
slots={canCreate ? slots : slots.filter(s => s.appointment !== null)}
loading={loading || dayQuery.isLoading}
queryKey={queryKey}
onView={onView}
onBook={onBook}
emptyReason={data?.empty_reason ?? null}
errorMessage={dayQuery.isError ? ((dayQuery.error as Error)?.message || 'خطای نامشخص') : null}
/>
</div>
);
}
@@ -18,10 +18,17 @@ export interface ServicePick { serviceUuids: string[]; durations: Record<string,
* با اعمال همان override محاسبه می‌شوند. انتخاب را از طریق onSelect بالا می‌فرستد.
*/
export default function ServiceSlotPicker({
doctorUuid, date, services, onSelect, editableDuration = true, clinicUuidOverride,
doctorUuid, resourceUuid, date, services, onSelect, editableDuration = true, clinicUuidOverride,
excludeAppointmentUuid, initialSelection,
}: {
doctorUuid: string;
/**
* نوبت‌دهی روی یک منبع: زمان‌ها از تقویم خودِ منبع می‌آیند، نه از برنامهٔ پزشک.
*
* فرمِ انتخاب سرویس در هر دو حالت یکی است (بخش → سرویس → مدت → زمان)، پس فقط
* منبعِ زمان عوض می‌شود نه کامپوننت — دو نسخه یعنی دو رفتار.
*/
resourceUuid?: string;
date: string;
services: BookingService[];
onSelect: (v: ServicePick) => void;
@@ -59,8 +66,8 @@ export default function ServiceSlotPicker({
useEffect(() => {
if (!mounted.current) { mounted.current = true; return; }
setSelected([]); setSectionUuid('');
}, [doctorUuid]);
useEffect(() => { setPickedSlot(null); }, [selected, date, doctorUuid]);
}, [doctorUuid, resourceUuid]);
useEffect(() => { setPickedSlot(null); }, [selected, date, doctorUuid, resourceUuid]);
const serviceUuids = useMemo(() => selected.map(s => s.uuid), [selected]);
const durations = useMemo(
@@ -71,16 +78,19 @@ export default function ServiceSlotPicker({
useEffect(() => { onSelect({ serviceUuids, durations, slot: pickedSlot }); }, [serviceUuids, durations, pickedSlot]);
const durationsQs = selected.map(s => `&durations[${encodeURIComponent(s.uuid)}]=${s.duration}`).join('');
const servicesQs = serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('');
const slotsQ = useQuery<ApiResponse<any>>({
queryKey: ['service-slots-picker', doctorUuid, date, serviceUuids, durations, clinicUuid, excludeAppointmentUuid],
queryKey: ['service-slots-picker', resourceUuid ?? doctorUuid, date, serviceUuids, durations, clinicUuid, excludeAppointmentUuid],
queryFn: () => api.get(
`/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}&management=1`
+ serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('')
+ durationsQs
+ (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : '')
+ (excludeAppointmentUuid ? `&exclude_appointment_uuid=${encodeURIComponent(excludeAppointmentUuid)}` : ''),
resourceUuid
? `/api/v1/resource/${resourceUuid}/service-slots?date=${date}` + servicesQs + durationsQs
: `/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}&management=1`
+ servicesQs
+ durationsQs
+ (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : '')
+ (excludeAppointmentUuid ? `&exclude_appointment_uuid=${encodeURIComponent(excludeAppointmentUuid)}` : ''),
),
enabled: !!doctorUuid && !!date && serviceUuids.length > 0,
enabled: (!!resourceUuid || !!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;
@@ -98,7 +108,9 @@ export default function ServiceSlotPicker({
if (services.length === 0) {
return (
<div style={{ fontSize: 12.5, color: 'var(--danger)', margin: '6px 0' }}>
سرویسی با «نمایش در نوبتدهی» برای این پزشک تعریف نشده است.
{resourceUuid
? 'برای این منبع سرویسی تعریف نشده است — از تب «سرویس‌ها»ی همین منبع اضافه کنید.'
: 'سرویسی با «نمایش در نوبت‌دهی» برای این پزشک تعریف نشده است.'}
</div>
);
}
@@ -187,6 +187,13 @@ const EMPTY_REASON_TEXT: Record<string, { title: string; hint: string }> = {
holiday: { title: 'این روز تعطیل است', hint: 'در تقویم تعطیلات، این روز برای پزشک تعطیل ثبت شده' },
day_off: { title: 'این روز شیفت کاری ندارد', hint: 'در برنامهٔ هفتگی، برای این روز شیفتی تعریف نشده است' },
outside_window: { title: 'خارج از بازهٔ نوبت‌دهی', hint: 'این تاریخ از بازهٔ مجاز رزرو گذشته یا نوبت‌دهی آنلاین خاموش است' },
// دلایلِ تقویمِ منبع — `resource/{uuid}/day-slots`.
no_shift: { title: 'این روز شیفت کاری ندارد', hint: 'در تقویم این منبع، برای این روز شیفتی تعریف نشده است' },
national_holiday: { title: 'تعطیل رسمی', hint: 'این روز در تقویم رسمی تعطیل است' },
tenant_holiday: { title: 'این روز تعطیل است', hint: 'در تقویم تعطیلات مجموعه، این روز تعطیل ثبت شده' },
exception: { title: 'استثنای تقویم', hint: 'کل ساعت کاری این روز با استثنای منبع پوشیده شده است' },
resource_inactive: { title: 'این منبع غیرفعال است', hint: 'برای نوبت‌دهی، منبع را از صفحهٔ «منابع» فعال کنید' },
address_inactive: { title: 'شعبهٔ این منبع غیرفعال است', hint: 'تا وقتی شعبه غیرفعال باشد، منابعش نوبت نمی‌گیرند' },
};
export default function TurnsTimeline({
@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest';
import { buildServiceTimeline } from './serviceTimeline';
import type { Appointment } from '../../types';
const DAY = 1_800_000_000; // نیمه‌شبِ فرضی
const at = (h: number) => DAY + h * 3600;
function appointment(from: number, to: number, over: Partial<Appointment> = {}): Appointment {
return {
uuid: `a-${from}`, patient_name: 'بیمار', patient_mobile: '09120000000',
doctor_uuid: 'd1', doctor_name: 'دکتر', slot_start: from, slot_end: to,
appointment_date: '', appointment_time: '', end_time: '',
status: 'confirmed', version: 1, created_at: '',
...over,
} as Appointment;
}
describe('buildServiceTimeline', () => {
const window = [{ start: at(8), end: at(14) }];
it('یک نوبت، بازهٔ کاری را به «خالی — نوبت — خالی» می‌شکند', () => {
const rows = buildServiceTimeline(window, [appointment(at(10), at(11))], at(0));
expect(rows.map(r => [r.start, r.end, r.appointment !== null])).toEqual([
[at(8), at(10), false],
[at(10), at(11), true],
[at(11), at(14), false],
]);
});
it('بدون نوبت، کل بازهٔ کاری یک ردیفِ خالی است', () => {
const rows = buildServiceTimeline(window, [], at(0));
expect(rows).toHaveLength(1);
expect(rows[0].is_available).toBe(true);
});
it('نوبت لغوشده جای خالی را نمی‌گیرد', () => {
const rows = buildServiceTimeline(
window,
[appointment(at(10), at(11), { status: 'cancelled_by_doctor' })],
at(0),
);
expect(rows).toHaveLength(1);
expect(rows[0].appointment).toBeNull();
});
it('نوبتِ رزرو (روزانه) بازه‌ای اشغال نمی‌کند', () => {
const rows = buildServiceTimeline(window, [appointment(at(10), at(11), { is_reserve: true })], at(0));
expect(rows).toHaveLength(1);
expect(rows[0].appointment).toBeNull();
});
it('بازهٔ خالیِ گذشته به «اکنون» بریده می‌شود و ردیفِ تمام‌گذشته حذف', () => {
const rows = buildServiceTimeline([{ start: at(8), end: at(9) }, { start: at(10), end: at(14) }], [], at(11));
expect(rows).toHaveLength(1);
expect(rows[0].start).toBe(at(11));
expect(rows[0].end).toBe(at(14));
});
it('نوبتِ خارج از بازهٔ کاری، ردیف نمی‌سازد', () => {
const rows = buildServiceTimeline(window, [appointment(at(20), at(21))], at(0));
expect(rows.every(r => r.appointment === null)).toBe(true);
});
});
@@ -0,0 +1,67 @@
import { formatTime } from '../../lib/utils';
import { CANCELLED_STATUSES } from './turnStatus';
import type { Appointment } from '../../types';
import type { TimelineSlot } from './types';
/** یک بازهٔ کاری: شیفتِ منبع یا سشنِ برنامهٔ هفتگیِ پزشک. */
export interface WorkingWindow {
start: number;
end: number;
}
/**
* تایم‌لاینِ نوبت‌دهی **سرویسی**: نوبت‌های رزروشده + بازه‌های خالیِ بینشان.
*
* حالت سرویسی اسلاتِ ثابت ندارد — طول هر نوبت از سرویس‌هایش می‌آید — پس ردیف‌ها از
* روی بازهٔ کاری و نوبت‌های واقعی ساخته می‌شوند، نه از شبکهٔ اسلات.
*
* پزشک و منبع همین یک الگوریتم را دارند: بازهٔ کاری یکی از برنامهٔ هفتگی می‌آید و
* دیگری از تقویم منبع، ولی چیدنِ کارت‌ها فرقی نمی‌کند و دو نسخه‌اش یعنی دو رفتار.
*/
export function buildServiceTimeline(
windows: WorkingWindow[],
appointments: Appointment[],
now: number = Math.floor(Date.now() / 1000),
): TimelineSlot[] {
const booked = appointments
.filter(a => !CANCELLED_STATUSES.has(a.status) && !a.is_reserve)
.map(a => ({
appointment: a,
start: Number(a.slot_start),
end: Number(a.slot_end),
}))
.sort((a, b) => a.start - b.start);
const out: TimelineSlot[] = [];
/** بازهٔ خالی؛ تکهٔ گذشته‌اش بریده می‌شود چون قابل رزرو نیست. */
const pushFree = (start: number, end: number) => {
const from = start < now ? now : start;
if (end <= from) return;
out.push({
start: from, end,
start_time: formatTime(from), end_time: formatTime(end),
is_available: true, appointment: null, cancelled_appointment: null,
});
};
windows.forEach(({ start: winStart, end: winEnd }) => {
let cursor = winStart;
booked
.filter(b => b.start >= winStart && b.start < winEnd)
.forEach(({ appointment, start, end }) => {
if (start > cursor) pushFree(cursor, start);
out.push({
start, end,
start_time: formatTime(start), end_time: formatTime(end),
is_available: false, appointment, cancelled_appointment: null,
});
cursor = Math.max(cursor, end);
});
if (cursor < winEnd) pushFree(cursor, winEnd);
});
return out;
}
@@ -0,0 +1,38 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { BookingService } from './useDoctorBookingServices';
import type { ResourceServiceOffering } from '../types';
/**
* سرویس‌های قابلِ رزروِ یک منبع، به همان شکلِ سرویس‌های پزشک.
*
* نگاشت عمدی است: فرمِ انتخاب سرویس (`ServiceSlotPicker`) یک قرارداد دارد — بخش،
* نام، مدت — و ساختنِ نسخهٔ دومش برای منبع یعنی دو فرم که با هم درمی‌روند.
*
* مدتِ مؤثر مبناست نه `duration_minutes` خام: خالی‌بودنِ ستون یعنی «ارث از سطح
* بالاتر»، و نمایشش به‌صورت صفر همان دستگاه را بی‌مدت نشان می‌داد.
*/
export function useResourceBookingServices(resourceUuid: string | null | undefined) {
const q = useQuery<ApiResponse<ResourceServiceOffering[]>>({
queryKey: ['resource-services', resourceUuid],
queryFn: () => api.get(`/api/v1/resource/${resourceUuid}/services`),
enabled: !!resourceUuid,
});
const services: BookingService[] = useMemo(
() => (q.data?.data ?? [])
.filter(o => o.active && o.service_section)
.map(o => ({
uuid: o.service_uuid,
name: o.service_name,
duration_minutes: o.effective_duration_minutes,
price_rials: o.effective_price_rials,
service_section: o.service_section!,
})),
[q.data],
);
return { services, isLoading: q.isLoading };
}
@@ -9,7 +9,7 @@ vi.mock('../lib/api', () => ({
import { api } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import { NewAppointmentModal } from './AppointmentsPage';
import NewAppointmentModal from '../components/appointments/NewAppointmentModal';
const get = api.get as ReturnType<typeof vi.fn>;
const post = api.post as ReturnType<typeof vi.fn>;
+23 -8
View File
@@ -72,6 +72,14 @@ describe('AppointmentsPage — پروفایل کلینیک چندپزشکه', ()
}
if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [], empty_reason: 'holiday' } });
if (url.includes('/my/appointments')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
// بازهٔ کاری منبع از تقویم خودش می‌آید، نه از برنامهٔ هفتگی پزشک.
if (url.includes('/day-slots')) {
const day = Math.floor(Date.now() / 1000) + 7 * 86400;
return Promise.resolve({ success: true, data: {
windows: [{ start: day, end: day + 4 * 3600, start_time: '08:00', end_time: '12:00' }],
empty_reason: null,
} });
}
// منابع زیر نظر پزشک‌اند؛ تب هر منبع فقط زیر ناظرِ خودش دیده می‌شود.
if (url.includes('/api/v1/resources')) return Promise.resolve({ success: true, data: [
{ uuid: 'r-1', name: 'اتاق ۱', type_name: 'اتاق درمان', supervisor: { uuid: 'd1', name: 'دکتر محمدی' } },
@@ -147,25 +155,32 @@ describe('AppointmentsPage — پروفایل کلینیک چندپزشکه', ()
await screen.findByText('دکتر محمدی');
await user.click(await screen.findByRole('button', { name: 'لیزر CO2' }));
expect(await screen.findByText(/نوبت‌دهی این منبع سرویسی است/)).toBeInTheDocument();
expect(await screen.findByText(/نوبت‌ها بر اساس مدت سرویس چیده می‌شوند/)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'نوبت‌های خود پزشک' }));
expect(screen.queryByText(/نوبت‌دهی این منبع سرویسی است/)).toBeNull();
expect(screen.queryByText(/نوبت‌ها بر اساس مدت سرویس چیده می‌شوند/)).toBeNull();
});
/** باگ گزارش‌شده: نمای منبع نباید اسلاتی باشد. */
it('نمای منبع سرویسی است و تایم‌لاین اسلاتی ندارد', async () => {
/**
* نمای منبع همان تایم‌لاینِ سرویسی است: بازهٔ کاری از `day-slots` خودِ منبع می‌آید
* و ردیف‌های خالی «افزودن نوبت سریع»اند — نه شبکهٔ اسلاتِ پزشک.
*/
it('نمای منبع، تایم‌لاین سرویسیِ تقویم خودِ منبع است', async () => {
const user = userEvent.setup();
renderWithProviders(<AppointmentsPage />);
await screen.findByText('دکتر محمدی');
await user.click(await screen.findByRole('button', { name: 'لیزر CO2' }));
expect(await screen.findByText(/نوبت‌دهی این منبع سرویسی است/)).toBeInTheDocument();
expect(screen.getByText('افزودن نوبت سرویس')).toBeInTheDocument();
// ورودی اسلاتی نباید باشد.
expect(screen.queryByText('افزودن نوبت سریع')).toBeNull();
expect(await screen.findByText(/نوبت‌ها بر اساس مدت سرویس چیده می‌شوند/)).toBeInTheDocument();
expect(await screen.findByText('افزودن نوبت سریع')).toBeInTheDocument();
expect(screen.getByText(/ساعت کاری/)).toBeInTheDocument();
// بازهٔ کاری از تقویم منبع خوانده می‌شود، نه از اسلات‌های پزشک.
const askedResourceDay = get.mock.calls.some(
(c: any[]) => typeof c[0] === 'string' && c[0].includes('/api/v1/resource/r-2/day-slots'),
);
expect(askedResourceDay).toBe(true);
expect(screen.queryByText('این روز تعطیل است')).toBeNull();
});
+38 -425
View File
@@ -1,17 +1,14 @@
import React, { useEffect, useRef, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
PlusIcon, ChevronRightIcon, ChevronLeftIcon, ChevronDownIcon, CalendarDaysIcon,
AdjustmentsHorizontalIcon, ClockIcon, MagnifyingGlassIcon, CheckCircleIcon,
AdjustmentsHorizontalIcon,
} from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { PaginatedResponse, ApiResponse } from '../lib/api';
import type { Appointment, ClinicResource } from '../types';
import { formatDate, toGregorianDate, todayIso, formatTime, digitsOnly, sanitizeMobileInput, rialToToman, tomanToRial, formatRial } from '../lib/utils';
import PriceInput from '../components/ui/PriceInput';
import Modal from '../components/ui/Modal';
import { formatDate, toGregorianDate, todayIso, formatTime } from '../lib/utils';
import { useAuthStore } from '../stores/authStore';
import { useClinicContext } from '../hooks/useClinicContext';
import Pagination from '../components/ui/Pagination';
@@ -25,14 +22,16 @@ import TurnsViewToggle from '../components/appointments/TurnsViewToggle';
import type { TurnsViewMode } from '../components/appointments/TurnsViewToggle';
import DoctorTabs from '../components/appointments/DoctorTabs';
import { useResources } from '../hooks/useResources';
import ResourceBookingModal from '../components/appointments/ResourceBookingModal';
import ResourceDayPanel from '../components/appointments/ResourceDayPanel';
import { useResourceBookingServices } from '../hooks/useResourceBookingServices';
import { useUrlState } from '../hooks/useUrlState';
import TurnsTimeline from '../components/appointments/TurnsTimeline';
import TurnsTable from '../components/appointments/TurnsTable';
import { usePermissions } from '../hooks/usePermissions';
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
import NewAppointmentModal from '../components/appointments/NewAppointmentModal';
import type { BookingSlot } from '../components/appointments/NewAppointmentModal';
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
import { buildServiceTimeline } from '../components/appointments/serviceTimeline';
import { CANCELLED_STATUSES } from '../components/appointments/turnStatus';
import type { TimelineSlot } from '../components/appointments/types';
@@ -99,388 +98,6 @@ function DateNavigator({ date, onChange }: { date: string; onChange: (d: string)
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Quick booking modal (کلیک روی اسلات خالیِ زمانبندی)
// ─────────────────────────────────────────────────────────────────────────────
interface BookingSlot { start: number; end: number; start_time: string; end_time: string; doctor_uuid: string; doctor_name: string; }
interface PatientLookup { found: boolean; name?: string | null; mobile?: string; national_code?: string | null }
export function NewAppointmentModal({
slot, onClose, onSuccess, serviceMode = false, services = [], date, clinicUuid = null,
}: {
slot: BookingSlot;
onClose: () => void;
onSuccess: () => void;
serviceMode?: boolean;
services?: import('../hooks/useDoctorBookingServices').BookingService[];
date?: string;
/** محل نوبت — بدون آن backend نوبت را به مطب شخصی نسبت می‌دهد. */
clinicUuid?: string | null;
}) {
const [mobile, setMobile] = useState('');
const [lookup, setLookup] = useState<PatientLookup | null>(null);
const [patientName, setPatientName] = useState('');
const [nationalCode, setNationalCode] = useState('');
// معیار جستجوی بیمار: کد ملی (پیش‌فرض) یا موبایل.
const [searchBy, setSearchBy] = useState<'mobile' | 'national'>('national');
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';
// هزینه ویزیت — الزامی بودن از تنظیمات «الزامی کردن هزینه ویزیت». فیلد UI تومان،
// API ریالی (visit_price_rials). بدون این مقدار، وقتی فلگ فعال است backend خطای ۴۲۲ می‌دهد.
//
// قیمت باید از تنظیمات نوبت‌دهیِ *پزشکِ همین اسلات* بیاید، نه از entity کاربر جاری؛
// منشی/کلینیک قیمت خودشان را ندارند و فیلد صفر می‌ماند. اگر دسترسی به تنظیمات آن
// پزشک نبود (۴۰۳)، به تنظیمات خودِ کاربر برمی‌گردیم تا فلگ الزامی‌بودن از دست نرود.
type Pricing = { data: { free_visit_price_rials: number; require_visit_price: boolean } };
const doctorPricingQ = useQuery<Pricing>({
queryKey: ['insurance-pricing', slot.doctor_uuid],
queryFn: () => api.get(`/api/v1/insurance-pricing?doctor_uuid=${encodeURIComponent(slot.doctor_uuid)}`),
retry: false,
});
const selfPricingQ = useQuery<Pricing>({
queryKey: ['insurance-pricing'],
queryFn: () => api.get('/api/v1/insurance-pricing'),
enabled: doctorPricingQ.isError,
});
const pricing = (doctorPricingQ.data ?? selfPricingQ.data) as any;
const freeVisit = pricing?.data?.free_visit_price_rials ?? 0;
const requireVisit = pricing?.data?.require_visit_price ?? false;
const pricingLoading = doctorPricingQ.isLoading || selfPricingQ.isLoading;
const [visitPriceToman, setVisitPriceToman] = useState(0);
const [visitPriceTouched, setVisitPriceTouched] = useState(false);
useEffect(() => {
if (!visitPriceTouched && freeVisit > 0) setVisitPriceToman(rialToToman(freeVisit));
}, [freeVisit, visitPriceTouched]);
// هزینه ویزیت اختیاری داخل کلپسِ بسته می‌نشیند؛ وقتی الزامی است کلپس همیشه باز است.
const [visitPriceOpen, setVisitPriceOpen] = useState(false);
const visitPriceExpanded = requireVisit || visitPriceOpen;
const mobileValid = /^09\d{9}$/.test(mobile);
const nationalCodeValid = /^\d{10}$/.test(nationalCode);
// اعتبار کلید جستجو بسته به معیار انتخاب‌شده.
const searchValid = searchBy === 'mobile' ? mobileValid : nationalCodeValid;
const serviceTimingValid = !serviceMode || (pick.serviceUuids.length > 0 && !!pick.slot);
// یک بیمارِ یافت‌شده که کد ملی دارد، بدون فرم اضافی قابل استفاده است.
const foundWithNationalCode = !!lookup?.found && !!lookup.national_code;
const needsDetails = lookup !== null && !foundWithNationalCode; // یافت‌نشده، یا یافت‌شده بدون کد ملی
const effectiveName = foundWithNationalCode ? (lookup?.name ?? '') : patientName.trim();
const effectiveNationalCode = foundWithNationalCode ? (lookup?.national_code ?? '') : nationalCode;
const detailsValid = effectiveName.length >= 2 && effectiveNationalCode.length === 10;
const visitPriceValid = !requireVisit || visitPriceToman > 0;
const isValid = mobileValid && (foundWithNationalCode || (needsDetails && detailsValid)) && serviceTimingValid && visitPriceValid;
const search = useMutation({
mutationFn: () => {
const q = searchBy === 'mobile'
? `mobile=${encodeURIComponent(mobile)}`
: `national_code=${encodeURIComponent(nationalCode)}`;
return api.get(`/api/v1/my/appointment/patient-lookup?${q}`);
},
onSuccess: (res: any) => {
const data: PatientLookup = res?.data ?? { found: false };
setLookup(data);
setPatientName(data.found ? (data.name ?? '') : '');
// موبایل و کد ملیِ بیمارِ یافت‌شده را پر می‌کنیم تا ثبت مستقل از معیار جستجو کار کند.
if (data.found) {
if (data.mobile) setMobile(data.mobile);
setNationalCode(data.national_code ?? '');
} else if (searchBy === 'mobile') {
setNationalCode('');
}
},
onError: (e: any) => toast.error(e?.response?.data?.errors?.[0]?.message ?? e?.message ?? 'خطا در جستجو'),
});
const mutation = useMutation({
mutationFn: () => api.post(createEndpoint, {
doctor_uuid: slot.doctor_uuid,
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,
...(clinicUuid ? { clinic_uuid: clinicUuid } : {}),
...(serviceMode ? { service_item_uuids: pick.serviceUuids } : {}),
...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}),
}),
onSuccess: () => {
toast.success('نوبت با موفقیت ثبت شد');
onSuccess();
onClose();
},
onError: (e: any) => {
const msg = e?.response?.data?.errors?.[0]?.message ?? e?.message ?? 'خطا در ثبت نوبت';
toast.error(msg);
},
});
// تغییر کلید جستجو نتیجه‌ی جستجوی قبلی را باطل می‌کند تا کاربر دوباره جستجو کند.
function invalidateLookup() {
if (lookup !== null) { setLookup(null); setPatientName(''); }
}
function onMobileChange(v: string) {
// ارقام فارسی/عربی → انگلیسی، فقط رقم، حداکثر ۱۱ رقم (کیبورد فارسی هم پذیرفته می‌شود).
setMobile(sanitizeMobileInput(v));
if (lookup !== null) { setLookup(null); setPatientName(''); if (searchBy === 'mobile') setNationalCode(''); }
}
function onNationalSearchChange(v: string) {
setNationalCode(digitsOnly(v, 10));
invalidateLookup();
}
// جابه‌جایی معیار جستجو همه‌چیز را از نو شروع می‌کند.
function onSwitchSearchBy(mode: 'mobile' | 'national') {
setSearchBy(mode);
setLookup(null); setPatientName('');
setMobile(''); setNationalCode('');
}
const priceHint = pricingLoading
? 'در حال خواندن تعرفهٔ پزشک…'
: freeVisit > 0
? `تعرفهٔ نوبت‌دهی ${slot.doctor_name}: ${formatRial(freeVisit)} — در صورت نیاز تغییر دهید`
: 'برای این پزشک تعرفه‌ای ثبت نشده — در صورت نیاز مبلغ را وارد کنید';
return (
<Modal
open
title="ثبت نوبت"
size="sm"
onClose={onClose}
footer={
<>
<button className="btn ghost" onClick={onClose}>انصراف</button>
<button
className="btn primary"
onClick={() => mutation.mutate()}
disabled={!isValid || mutation.isPending}
>
{mutation.isPending ? 'در حال ثبت…' : 'ثبت نوبت'}
</button>
</>
}
>
{/* اسلات انتخاب‌شده */}
<div style={{
display: 'flex', alignItems: 'center', gap: 10, marginBottom: 18,
padding: '10px 14px', borderRadius: 'var(--r-sm)', background: 'var(--primary-soft)',
}}>
<ClockIcon style={{ width: 18, height: 18, color: 'var(--primary-700)', flexShrink: 0 }} />
<span style={{ fontSize: 13.5, color: 'var(--text)' }}>
{serviceMode ? slot.doctor_name : `${slot.start_time} تا ${slot.end_time}`}
</span>
{!serviceMode && (
<span style={{ fontSize: 12.5, color: 'var(--text-2)', marginInlineStart: 'auto' }}>
{slot.doctor_name}
</span>
)}
</div>
{serviceMode && date && (
<div style={{ marginBottom: 18 }}>
<ServiceSlotPicker
doctorUuid={slot.doctor_uuid}
date={date}
services={services}
onSelect={setPick}
clinicUuidOverride={clinicUuid}
/>
</div>
)}
<div className="field-block" style={{ marginBottom: 14 }}>
<label>جستجوی بیمار <span className="req">*</span></label>
{/* انتخاب معیار جستجو: موبایل یا کد ملی */}
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
{(['national', 'mobile'] as const).map(mode => (
<button
key={mode}
type="button"
className={`btn sm ${searchBy === mode ? 'primary' : 'ghost'}`}
onClick={() => onSwitchSearchBy(mode)}
style={{ flex: 1 }}
>
{mode === 'mobile' ? 'شماره موبایل' : 'کد ملی'}
</button>
))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<div className="field" style={{ flex: 1 }}>
{searchBy === 'mobile' ? (
<input
type="tel"
inputMode="numeric"
maxLength={11}
value={mobile}
onChange={e => onMobileChange(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }}
placeholder="مثال: 09123456789"
style={{ direction: 'ltr' }}
autoFocus
/>
) : (
<input
type="text"
inputMode="numeric"
lang="en"
maxLength={10}
value={nationalCode}
onChange={e => onNationalSearchChange(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }}
placeholder="کد ملی ۱۰ رقمی"
style={{ direction: 'ltr' }}
autoFocus
/>
)}
</div>
<button
className="btn soft"
onClick={() => search.mutate()}
disabled={!searchValid || search.isPending}
style={{ whiteSpace: 'nowrap' }}
>
<MagnifyingGlassIcon style={{ width: 16, height: 16 }} />
{search.isPending ? '...' : 'جستجو'}
</button>
</div>
</div>
{foundWithNationalCode && (
<div style={{
marginBottom: 16, padding: '10px 14px', borderRadius: 'var(--r-sm)',
background: 'var(--success-bg)', fontSize: 13,
display: 'flex', alignItems: 'center', gap: 10,
}}>
<CheckCircleIcon style={{ width: 20, height: 20, color: 'var(--success)', flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 700, color: 'var(--success)', fontSize: 12 }}>بیمار یافت شد</div>
<div style={{ fontWeight: 700, color: 'var(--text)' }}>{lookup?.name}</div>
<div style={{ color: 'var(--text-2)', fontSize: 12 }}>کد ملی: {lookup?.national_code}</div>
</div>
</div>
)}
{needsDetails && (
<>
<div style={{
fontSize: 12.5, color: 'var(--text-2)', marginBottom: 12,
padding: '9px 12px', borderRadius: 'var(--r-sm)', background: 'var(--warning-bg)',
}}>
{lookup?.found ? 'برای این بیمار کد ملی ثبت نشده — لطفاً تکمیل کنید:' : 'بیماری با این مشخصات یافت نشد — بیمار جدید:'}
</div>
<div className="field-block" style={{ marginBottom: 14 }}>
<label>نام و نام خانوادگی بیمار <span className="req">*</span></label>
<div className="field">
<input
type="text"
value={patientName}
onChange={e => setPatientName(e.target.value)}
placeholder="مثال: علی محمدی"
/>
</div>
</div>
{/* در جستجو با کد ملی، موبایل هنوز نامعلوم است و برای ثبت لازم می‌شود. */}
{searchBy === 'national' && (
<div className="field-block" style={{ marginBottom: 14 }}>
<label>شماره موبایل بیمار <span className="req">*</span></label>
<div className="field">
<input
type="tel"
inputMode="numeric"
maxLength={11}
value={mobile}
onChange={e => setMobile(sanitizeMobileInput(e.target.value))}
placeholder="مثال: 09123456789"
style={{ direction: 'ltr' }}
/>
</div>
</div>
)}
{/* در جستجو با کد ملی، همان مقدار کلیدِ جستجو استفاده می‌شود و فیلد تکراری لازم نیست. */}
{searchBy === 'mobile' && (
<div className="field-block" style={{ marginBottom: 14 }}>
<label>کد ملی بیمار <span className="req">*</span></label>
<div className="field">
<input
type="text"
inputMode="numeric"
lang="en"
maxLength={10}
value={nationalCode}
onChange={e => setNationalCode(digitsOnly(e.target.value, 10))}
placeholder="کد ملی ۱۰ رقمی"
style={{ direction: 'ltr' }}
/>
</div>
</div>
)}
</>
)}
<div className="field-block">
{requireVisit ? (
<label>
هزینه ویزیت (تومان)<span className="req"> *</span>
</label>
) : (
// سرِ کلپس — با کلیک باز/بسته می‌شود (فقط وقتی اختیاری است).
<button
type="button"
onClick={() => setVisitPriceOpen(o => !o)}
aria-expanded={visitPriceExpanded}
style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
background: 'none', border: 'none', cursor: 'pointer', font: 'inherit',
padding: 0, color: 'var(--text)',
}}
>
<span>هزینه ویزیت (تومان) <span className="opt">(اختیاری)</span></span>
<ChevronDownIcon
style={{
width: 15, height: 15, marginInlineStart: 'auto', flexShrink: 0,
transition: 'transform .2s var(--ease)',
transform: visitPriceExpanded ? 'rotate(180deg)' : 'none',
}}
/>
</button>
)}
{visitPriceExpanded && (
<>
<div
className="field"
style={requireVisit && visitPriceToman <= 0 ? { borderColor: 'var(--danger)' } : undefined}
>
<PriceInput
value={visitPriceToman}
onChange={(v) => { setVisitPriceToman(v); setVisitPriceTouched(true); }}
suffix="تومان"
/>
</div>
{requireVisit && visitPriceToman <= 0
? <span className="field-err">هزینه ویزیت الزامی است</span>
: <span className="field-hint">{priceHint}</span>}
{freeVisit > 0 && visitPriceToman !== rialToToman(freeVisit) && (
<button
type="button"
className="btn ghost sm"
style={{ marginTop: 8, alignSelf: 'flex-start' }}
onClick={() => { setVisitPriceToman(rialToToman(freeVisit)); setVisitPriceTouched(true); }}
>
استفاده از تعرفهٔ پزشک
</button>
)}
</>
)}
</div>
</Modal>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Main Page — «نوبت‌ ها» (طرح tauri)
// ─────────────────────────────────────────────────────────────────────────────
@@ -538,6 +155,9 @@ export default function AppointmentsPage() {
const [bookingResource, setBookingResource] = useState<ClinicResource | null>(null);
const activeResource = bookableResources.find((r) => r.uuid === selectedResourceUuid) ?? null;
// سرویس‌های همان منبع، نه کل کاتالوگ: منبعی که سرویسی را نمی‌دهد نباید در فهرست
// بیاید — سرور هم همان را با ۴۲۲ رد می‌کند.
const { services: resourceServices } = useResourceBookingServices(bookingResource?.uuid);
/**
* منبع زیرمجموعهٔ پزشک است، نه رقیبش: انتخاب یک منبع فقط نما را داخل همان پزشک
@@ -709,43 +329,19 @@ export default function AppointmentsPage() {
// حالت سرویسی: اسلات ثابت وجود ندارد — برای هر شیفتِ کاری، نوبت‌های رزروشده
// نمایش داده می‌شوند و باقیِ زمان به‌صورت بازه‌(های) خالیِ قابل‌رزرو بین آن‌ها.
// همان الگوریتمِ تایم‌لاینِ منبع؛ فقط بازهٔ کاری از برنامهٔ هفتگی می‌آید.
if (serviceMode) {
const fmt = (ts: number) => formatTime(ts);
const parseHM = (t: string) => { const [h, m] = (t ?? '00:00').split(':').map(Number); return (h * 3600) + (m * 60); };
const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? [];
const booked = [...activeByStart.values()].sort((a, b) => Number(a.slot_start) - Number(b.slot_start));
const now = Math.floor(Date.now() / 1000);
const out: TimelineSlot[] = [];
// بازهٔ خالیِ قابل‌رزرو؛ اگر روی «اکنون» بیفتد به اکنون بریده می‌شود و بازه‌های
// کاملاً گذشته حذف می‌شوند تا فقط زمان قابل‌رزرو باقی بماند.
const pushFree = (start: number, end: number) => {
const s = start < now ? now : start;
if (end <= s) return;
out.push({ start: s, end, start_time: fmt(s), end_time: fmt(end), is_available: true, appointment: null, cancelled_appointment: null });
};
rawSessions.forEach((session: any) => {
const windows = rawSessions.flatMap((session: any) => {
const slots = (session.slots as any[]) ?? [];
if (!slots.length) return;
if (!slots.length) return [];
const dayStart = Number(slots[0].start) - parseHM(session.start_time);
const winStart = dayStart + parseHM(session.start_time);
const winEnd = dayStart + parseHM(session.end_time);
const inWin = booked.filter(a => Number(a.slot_start) >= winStart && Number(a.slot_start) < winEnd);
let cursor = winStart;
inWin.forEach(a => {
const s = Number(a.slot_start), e = Number(a.slot_end);
if (s > cursor) pushFree(cursor, s);
out.push({
start: s, end: e, start_time: fmt(s), end_time: fmt(e),
is_available: false, appointment: a, cancelled_appointment: null,
});
cursor = Math.max(cursor, e);
});
if (cursor < winEnd) pushFree(cursor, winEnd);
return [{ start: dayStart + parseHM(session.start_time), end: dayStart + parseHM(session.end_time) }];
});
return out;
return buildServiceTimeline(windows, appointments);
}
const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? [];
@@ -920,13 +516,15 @@ export default function AppointmentsPage() {
)}
</>
) : activeResource ? (
/* منبع اسلات ندارد: تقویمش سرویسی است، پس تایم‌لاین اسلاتیِ پزشک اینجا
اصلاً رندر نمی‌شود — فهرست نوبت‌های همین منبع و یک ورودیِ سرویسی. */
/* منبع اسلات ندارد: تقویمش سرویسی است، پس تایم‌لاینش هم سرویسی ساخته
می‌شود — نوبت‌های همین منبع و بازه‌های خالیِ بینشان. */
<ResourceDayPanel
resource={activeResource}
date={selectedDate}
appointments={filteredAppointments}
loading={apptQuery.isLoading}
canCreate={!isRepresentation && canCreateAppt}
queryKey={apptQueryKey}
onBook={() => setBookingResource(activeResource)}
onView={openDetail}
/>
@@ -967,11 +565,26 @@ export default function AppointmentsPage() {
</div>
</div>
{/* ثبت نوبتِ منبع — همان مودالِ نوبت‌دهی سرویسی، با تقویمِ خودِ منبع */}
{bookingResource && (
<ResourceBookingModal
resource={bookingResource}
<NewAppointmentModal
slot={{
start: 0, end: 0, start_time: '', end_time: '',
// پزشکِ نوبت همان ناظرِ منبع است؛ اینجا فقط برای نمایش و تعرفهٔ ویزیت
// می‌آید و سرور خودش هم از منبع استنتاجش می‌کند.
doctor_uuid: bookingResource.supervisor?.uuid ?? '',
doctor_name: bookingResource.supervisor?.name ?? '',
}}
resource={{ uuid: bookingResource.uuid, name: bookingResource.name }}
services={resourceServices}
date={selectedDate}
clinicUuid={effectiveClinicUuid}
onClose={() => setBookingResource(null)}
onBooked={() => qc.invalidateQueries({ queryKey: apptQueryKey })}
onSuccess={() => {
qc.invalidateQueries({ queryKey: apptQueryKey });
qc.invalidateQueries({ queryKey: ['resource-day-slots', bookingResource.uuid, selectedDate] });
qc.invalidateQueries({ queryKey: ['appt-today-stats', selectedDate] });
}}
/>
)}
+2
View File
@@ -1243,6 +1243,8 @@ export interface ResourceServiceOffering {
effective_price_rials: number;
duration_source: string | null;
price_source: string;
/** بخشِ سرویس — مودالِ رزرو منبع سرویس‌ها را زیر بخششان گروه می‌کند. */
service_section?: { uuid: string; name: string };
}
/** گزینهٔ انتخاب سرویس در مودال منبع. */