From d489702751be412c6f482733fa7dad171c3d63d3 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Wed, 10 Jun 2026 17:41:04 +0330 Subject: [PATCH] feat: add holiday management endpoints and jalaali-js dependency - Added a new endpoint to list holidays for a specific doctor. - Added a new endpoint to delete a holiday, with access control for doctors and admins. - Implemented a repository method to retrieve all holidays for a doctor. - Added jalaali-js library to handle date conversions. --- assets/admin/pages/DoctorDetailPage.tsx | 741 +++++++++++++++++- package-lock.json | 7 + package.json | 1 + .../AppointmentSettingsController.php | 30 + .../Repository/HolidayRepository.php | 11 + yarn.lock | 5 + 6 files changed, 775 insertions(+), 20 deletions(-) diff --git a/assets/admin/pages/DoctorDetailPage.tsx b/assets/admin/pages/DoctorDetailPage.tsx index 266f0b01..70017a3f 100644 --- a/assets/admin/pages/DoctorDetailPage.tsx +++ b/assets/admin/pages/DoctorDetailPage.tsx @@ -10,7 +10,7 @@ import { XCircleIcon, PhoneIcon, CalendarIcon, ClipboardDocumentIcon, EllipsisVerticalIcon, StarIcon, BuildingOfficeIcon, MapPinIcon, AcademicCapIcon, UserIcon, - PlusIcon, CameraIcon, ChevronDownIcon, ChevronRightIcon, + PlusIcon, CameraIcon, ChevronDownIcon, ChevronRightIcon, ChevronLeftIcon, CheckCircleIcon, XMarkIcon, } from '@heroicons/react/24/outline'; import { StarIcon as StarSolid } from '@heroicons/react/24/solid'; @@ -18,7 +18,7 @@ import { toast } from 'sonner'; import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; -import { api } from '../lib/api'; +import { api, ApiError } from '../lib/api'; import { useAuthStore } from '../stores/authStore'; import type { ApiResponse } from '../lib/api'; import { formatNumber } from '../lib/utils'; @@ -63,6 +63,15 @@ interface AddressData { province: { id: string; name: string } | null; } +// ── Schedule types ───────────────────────────────────────────────────────── + +interface SlotConfig { start: string; end: string; duration: number; } +interface DayConfig { active: boolean; slots: SlotConfig[]; } +type ScheduleMap = Record; +interface WeeklyScheduleData { uuid: string; doctor_uuid: string; schedule: ScheduleMap; } +interface DateOverrideData { uuid: string; doctor_uuid: string; date: number; active: boolean; reason: string | null; custom_slots: SlotConfig[]; } +interface HolidayData { uuid: string; doctor_uuid: string; start_date: number; end_date: number; active: boolean; reason: string | null; } + // ── Constants ────────────────────────────────────────────────────────────── const DEGREE_LABELS: Record = { @@ -90,6 +99,175 @@ 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: 'saturday', label: 'شنبه' }, + { key: 'sunday', label: 'یکشنبه' }, + { key: 'monday', label: 'دوشنبه' }, + { key: 'tuesday', label: 'سه‌شنبه' }, + { key: 'wednesday', label: 'چهارشنبه' }, + { key: 'thursday', label: 'پنجشنبه' }, + { key: 'friday', label: 'جمعه' }, +]; +const DURATION_OPTS = [15, 20, 30, 45, 60]; +const EMPTY_SCHEDULE: ScheduleMap = Object.fromEntries( + SCHEDULE_DAYS.map(d => [d.key, { active: false, slots: [] }]) +); +function tsToDate(ts: number): string { + return new Date(ts * 1000).toISOString().slice(0, 10); +} + // ── Image upload ─────────────────────────────────────────────────────────── async function uploadDoctorImage(file: File): Promise { @@ -736,6 +914,501 @@ function AddressCard({ addr, onEdit, onDelete }: { ); } +// ── Schedule components ──────────────────────────────────────────────────── + +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" /> + + +
+ ))} + +
+ ); +} + +// ── Weekly Schedule Tab ──────────────────────────────────────────────────── + +function WeeklyScheduleTab({ doctorUuid }: { doctorUuid: string }) { + const qc = useQueryClient(); + const [scheduleMap, setScheduleMap] = useState(EMPTY_SCHEDULE); + const [scheduleUuid, setScheduleUuid] = useState(null); + + 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) { setScheduleMap({ ...EMPTY_SCHEDULE, ...d.schedule }); setScheduleUuid(d.uuid); } + } else if (scheduleQ.error instanceof ApiError && scheduleQ.error.status === 404) { + setScheduleMap(EMPTY_SCHEDULE); setScheduleUuid(null); + } + }, [scheduleQ.data, scheduleQ.error]); + + const saveMut = useMutation({ + mutationFn: () => scheduleUuid + ? api.patch>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap }) + : api.post>('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap }), + onSuccess: (res) => { + const d: WeeklyScheduleData = res?.data?.data ?? res?.data; + if (d?.uuid && !scheduleUuid) setScheduleUuid(d.uuid); + toast.success('برنامه هفتگی ذخیره شد'); + qc.invalidateQueries({ queryKey: ['doctor-schedule', doctorUuid] }); + }, + onError: (e: Error) => toast.error(e.message), + }); + + const setDay = (key: string, patch: Partial) => + setScheduleMap(prev => ({ ...prev, [key]: { ...prev[key], ...patch } })); + + if (scheduleQ.isLoading) return ( +
{Array.from({ length: 4 }).map((_, i) =>
)}
+ ); + + return ( +
+ {SCHEDULE_DAYS.map(day => { + const cfg = scheduleMap[day.key] ?? { active: false, slots: [] }; + const totalSlots = cfg.slots.reduce((acc, s) => { + const [sh, sm] = s.start.split(':').map(Number); + const [eh, em] = s.end.split(':').map(Number); + const mins = (eh * 60 + em) - (sh * 60 + sm); + return acc + Math.floor(mins / Math.max(s.duration, 1)); + }, 0); + return ( +
+
+ + {day.label} + {!cfg.active && تعطیل} + {cfg.active && cfg.slots.length > 0 && ( + + {cfg.slots.length} بازه · {totalSlots} نوبت + + )} + {cfg.active && cfg.slots.length === 0 && ( + هنوز بازه‌ای تنظیم نشده + )} +
+ {cfg.active && ( +
+ setDay(day.key, { slots })} /> +
+ )} +
+ ); + })} +
+ +
+
+ ); +} + +// ── Date Override Modal ──────────────────────────────────────────────────── + +function DateOverrideModal({ open, onClose, existing, doctorUuid, onSaved }: { + open: boolean; onClose: () => void; + existing: DateOverrideData | null; doctorUuid: string; + onSaved: () => void; +}) { + const [dateStr, setDateStr] = useState(''); + const [active, setActive] = useState(false); + const [reason, setReason] = useState(''); + const [slots, setSlots] = useState([]); + + useEffect(() => { + if (open) { + if (existing) { + setDateStr(tsToDate(existing.date)); + setActive(existing.active); + setReason(existing.reason ?? ''); + setSlots(existing.custom_slots ?? []); + } else { + setDateStr(new Date().toISOString().slice(0, 10)); + setActive(false); setReason(''); setSlots([]); + } + } + }, [open, existing]); + + const saveMut = useMutation({ + mutationFn: () => { + 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 ( + + + + + } + > +
+
+ + +
+
+ + + {active ? 'این روز فعال است (نوبت‌دهی می‌شود)' : 'این روز تعطیل است (بدون نوبت)'} + +
+
+ + setReason(e.target.value)} + placeholder="مثال: شیفت اضطراری، جلسه..." className="cp-input" /> +
+ {active ? ( +
+ +

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

+ +
+ ) : ( +
+

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

+
+ )} +
+
+ ); +} + +// ── Date Overrides Tab ───────────────────────────────────────────────────── + +function DateOverridesTab({ doctorUuid }: { doctorUuid: string }) { + 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) =>
)}
+ ); + + return ( +
+
+ +
+ {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}

} +
+
+ + +
+
+ ))} +
+ )} + { setModalOpen(false); setEditing(null); }} + existing={editing} doctorUuid={doctorUuid} + 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="cp-input" /> +
+
+
+ ); +} + +// ── Holidays Tab ─────────────────────────────────────────────────────────── + +function HolidaysTab({ doctorUuid }: { doctorUuid: string }) { + 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 ( +
+
+ +
+ {holidays.length === 0 ? ( +
+ +

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

+
+ ) : ( +
+ {holidays.map(h => { + 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))}`} + + + {h.active ? 'فعال' : 'غیرفعال'} + +
+ {h.reason &&

{h.reason}

} +
+
+ + + +
+
+ ); + })} +
+ )} + { 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: 'تعطیلات' }, +]; + +function ScheduleSection({ doctorUuid }: { doctorUuid: string }) { + const [tab, setTab] = useState<'weekly' | 'overrides' | 'holidays'>('weekly'); + return ( +
+

برنامه کاری

+
+ {SCHEDULE_TABS.map(t => ( + + ))} +
+ {tab === 'weekly' && } + {tab === 'overrides' && } + {tab === 'holidays' && } +
+ ); +} + // ── Edit schema ──────────────────────────────────────────────────────────── const editSchema = z.object({ @@ -758,13 +1431,14 @@ export default function DoctorDetailPage() { const [searchParams] = useSearchParams(); const qc = useQueryClient(); - const [editOpen, setEditOpen] = useState(searchParams.get('edit') === '1'); - const [deleteOpen, setDeleteOpen] = useState(false); - const [menuOpen, setMenuOpen] = useState(false); - const [uploadingImg, setUploadingImg] = useState(false); - const [addrModalOpen, setAddrModalOpen] = useState(false); - const [editingAddr, setEditingAddr] = useState(null); - const [deletingAddrId, setDeletingAddrId] = useState(null); + const [editOpen, setEditOpen] = useState(searchParams.get('edit') === '1'); + const [deleteOpen, setDeleteOpen] = useState(false); + const [toggleConfirm, setToggleConfirm] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + const [uploadingImg, setUploadingImg] = useState(false); + const [addrModalOpen, setAddrModalOpen] = useState(false); + const [editingAddr, setEditingAddr] = useState(null); + const [deletingAddrId, setDeletingAddrId] = useState(null); // ── Queries ── @@ -995,26 +1669,37 @@ export default function DoctorDetailPage() { )}
-
+
+ + {/* Toggle active button — styled by current state */} + +
{menuOpen && ( -
- -
+
+ {uuid && } + {doctor.clinics && doctor.clinics.length > 0 && (

@@ -1270,6 +1957,20 @@ export default function DoctorDetailPage() { onConfirm={() => deletingAddrId && deleteAddrMut.mutate(deletingAddrId)} onCancel={() => setDeletingAddrId(null)} /> + { toggleMut.mutate(); setToggleConfirm(false); }} + onCancel={() => setToggleConfirm(false)} + /> =0.10.0" } }, + "node_modules/jalaali-js": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/jalaali-js/-/jalaali-js-1.2.8.tgz", + "integrity": "sha512-Jl/EwY84JwjW2wsWqeU4pNd22VNQ7EkjI36bDuLw31wH98WQW4fPjD0+mG7cdCK+Y8D6s9R3zLiQ3LaKu6bD8A==", + "license": "MIT" + }, "node_modules/jest-regex-util": { "version": "30.4.0", "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", diff --git a/package.json b/package.json index 20637603..8632392a 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@tanstack/react-query": "^5.0.0", "@tanstack/react-table": "^8.0.0", "@types/leaflet": "^1.9.21", + "jalaali-js": "^1.2.8", "leaflet": "^1.9.4", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/src/Appointment/Controller/AppointmentSettingsController.php b/src/Appointment/Controller/AppointmentSettingsController.php index c6a3b2fd..6e763f3d 100644 --- a/src/Appointment/Controller/AppointmentSettingsController.php +++ b/src/Appointment/Controller/AppointmentSettingsController.php @@ -223,6 +223,36 @@ class AppointmentSettingsController extends BaseController // ── Holidays ────────────────────────────────────────────────────────────── + #[Route('/api/v1/appointment-settings/holidays/list/{doctorUuid}', methods: ['GET'])] + public function listHolidays(string $doctorUuid): JsonResponse + { + $doctor = $this->doctorRepo->findByUuid($doctorUuid); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); + } + + $items = array_map(fn(Holiday $h) => $h->toArray(), $this->holidayRepo->findAllByDoctor($doctor)); + + return $this->success(['data' => $items]); + } + + #[Route('/api/v1/appointment-settings/holidays/{uuid}', methods: ['DELETE'])] + public function deleteHoliday(string $uuid, #[CurrentUser] User $user): JsonResponse + { + $holiday = $this->holidayRepo->findByUuid($uuid); + if ($holiday === null) { + 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); + } + + $this->holidayRepo->remove($holiday); + + return $this->success(['message' => 'تعطیلات حذف شد']); + } + #[Route('/api/v1/appointment-settings/holidays', methods: ['POST'])] public function createHoliday(Request $request, #[CurrentUser] User $user): JsonResponse { diff --git a/src/Appointment/Repository/HolidayRepository.php b/src/Appointment/Repository/HolidayRepository.php index aa10349f..f27222bb 100644 --- a/src/Appointment/Repository/HolidayRepository.php +++ b/src/Appointment/Repository/HolidayRepository.php @@ -19,6 +19,17 @@ class HolidayRepository extends ServiceEntityRepository return $this->findOneBy(['uuid' => $uuid]); } + /** @return Holiday[] */ + public function findAllByDoctor(Doctor $doctor): array + { + return $this->createQueryBuilder('h') + ->where('h.doctor = :doctor') + ->setParameter('doctor', $doctor) + ->orderBy('h.startDate', 'DESC') + ->getQuery() + ->getResult(); + } + /** @return Holiday[] */ public function findActiveByDoctor(Doctor $doctor, int $from, int $to): array { diff --git a/yarn.lock b/yarn.lock index a2ec4de4..0e3ba861 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2833,6 +2833,11 @@ isobject@^3.0.1: resolved "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz" integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg== +jalaali-js@^1.2.8: + version "1.2.8" + resolved "https://registry.npmjs.org/jalaali-js/-/jalaali-js-1.2.8.tgz" + integrity sha512-Jl/EwY84JwjW2wsWqeU4pNd22VNQ7EkjI36bDuLw31wH98WQW4fPjD0+mG7cdCK+Y8D6s9R3zLiQ3LaKu6bD8A== + jest-regex-util@30.4.0: version "30.4.0" resolved "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz"