import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { PencilIcon, TrashIcon, PlusIcon, CalendarIcon, ChevronDownIcon, ChevronRightIcon, ChevronLeftIcon, CheckCircleIcon, XCircleIcon, XMarkIcon, ExclamationTriangleIcon, LockClosedIcon, MapPinIcon, GlobeAltIcon, } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api, ApiError } from '../../lib/api'; import type { ApiResponse } from '../../lib/api'; import { formatNumber, digitsOnly } from '../../lib/utils'; import Modal from '../ui/Modal'; import ConfirmDialog from '../ui/ConfirmDialog'; import GlobalSearchableSelect from '../ui/SearchableSelect'; /** * ساختار واحد تنظیمات نوبت‌دهی یک پزشک — برنامه هفتگی، استثناهای تاریخ و تعطیلات. * * همین کامپوننت هم در پنل شخصی پزشک رندر می‌شود و هم در تب‌های پنل کلینیک؛ * تنها ورودی متمایزکننده `doctorUuid` است تا دو پیاده‌سازی موازی به وجود نیاید. */ export interface AddressData { id: string; uuid: string; type: 'personal' | 'clinic'; clinic_id: string | null; clinic_name: string | null; name: string | null; address: string | null; telephone: string | null; map: { latitude: string | null; longitude: string | null }; city: { id: string; name: string } | null; province: { id: string; name: string } | null; } // ── Schedule types ───────────────────────────────────────────────────────── interface SlotConfig { start: string; end: string; duration: number; } interface DateOverrideData { uuid: string; doctor_uuid: string; date: number; active: boolean; reason: string | null; custom_slots: SessionConfig[]; } interface HolidayData { uuid: string; doctor_uuid: string; start_date: number; end_date: number; active: boolean; reason: string | null; } interface SessionConfig { active: boolean; location_id: number | null; start_time: string; end_time: string; duration_per_patient: number; has_rest: boolean; rest_interval: number; time_to_rest: number; patient_limit: number | null; } interface NewDayConfig { sessions: SessionConfig[]; } type NewScheduleMap = Record; interface BookingMeta { online_booking_enabled: boolean; booking_window_value: number; booking_window_unit: 'week' | 'month'; booking_mode: 'slot' | 'service'; buffer_minutes: number; } const DEFAULT_BOOKING_META: BookingMeta = { online_booking_enabled: true, booking_window_value: 1, booking_window_unit: 'month', booking_mode: 'slot', buffer_minutes: 0, }; interface WeeklyScheduleData { uuid: string; doctor_uuid: string; schedule: NewScheduleMap; meta?: BookingMeta; booking_mode_locked?: boolean; } // ── Persian (Jalali) date utilities ─────────────────────────────────────── // eslint-disable-next-line @typescript-eslint/no-require-imports const jalaali = require('jalaali-js') as { toJalaali: (gy: number, gm: number, gd: number) => { jy: number; jm: number; jd: number }; toGregorian: (jy: number, jm: number, jd: number) => { gy: number; gm: number; gd: number }; jalaaliMonthLength: (jy: number, jm: number) => number; }; const JALALI_MONTHS = ['فروردین','اردیبهشت','خرداد','تیر','مرداد','شهریور','مهر','آبان','آذر','دی','بهمن','اسفند']; const JALALI_DAYS_SHORT = ['ش','ی','د','س','چ','پ','ج']; function toPersianNums(n: number | string): string { return String(n).replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[parseInt(d)]); } function gToJ(gDate: string): { jy: number; jm: number; jd: number } | null { if (!gDate) return null; const [gy, gm, gd] = gDate.split('-').map(Number); return jalaali.toJalaali(gy, gm, gd); } function jToG(jy: number, jm: number, jd: number): string { const { gy, gm, gd } = jalaali.toGregorian(jy, jm, jd); return `${gy}-${String(gm).padStart(2,'0')}-${String(gd).padStart(2,'0')}`; } function formatPersianDate(gDate: string): string { const j = gToJ(gDate); if (!j) return gDate; return `${toPersianNums(j.jd)} ${JALALI_MONTHS[j.jm - 1]} ${toPersianNums(j.jy)}`; } function jFirstDayOfWeek(jy: number, jm: number): number { const { gy, gm, gd } = jalaali.toGregorian(jy, jm, 1); const dow = new Date(gy, gm - 1, gd).getDay(); // 0=Sunday return (dow + 1) % 7; // 0=Saturday, 1=Sunday, ..., 6=Friday } function todayGregorian(): string { return new Date().toISOString().slice(0, 10); } // ── Persian Date Picker ──────────────────────────────────────────────────── function PersianDateInput({ value, onChange, minDate, placeholder }: { value: string; onChange: (v: string) => void; minDate?: string; placeholder?: string; }) { const [open, setOpen] = useState(false); const [viewJy, setViewJy] = useState(0); const [viewJm, setViewJm] = useState(0); const [rect, setRect] = useState(null); const btnRef = useRef(null); const dropRef = useRef(null); useEffect(() => { if (!open) return; const j = gToJ(value || todayGregorian()); if (j) { setViewJy(j.jy); setViewJm(j.jm); } }, [open, value]); useEffect(() => { if (!open) return; const onDown = (e: MouseEvent) => { if (dropRef.current && !dropRef.current.contains(e.target as Node) && btnRef.current && !btnRef.current.contains(e.target as Node)) setOpen(false); }; document.addEventListener('mousedown', onDown); return () => document.removeEventListener('mousedown', onDown); }, [open]); const openPicker = () => { if (!btnRef.current) return; setRect(btnRef.current.getBoundingClientRect()); setOpen(v => !v); }; const prevMonth = () => viewJm === 1 ? (setViewJy(y => y - 1), setViewJm(12)) : setViewJm(m => m - 1); const nextMonth = () => viewJm === 12 ? (setViewJy(y => y + 1), setViewJm(1)) : setViewJm(m => m + 1); const selectDay = (jd: number) => { onChange(jToG(viewJy, viewJm, jd)); setOpen(false); }; const selectedJ = gToJ(value); const minJ = minDate ? gToJ(minDate) : null; const todayJ = gToJ(todayGregorian()); const daysInMonth = viewJy && viewJm ? jalaali.jalaaliMonthLength(viewJy, viewJm) : 0; const firstDow = viewJy && viewJm ? jFirstDayOfWeek(viewJy, viewJm) : 0; const isSelected = (jd: number) => !!selectedJ && selectedJ.jy === viewJy && selectedJ.jm === viewJm && selectedJ.jd === jd; const isToday = (jd: number) => !!todayJ && todayJ.jy === viewJy && todayJ.jm === viewJm && todayJ.jd === jd; const isDisabled = (jd: number) => { if (!minJ) return false; if (viewJy < minJ.jy) return true; if (viewJy === minJ.jy && viewJm < minJ.jm) return true; if (viewJy === minJ.jy && viewJm === minJ.jm && jd < minJ.jd) return true; return false; }; const dropStyle: React.CSSProperties = rect ? { position: 'fixed', top: rect.bottom + 4, left: rect.left, zIndex: 9999, width: 288 } : {}; return (
{open && createPortal(
{viewJm > 0 ? JALALI_MONTHS[viewJm - 1] : ''} {toPersianNums(viewJy)}
{JALALI_DAYS_SHORT.map(d => (
{d}
))}
{Array.from({ length: firstDow }).map((_, i) =>
)} {Array.from({ length: daysInMonth }).map((_, i) => { const jd = i + 1; const sel = isSelected(jd); const tod = isToday(jd); const dis = isDisabled(jd); return ( ); })}
, document.body )}
); } const SCHEDULE_DAYS = [ { key: '0', label: 'شنبه' }, { key: '1', label: 'یکشنبه' }, { key: '2', label: 'دوشنبه' }, { key: '3', label: 'سه‌شنبه' }, { key: '4', label: 'چهارشنبه' }, { key: '5', label: 'پنجشنبه' }, { key: '6', label: 'جمعه' }, ]; const DURATION_OPTS = [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60]; const DEFAULT_SESSION: SessionConfig = { active: true, location_id: null, start_time: '09:00', end_time: '13:00', duration_per_patient: 20, has_rest: false, rest_interval: 60, time_to_rest: 10, patient_limit: null, }; const EMPTY_NEW_SCHEDULE: NewScheduleMap = Object.fromEntries( SCHEDULE_DAYS.map(d => [d.key, { sessions: [] }]) ); function parseMinutes(t: string): number { const [h, m] = t.split(':').map(Number); return h * 60 + m; } function hasOverlap(sessions: SessionConfig[]): boolean { const active = sessions.filter(s => s.active); const sorted = [...active].sort((a, b) => parseMinutes(a.start_time) - parseMinutes(b.start_time)); for (let i = 0; i < sorted.length - 1; i++) { if (parseMinutes(sorted[i].end_time) > parseMinutes(sorted[i + 1].start_time)) return true; } return false; } function addMinutes(time: string, mins: number): string { const total = Math.min(parseMinutes(time) + mins, 23 * 60 + 59); const h = Math.floor(total / 60).toString().padStart(2, '0'); const m = (total % 60).toString().padStart(2, '0'); return `${h}:${m}`; } function calcSlotCount(session: SessionConfig): number { const totalMins = parseMinutes(session.end_time) - parseMinutes(session.start_time); if (totalMins <= 0 || session.duration_per_patient <= 0) return 0; const dur = session.duration_per_patient; const limit = session.patient_limit ?? Infinity; let current = 0, elapsedWork = 0, count = 0; while (current + dur <= totalMins) { if (count >= limit) break; if (session.has_rest && session.rest_interval > 0 && elapsedWork > 0 && elapsedWork >= session.rest_interval) { current += session.time_to_rest; elapsedWork = 0; continue; } current += dur; elapsedWork += dur; count++; } return count; } function tsToDate(ts: number): string { return new Date(ts * 1000).toISOString().slice(0, 10); } // ── Schedule components ──────────────────────────────────────────────────── // انتخاب ساعت و دقیقه به صورت دو منوی جدا (24 ساعته، دقیقه با گام 5) const HOUR_VALUES = Array.from({ length: 24 }, (_, h) => h.toString().padStart(2, '0')); const MINUTE_VALUES = Array.from({ length: 12 }, (_, i) => (i * 5).toString().padStart(2, '0')); function TimeSelect({ value, onChange }: { value: string; onChange: (v: string) => void }) { const [hRaw, mRaw] = (value || '00:00').split(':'); const h = (hRaw ?? '00').padStart(2, '0'); // دقیقه را به نزدیک‌ترین گام ۵ گرد کن تا همیشه در لیست موجود باشد const mNum = Math.round((Number(mRaw) || 0) / 5) * 5; const m = (mNum >= 60 ? 55 : mNum).toString().padStart(2, '0'); return (
({ value: hv, label: hv }))} value={h} onChange={v => onChange(`${v ? String(v) : h}:${m}`)} height={38} />
:
({ value: mv, label: mv }))} value={m} onChange={v => onChange(`${h}:${v ? String(v) : m}`)} height={38} />
); } function SlotEditor({ slots, onChange }: { slots: SlotConfig[]; onChange: (slots: SlotConfig[]) => void; }) { const add = () => onChange([...slots, { start: '09:00', end: '13:00', duration: 30 }]); const remove = (i: number) => onChange(slots.filter((_, idx) => idx !== i)); const update = (i: number, field: keyof SlotConfig, val: string | number) => onChange(slots.map((s, idx) => idx === i ? { ...s, [field]: val } : s)); return (
{slots.map((slot, i) => (
از update(i, 'start', e.target.value)} className="cp-input h-8 w-28 text-sm font-mono text-center px-2" /> تا update(i, 'end', e.target.value)} className="cp-input h-8 w-28 text-sm font-mono text-center px-2" />
({ value: d, label: `${d} دقیقه` }))} value={slot.duration} onChange={(v) => update(i, 'duration', Number(v))} height={32} />
))}
); } function SessionEditor({ session, onChange, onRemove, addresses, serviceMode = false }: { session: SessionConfig; onChange: (s: SessionConfig) => void; onRemove: () => void; addresses: AddressData[]; /** حالت نوبت‌دهی سرویسی: فیلدهای اسلاتی (بازه هر نوبت، استراحت، شمارش) نمایش داده نمی‌شوند. */ serviceMode?: boolean; }) { const upd = (k: K, v: SessionConfig[K]) => onChange({ ...session, [k]: v }); const slotCount = calcSlotCount(session); return (
{/* Time inputs */}
از ساعت
upd('start_time', v)} />
تا ساعت
upd('end_time', v)} />
{/* Duration (slot mode only) + Location */}
{!serviceMode && (
بازه هر نوبت
({ value: d, label: `${d} دقیقه` }))} value={session.duration_per_patient} onChange={(v) => upd('duration_per_patient', Number(v))} />
)}
مکان نوبت
a.type === 'personal').map(a => ({ value: Number(a.id), label: `🏥 ${a.name ?? a.address ?? `مطب ${a.id}`}`, })), ...addresses.filter(a => a.type === 'clinic').map(a => ({ value: Number(a.id), label: `🏨 ${a.clinic_name ? `${a.clinic_name}${a.name ? ` — ${a.name}` : ''}` : (a.name ?? a.address ?? `کلینیک ${a.id}`)}`, })), ]} value={session.location_id ?? null} onChange={(v) => upd('location_id', v ? Number(v) : null)} placeholder="انتخاب مکان" isClearable />
{/* Rest toggle (slot mode only) */} {!serviceMode && (
استراحت دوره‌ای بین نوبت‌ها
توقف خودکار پس از مدت کاری مشخص
)} {/* Rest params */} {!serviceMode && session.has_rest && (
هر (دقیقه کار)
upd('rest_interval', Number(digitsOnly(e.target.value)) || 0)} />
استراحت (دقیقه)
upd('time_to_rest', Number(digitsOnly(e.target.value)) || 0)} />
)} {/* Slot count footer (slot mode only) */} {!serviceMode && (
تعداد نوبت محاسبه‌شده در این بازه {slotCount > 0 ? ( {slotCount} نوبت ) : ( )}
)}
); } // ── Weekly Schedule Tab ──────────────────────────────────────────────────── /** * هر درخواست تنظیمات نوبت‌دهی باید محیطش را حمل کند: بدون clinic_uuid یعنی مطب * شخصی پزشک، و با آن یعنی همان پزشک داخل آن کلینیک. این دو داده‌ی جدا دارند. */ const withClinic = (url: string, clinicUuid?: string | null): string => clinicUuid ? `${url}${url.includes('?') ? '&' : '?'}clinic_uuid=${encodeURIComponent(clinicUuid)}` : url; export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly = false }: { doctorUuid: string; clinicUuid?: string | null; addresses: AddressData[]; readOnly?: boolean }) { const qc = useQueryClient(); const [scheduleMap, setScheduleMap] = useState(EMPTY_NEW_SCHEDULE); const [scheduleUuid, setScheduleUuid] = useState(null); const [expandedDay, setExpandedDay] = useState(null); const [meta, setMeta] = useState(DEFAULT_BOOKING_META); // نوع نوبت‌دهی پس از اولین ثبت قفل می‌شود؛ confirmMode = دیالوگ هشدار قبل از ثبت اول. const [modeLocked, setModeLocked] = useState(false); const [confirmMode, setConfirmMode] = useState(false); const scheduleQ = useQuery({ queryKey: ['doctor-schedule', doctorUuid, clinicUuid ?? null], queryFn: () => api.get>(withClinic(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, clinicUuid)), staleTime: 0, retry: (count, err) => !(err instanceof ApiError && err.status === 404), }); useEffect(() => { if (scheduleQ.data) { const d: WeeklyScheduleData = scheduleQ.data?.data?.data ?? scheduleQ.data?.data; if (d?.schedule) { const merged: NewScheduleMap = { ...EMPTY_NEW_SCHEDULE }; for (const key of Object.keys(d.schedule)) { const raw = d.schedule[key] as any; if (raw?.sessions) merged[key] = { sessions: raw.sessions }; } setScheduleMap(merged); setScheduleUuid(d.uuid); } if (d?.meta) setMeta({ ...DEFAULT_BOOKING_META, ...d.meta }); setModeLocked(!!d?.booking_mode_locked); } else if (scheduleQ.error instanceof ApiError && scheduleQ.error.status === 404) { setScheduleMap(EMPTY_NEW_SCHEDULE); setScheduleUuid(null); } }, [scheduleQ.data, scheduleQ.error]); const overlapDays = useMemo(() => Object.fromEntries(SCHEDULE_DAYS.map(d => [d.key, hasOverlap(scheduleMap[d.key]?.sessions ?? [])])) , [scheduleMap]); const hasAnyOverlap = Object.values(overlapDays).some(Boolean); const totalSlots = useMemo(() => SCHEDULE_DAYS.reduce((sum, d) => { const sessions = scheduleMap[d.key]?.sessions ?? []; return sum + sessions.filter(s => s.active).reduce((s2, s) => s2 + calcSlotCount(s), 0); }, 0) , [scheduleMap]); const missingLocation = addresses.length > 0 && SCHEDULE_DAYS.some(d => (scheduleMap[d.key]?.sessions ?? []).some(s => s.active && s.location_id === null) ); const saveMut = useMutation({ mutationFn: () => { if (hasAnyOverlap) throw new Error('تداخل زمانی در برنامه وجود دارد'); if (missingLocation) throw new Error('مکان مطب برای همه بازه‌های فعال الزامی است'); return scheduleUuid ? api.patch>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap, meta, clinic_uuid: clinicUuid ?? null }) : api.post>('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap, meta, clinic_uuid: clinicUuid ?? null }); }, onSuccess: (res) => { const d: WeeklyScheduleData = res?.data?.data ?? res?.data; if (d?.uuid && !scheduleUuid) setScheduleUuid(d.uuid); setModeLocked(true); // پس از ثبت، نوع نوبت‌دهی قفل می‌شود toast.success('برنامه هفتگی ذخیره شد'); qc.invalidateQueries({ queryKey: ['doctor-schedule', doctorUuid, clinicUuid ?? null] }); }, onError: (e: Error) => toast.error(e.message), }); const setDaySessions = (key: string, sessions: SessionConfig[]) => setScheduleMap(prev => ({ ...prev, [key]: { sessions } })); const addSession = (key: string) => { const existing = scheduleMap[key]?.sessions ?? []; const lastEnd = existing.length > 0 ? existing.reduce((max, s) => parseMinutes(s.end_time) > parseMinutes(max) ? s.end_time : max, '00:00') : '09:00'; const newStart = existing.length > 0 ? addMinutes(lastEnd, 30) : '09:00'; const newEnd = addMinutes(newStart, 240); const defaultLoc = addresses.length > 0 ? Number(addresses[0].id) : null; setDaySessions(key, [...existing, { ...DEFAULT_SESSION, start_time: newStart, end_time: newEnd, location_id: defaultLoc }]); setExpandedDay(key); }; const removeSession = (key: string, idx: number) => setDaySessions(key, (scheduleMap[key]?.sessions ?? []).filter((_, i) => i !== idx)); const updateSession = (key: string, idx: number, s: SessionConfig) => setDaySessions(key, (scheduleMap[key]?.sessions ?? []).map((old, i) => i === idx ? s : old)); if (scheduleQ.isLoading) return (
{Array.from({ length: 4 }).map((_, i) =>
)}
); if (addresses.length === 0) return (

ابتدا آدرس مطب را ثبت کنید

برنامه کاری نیاز به حداقل یک مکان نوبت دارد

); // نمای فقط‌خواندنی برای نماینده: برنامه‌ی هفتگی به‌صورت متن، بدون فرم. if (readOnly) { if (scheduleQ.isLoading) { return
{Array.from({ length: 4 }).map((_, i) =>
)}
; } return (
{totalSlots > 0 && (
مجموع {totalSlots} نوبت در هفته
)} {SCHEDULE_DAYS.map(day => { const activeSessions = (scheduleMap[day.key]?.sessions ?? []).filter(s => s.active); const daySlots = activeSessions.reduce((sum, s) => sum + calcSlotCount(s), 0); return (
{day.label}
{activeSessions.length > 0 ? activeSessions.map((s, i) => ( {s.start_time} – {s.end_time} )) : ( تعطیل )}
{daySlots > 0 && ( {daySlots} نوبت )}
); })}
); } return (
{/* ─ خلاصه کل هفته */} {totalSlots > 0 && (
مجموع {totalSlots} نوبت در هفته
)} {/* ─ روش نوبت‌دهی */}
روش نوبت‌دهی
{([ ['slot', 'نوبت‌دهی اسلاتی', 'شما بازه‌های کاری و «مدت هر نوبت» را مشخص می‌کنید؛ سیستم بازه را به نوبت‌های هم‌اندازه تقسیم می‌کند. مناسب ویزیت‌های با زمان یکسان.'], ['service', 'نوبت‌دهی سرویسی', 'مدت هر نوبت از «مدت سرویس» انتخاب‌شده تعیین می‌شود؛ سیستم نزدیک‌ترین زمان خالیِ کافی را پیشنهاد می‌دهد. مناسب خدمات با زمان متفاوت.'], ] as const).map(([val, lbl, desc]) => { const selected = meta.booking_mode === val; return ( ); })}
{modeLocked ? (

نوع نوبت‌دهی ثبت شده و دیگر قابل تغییر نیست.

) : (

⚠️ توجه: نوع نوبت‌دهی پس از اولین ثبت به‌هیچ‌عنوان قابل تغییر نیست. پیش از ذخیره با دقت انتخاب کنید.

)} {meta.booking_mode === 'service' ? ( <>
فاصله بین نوبت‌ها setMeta(m => ({ ...m, buffer_minutes: Math.max(0, Number(digitsOnly(e.target.value)) || 0) }))} className="w-16 text-center text-sm rounded-lg border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-2 py-1.5 focus:outline-none focus:ring-0" /> دقیقه

مدت هر نوبت از «مدت سرویس» انتخاب‌شده تعیین می‌شود. لازم است حداقل یک سرویس با «نمایش در نوبت‌دهی» در بخش {clinicUuid ? 'سرویس‌های کلینیک' : 'سرویس‌ها'} تعریف کنید، وگرنه ذخیره نمی‌شود.

) : (

مدت هر نوبت از «زمان هر نوبت» در شیفت‌های زیر تعیین می‌شود.

)}
{/* ─ نوبت‌دهی آنلاین */}
{/* header + toggle */} {/* booking window control */}
رزرو آنلاین تا
setMeta(m => ({ ...m, booking_window_value: Math.max(1, Number(digitsOnly(e.target.value)) || 1) }))} className="w-14 text-center text-sm rounded-lg border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-900 focus:outline-none focus:ring-0 px-2 py-1.5" />
setMeta(m => ({ ...m, booking_window_unit: (v as 'week' | 'month') }))} isDisabled={!meta.online_booking_enabled} height={38} />
آینده

