diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 4b940649..586afcee 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -62,6 +62,7 @@ import SettingsMenuPage from './pages/SettingsMenuPage'; import AccountSettingsPage from './pages/AccountSettingsPage'; import TagsSettingsPage from './pages/TagsSettingsPage'; import AppointmentSettingsPage from './pages/AppointmentSettingsPage'; +import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage'; import PatientsListPage from './pages/PatientsListPage'; import InventoryPage from './pages/InventoryPage'; import PatientRecordFormPage from './pages/PatientRecordFormPage'; @@ -199,6 +200,7 @@ export default function App() { {/* پزشکان کلینیک — تب تنظیماتِ مالک کلینیک */} } /> + } /> {/* مسیر قدیمی «مدیریت مطب» → ریدایرکت به تب جدید */} } /> diff --git a/assets/admin/components/FreeVisitPrice.tsx b/assets/admin/components/FreeVisitPrice.tsx index f10a56ba..e5413005 100644 --- a/assets/admin/components/FreeVisitPrice.tsx +++ b/assets/admin/components/FreeVisitPrice.tsx @@ -6,15 +6,18 @@ import { formatRial, rialToToman, tomanToRial } from '../lib/utils'; interface Pricing { free_visit_price_rials: number; require_visit_price: boolean } -export default function FreeVisitPrice() { +/** بدون doctorUuid روی موجودیت کاربر جاری کار می‌کند؛ با آن، قیمت همان پزشک. */ +export default function FreeVisitPrice({ doctorUuid }: { doctorUuid?: string }) { const qc = useQueryClient(); const [value, setValue] = useState(''); const [required, setRequired] = useState(false); const [error, setError] = useState(''); const { data } = useQuery<{ data: Pricing }>({ - queryKey: ['insurance-pricing'], - queryFn: () => api.get('/api/v1/insurance-pricing'), + queryKey: ['insurance-pricing', doctorUuid ?? 'self'], + queryFn: () => api.get(doctorUuid + ? `/api/v1/insurance-pricing?doctor_uuid=${doctorUuid}` + : '/api/v1/insurance-pricing'), }); const pricing = (data as any)?.data as Pricing | undefined; @@ -29,10 +32,11 @@ export default function FreeVisitPrice() { mutationFn: () => api.put('/api/v1/insurance-pricing', { free_visit_price_rials: tomanToRial(Number(value) || 0), require_visit_price: required, + ...(doctorUuid ? { doctor_uuid: doctorUuid } : {}), }), onSuccess: () => { toast.success('قیمت ویزیت ذخیره شد'); - qc.invalidateQueries({ queryKey: ['insurance-pricing'] }); + qc.invalidateQueries({ queryKey: ['insurance-pricing', doctorUuid ?? 'self'] }); }, onError: (e: Error) => toast.error(e.message), }); diff --git a/assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx b/assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx index f7a22de9..ceea014e 100644 --- a/assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx +++ b/assets/admin/components/layout/PurchaseSubscriptionSidebar.tsx @@ -20,6 +20,7 @@ const NAV_ITEMS: NavItem[] = [ { key: 'subscription', label: 'خرید اشتراک', to: '/admin/subscription' }, { key: 'payment', label: 'مدیریت پرداخت', to: '/admin/my-financial' }, { key: 'appointment', label: 'مدیریت نوبت دهی', to: '/admin/appointment-settings', roles: ['doctor'] }, + { key: 'appointment', label: 'مدیریت نوبت دهی', to: '/admin/settings/appointment-settings', roles: ['clinic'] }, { key: 'insurance', label: 'مدیریت بیمه', to: '/admin/insurance-pricing' }, { key: 'discounts', label: 'مدیریت تخفیف‌ها', to: '/admin/discounts', roles: ['doctor', 'clinic'] }, { key: 'tags', label: 'تگ ها', to: '/admin/tags-settings' }, diff --git a/assets/admin/components/layout/SettingsLayout.tsx b/assets/admin/components/layout/SettingsLayout.tsx index 40439925..6dd76f1a 100644 --- a/assets/admin/components/layout/SettingsLayout.tsx +++ b/assets/admin/components/layout/SettingsLayout.tsx @@ -23,6 +23,7 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [ { key: 'subscription', label: 'خرید اشتراک', icon: CreditCardIcon, to: '/admin/subscription' }, { key: 'doctor', label: 'مدیریت پزشک', icon: UserIcon, to: '/admin/profile', roles: ['doctor'] }, { key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/appointment-settings', roles: ['doctor'] }, + { key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/settings/appointment-settings', roles: ['clinic'] }, { key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, to: '/admin/settings/clinic-doctors', roles: ['clinic'] }, { key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial' }, { key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' }, diff --git a/assets/admin/components/schedule/ScheduleSection.tsx b/assets/admin/components/schedule/ScheduleSection.tsx new file mode 100644 index 00000000..a49a4abb --- /dev/null +++ b/assets/admin/components/schedule/ScheduleSection.tsx @@ -0,0 +1,1334 @@ +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 } 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(e.target.value))} /> +
+
+
+
استراحت (دقیقه)
+
+ upd('time_to_rest', Number(e.target.value))} /> +
+
+
+ )} + + {/* Slot count footer (slot mode only) */} + {!serviceMode && ( +
+ تعداد نوبت محاسبه‌شده در این بازه + {slotCount > 0 ? ( + + + {slotCount} نوبت + + ) : ( + + )} +
+ )} +
+ ); +} + +// ── Weekly Schedule Tab ──────────────────────────────────────────────────── + +export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: { doctorUuid: string; 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], + queryFn: () => api.get>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`), + 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 }) + : api.post>('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap, meta }); + }, + 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] }); + }, + 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(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" + /> + دقیقه +
+

