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.
This commit is contained in:
hamed
2026-06-10 17:41:04 +03:30
parent a5876b9324
commit d489702751
6 changed files with 775 additions and 20 deletions
+721 -20
View File
@@ -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<string, DayConfig>;
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<string, string> = {
@@ -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<DOMRect | null>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const dropRef = useRef<HTMLDivElement>(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 (
<div>
<button ref={btnRef} type="button" onClick={openPicker}
className="cp-input h-11 flex items-center justify-between w-full cursor-pointer">
<span className={value ? 'text-slate-800 dark:text-slate-200' : 'text-slate-400 dark:text-slate-500'}>
{value ? formatPersianDate(value) : (placeholder ?? 'انتخاب تاریخ')}
</span>
<CalendarIcon className="w-4 h-4 text-slate-400 shrink-0 mr-2" />
</button>
{open && createPortal(
<div ref={dropRef} style={dropStyle}
className="bg-white dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-2xl shadow-2xl overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100 dark:border-gray-800">
<button type="button" onClick={nextMonth}
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 dark:hover:bg-gray-800 text-slate-500 transition-colors">
<ChevronRightIcon className="w-4 h-4" />
</button>
<span className="text-sm font-semibold text-slate-800 dark:text-slate-100" dir="rtl">
{viewJm > 0 ? JALALI_MONTHS[viewJm - 1] : ''} {toPersianNums(viewJy)}
</span>
<button type="button" onClick={prevMonth}
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 dark:hover:bg-gray-800 text-slate-500 transition-colors">
<ChevronLeftIcon className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-7 px-3 pt-2 pb-1">
{JALALI_DAYS_SHORT.map(d => (
<div key={d} className="text-center text-xs font-medium text-slate-400 dark:text-slate-500 py-1">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-0.5 px-3 pb-3">
{Array.from({ length: firstDow }).map((_, i) => <div key={`e-${i}`} />)}
{Array.from({ length: daysInMonth }).map((_, i) => {
const jd = i + 1;
const sel = isSelected(jd);
const tod = isToday(jd);
const dis = isDisabled(jd);
return (
<button key={jd} type="button" disabled={dis} onClick={() => selectDay(jd)}
className={`w-full aspect-square flex items-center justify-center rounded-lg text-sm transition-colors
${dis ? 'text-slate-300 dark:text-gray-600 cursor-not-allowed'
: sel ? 'bg-primary-500 text-white font-semibold shadow-sm'
: tod ? 'ring-1 ring-primary-400 text-primary-700 dark:text-primary-300 font-medium'
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-gray-800'}`}>
{toPersianNums(jd)}
</button>
);
})}
</div>
</div>,
document.body
)}
</div>
);
}
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<ImageFileData> {
@@ -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 (
<div className="space-y-2">
{slots.map((slot, i) => (
<div key={i} className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-slate-500 dark:text-slate-400 shrink-0">از</span>
<input type="time" value={slot.start} onChange={e => update(i, 'start', e.target.value)}
className="cp-input h-8 w-28 text-sm font-mono text-center px-2" />
<span className="text-xs text-slate-500 dark:text-slate-400 shrink-0">تا</span>
<input type="time" value={slot.end} onChange={e => update(i, 'end', e.target.value)}
className="cp-input h-8 w-28 text-sm font-mono text-center px-2" />
<select value={slot.duration} onChange={e => update(i, 'duration', Number(e.target.value))}
className="cp-select h-8 text-sm w-24">
{DURATION_OPTS.map(d => <option key={d} value={d}>{d} دقیقه</option>)}
</select>
<button type="button" onClick={() => remove(i)}
className="w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors shrink-0">
<XMarkIcon className="w-3.5 h-3.5" />
</button>
</div>
))}
<button type="button" onClick={add}
className="flex items-center gap-1.5 text-xs text-primary-600 dark:text-primary-400 hover:underline mt-1">
<PlusIcon className="w-3.5 h-3.5" />افزودن بازه زمانی
</button>
</div>
);
}
// ── Weekly Schedule Tab ────────────────────────────────────────────────────
function WeeklyScheduleTab({ doctorUuid }: { doctorUuid: string }) {
const qc = useQueryClient();
const [scheduleMap, setScheduleMap] = useState<ScheduleMap>(EMPTY_SCHEDULE);
const [scheduleUuid, setScheduleUuid] = useState<string | null>(null);
const scheduleQ = useQuery({
queryKey: ['doctor-schedule', doctorUuid],
queryFn: () => api.get<ApiResponse<any>>(`/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<ApiResponse<any>>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap })
: api.post<ApiResponse<any>>('/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<DayConfig>) =>
setScheduleMap(prev => ({ ...prev, [key]: { ...prev[key], ...patch } }));
if (scheduleQ.isLoading) return (
<div className="space-y-3">{Array.from({ length: 4 }).map((_, i) => <div key={i} className="h-12 rounded-xl skeleton" />)}</div>
);
return (
<div className="space-y-2">
{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 (
<div key={day.key} className={`rounded-xl border transition-colors ${cfg.active ? 'border-primary-200 dark:border-primary-500/30 bg-primary-50/30 dark:bg-primary-500/5' : 'border-slate-200 dark:border-gray-700 bg-slate-50/60 dark:bg-gray-800/40'}`}>
<div className="flex items-center gap-3 px-4 py-3">
<button type="button"
onClick={() => setDay(day.key, { active: !cfg.active })}
className={`relative w-10 h-5 rounded-full transition-colors shrink-0 focus:outline-none ${cfg.active ? 'bg-primary-500' : 'bg-slate-300 dark:bg-gray-600'}`}>
<span className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-transform duration-150 ${cfg.active ? 'translate-x-5' : 'translate-x-0.5'}`} />
</button>
<span className={`text-sm font-medium w-20 shrink-0 ${cfg.active ? 'text-primary-700 dark:text-primary-300' : 'text-slate-500 dark:text-slate-400'}`}>{day.label}</span>
{!cfg.active && <span className="text-xs text-slate-400 dark:text-slate-500">تعطیل</span>}
{cfg.active && cfg.slots.length > 0 && (
<span className="text-xs text-slate-500 dark:text-slate-400">
{cfg.slots.length} بازه · {totalSlots} نوبت
</span>
)}
{cfg.active && cfg.slots.length === 0 && (
<span className="text-xs text-amber-500 dark:text-amber-400">هنوز بازهای تنظیم نشده</span>
)}
</div>
{cfg.active && (
<div className="border-t border-primary-100 dark:border-primary-500/20 px-4 pb-4 pt-3">
<SlotEditor slots={cfg.slots} onChange={slots => setDay(day.key, { slots })} />
</div>
)}
</div>
);
})}
<div className="flex justify-end pt-2">
<button type="button" onClick={() => saveMut.mutate()} disabled={saveMut.isPending}
className="cp-btn-primary">
{saveMut.isPending
? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />در حال ذخیره...</>
: 'ذخیره برنامه هفتگی'
}
</button>
</div>
</div>
);
}
// ── 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<SlotConfig[]>([]);
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<ApiResponse<any>>(`/api/v1/appointment-settings/date-override/${existing.uuid}`, body);
return api.post<ApiResponse<any>>('/api/v1/appointment-settings/date-override', { ...body, doctor_uuid: doctorUuid });
},
onSuccess: () => { toast.success(existing ? 'ویرایش شد' : 'تاریخ خاص اضافه شد'); onSaved(); onClose(); },
onError: (e: Error) => toast.error(e.message),
});
return (
<Modal open={open} title={existing ? 'ویرایش تاریخ خاص' : 'افزودن تاریخ خاص'} size="md" onClose={onClose}
footer={
<>
<button type="button" onClick={onClose} className="cp-btn-secondary px-5">لغو</button>
<button type="button" onClick={() => saveMut.mutate()} disabled={saveMut.isPending || !dateStr}
className="cp-btn-primary">
{saveMut.isPending ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</>
}
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">تاریخ</label>
<PersianDateInput value={dateStr} onChange={setDateStr} placeholder="انتخاب تاریخ" />
</div>
<div className="flex items-center gap-3">
<button type="button" onClick={() => setActive(v => !v)}
className={`relative w-10 h-5 rounded-full transition-colors shrink-0 focus:outline-none ${active ? 'bg-emerald-500' : 'bg-slate-300 dark:bg-gray-600'}`}>
<span className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-transform duration-150 ${active ? 'translate-x-5' : 'translate-x-0.5'}`} />
</button>
<span className="text-sm text-slate-700 dark:text-slate-300">
{active ? 'این روز فعال است (نوبت‌دهی می‌شود)' : 'این روز تعطیل است (بدون نوبت)'}
</span>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">دلیل (اختیاری)</label>
<input type="text" value={reason} onChange={e => setReason(e.target.value)}
placeholder="مثال: شیفت اضطراری، جلسه..." className="cp-input" />
</div>
{active ? (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">بازههای زمانی سفارشی</label>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-2">اگر خالی بماند از برنامه هفتگی استفاده میشود</p>
<SlotEditor slots={slots} onChange={setSlots} />
</div>
) : (
<div className="p-3 rounded-xl bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30">
<p className="text-xs text-amber-700 dark:text-amber-400">
در این روز هیچ نوبتی داده نخواهد شد، حتی اگر در برنامه هفتگی فعال باشد.
</p>
</div>
)}
</div>
</Modal>
);
}
// ── Date Overrides Tab ─────────────────────────────────────────────────────
function DateOverridesTab({ doctorUuid }: { doctorUuid: string }) {
const qc = useQueryClient();
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<DateOverrideData | null>(null);
const [deletingUuid, setDeletingUuid] = useState<string | null>(null);
const listQ = useQuery({
queryKey: ['doctor-overrides', doctorUuid],
queryFn: () => api.get<ApiResponse<any>>(`/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<ApiResponse<any>>(`/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 (
<div className="space-y-2">{Array.from({ length: 3 }).map((_, i) => <div key={i} className="h-14 rounded-xl skeleton" />)}</div>
);
return (
<div className="space-y-3">
<div className="flex justify-end">
<button type="button" onClick={() => { setEditing(null); setModalOpen(true); }}
className="flex items-center gap-1.5 text-sm cp-btn-primary">
<PlusIcon className="w-4 h-4" />تاریخ خاص جدید
</button>
</div>
{overrides.length === 0 ? (
<div className="text-center py-10 text-slate-400 dark:text-slate-500">
<CalendarIcon className="w-10 h-10 mx-auto mb-2 opacity-30" />
<p className="text-sm">هیچ تاریخ خاصی تنظیم نشده</p>
</div>
) : (
<div className="space-y-2">
{overrides.map(ov => (
<div key={ov.uuid} className="flex items-center gap-3 p-3.5 rounded-xl border border-slate-200 dark:border-gray-700 bg-slate-50 dark:bg-gray-800/40 group">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-slate-800 dark:text-slate-200">{formatPersianDate(tsToDate(ov.date))}</span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${ov.active ? 'bg-emerald-100 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-300' : 'bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400'}`}>
{ov.active ? 'فعال' : 'تعطیل'}
</span>
{ov.custom_slots.length > 0 && (
<span className="text-xs text-slate-400 dark:text-slate-500">{ov.custom_slots.length} بازه سفارشی</span>
)}
</div>
{ov.reason && <p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{ov.reason}</p>}
</div>
<div className="opacity-0 group-hover:opacity-100 flex items-center gap-1 transition-opacity shrink-0">
<button onClick={() => { setEditing(ov); setModalOpen(true); }}
className="w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-500/10 transition-colors">
<PencilIcon className="w-3.5 h-3.5" />
</button>
<button onClick={() => setDeletingUuid(ov.uuid)}
className="w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors">
<TrashIcon className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
)}
<DateOverrideModal open={modalOpen} onClose={() => { setModalOpen(false); setEditing(null); }}
existing={editing} doctorUuid={doctorUuid}
onSaved={() => qc.invalidateQueries({ queryKey: ['doctor-overrides', doctorUuid] })} />
<ConfirmDialog open={!!deletingUuid} title="حذف تاریخ خاص" message="آیا از حذف این تاریخ خاص اطمینان دارید؟"
confirmLabel="بله، حذف کن" danger loading={deleteMut.isPending}
onConfirm={() => deletingUuid && deleteMut.mutate(deletingUuid)} onCancel={() => setDeletingUuid(null)} />
</div>
);
}
// ── 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<ApiResponse<any>>(`/api/v1/appointment-settings/holidays/${existing.uuid}`, body);
return api.post<ApiResponse<any>>('/api/v1/appointment-settings/holidays', { ...body, doctor_uuid: doctorUuid });
},
onSuccess: () => { toast.success(existing ? 'تعطیلات ویرایش شد' : 'تعطیلات اضافه شد'); onSaved(); onClose(); },
onError: (e: Error) => toast.error(e.message),
});
return (
<Modal open={open} title={existing ? 'ویرایش تعطیلات' : 'افزودن تعطیلات'} size="sm" onClose={onClose}
footer={
<>
<button type="button" onClick={onClose} className="cp-btn-secondary px-5">لغو</button>
<button type="button" onClick={() => saveMut.mutate()} disabled={saveMut.isPending || invalid}
className="cp-btn-primary">
{saveMut.isPending ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</>
}
>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">از تاریخ</label>
<PersianDateInput value={startDate} onChange={v => { setStartDate(v); if (endDate && v > endDate) setEndDate(v); }} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">تا تاریخ</label>
<PersianDateInput value={endDate} onChange={setEndDate} minDate={startDate} />
</div>
</div>
{endDate && startDate && endDate < startDate && (
<p className="text-xs text-red-500">تاریخ پایان نمیتواند قبل از تاریخ شروع باشد</p>
)}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">دلیل (اختیاری)</label>
<input type="text" value={reason} onChange={e => setReason(e.target.value)}
placeholder="مثال: سفر، کنگره پزشکی..." className="cp-input" />
</div>
</div>
</Modal>
);
}
// ── Holidays Tab ───────────────────────────────────────────────────────────
function HolidaysTab({ doctorUuid }: { doctorUuid: string }) {
const qc = useQueryClient();
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<HolidayData | null>(null);
const [deletingUuid, setDeletingUuid] = useState<string | null>(null);
const listQ = useQuery({
queryKey: ['doctor-holidays', doctorUuid],
queryFn: () => api.get<ApiResponse<any>>(`/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<ApiResponse<any>>(`/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<ApiResponse<any>>(`/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 (
<div className="space-y-2">{Array.from({ length: 3 }).map((_, i) => <div key={i} className="h-14 rounded-xl skeleton" />)}</div>
);
return (
<div className="space-y-3">
<div className="flex justify-end">
<button type="button" onClick={() => { setEditing(null); setModalOpen(true); }}
className="flex items-center gap-1.5 text-sm cp-btn-primary">
<PlusIcon className="w-4 h-4" />تعطیلات جدید
</button>
</div>
{holidays.length === 0 ? (
<div className="text-center py-10 text-slate-400 dark:text-slate-500">
<CalendarIcon className="w-10 h-10 mx-auto mb-2 opacity-30" />
<p className="text-sm">هیچ تعطیلاتی ثبت نشده</p>
</div>
) : (
<div className="space-y-2">
{holidays.map(h => {
const sameDay = tsToDate(h.start_date) === tsToDate(h.end_date);
return (
<div key={h.uuid} className="flex items-center gap-3 p-3.5 rounded-xl border border-slate-200 dark:border-gray-700 bg-slate-50 dark:bg-gray-800/40 group">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-slate-800 dark:text-slate-200">
{sameDay ? formatPersianDate(tsToDate(h.start_date)) : `${formatPersianDate(tsToDate(h.start_date))} تا ${formatPersianDate(tsToDate(h.end_date))}`}
</span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${h.active ? 'bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400' : 'bg-slate-100 dark:bg-gray-700 text-slate-500 dark:text-slate-400'}`}>
{h.active ? 'فعال' : 'غیرفعال'}
</span>
</div>
{h.reason && <p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{h.reason}</p>}
</div>
<div className="opacity-0 group-hover:opacity-100 flex items-center gap-1.5 transition-opacity shrink-0">
<button onClick={() => toggleMut.mutate(h)} disabled={toggleMut.isPending}
className="text-xs px-2.5 h-7 rounded-lg border border-slate-200 dark:border-gray-600 text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-100 transition-colors disabled:opacity-50">
{h.active ? 'غیرفعال کن' : 'فعال کن'}
</button>
<button onClick={() => { setEditing(h); setModalOpen(true); }}
className="w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-500/10 transition-colors">
<PencilIcon className="w-3.5 h-3.5" />
</button>
<button onClick={() => setDeletingUuid(h.uuid)}
className="w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors">
<TrashIcon className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
</div>
)}
<HolidayModal open={modalOpen} onClose={() => { setModalOpen(false); setEditing(null); }}
existing={editing} doctorUuid={doctorUuid}
onSaved={() => qc.invalidateQueries({ queryKey: ['doctor-holidays', doctorUuid] })} />
<ConfirmDialog open={!!deletingUuid} title="حذف تعطیلات" message="آیا از حذف این تعطیلات اطمینان دارید؟"
confirmLabel="بله، حذف کن" danger loading={deleteMut.isPending}
onConfirm={() => deletingUuid && deleteMut.mutate(deletingUuid)} onCancel={() => setDeletingUuid(null)} />
</div>
);
}
// ── 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 (
<div className="cp-card p-6">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-4">برنامه کاری</h2>
<div className="flex gap-1 mb-5 bg-slate-100 dark:bg-gray-800 p-1 rounded-xl">
{SCHEDULE_TABS.map(t => (
<button key={t.id} type="button" onClick={() => setTab(t.id)}
className={`flex-1 text-xs sm:text-sm font-medium px-2 py-1.5 rounded-lg transition-colors ${
tab === t.id
? 'bg-white dark:bg-gray-700 text-slate-800 dark:text-slate-100 shadow-sm'
: 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200'
}`}>
{t.label}
</button>
))}
</div>
{tab === 'weekly' && <WeeklyScheduleTab doctorUuid={doctorUuid} />}
{tab === 'overrides' && <DateOverridesTab doctorUuid={doctorUuid} />}
{tab === 'holidays' && <HolidaysTab doctorUuid={doctorUuid} />}
</div>
);
}
// ── 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<AddressData | null>(null);
const [deletingAddrId, setDeletingAddrId] = useState<string | null>(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<AddressData | null>(null);
const [deletingAddrId, setDeletingAddrId] = useState<string | null>(null);
// ── Queries ──
@@ -995,26 +1669,37 @@ export default function DoctorDetailPage() {
)}
</div>
<div className="flex items-center gap-2 sm:mb-1">
<div className="flex items-center gap-2 sm:mb-1 flex-wrap">
<button onClick={() => setEditOpen(true)} className="cp-btn-primary text-sm">
<PencilIcon className="w-4 h-4" />ویرایش
</button>
{/* Toggle active button — styled by current state */}
<button
onClick={() => setToggleConfirm(true)}
disabled={toggleMut.isPending}
className={`inline-flex items-center gap-1.5 text-sm font-medium px-3 h-10 rounded-xl border transition-colors disabled:opacity-50 ${
doctor.active
? 'border-orange-300 dark:border-orange-500/40 text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-500/10 hover:bg-orange-100 dark:hover:bg-orange-500/20'
: 'border-emerald-300 dark:border-emerald-500/40 text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 hover:bg-emerald-100 dark:hover:bg-emerald-500/20'
}`}
>
{toggleMut.isPending
? <span className="w-3.5 h-3.5 border-2 border-current/30 border-t-current rounded-full animate-spin" />
: doctor.active
? <XCircleIcon className="w-4 h-4" />
: <CheckCircleIcon className="w-4 h-4" />
}
{doctor.active ? 'غیرفعال کردن' : 'فعال کردن'}
</button>
<div className="relative" data-dmenu>
<button data-dmenu onClick={() => setMenuOpen(!menuOpen)}
className="cp-btn-secondary h-10 w-10 flex items-center justify-center px-0">
<EllipsisVerticalIcon className="w-4 h-4" />
</button>
{menuOpen && (
<div className="absolute left-0 top-12 z-30 w-56 cp-card shadow-2xl border border-slate-200 dark:border-gray-700 py-1.5 animate-scale-in" data-dmenu>
<button onClick={() => { toggleMut.mutate(); setMenuOpen(false); }}
disabled={toggleMut.isPending}
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-gray-800 disabled:opacity-50">
{doctor.active
? <><XCircleIcon className="w-4 h-4 text-orange-400 shrink-0" />غیرفعال کردن پزشک</>
: <><CheckCircleIcon className="w-4 h-4 text-emerald-500 shrink-0" />فعال کردن پزشک</>
}
</button>
<div className="border-t border-slate-100 dark:border-gray-700 my-1" />
<div className="absolute left-0 top-12 z-30 w-52 cp-card shadow-2xl border border-slate-200 dark:border-gray-700 py-1.5 animate-scale-in" data-dmenu>
<button onClick={() => { setDeleteOpen(true); setMenuOpen(false); }}
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10">
<TrashIcon className="w-4 h-4 shrink-0" />حذف این پزشک
@@ -1080,6 +1765,8 @@ export default function DoctorDetailPage() {
)}
</div>
{uuid && <ScheduleSection doctorUuid={uuid} />}
{doctor.clinics && doctor.clinics.length > 0 && (
<div className="cp-card p-6">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">
@@ -1270,6 +1957,20 @@ export default function DoctorDetailPage() {
onConfirm={() => deletingAddrId && deleteAddrMut.mutate(deletingAddrId)}
onCancel={() => setDeletingAddrId(null)}
/>
<ConfirmDialog
open={toggleConfirm}
title={doctor.active ? 'غیرفعال کردن پزشک' : 'فعال کردن پزشک'}
message={
doctor.active
? `آیا مطمئن هستید که می‌خواهید دکتر "${doctor.name}" را غیرفعال کنید؟ پزشک دیگر قادر به دریافت نوبت نخواهد بود.`
: `آیا می‌خواهید دکتر "${doctor.name}" را فعال کنید؟ پزشک مجدداً قادر به دریافت نوبت خواهد بود.`
}
confirmLabel={doctor.active ? 'بله، غیرفعال کن' : 'بله، فعال کن'}
danger={doctor.active}
loading={toggleMut.isPending}
onConfirm={() => { toggleMut.mutate(); setToggleConfirm(false); }}
onCancel={() => setToggleConfirm(false)}
/>
<ConfirmDialog
open={deleteOpen} title="حذف پزشک"
message={`آیا از حذف دکتر "${doctor.name}" اطمینان دارید؟ این عمل قابل بازگشت نیست.`}
+7
View File
@@ -11,6 +11,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",
@@ -5708,6 +5709,12 @@
"node": ">=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",
+1
View File
@@ -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",
@@ -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
{
@@ -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
{
+5
View File
@@ -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"