بیمار فقط تا این بازه می‌تواند آنلاین نوبت بگیرد؛ روزهای بعد از آن روی تقویم غیرفعال‌اند.

{SCHEDULE_DAYS.map(day => { const sessions = scheduleMap[day.key]?.sessions ?? []; const isOverlap = overlapDays[day.key]; const isExpanded = expandedDay === day.key; const activeSessions = sessions.filter(s => s.active); const daySlots = activeSessions.reduce((sum, s) => sum + calcSlotCount(s), 0); return (
{/* Day header */}
setExpandedDay(isExpanded ? null : day.key)} > {day.label}
{activeSessions.map((s, i) => ( {s.start_time} — {s.end_time} ))} {sessions.length === 0 && تعطیل}
{daySlots > 0 && ( {daySlots} نوبت )} {isOverlap && ( تداخل )}
{/* Expanded sessions */} {isExpanded && (
{sessions.length === 0 ? (

هیچ بازه‌ای برای این روز تنظیم نشده

) : sessions.map((session, idx) => ( updateSession(day.key, idx, s)} onRemove={() => removeSession(day.key, idx)} /> ))}
)}
); })}
{(hasAnyOverlap || missingLocation) && (

{hasAnyOverlap ? 'تداخل زمانی در برنامه وجود دارد' : 'مکان مطب برای همه بازه‌ها الزامی است'}

)} {!readOnly && ( )}
{ setConfirmMode(false); saveMut.mutate(); }} onCancel={() => setConfirmMode(false)} />
); } // ── Date Override Modal ──────────────────────────────────────────────────── function DateOverrideModal({ open, onClose, existing, doctorUuid, clinicUuid, onSaved, addresses }: { open: boolean; onClose: () => void; existing: DateOverrideData | null; doctorUuid: string; clinicUuid?: string | null; onSaved: () => void; addresses: AddressData[]; }) { const [dateStr, setDateStr] = useState(''); const [overrideType, setOverrideType] = useState<'closed' | 'custom'>('closed'); const [reason, setReason] = useState(''); const [slots, setSlots] = useState([]); const toSession = useCallback((s: any): SessionConfig => ({ active: true, start_time: s.start_time ?? s.start ?? '09:00', end_time: s.end_time ?? s.end ?? '13:00', duration_per_patient: s.duration_per_patient ?? s.duration ?? 20, location_id: s.location_id ?? null, has_rest: s.has_rest ?? false, rest_interval: s.rest_interval ?? 60, time_to_rest: s.time_to_rest ?? 10, patient_limit: s.patient_limit ?? null, }), []); useEffect(() => { if (open) { if (existing) { setDateStr(tsToDate(existing.date)); setOverrideType(existing.active ? 'custom' : 'closed'); setReason(existing.reason ?? ''); setSlots((existing.custom_slots ?? []).map(toSession)); } else { setDateStr(new Date().toISOString().slice(0, 10)); setOverrideType('closed'); setReason(''); setSlots([]); } } }, [open, existing, toSession]); const saveMut = useMutation({ mutationFn: () => { const active = overrideType === 'custom'; const body = { date: dateStr, active, reason: reason || undefined, custom_slots: active ? slots : [] }; if (existing) return api.patch>(`/api/v1/appointment-settings/date-override/${existing.uuid}`, body); return api.post>('/api/v1/appointment-settings/date-override', { ...body, doctor_uuid: doctorUuid, clinic_uuid: clinicUuid ?? null }); }, onSuccess: () => { toast.success(existing ? 'ویرایش شد' : 'تاریخ خاص اضافه شد'); onSaved(); onClose(); }, onError: (e: Error) => toast.error(e.message), }); return ( } >
{/* ─ نوع override */}
setReason(e.target.value)} placeholder={overrideType === 'closed' ? 'مثال: سفر، مریضی، کنگره...' : 'مثال: شیفت اضطراری، ویزیت خاص...'} className="input" />
{overrideType === 'custom' && (
{slots.length === 0 ? (

اگر خالی بماند از برنامه هفتگی استفاده می‌شود

) : (
{slots.map((session, idx) => ( setSlots(prev => prev.map((old, i) => i === idx ? s : old))} onRemove={() => setSlots(prev => prev.filter((_, i) => i !== idx))} /> ))}
)}
)} {overrideType === 'closed' && (

در این روز هیچ نوبتی داده نخواهد شد، حتی اگر در برنامه هفتگی ساعات کاری داشته باشید.

)}
); } // ── Date Overrides Tab ───────────────────────────────────────────────────── function DateOverridesTab({ doctorUuid, clinicUuid, addresses, readOnly = false }: { doctorUuid: string; clinicUuid?: string | null; addresses: AddressData[]; readOnly?: boolean }) { const qc = useQueryClient(); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [deletingUuid, setDeletingUuid] = useState(null); const listQ = useQuery({ queryKey: ['doctor-overrides', doctorUuid, clinicUuid ?? null], queryFn: () => api.get>(withClinic(`/api/v1/appointment-settings/date-override/list/${doctorUuid}`, clinicUuid)), staleTime: 0, }); const overrides: DateOverrideData[] = useMemo( () => listQ.data?.data?.data ?? listQ.data?.data ?? [], [listQ.data] ); const deleteMut = useMutation({ mutationFn: (uuid: string) => api.delete>(`/api/v1/appointment-settings/date-override/${uuid}`), onSuccess: () => { toast.success('حذف شد'); setDeletingUuid(null); qc.invalidateQueries({ queryKey: ['doctor-overrides', doctorUuid, clinicUuid ?? null] }); }, onError: (e: Error) => toast.error(e.message), }); if (listQ.isLoading) return (
{Array.from({ length: 3 }).map((_, i) =>
)}
); if (addresses.length === 0) return (

ابتدا آدرس مطب را ثبت کنید

برنامه کاری نیاز به حداقل یک مکان نوبت دارد

); return (
{!readOnly && (
)} {overrides.length === 0 ? (

هیچ تاریخ خاصی تنظیم نشده

) : (
{overrides.map(ov => (
{formatPersianDate(tsToDate(ov.date))} {ov.active ? ( {ov.custom_slots.length > 0 ? `${ov.custom_slots.length} بازه سفارشی` : 'ساعات خاص'} ) : ( تعطیل )}
{ov.reason &&

{ov.reason}

}
{!readOnly && (
)}
))}
)} { setModalOpen(false); setEditing(null); }} existing={editing} doctorUuid={doctorUuid} clinicUuid={clinicUuid} addresses={addresses} onSaved={() => qc.invalidateQueries({ queryKey: ['doctor-overrides', doctorUuid, clinicUuid ?? null] })} /> deletingUuid && deleteMut.mutate(deletingUuid)} onCancel={() => setDeletingUuid(null)} />
); } // ── Holiday Modal ────────────────────────────────────────────────────────── function HolidayModal({ open, onClose, existing, doctorUuid, clinicUuid, onSaved }: { open: boolean; onClose: () => void; existing: HolidayData | null; doctorUuid: string; clinicUuid?: string | null; onSaved: () => void; }) { const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); const [reason, setReason] = useState(''); useEffect(() => { if (open) { if (existing) { setStartDate(tsToDate(existing.start_date)); setEndDate(tsToDate(existing.end_date)); setReason(existing.reason ?? ''); } else { const today = new Date().toISOString().slice(0, 10); setStartDate(today); setEndDate(today); setReason(''); } } }, [open, existing]); const invalid = !startDate || !endDate || endDate < startDate; const saveMut = useMutation({ mutationFn: () => { const body = { start_date: startDate, end_date: endDate, reason: reason || undefined }; if (existing) return api.patch>(`/api/v1/appointment-settings/holidays/${existing.uuid}`, body); return api.post>('/api/v1/appointment-settings/holidays', { ...body, doctor_uuid: doctorUuid, clinic_uuid: clinicUuid ?? null }); }, onSuccess: () => { toast.success(existing ? 'تعطیلات ویرایش شد' : 'تعطیلات اضافه شد'); onSaved(); onClose(); }, onError: (e: Error) => toast.error(e.message), }); return ( } >
{ setStartDate(v); if (endDate && v > endDate) setEndDate(v); }} />
{endDate && startDate && endDate < startDate && (

تاریخ پایان نمی‌تواند قبل از تاریخ شروع باشد

)}
setReason(e.target.value)} placeholder="مثال: سفر، کنگره پزشکی..." className="input" />
); } // ── Holidays Tab ─────────────────────────────────────────────────────────── function HolidaysTab({ doctorUuid, clinicUuid, readOnly = false }: { doctorUuid: string; clinicUuid?: string | null; readOnly?: boolean }) { const qc = useQueryClient(); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [deletingUuid, setDeletingUuid] = useState(null); const listQ = useQuery({ queryKey: ['doctor-holidays', doctorUuid, clinicUuid ?? null], queryFn: () => api.get>(withClinic(`/api/v1/appointment-settings/holidays/list/${doctorUuid}`, clinicUuid)), staleTime: 0, }); const holidays: HolidayData[] = useMemo( () => listQ.data?.data?.data ?? listQ.data?.data ?? [], [listQ.data] ); const deleteMut = useMutation({ mutationFn: (uuid: string) => api.delete>(`/api/v1/appointment-settings/holidays/${uuid}`), onSuccess: () => { toast.success('حذف شد'); setDeletingUuid(null); qc.invalidateQueries({ queryKey: ['doctor-holidays', doctorUuid, clinicUuid ?? null] }); }, onError: (e: Error) => toast.error(e.message), }); const toggleMut = useMutation({ mutationFn: (h: HolidayData) => api.patch>(`/api/v1/appointment-settings/holidays/${h.uuid}`, { active: !h.active }), onSuccess: () => { toast.success('وضعیت بروز شد'); qc.invalidateQueries({ queryKey: ['doctor-holidays', doctorUuid, clinicUuid ?? null] }); }, onError: (e: Error) => toast.error(e.message), }); if (listQ.isLoading) return (
{Array.from({ length: 3 }).map((_, i) =>
)}
); return (
{!readOnly && (
)} {holidays.length === 0 ? (

هیچ تعطیلاتی ثبت نشده

) : (
{holidays.map(h => { const now = Math.floor(Date.now() / 1000); const isPast = h.end_date < now; const isCurrent = h.start_date <= now && h.end_date >= now; const sameDay = tsToDate(h.start_date) === tsToDate(h.end_date); return (
{sameDay ? formatPersianDate(tsToDate(h.start_date)) : `${formatPersianDate(tsToDate(h.start_date))} تا ${formatPersianDate(tsToDate(h.end_date))}`} {isCurrent && h.active && ( در جریان )} {!isCurrent && !isPast && h.active && ( آینده )} {isPast && ( گذشته )} {!h.active && ( غیرفعال )}
{h.reason &&

{h.reason}

}
{!readOnly && (
)}
); })}
)} { setModalOpen(false); setEditing(null); }} existing={editing} doctorUuid={doctorUuid} clinicUuid={clinicUuid} onSaved={() => qc.invalidateQueries({ queryKey: ['doctor-holidays', doctorUuid, clinicUuid ?? null] })} /> deletingUuid && deleteMut.mutate(deletingUuid)} onCancel={() => setDeletingUuid(null)} />
); } // ── Schedule Section ─────────────────────────────────────────────────────── const SCHEDULE_TABS = [ { id: 'weekly' as const, label: 'ساعات کاری هفتگی' }, { id: 'overrides' as const, label: 'تاریخ‌های خاص' }, { id: 'holidays' as const, label: 'تعطیلات' }, ]; export function ScheduleSection({ doctorUuid, clinicUuid, readOnly = false }: { doctorUuid: string; clinicUuid?: string | null; readOnly?: boolean }) { const [tab, setTab] = useState<'weekly' | 'overrides' | 'holidays'>('weekly'); const locationsQ = useQuery({ queryKey: ['available-locations', doctorUuid, clinicUuid ?? null], queryFn: () => api.get>(withClinic(`/api/v1/appointment-settings/available-locations/${doctorUuid}`, clinicUuid)), enabled: !!doctorUuid, staleTime: 30_000, }); const availableLocations: AddressData[] = locationsQ.data?.data?.data ?? locationsQ.data?.data ?? []; return (

برنامه کاری

{SCHEDULE_TABS.map(t => ( ))}
{tab === 'weekly' && } {tab === 'overrides' && } {tab === 'holidays' && }
); }