+ مدت هر نوبت از «مدت سرویس» انتخاب‌شده تعیین می‌شود. لازم است حداقل یک سرویس با «نمایش در نوبت‌دهی» در بخش سرویس‌ها تعریف کنید، وگرنه ذخیره نمی‌شود. +

+ + ) : ( +

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

+ )} +
+
+ + {/* ─ نوبت‌دهی آنلاین */} +
+ {/* header + toggle */} + + + {/* booking window control */} +
+
+ رزرو آنلاین تا +
+ setMeta(m => ({ ...m, booking_window_value: Math.max(1, Number(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, onSaved, addresses }: { + open: boolean; onClose: () => void; + existing: DateOverrideData | null; doctorUuid: string; + 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 }); + }, + 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, addresses, readOnly = false }: { doctorUuid: string; 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], + queryFn: () => api.get>(`/api/v1/appointment-settings/date-override/list/${doctorUuid}`), + 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] }); }, + 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} addresses={addresses} + onSaved={() => qc.invalidateQueries({ queryKey: ['doctor-overrides', doctorUuid] })} /> + deletingUuid && deleteMut.mutate(deletingUuid)} onCancel={() => setDeletingUuid(null)} /> +
+ ); +} + +// ── Holiday Modal ────────────────────────────────────────────────────────── + +function HolidayModal({ open, onClose, existing, doctorUuid, onSaved }: { + open: boolean; onClose: () => void; + existing: HolidayData | null; doctorUuid: string; + 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 }); + }, + 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, readOnly = false }: { doctorUuid: string; 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], + queryFn: () => api.get>(`/api/v1/appointment-settings/holidays/list/${doctorUuid}`), + 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] }); }, + 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] }); }, + 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} + onSaved={() => qc.invalidateQueries({ queryKey: ['doctor-holidays', doctorUuid] })} /> + 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, readOnly = false }: { doctorUuid: string; readOnly?: boolean }) { + const [tab, setTab] = useState<'weekly' | 'overrides' | 'holidays'>('weekly'); + + const locationsQ = useQuery({ + queryKey: ['available-locations', doctorUuid], + queryFn: () => api.get>(`/api/v1/appointment-settings/available-locations/${doctorUuid}`), + 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' && } +
+ ); +} + diff --git a/assets/admin/pages/AppointmentSettingsPage.tsx b/assets/admin/pages/AppointmentSettingsPage.tsx index b00d6237..d7fb87ec 100644 --- a/assets/admin/pages/AppointmentSettingsPage.tsx +++ b/assets/admin/pages/AppointmentSettingsPage.tsx @@ -1,7 +1,7 @@ import { useAuthStore } from '../stores/authStore'; import SettingsLayout from '../components/layout/SettingsLayout'; import FreeVisitPrice from '../components/FreeVisitPrice'; -import { ScheduleSection } from './DoctorDetailPage'; +import { ScheduleSection } from '../components/schedule/ScheduleSection'; /** * مدیریت نوبت دهی — the doctor's appointment settings: visit price and the full diff --git a/assets/admin/pages/ClinicAppointmentSettingsPage.tsx b/assets/admin/pages/ClinicAppointmentSettingsPage.tsx new file mode 100644 index 00000000..55679c8b --- /dev/null +++ b/assets/admin/pages/ClinicAppointmentSettingsPage.tsx @@ -0,0 +1,107 @@ +import { useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { UserGroupIcon } from '@heroicons/react/24/outline'; +import { api } from '../lib/api'; +import type { ApiResponse } from '../lib/api'; +import { useAuthStore } from '../stores/authStore'; +import SettingsLayout from '../components/layout/SettingsLayout'; +import { ScheduleSection } from '../components/schedule/ScheduleSection'; +import FreeVisitPrice from '../components/FreeVisitPrice'; +import type { ClinicDoctorItem } from '../components/ClinicDoctorsManager'; + +/** + * تنظیمات نوبت‌دهی همه پزشکان کلینیک — یک تب به ازای هر پزشک. + * + * هر تب دقیقاً همان ScheduleSection پنل پزشک مستقل را رندر می‌کند؛ تنها تفاوت، + * امکان جابه‌جایی بین پزشکان است. + */ +function ClinicAppointmentSettingsContent() { + const { dbUuid, context, availableContexts } = useAuthStore(); + const [activeUuid, setActiveUuid] = useState(null); + + // کاربری که هم پزشک است هم مالک کلینیک، dbUuid‌اش ممکن است uuid پزشک باشد. + const clinicUuid = useMemo(() => { + if (context?.type === 'clinic') return dbUuid; + return availableContexts.find(c => c.type === 'clinic')?.db_uuid ?? null; + }, [context, dbUuid, availableContexts]); + + const doctorsQ = useQuery({ + queryKey: ['clinic-doctors', clinicUuid], + queryFn: () => api.get>(`/api/v1/clinic/doctor-list/${clinicUuid}`), + enabled: !!clinicUuid, + }); + + const doctorList: ClinicDoctorItem[] = useMemo(() => { + const raw = doctorsQ.data?.data; + return (raw as any)?.data ?? raw ?? []; + }, [doctorsQ.data]); + + const selected = activeUuid ?? doctorList[0]?.uuid ?? null; + + if (!clinicUuid) { + return ( +
+

+ {dbUuid ? 'کلینیکی برای این حساب کاربری یافت نشد' : 'در حال بارگذاری اطلاعات کلینیک...'} +

+
+ ); + } + + return ( +
+
+
+

مدیریت نوبت دهی

+
تنظیمات نوبت‌دهی پزشکان کلینیک
+
+
+ + {doctorsQ.isLoading ? ( +

در حال بارگذاری پزشکان...

+ ) : doctorList.length === 0 ? ( +
+
+ +

هیچ پزشکی به این کلینیک متصل نیست

+ مدیریت پزشکان کلینیک +
+
+ ) : ( + <> +
+
+ {doctorList.map(doc => ( + + ))} +
+
+ + {/* key اجباری است: بدون آن state ویرایشِ برنامه بین پزشکان نشت می‌کند */} + {selected && ( +
+ + +
+ )} + + )} +
+ ); +} + +export default function ClinicAppointmentSettingsPage() { + return ( + + + + ); +} diff --git a/assets/admin/pages/DoctorDetailPage.tsx b/assets/admin/pages/DoctorDetailPage.tsx index 624ad061..ab64be10 100644 --- a/assets/admin/pages/DoctorDetailPage.tsx +++ b/assets/admin/pages/DoctorDetailPage.tsx @@ -31,6 +31,8 @@ import NotificationMobileCard from '../components/ui/NotificationMobileCard'; import GlobalSearchableSelect from '../components/ui/SearchableSelect'; import PersianDatePicker from '../components/ui/PersianDatePicker'; import ImageCropModal from '../components/ImageCropModal'; +import { ScheduleSection } from '../components/schedule/ScheduleSection'; +import type { AddressData } from '../components/schedule/ScheduleSection'; // Fix leaflet default marker icons delete (L.Icon.Default.prototype as any)._getIconUrl; @@ -67,53 +69,6 @@ interface ProvinceOpt { id: number; uuid: string; name: string; } interface CityOpt { id: number; uuid: string; name: string; } interface ImageFileData { fid: number; uuid: string; url: string; filename: string; filemime: string; filesize: number; } -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; } - // ── Constants ────────────────────────────────────────────────────────────── const DEGREE_LABELS: Record = { @@ -141,219 +96,6 @@ const SPECIALTY_COLORS = [ 'bg-pink-100 dark:bg-pink-400/10 text-pink-700 dark:text-pink-300', ]; -// ── 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); -} - // ── Image upload ─────────────────────────────────────────────────────────── async function uploadDoctorImage(file: File): Promise { @@ -1038,1056 +780,6 @@ function AddressCard({ addr, onEdit, onDelete, readOnly = false }: { ); } -// ── 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(e.target.value))} /> -
-
-
-
استراحت (دقیقه)
-
- upd('time_to_rest', Number(e.target.value))} /> -
-
-
- )} - - {/* Slot count footer (slot mode only) */} - {!serviceMode && ( -
- تعداد نوبت محاسبه‌شده در این بازه - {slotCount > 0 ? ( - - - {slotCount} نوبت - - ) : ( - - )} -
- )} -
- ); -} - -// ── Weekly Schedule Tab ──────────────────────────────────────────────────── - -export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: { doctorUuid: string; 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], - queryFn: () => api.get>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`), - 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 }) - : api.post>('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap, meta }); - }, - 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] }); - }, - 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(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" - /> - دقیقه -
-

- مدت هر نوبت از «مدت سرویس» انتخاب‌شده تعیین می‌شود. لازم است حداقل یک سرویس با «نمایش در نوبت‌دهی» در بخش سرویس‌ها تعریف کنید، وگرنه ذخیره نمی‌شود. -

- - ) : ( -

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

- )} -
-
- - {/* ─ نوبت‌دهی آنلاین */} -
- {/* header + toggle */} - - - {/* booking window control */} -
-
- رزرو آنلاین تا -
- setMeta(m => ({ ...m, booking_window_value: Math.max(1, Number(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, onSaved, addresses }: { - open: boolean; onClose: () => void; - existing: DateOverrideData | null; doctorUuid: string; - 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 }); - }, - 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, addresses, readOnly = false }: { doctorUuid: string; 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], - queryFn: () => api.get>(`/api/v1/appointment-settings/date-override/list/${doctorUuid}`), - 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] }); }, - 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} addresses={addresses} - onSaved={() => qc.invalidateQueries({ queryKey: ['doctor-overrides', doctorUuid] })} /> - deletingUuid && deleteMut.mutate(deletingUuid)} onCancel={() => setDeletingUuid(null)} /> -
- ); -} - -// ── Holiday Modal ────────────────────────────────────────────────────────── - -function HolidayModal({ open, onClose, existing, doctorUuid, onSaved }: { - open: boolean; onClose: () => void; - existing: HolidayData | null; doctorUuid: string; - 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 }); - }, - 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, readOnly = false }: { doctorUuid: string; 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], - queryFn: () => api.get>(`/api/v1/appointment-settings/holidays/list/${doctorUuid}`), - 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] }); }, - 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] }); }, - 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} - onSaved={() => qc.invalidateQueries({ queryKey: ['doctor-holidays', doctorUuid] })} /> - 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, readOnly = false }: { doctorUuid: string; readOnly?: boolean }) { - const [tab, setTab] = useState<'weekly' | 'overrides' | 'holidays'>('weekly'); - - const locationsQ = useQuery({ - queryKey: ['available-locations', doctorUuid], - queryFn: () => api.get>(`/api/v1/appointment-settings/available-locations/${doctorUuid}`), - 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' && } -
- ); -} - // ── Edit form helpers ────────────────────────────────────────────────────── const EDIT_DEGREE_OPTIONS = [ diff --git a/docs/api/appointment-settings.md b/docs/api/appointment-settings.md index badaf1e6..5076b4a6 100644 --- a/docs/api/appointment-settings.md +++ b/docs/api/appointment-settings.md @@ -1,10 +1,23 @@ # Appointment Settings API > **Prefix:** `/api/v1/appointment-settings` -> **Permission:** All write endpoints require `AUTH` — must be the doctor owner or `ROLE_ADMIN` +> **Permission:** every endpoint requires `AUTH` and resolves access through one shared rule (below) Doctors configure their availability via three resources: **weekly schedule**, **date overrides**, and **holidays**. +## Access rule + +All 14 endpoints in this file share a single check. Given the target doctor (resolved from the path/body uuid, or from the parent schedule/override/holiday), access is granted when the caller is: + +1. `ROLE_ADMIN`, **or** +2. the doctor themselves, **or** +3. the **owner of a clinic** the doctor belongs to, **or** +4. a **doctor member of that clinic** holding the `appointment_settings` permission — `view` for `GET`, `update` for `POST`/`PATCH`/`DELETE` (see `docs/api/clinic.md` → *Clinic Doctor Permissions*) + +Anything else → `403 ERR_AUTH_006`. This is what lets the clinic panel manage every member doctor's booking settings from `تنظیمات → نوبت‌دهی`, one tab per doctor, using the same endpoints the doctor's own panel calls. + +A doctor's own settings are never affected by clinic permissions — rule 2 short-circuits before any permission lookup. + --- ## Weekly Schedule @@ -29,7 +42,7 @@ Each doctor has **one** weekly schedule (upsert). The schedule is keyed by **day Create or update the weekly schedule for a doctor (upsert). -**Permission:** `AUTH` — must be the doctor owner or `ROLE_ADMIN` +**Permission:** `AUTH` — see [Access rule](#access-rule) > **الزام آدرس:** هر session با `active=true` باید `location_id` (آدرس مطب/کلینیک) داشته باشد. در غیر این صورت `422 ERR_VALIDATION_001` («برای هر شیفت فعال باید آدرس انتخاب شود»). این آدرس هنگام رزرو خودکار روی نوبت ذخیره می‌شود. @@ -190,7 +203,7 @@ Same structure as POST response. Update weekly schedule. `{uuid}` can be schedule UUID or doctor UUID. -**Permission:** `AUTH` — must be the doctor owner or `ROLE_ADMIN` +**Permission:** `AUTH` — see [Access rule](#access-rule) ### Request Body ```json @@ -233,7 +246,7 @@ Updated schedule object (same structure as POST). Delete a weekly schedule. -**Permission:** `AUTH` — must be the doctor owner or `ROLE_ADMIN` +**Permission:** `AUTH` — see [Access rule](#access-rule) > Note: route is `/booking-setting/`, not `/appointment-settings/` @@ -259,7 +272,7 @@ Override a specific date — mark it inactive (day off) or give it custom sessio Get all date overrides for a doctor. -**Permission:** `AUTH` — must be the owning doctor or `ROLE_ADMIN` (`403 ERR_AUTH_006` otherwise). +**Permission:** `AUTH` — see [Access rule](#access-rule) (`403 ERR_AUTH_006` otherwise). ### Response `200` ```json @@ -289,7 +302,7 @@ Get all date overrides for a doctor. Create a date override. -**Permission:** `AUTH` — must be the doctor owner or `ROLE_ADMIN` +**Permission:** `AUTH` — see [Access rule](#access-rule) ### Request Body ```json @@ -380,7 +393,7 @@ Override object (same structure as above). Update a date override. -**Permission:** `AUTH` — must be the doctor owner or `ROLE_ADMIN` +**Permission:** `AUTH` — see [Access rule](#access-rule) ### Request Body (all optional) ```json @@ -419,7 +432,7 @@ Updated override object. Delete a date override. -**Permission:** `AUTH` — must be the doctor owner or `ROLE_ADMIN` +**Permission:** `AUTH` — see [Access rule](#access-rule) ### Response `200` ```json @@ -436,7 +449,7 @@ Mark a date range as holiday — all slots blocked, no overrides apply. Get all holidays for a doctor. -**Permission:** `AUTH` — must be the owning doctor or `ROLE_ADMIN` (`403 ERR_AUTH_006` otherwise). +**Permission:** `AUTH` — see [Access rule](#access-rule) (`403 ERR_AUTH_006` otherwise). ### Response `200` ```json @@ -466,7 +479,7 @@ Get all holidays for a doctor. Create a holiday range. -**Permission:** `AUTH` — must be the doctor owner or `ROLE_ADMIN` +**Permission:** `AUTH` — see [Access rule](#access-rule) ### Request Body ```json @@ -525,7 +538,7 @@ Get a single holiday. Update a holiday. -**Permission:** `AUTH` — must be the doctor owner or `ROLE_ADMIN` +**Permission:** `AUTH` — see [Access rule](#access-rule) ### Request Body (all optional) ```json @@ -546,7 +559,7 @@ Updated holiday object. Delete a holiday. -**Permission:** `AUTH` — must be the doctor owner or `ROLE_ADMIN` +**Permission:** `AUTH` — see [Access rule](#access-rule) ### Response `200` ```json @@ -584,7 +597,7 @@ The `SlotCalculatorService` calculates available slots in this priority order: ### `GET /api/v1/appointment-settings/available-locations/{doctorUuid}` -**Permission:** `AUTH` — must be the owning doctor or `ROLE_ADMIN` (`403 ERR_AUTH_006` otherwise). +**Permission:** `AUTH` — see [Access rule](#access-rule) (`403 ERR_AUTH_006` otherwise). Returns all locations a doctor can assign as `location_id` in their schedule sessions. Includes both the doctor's personal addresses and the addresses of all clinics they belong to. diff --git a/docs/api/insurance.md b/docs/api/insurance.md index 95cf26c8..f1a971df 100644 --- a/docs/api/insurance.md +++ b/docs/api/insurance.md @@ -284,6 +284,13 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR **Permission:** `AUTH` (`ROLE_DOCTOR` یا `ROLE_CLINIC`) +### Query Parameters +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `doctor_uuid` | string (UUID) | ❌ | قیمت‌گذاری همان پزشک را برمی‌گرداند به‌جای موجودیت کاربر جاری. برای تب‌های نوبت‌دهی پنل کلینیک. | + +با `doctor_uuid`، دسترسی این‌گونه بررسی می‌شود: `ROLE_ADMIN`، خودِ پزشک، مالک کلینیکی که پزشک عضو آن است، یا پزشکِ عضو همان کلینیک با مجوز `services.view` (برای `PUT`: `services.update`). در غیر این صورت `403 ERR_ACCESS_DENIED`؛ پزشکِ ناموجود `404 ERR_NOT_FOUND_001`. بدون این پارامتر رفتار قبلی (موجودیت کاربر جاری) دست‌نخورده است. + ### Response `200` ```json { @@ -326,8 +333,11 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR **Permission:** `AUTH` (`ROLE_DOCTOR` یا `ROLE_CLINIC`) ### Request Body +> `doctor_uuid` (اختیاری) در بدنه پذیرفته می‌شود و مثل نسخهٔ `GET` عمل می‌کند — همان قواعد دسترسی، با اکشن `services.update`. + ```json { + "doctor_uuid": "550e8400-e29b-41d4-a716-446655440000", "free_visit_price_rials": 5000000, "require_visit_price": true, "insurances": [ diff --git a/src/Appointment/Controller/AppointmentSettingsController.php b/src/Appointment/Controller/AppointmentSettingsController.php index 0d4c7b87..01a15ffc 100644 --- a/src/Appointment/Controller/AppointmentSettingsController.php +++ b/src/Appointment/Controller/AppointmentSettingsController.php @@ -35,6 +35,7 @@ class AppointmentSettingsController extends BaseController private readonly DoctorAddressRepository $addressRepo, private readonly ClinicRepository $clinicRepo, private readonly \App\ClinicService\Repository\ServiceItemRepository $itemRepo, + private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker, ) {} /** @@ -72,8 +73,8 @@ class AppointmentSettingsController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } - if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { - return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + if (($err = $this->denyDoctorAccess($doctor, $user, 'update')) !== null) { + return $err; } if (($err = $this->validateSessionsHaveLocation($data['schedule'] ?? [])) !== null) { @@ -120,8 +121,8 @@ class AppointmentSettingsController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404); } - if ($schedule->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { - return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + if (($err = $this->denyDoctorAccess($schedule->getDoctor(), $user, 'update')) !== null) { + return $err; } $prevMode = $schedule->getStoredBookingMode(); @@ -162,8 +163,8 @@ class AppointmentSettingsController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404); } - if ($schedule->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { - return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + if (($err = $this->denyDoctorAccess($schedule->getDoctor(), $user, 'view')) !== null) { + return $err; } return $this->success(['data' => $schedule->toArray()]); @@ -177,8 +178,8 @@ class AppointmentSettingsController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404); } - if ($schedule->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { - return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + if (($err = $this->denyDoctorAccess($schedule->getDoctor(), $user, 'update')) !== null) { + return $err; } $this->scheduleRepo->remove($schedule); @@ -196,8 +197,8 @@ class AppointmentSettingsController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } - if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { - return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + if (($err = $this->denyDoctorAccess($doctor, $user, 'view')) !== null) { + return $err; } $overrides = array_map( @@ -220,8 +221,8 @@ class AppointmentSettingsController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } - if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { - return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + if (($err = $this->denyDoctorAccess($doctor, $user, 'update')) !== null) { + return $err; } $timestamp = strtotime($dateStr); @@ -246,8 +247,8 @@ class AppointmentSettingsController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'Override یافت نشد', 404); } - if ($override->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { - return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + if (($err = $this->denyDoctorAccess($override->getDoctor(), $user, 'update')) !== null) { + return $err; } $data = json_decode($request->getContent(), true) ?? []; @@ -272,8 +273,8 @@ class AppointmentSettingsController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'Override یافت نشد', 404); } - if ($override->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { - return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + if (($err = $this->denyDoctorAccess($override->getDoctor(), $user, 'update')) !== null) { + return $err; } $this->overrideRepo->remove($override); @@ -289,8 +290,8 @@ class AppointmentSettingsController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'Override یافت نشد', 404); } - if ($override->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { - return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + if (($err = $this->denyDoctorAccess($override->getDoctor(), $user, 'view')) !== null) { + return $err; } return $this->success(['data' => $override->toArray()]); @@ -306,8 +307,8 @@ class AppointmentSettingsController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } - if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { - return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + if (($err = $this->denyDoctorAccess($doctor, $user, 'view')) !== null) { + return $err; } $items = array_map(fn(Holiday $h) => $h->toArray(), $this->holidayRepo->findAllByDoctor($doctor)); @@ -323,8 +324,8 @@ class AppointmentSettingsController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'تعطیلات یافت نشد', 404); } - if ($holiday->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { - return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + if (($err = $this->denyDoctorAccess($holiday->getDoctor(), $user, 'update')) !== null) { + return $err; } $this->holidayRepo->remove($holiday); @@ -345,8 +346,8 @@ class AppointmentSettingsController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } - if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { - return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + if (($err = $this->denyDoctorAccess($doctor, $user, 'update')) !== null) { + return $err; } $startTs = strtotime($startStr); @@ -372,8 +373,8 @@ class AppointmentSettingsController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'تعطیلات یافت نشد', 404); } - if ($holiday->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { - return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + if (($err = $this->denyDoctorAccess($holiday->getDoctor(), $user, 'update')) !== null) { + return $err; } $data = json_decode($request->getContent(), true) ?? []; @@ -403,8 +404,8 @@ class AppointmentSettingsController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); } - if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { - return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + if (($err = $this->denyDoctorAccess($doctor, $user, 'view')) !== null) { + return $err; } $clinics = $this->clinicRepo->findByDoctor($doctor); @@ -425,6 +426,29 @@ class AppointmentSettingsController extends BaseController return $this->success(['data' => $result]); } + /** + * تنها نقطهٔ تصمیم‌گیری دربارهٔ «چه کسی تنظیمات نوبت‌دهی این پزشک را می‌بیند/می‌نویسد». + * + * مجاز: ادمین، خود پزشک، مالکِ کلینیکی که پزشک عضو آن است، و پزشکِ عضوِ همان + * کلینیک در صورت داشتن مجوز appointment_settings مربوطه. + * + * @param 'view'|'update' $action + */ + private function denyDoctorAccess(\App\Doctor\Entity\Doctor $doctor, User $user, string $action): ?JsonResponse + { + if ($user->hasRole('ROLE_ADMIN') || $doctor->getUser()->getId() === $user->getId()) { + return null; + } + + foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) { + if ($this->permChecker->can($user, $clinic, 'appointment_settings', $action)) { + return null; + } + } + + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + /** * هر session فعال در برنامه‌ی هفتگی باید آدرس (location_id) داشته باشد. * در صورت نقص، پیام خطا برمی‌گرداند؛ در غیر این صورت null. diff --git a/src/Insurance/Controller/InsuranceController.php b/src/Insurance/Controller/InsuranceController.php index 66da8b20..e2d7dd8b 100644 --- a/src/Insurance/Controller/InsuranceController.php +++ b/src/Insurance/Controller/InsuranceController.php @@ -42,9 +42,42 @@ class InsuranceController extends BaseController private readonly TenantInsuranceService $tenantInsuranceService, private readonly ServiceItemRepository $serviceItemRepo, private readonly FileValidatorService $fileValidator, + private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker, private readonly string $projectDir, ) {} + /** + * وقتی doctor_uuid داده شود، قیمت‌گذاری همان پزشک هدف است — برای مدیریت پزشکان + * کلینیک از پنل کلینیک. بدون آن، رفتار قبلی (موجودیتِ خودِ کاربر) حفظ می‌شود. + * + * @param 'view'|'update' $action + * @return array{0: string, 1: int|null, 2: JsonResponse|null} + */ + private function resolveTargetEntity(User $user, ?string $doctorUuid, string $action): array + { + if ($doctorUuid === null || $doctorUuid === '') { + [$type, $id] = $this->resolveEntity($user); + return [$type, $id, null]; + } + + $doctor = $this->doctorRepo->findByUuid($doctorUuid); + if ($doctor === null) { + return ['unknown', null, $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404)]; + } + + if ($user->hasRole('ROLE_ADMIN') || $doctor->getUser()->getId() === $user->getId()) { + return [EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), null]; + } + + foreach ($this->clinicRepo->findByDoctor($doctor) as $clinic) { + if ($this->permChecker->can($user, $clinic, 'services', $action)) { + return [EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), null]; + } + } + + return ['unknown', null, $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403)]; + } + private function resolveEntity(User $user): array { if ($user->hasRole('ROLE_DOCTOR')) { @@ -219,13 +252,21 @@ class InsuranceController extends BaseController #[Route('/api/v1/insurance-pricing', methods: ['GET'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] - public function getInsurancePricing(#[CurrentUser] User $user): JsonResponse + public function getInsurancePricing(Request $request, #[CurrentUser] User $user): JsonResponse { - [$entityType, $entityId] = $this->resolveEntity($user); + [$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $request->query->get('doctor_uuid'), 'view'); + if ($err !== null) { + return $err; + } if ($entityId === null) { return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); } + return $this->success($this->pricingPayload($entityType, $entityId)); + } + + private function pricingPayload(string $entityType, int $entityId): array + { $rows = $this->pricingRepo->findByEntity($entityType, $entityId); $freeVisitPriceRials = 0; @@ -249,26 +290,29 @@ class InsuranceController extends BaseController ]; }, $this->insuranceRepo->findActive(null)); - return $this->success([ + return [ 'entity_type' => $entityType, 'entity_id' => $entityId, 'free_visit_price_rials' => $freeVisitPriceRials, 'require_visit_price' => $requireVisitPrice, 'insurances' => $insurances, - ]); + ]; } #[Route('/api/v1/insurance-pricing', methods: ['PUT'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function saveInsurancePricing(Request $request, #[CurrentUser] User $user): JsonResponse { - [$entityType, $entityId] = $this->resolveEntity($user); + $data = json_decode($request->getContent(), true) ?? []; + + [$entityType, $entityId, $err] = $this->resolveTargetEntity($user, $data['doctor_uuid'] ?? null, 'update'); + if ($err !== null) { + return $err; + } if ($entityId === null) { return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); } - $data = json_decode($request->getContent(), true) ?? []; - $freeVisitRow = $this->pricingRepo->findOneForInsurance($entityType, $entityId, null); $requireVisitPrice = array_key_exists('require_visit_price', $data) @@ -306,7 +350,7 @@ class InsuranceController extends BaseController $this->pricingRepo->getEntityManager()->flush(); - return $this->getInsurancePricing($user); + return $this->success($this->pricingPayload($entityType, $entityId)); } private function upsertPricing(string $entityType, int $entityId, ?int $insuranceId, int $shareRials): EntityInsurancePricing diff --git a/tests/Appointment/ClinicOwnerScheduleAccessTest.php b/tests/Appointment/ClinicOwnerScheduleAccessTest.php new file mode 100644 index 00000000..9f91be45 --- /dev/null +++ b/tests/Appointment/ClinicOwnerScheduleAccessTest.php @@ -0,0 +1,177 @@ +createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = new Doctor($user, $name); + $doctor->setMobileNumber($user->getMobileNumber()); + $this->em->persist($doctor); + $this->em->flush(); + + return $doctor; + } + + private function makeClinicWith(Doctor ...$doctors): array + { + $owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); + $clinic = new Clinic($owner); + $clinic->setName('کلینیک تست'); + foreach ($doctors as $d) { + $clinic->getDoctors()->add($d); + } + $this->em->persist($clinic); + $this->em->flush(); + + return [$owner, $clinic]; + } + + private function addressFor(Doctor $doctor): DoctorAddress + { + $address = DoctorAddress::forDoctor($doctor); + $this->em->persist($address); + $this->em->flush(); + + return $address; + } + + private function schedulePayload(Doctor $doctor, int $locationId, string $start): array + { + return [ + 'doctor_uuid' => $doctor->getUuid(), + 'schedule' => [ + ['day' => 'saturday', 'sessions' => [ + ['active' => true, 'location_id' => $locationId, 'start' => $start, 'end' => '12:00'], + ]], + ], + ]; + } + + public function testClinicOwnerCanReadAndWriteMemberDoctorSchedule(): void + { + $doctor = $this->makeDoctor('دکتر عضو'); + [$owner] = $this->makeClinicWith($doctor); + $address = $this->addressFor($doctor); + + $this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($doctor, $address->getId(), '09:00')); + self::assertSame(201, $this->responseCode()); + + $this->authJson('GET', "/api/v1/appointment-settings/weekly-schedule/{$doctor->getUuid()}", $owner); + self::assertSame(200, $this->responseCode()); + + $this->authJson('PATCH', "/api/v1/appointment-settings/weekly-schedule/{$doctor->getUuid()}", $owner, [ + 'schedule' => [ + ['day' => 'saturday', 'sessions' => [ + ['active' => true, 'location_id' => $address->getId(), 'start' => '10:00', 'end' => '13:00'], + ]], + ], + ]); + self::assertSame(200, $this->responseCode()); + } + + public function testClinicOwnerCannotTouchOutsideDoctor(): void + { + $member = $this->makeDoctor('دکتر عضو'); + [$owner] = $this->makeClinicWith($member); + $stranger = $this->makeDoctor('دکتر بیرونی'); + $address = $this->addressFor($stranger); + + $this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($stranger, $address->getId(), '09:00')); + + self::assertSame(403, $this->responseCode()); + } + + public function testDoctorKeepsFullAccessToOwnSchedule(): void + { + $doctor = $this->makeDoctor('دکتر مستقل'); + $address = $this->addressFor($doctor); + + $this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $doctor->getUser(), $this->schedulePayload($doctor, $address->getId(), '09:00')); + self::assertSame(201, $this->responseCode()); + + $this->authJson('GET', "/api/v1/appointment-settings/weekly-schedule/{$doctor->getUuid()}", $doctor->getUser()); + self::assertSame(200, $this->responseCode()); + } + + public function testDoctorCannotTouchAnotherDoctorSchedule(): void + { + $mine = $this->makeDoctor('دکتر یک'); + $theirs = $this->makeDoctor('دکتر دو'); + $address = $this->addressFor($theirs); + + $this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $mine->getUser(), $this->schedulePayload($theirs, $address->getId(), '09:00')); + + self::assertSame(403, $this->responseCode()); + } + + public function testAdminCanWriteAnyDoctorSchedule(): void + { + $doctor = $this->makeDoctor('دکتر هدف'); + $admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']); + $address = $this->addressFor($doctor); + + $this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $admin, $this->schedulePayload($doctor, $address->getId(), '09:00')); + + self::assertSame(201, $this->responseCode()); + } + + public function testMemberDoctorLosesAccessWhenPermissionRevoked(): void + { + $doctor = $this->makeDoctor('دکتر عضو'); + $other = $this->makeDoctor('دکتر دیگر'); + [, $clinic] = $this->makeClinicWith($doctor, $other); + $address = $this->addressFor($other); + + $perm = static::getContainer()->get(ClinicDoctorPermissionRepository::class)->getOrCreate($clinic, $doctor); + $perm->mergePermissions(['resources' => ['appointment_settings' => ['update' => false, 'view' => false]]]); + $this->em->flush(); + + // پزشک همچنان به برنامهٔ خودش دسترسی دارد؛ مجوز کلینیک فقط دیگران را محدود می‌کند + $this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $doctor->getUser(), $this->schedulePayload($other, $address->getId(), '09:00')); + + self::assertSame(403, $this->responseCode()); + } + + public function testEditingOneDoctorDoesNotAffectAnother(): void + { + $first = $this->makeDoctor('دکتر اول'); + $second = $this->makeDoctor('دکتر دوم'); + [$owner] = $this->makeClinicWith($first, $second); + $addrFirst = $this->addressFor($first); + $addrSecond = $this->addressFor($second); + + $this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($first, $addrFirst->getId(), '08:00')); + $this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($second, $addrSecond->getId(), '16:00')); + + $this->authJson('PATCH', "/api/v1/appointment-settings/weekly-schedule/{$first->getUuid()}", $owner, [ + 'schedule' => [ + ['day' => 'saturday', 'sessions' => [ + ['active' => true, 'location_id' => $addrFirst->getId(), 'start' => '11:00', 'end' => '15:00'], + ]], + ], + ]); + self::assertSame(200, $this->responseCode()); + + $this->em->clear(); + $reloaded = $this->em->getRepository(WeeklySchedule::class)->findOneBy([ + 'doctor' => $this->em->getRepository(Doctor::class)->find($second->getId()), + ]); + + self::assertSame('16:00', $reloaded->getSetting()[0]['sessions'][0]['start'], "the other doctor's schedule is untouched"); + } +}