Refactor SlotCalculatorService to improve slot calculation logic

- Removed unused DAY_MAP constant.
- Enhanced getAvailableSlots method to prioritize holiday checks and date overrides.
- Simplified session handling by filtering and sorting active sessions.
- Introduced buildSessionSlots method to handle session slot creation with support for rest breaks and patient limits.
- Updated buildFlatSlots method for backward compatibility with date overrides.
- Improved filterBookedSlots method for clarity and conciseness.
This commit is contained in:
hamed
2026-06-10 19:00:31 +03:30
parent d489702751
commit 916bdaaeb0
2 changed files with 514 additions and 138 deletions
+411 -99
View File
@@ -11,7 +11,7 @@ import {
ClipboardDocumentIcon, EllipsisVerticalIcon, StarIcon,
BuildingOfficeIcon, MapPinIcon, AcademicCapIcon, UserIcon,
PlusIcon, CameraIcon, ChevronDownIcon, ChevronRightIcon, ChevronLeftIcon,
CheckCircleIcon, XMarkIcon,
CheckCircleIcon, XMarkIcon, ExclamationTriangleIcon,
} from '@heroicons/react/24/outline';
import { StarIcon as StarSolid } from '@heroicons/react/24/solid';
import { toast } from 'sonner';
@@ -66,12 +66,24 @@ interface AddressData {
// ── 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; }
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<string, NewDayConfig>;
interface WeeklyScheduleData { uuid: string; doctor_uuid: string; schedule: NewScheduleMap; }
// ── Constants ──────────────────────────────────────────────────────────────
const DEGREE_LABELS: Record<string, string> = {
@@ -252,18 +264,62 @@ function PersianDateInput({ value, onChange, minDate, placeholder }: {
}
const SCHEDULE_DAYS = [
{ key: 'saturday', label: 'شنبه' },
{ key: 'sunday', label: 'یکشنبه' },
{ key: 'monday', label: 'دوشنبه' },
{ key: 'tuesday', label: 'سه‌شنبه' },
{ key: 'wednesday', label: 'چهارشنبه' },
{ key: 'thursday', label: 'پنجشنبه' },
{ key: 'friday', label: 'جمعه' },
{ key: '0', label: 'شنبه' },
{ key: '1', label: 'یکشنبه' },
{ key: '2', label: 'دوشنبه' },
{ key: '3', label: 'سه‌شنبه' },
{ key: '4', label: 'چهارشنبه' },
{ key: '5', label: 'پنجشنبه' },
{ key: '6', label: 'جمعه' },
];
const DURATION_OPTS = [15, 20, 30, 45, 60];
const EMPTY_SCHEDULE: ScheduleMap = Object.fromEntries(
SCHEDULE_DAYS.map(d => [d.key, { active: false, slots: [] }])
const DURATION_OPTS = [10, 15, 20, 30, 45, 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);
}
@@ -953,12 +1009,129 @@ function SlotEditor({ slots, onChange }: {
);
}
function SessionEditor({ session, onChange, onRemove, addresses }: {
session: SessionConfig;
onChange: (s: SessionConfig) => void;
onRemove: () => void;
addresses: AddressData[];
}) {
const upd = <K extends keyof SessionConfig>(k: K, v: SessionConfig[K]) =>
onChange({ ...session, [k]: v });
const hasAdvanced = session.has_rest || session.patient_limit !== null;
const [showAdvanced, setShowAdvanced] = useState(hasAdvanced);
const slotCount = session.active ? calcSlotCount(session) : 0;
return (
<div className={`rounded-xl border transition-colors ${session.active ? 'border-primary-200 dark:border-primary-500/30 bg-white dark:bg-gray-800/60' : 'border-slate-200 dark:border-gray-700 bg-slate-50/40 dark:bg-gray-800/30'}`}>
{/* ─ row اصلی */}
<div className="flex items-center gap-2 px-3 py-2.5 flex-wrap">
<button type="button"
onClick={() => upd('active', !session.active)}
className={`relative w-10 h-5 rounded-full transition-colors shrink-0 focus:outline-none ${session.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 ${session.active ? 'translate-x-5' : 'translate-x-0.5'}`} />
</button>
<div className="flex items-center gap-1.5">
<span className="text-xs text-slate-400 shrink-0">از</span>
<input type="time" value={session.start_time} onChange={e => upd('start_time', e.target.value)}
className="cp-input h-8 w-28 text-sm font-mono text-center px-2" />
<span className="text-xs text-slate-400 shrink-0">تا</span>
<input type="time" value={session.end_time} onChange={e => upd('end_time', e.target.value)}
className="cp-input h-8 w-28 text-sm font-mono text-center px-2" />
</div>
<select value={session.duration_per_patient} onChange={e => upd('duration_per_patient', Number(e.target.value))}
className="cp-select h-8 text-sm">
{DURATION_OPTS.map(d => <option key={d} value={d}>{d} دقیقه</option>)}
</select>
{session.active && slotCount > 0 && (
<span className="text-xs font-medium bg-emerald-100 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 px-2 py-0.5 rounded-full">
{slotCount} نوبت
</span>
)}
<button type="button" onClick={onRemove}
className="mr-auto 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">
<TrashIcon className="w-3.5 h-3.5" />
</button>
</div>
{session.active && (
<div className="border-t border-slate-100 dark:border-gray-700 px-3 pb-3 pt-2.5 space-y-2">
{/* ─ مکان مطب — اجباری */}
{addresses.length > 0 && (
<div className="flex items-center gap-2">
<label className="text-xs text-slate-500 dark:text-slate-400 shrink-0 w-16">
مکان مطب <span className="text-red-500">*</span>
</label>
<div className="flex-1">
<select value={session.location_id ?? ''}
onChange={e => upd('location_id', e.target.value ? Number(e.target.value) : null)}
className={`cp-select h-8 text-sm w-full ${!session.location_id ? 'border-red-300 dark:border-red-500/60 focus:ring-red-400' : ''}`}>
<option value="" disabled>انتخاب مطب...</option>
{addresses.map(a => (
<option key={a.id} value={a.id}>{a.name ?? a.address ?? `مطب ${a.id}`}</option>
))}
</select>
{!session.location_id && (
<p className="text-xs text-red-500 mt-0.5">انتخاب مکان مطب الزامی است</p>
)}
</div>
</div>
)}
{/* ─ تنظیمات پیشرفته collapse */}
<button type="button" onClick={() => setShowAdvanced(v => !v)}
className="flex items-center gap-1 text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors">
<ChevronDownIcon className={`w-3.5 h-3.5 transition-transform ${showAdvanced ? 'rotate-180' : ''}`} />
{showAdvanced ? 'پنهان کردن' : 'تنظیمات پیشرفته (استراحت · محدودیت نوبت)'}
</button>
{showAdvanced && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1">
<div>
<label className="text-xs text-slate-500 dark:text-slate-400 block mb-1">حداکثر نوبت</label>
<input type="number" min={1} placeholder="بدون محدودیت"
value={session.patient_limit ?? ''}
onChange={e => upd('patient_limit', e.target.value ? Number(e.target.value) : null)}
className="cp-input h-8 text-sm w-full" />
</div>
<div className="sm:col-span-2 space-y-2">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" checked={session.has_rest} onChange={e => upd('has_rest', e.target.checked)}
className="w-3.5 h-3.5 rounded accent-primary-500" />
<span className="text-xs text-slate-600 dark:text-slate-400">استراحت دورهای بین نوبتها</span>
</label>
{session.has_rest && (
<div className="flex items-center gap-4 flex-wrap pr-5">
<div className="flex items-center gap-1.5">
<span className="text-xs text-slate-500">هر</span>
<input type="number" min={1} value={session.rest_interval}
onChange={e => upd('rest_interval', Number(e.target.value))}
className="cp-input h-7 w-16 text-sm text-center px-1" />
<span className="text-xs text-slate-500">دقیقه کار</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-slate-500">استراحت</span>
<input type="number" min={1} value={session.time_to_rest}
onChange={e => upd('time_to_rest', Number(e.target.value))}
className="cp-input h-7 w-16 text-sm text-center px-1" />
<span className="text-xs text-slate-500">دقیقه</span>
</div>
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
);
}
// ── Weekly Schedule Tab ────────────────────────────────────────────────────
function WeeklyScheduleTab({ doctorUuid }: { doctorUuid: string }) {
function WeeklyScheduleTab({ doctorUuid, addresses }: { doctorUuid: string; addresses: AddressData[] }) {
const qc = useQueryClient();
const [scheduleMap, setScheduleMap] = useState<ScheduleMap>(EMPTY_SCHEDULE);
const [scheduleMap, setScheduleMap] = useState<NewScheduleMap>(EMPTY_NEW_SCHEDULE);
const [scheduleUuid, setScheduleUuid] = useState<string | null>(null);
const [expandedDay, setExpandedDay] = useState<string | null>(null);
const scheduleQ = useQuery({
queryKey: ['doctor-schedule', doctorUuid],
@@ -970,16 +1143,44 @@ function WeeklyScheduleTab({ doctorUuid }: { doctorUuid: string }) {
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); }
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);
}
} else if (scheduleQ.error instanceof ApiError && scheduleQ.error.status === 404) {
setScheduleMap(EMPTY_SCHEDULE); setScheduleUuid(null);
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: () => 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 }),
mutationFn: () => {
if (hasAnyOverlap) throw new Error('تداخل زمانی در برنامه وجود دارد');
if (missingLocation) throw new Error('مکان مطب برای همه بازه‌های فعال الزامی است');
return 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);
@@ -989,8 +1190,26 @@ function WeeklyScheduleTab({ doctorUuid }: { doctorUuid: string }) {
onError: (e: Error) => toast.error(e.message),
});
const setDay = (key: string, patch: Partial<DayConfig>) =>
setScheduleMap(prev => ({ ...prev, [key]: { ...prev[key], ...patch } }));
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 (
<div className="space-y-3">{Array.from({ length: 4 }).map((_, i) => <div key={i} className="h-12 rounded-xl skeleton" />)}</div>
@@ -998,44 +1217,99 @@ function WeeklyScheduleTab({ doctorUuid }: { doctorUuid: string }) {
return (
<div className="space-y-2">
{/* ─ خلاصه کل هفته */}
{totalSlots > 0 && (
<div className="flex items-center gap-2 mb-3 p-3 rounded-xl bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/30">
<CalendarIcon className="w-4 h-4 text-emerald-600 dark:text-emerald-400 shrink-0" />
<span className="text-sm text-emerald-700 dark:text-emerald-300">
مجموع <strong>{totalSlots}</strong> نوبت در هفته
</span>
</div>
)}
{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);
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);
const hasAny = sessions.length > 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 key={day.key} className={`rounded-xl border transition-all ${hasAny ? 'border-primary-200 dark:border-primary-500/30' : 'border-slate-200 dark:border-gray-700'} ${isOverlap ? 'border-red-300 dark:border-red-500/50' : ''}`}>
{/* ─ header هر روز */}
<div
className={`flex items-center gap-2 px-4 py-3 cursor-pointer select-none rounded-xl transition-colors ${hasAny ? 'bg-primary-50/40 dark:bg-primary-500/5 hover:bg-primary-50/70 dark:hover:bg-primary-500/10' : 'bg-slate-50/60 dark:bg-gray-800/30 hover:bg-slate-100/60 dark:hover:bg-gray-800/50'}`}
onClick={() => setExpandedDay(isExpanded ? null : day.key)}
>
<span className={`text-sm font-semibold w-16 shrink-0 ${hasAny ? 'text-primary-700 dark:text-primary-300' : 'text-slate-500 dark:text-slate-400'}`}>
{day.label}
</span>
{/* chips بازه‌های active */}
<div className="flex items-center gap-1.5 flex-wrap flex-1 min-w-0">
{sessions.length === 0 && (
<span className="text-xs text-slate-400 dark:text-slate-500">تعطیل</span>
)}
{activeSessions.map((s, i) => (
<span key={i} className="text-xs bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300 px-2 py-0.5 rounded-full font-mono whitespace-nowrap">
{s.start_time}{s.end_time}
</span>
))}
{daySlots > 0 && (
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">
({daySlots} نوبت)
</span>
)}
{isOverlap && (
<span className="flex items-center gap-0.5 text-xs text-red-500 dark:text-red-400">
<ExclamationTriangleIcon className="w-3.5 h-3.5" />تداخل
</span>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<button type="button"
onClick={e => { e.stopPropagation(); addSession(day.key); }}
className="flex items-center gap-0.5 text-xs text-primary-600 dark:text-primary-400 hover:bg-primary-100 dark:hover:bg-primary-500/20 px-2 py-1 rounded-lg transition-colors">
<PlusIcon className="w-3.5 h-3.5" />بازه
</button>
<ChevronDownIcon className={`w-4 h-4 text-slate-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
</div>
</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 })} />
{/* ─ محتوای expanded */}
{isExpanded && (
<div className="border-t border-slate-100 dark:border-gray-700 px-4 pb-4 pt-3 space-y-2">
{sessions.length === 0 ? (
<div className="text-center py-4">
<p className="text-xs text-slate-400 dark:text-slate-500 mb-2">هیچ بازهای برای این روز تنظیم نشده</p>
<button type="button" onClick={() => addSession(day.key)}
className="text-xs text-primary-600 dark:text-primary-400 hover:underline flex items-center gap-1 mx-auto">
<PlusIcon className="w-3.5 h-3.5" />افزودن اولین بازه
</button>
</div>
) : sessions.map((session, idx) => (
<SessionEditor key={idx} session={session} addresses={addresses}
onChange={s => updateSession(day.key, idx, s)}
onRemove={() => removeSession(day.key, idx)} />
))}
</div>
)}
</div>
);
})}
<div className="flex justify-end pt-2">
<button type="button" onClick={() => saveMut.mutate()} disabled={saveMut.isPending}
className="cp-btn-primary">
<div className="flex items-center justify-between pt-3">
{(hasAnyOverlap || missingLocation) && (
<p className="text-xs text-red-500 dark:text-red-400 flex items-center gap-1">
<ExclamationTriangleIcon className="w-3.5 h-3.5" />
{hasAnyOverlap ? 'تداخل زمانی در برنامه وجود دارد' : 'مکان مطب برای همه بازه‌ها الزامی است'}
</p>
)}
<button type="button" onClick={() => saveMut.mutate()}
disabled={saveMut.isPending || hasAnyOverlap || missingLocation}
className="mr-auto cp-btn-primary disabled:opacity-50 disabled:cursor-not-allowed">
{saveMut.isPending
? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />در حال ذخیره...</>
: 'ذخیره برنامه هفتگی'
@@ -1053,28 +1327,29 @@ function DateOverrideModal({ open, onClose, existing, doctorUuid, onSaved }: {
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[]>([]);
const [dateStr, setDateStr] = useState('');
const [overrideType, setOverrideType] = useState<'closed' | 'custom'>('closed');
const [reason, setReason] = useState('');
const [slots, setSlots] = useState<SlotConfig[]>([]);
useEffect(() => {
if (open) {
if (existing) {
setDateStr(tsToDate(existing.date));
setActive(existing.active);
setOverrideType(existing.active ? 'custom' : 'closed');
setReason(existing.reason ?? '');
setSlots(existing.custom_slots ?? []);
} else {
setDateStr(new Date().toISOString().slice(0, 10));
setActive(false); setReason(''); setSlots([]);
setOverrideType('closed'); setReason(''); setSlots([]);
}
}
}, [open, existing]);
const saveMut = useMutation({
mutationFn: () => {
const body = { date: dateStr, active, reason: reason || undefined, custom_slots: active ? slots : [] };
const active = overrideType === 'custom';
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 });
@@ -1100,30 +1375,52 @@ function DateOverrideModal({ open, onClose, existing, doctorUuid, onSaved }: {
<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>
{/* ─ نوع override */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">نوع این روز</label>
<div className="grid grid-cols-2 gap-2">
<button type="button"
onClick={() => setOverrideType('closed')}
className={`flex items-center gap-2 p-3 rounded-xl border-2 text-sm transition-colors text-right ${overrideType === 'closed' ? 'border-red-400 bg-red-50 dark:bg-red-500/10 text-red-700 dark:text-red-300' : 'border-slate-200 dark:border-gray-700 text-slate-600 dark:text-slate-400 hover:border-slate-300'}`}>
<XCircleIcon className="w-5 h-5 shrink-0" />
<div>
<p className="font-medium leading-tight">تعطیل است</p>
<p className="text-xs opacity-70 mt-0.5">هیچ نوبتی داده نمیشود</p>
</div>
</button>
<button type="button"
onClick={() => setOverrideType('custom')}
className={`flex items-center gap-2 p-3 rounded-xl border-2 text-sm transition-colors text-right ${overrideType === 'custom' ? 'border-amber-400 bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300' : 'border-slate-200 dark:border-gray-700 text-slate-600 dark:text-slate-400 hover:border-slate-300'}`}>
<CalendarIcon className="w-5 h-5 shrink-0" />
<div>
<p className="font-medium leading-tight">ساعات خاص</p>
<p className="text-xs opacity-70 mt-0.5">جایگزین برنامه هفتگی</p>
</div>
</button>
</div>
</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" />
placeholder={overrideType === 'closed' ? 'مثال: سفر، مریضی، کنگره...' : 'مثال: شیفت اضطراری، ویزیت خاص...'}
className="cp-input" />
</div>
{active ? (
{overrideType === 'custom' && (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">بازههای زمانی سفارشی</label>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">بازههای زمانی</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">
در این روز هیچ نوبتی داده نخواهد شد، حتی اگر در برنامه هفتگی فعال باشد.
)}
{overrideType === 'closed' && (
<div className="flex items-start gap-2 p-3 rounded-xl bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/30">
<ExclamationTriangleIcon className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
<p className="text-xs text-red-700 dark:text-red-400">
در این روز هیچ نوبتی داده نخواهد شد، حتی اگر در برنامه هفتگی ساعات کاری داشته باشید.
</p>
</div>
)}
@@ -1175,26 +1472,29 @@ function DateOverridesTab({ doctorUuid }: { doctorUuid: string }) {
) : (
<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 key={ov.uuid} className="flex items-center gap-3 p-3.5 rounded-xl border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800/60">
<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>
{ov.active ? (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300">
{ov.custom_slots.length > 0 ? `${ov.custom_slots.length} بازه سفارشی` : 'ساعات خاص'}
</span>
) : (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400">
تعطیل
</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">
<div className="flex items-center gap-1 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">
className="w-8 h-8 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">
className="w-8 h-8 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>
@@ -1250,7 +1550,7 @@ function HolidayModal({ open, onClose, existing, doctorUuid, onSaved }: {
});
return (
<Modal open={open} title={existing ? 'ویرایش تعطیلات' : 'افزودن تعطیلات'} size="sm" onClose={onClose}
<Modal open={open} title={existing ? 'ویرایش تعطیلات' : 'افزودن تعطیلات'} size="md" onClose={onClose}
footer={
<>
<button type="button" onClick={onClose} className="cp-btn-secondary px-5">لغو</button>
@@ -1334,31 +1634,43 @@ function HolidaysTab({ doctorUuid }: { doctorUuid: string }) {
) : (
<div className="space-y-2">
{holidays.map(h => {
const sameDay = tsToDate(h.start_date) === tsToDate(h.end_date);
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 (
<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 key={h.uuid} className={`flex items-center gap-3 p-3.5 rounded-xl border transition-colors ${isCurrent ? 'border-red-200 dark:border-red-500/30 bg-red-50/40 dark:bg-red-500/5' : isPast ? 'border-slate-200 dark:border-gray-700 bg-slate-50/40 dark:bg-gray-800/30 opacity-70' : 'border-orange-200 dark:border-orange-500/30 bg-orange-50/30 dark:bg-orange-500/5'}`}>
<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">
<span className={`text-sm font-medium ${isPast ? 'text-slate-500 dark:text-slate-400' : '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>
{isCurrent && h.active && (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 animate-pulse">در جریان</span>
)}
{!isCurrent && !isPast && h.active && (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-orange-100 dark:bg-orange-500/20 text-orange-600 dark:text-orange-400">آینده</span>
)}
{isPast && (
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 dark:bg-gray-700 text-slate-400 dark:text-slate-500">گذشته</span>
)}
{!h.active && (
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 dark:bg-gray-700 text-slate-400 dark:text-slate-500">غیرفعال</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">
<div className="flex items-center gap-1 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 ? 'غیرفعال کن' : 'فعال کن'}
className={`text-xs px-2.5 h-7 rounded-lg border transition-colors disabled:opacity-50 ${h.active ? 'border-slate-200 dark:border-gray-600 text-slate-500 dark:text-slate-400 hover:border-slate-300 hover:text-slate-700 dark:hover:text-slate-200' : 'border-emerald-200 dark:border-emerald-500/40 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-500/10'}`}>
{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">
className="w-8 h-8 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">
className="w-8 h-8 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>
@@ -1385,7 +1697,7 @@ const SCHEDULE_TABS = [
{ id: 'holidays' as const, label: 'تعطیلات' },
];
function ScheduleSection({ doctorUuid }: { doctorUuid: string }) {
function ScheduleSection({ doctorUuid, addresses }: { doctorUuid: string; addresses: AddressData[] }) {
const [tab, setTab] = useState<'weekly' | 'overrides' | 'holidays'>('weekly');
return (
<div className="cp-card p-6">
@@ -1402,7 +1714,7 @@ function ScheduleSection({ doctorUuid }: { doctorUuid: string }) {
</button>
))}
</div>
{tab === 'weekly' && <WeeklyScheduleTab doctorUuid={doctorUuid} />}
{tab === 'weekly' && <WeeklyScheduleTab doctorUuid={doctorUuid} addresses={addresses} />}
{tab === 'overrides' && <DateOverridesTab doctorUuid={doctorUuid} />}
{tab === 'holidays' && <HolidaysTab doctorUuid={doctorUuid} />}
</div>
@@ -1765,7 +2077,7 @@ export default function DoctorDetailPage() {
)}
</div>
{uuid && <ScheduleSection doctorUuid={uuid} />}
{uuid && <ScheduleSection doctorUuid={uuid} addresses={doctor.address ?? []} />}
{doctor.clinics && doctor.clinics.length > 0 && (
<div className="cp-card p-6">
+103 -39
View File
@@ -2,20 +2,15 @@
namespace App\Appointment\Service;
use App\Appointment\Entity\Appointment;
use App\Appointment\Repository\AppointmentRepository;
use App\Appointment\Repository\DateOverrideRepository;
use App\Appointment\Repository\HolidayRepository;
use App\Appointment\Repository\WeeklyScheduleRepository;
use App\Doctor\Entity\Doctor;
class SlotCalculatorService
{
private const DAY_MAP = [
0 => 'sunday', 1 => 'monday', 2 => 'tuesday', 3 => 'wednesday',
4 => 'thursday', 5 => 'friday', 6 => 'saturday',
];
public function __construct(
private readonly WeeklyScheduleRepository $scheduleRepo,
private readonly DateOverrideRepository $overrideRepo,
@@ -25,64 +20,133 @@ class SlotCalculatorService
/**
* Returns available slots for a doctor on a given date.
* @param string $date 'Y-m-d' format
* @return array[] [{start: int, end: int, start_time: string, end_time: string}]
* Day index convention: 0=Saturday(شنبه), 1=Sunday, ..., 6=Friday(جمعه)
*
* @return array[] [{start: int, end: int, start_time: string, end_time: string, location_id: int|null}]
*/
public function getAvailableSlots(Doctor $doctor, string $date): array
{
$dayStart = (int) strtotime($date . ' 00:00:00');
$dayEnd = $dayStart + 86400;
// Check if in holiday
$holidays = $this->holidayRepo->findActiveByDoctor($doctor, $dayStart, $dayEnd - 1);
if (!empty($holidays)) return [];
// 1. Blocked by holiday
if (!empty($this->holidayRepo->findActiveByDoctor($doctor, $dayStart, $dayEnd - 1))) {
return [];
}
// Check date override first
$overrides = $this->overrideRepo->findByDoctor($doctor);
foreach ($overrides as $override) {
$overDate = date('Y-m-d', $override->getDate());
if ($overDate === $date) {
// 2. Date override takes precedence over weekly schedule
foreach ($this->overrideRepo->findByDoctor($doctor) as $override) {
if (date('Y-m-d', $override->getDate()) === $date) {
if (!$override->isActive()) return [];
return $this->buildSlots($override->getSetting() ?? [], $dayStart);
return $this->filterBookedSlots(
$doctor,
$this->buildFlatSlots($override->getSetting() ?? [], $dayStart)
);
}
}
// Fall back to weekly schedule
// 3. Weekly schedule
$schedule = $this->scheduleRepo->findByDoctor($doctor);
if ($schedule === null) return [];
$dow = (int) date('w', $dayStart);
$dayName = self::DAY_MAP[$dow];
$setting = $schedule->getSetting();
// Convert PHP date('w') (0=Sunday) to Iranian week index (0=Saturday)
$phpDow = (int) date('w', $dayStart);
$dayKey = (string)(($phpDow + 1) % 7);
$dayConfig = $setting[$dayName] ?? null;
if ($dayConfig === null || !($dayConfig['active'] ?? false)) return [];
$dayConf = $schedule->getSetting()[$dayKey] ?? null;
if ($dayConf === null) return [];
$rawSlots = $this->buildSlots($dayConfig['slots'] ?? [], $dayStart);
$sessions = $dayConf['sessions'] ?? [];
// Sort active sessions by start_time, skip overlapping ones
$activeSessions = array_filter($sessions, fn($s) => $s['active'] ?? false);
usort($activeSessions, fn($a, $b) =>
$this->parseTime($a['start_time'] ?? '00:00') <=> $this->parseTime($b['start_time'] ?? '00:00')
);
// Filter out already-booked slots
return $this->filterBookedSlots($doctor, $rawSlots);
$allSlots = [];
$prevEnd = 0;
foreach ($activeSessions as $session) {
$sessionStart = $this->parseTime($session['start_time'] ?? '00:00');
if ($sessionStart < $prevEnd) continue; // skip overlapping session
$allSlots = array_merge($allSlots, $this->buildSessionSlots($session, $dayStart));
$prevEnd = $this->parseTime($session['end_time'] ?? '00:00');
}
usort($allSlots, fn($a, $b) => $a['start'] - $b['start']);
return $this->filterBookedSlots($doctor, $allSlots);
}
/** @return array[] */
private function buildSlots(array $slotConfigs, int $dayStart): array
/**
* Build slots from a morning/evening session config.
* Supports: rest breaks, patient limits.
*/
private function buildSessionSlots(array $session, int $dayStart): array
{
$startSec = $this->parseTime($session['start_time'] ?? '00:00');
$endSec = $this->parseTime($session['end_time'] ?? '00:00');
$dur = (int)($session['duration_per_patient'] ?? 20) * 60;
$hasRest = (bool)($session['has_rest'] ?? false);
$restInt = (int)($session['rest_interval'] ?? 60) * 60; // convert min → sec
$restDur = (int)($session['time_to_rest'] ?? 10) * 60; // convert min → sec
$limit = isset($session['patient_limit']) && $session['patient_limit'] !== null
? (int)$session['patient_limit'] : null;
$locationId = isset($session['location_id']) ? (int)$session['location_id'] : null;
if ($dur <= 0 || $endSec <= $startSec) return [];
$slots = [];
$currentSec = $startSec;
$elapsedWork = 0; // seconds worked since last rest
$patientCount = 0;
while ($currentSec + $dur <= $endSec) {
if ($limit !== null && $patientCount >= $limit) break;
// Insert rest break if needed
if ($hasRest && $restInt > 0 && $elapsedWork > 0 && $elapsedWork >= $restInt) {
$currentSec += $restDur;
$elapsedWork = 0;
continue;
}
$slots[] = [
'start' => $dayStart + $currentSec,
'end' => $dayStart + $currentSec + $dur,
'start_time' => gmdate('H:i', $currentSec),
'end_time' => gmdate('H:i', $currentSec + $dur),
'location_id' => $locationId,
];
$currentSec += $dur;
$elapsedWork += $dur;
$patientCount += 1;
}
return $slots;
}
/**
* Build slots from flat date-override format: [{start, end, duration}]
* Kept for backward-compatibility with DateOverride.custom_slots.
*/
private function buildFlatSlots(array $slotConfigs, int $dayStart): array
{
$slots = [];
foreach ($slotConfigs as $config) {
$startSec = $this->parseTime($config['start'] ?? '00:00');
$endSec = $this->parseTime($config['end'] ?? '00:00');
$duration = (int) ($config['duration'] ?? 30) * 60;
$duration = (int)($config['duration'] ?? 30) * 60;
if ($duration <= 0 || $endSec <= $startSec) continue;
for ($t = $startSec; $t + $duration <= $endSec; $t += $duration) {
$slotStart = $dayStart + $t;
$slotEnd = $slotStart + $duration;
$slots[] = [
'start' => $slotStart,
'end' => $slotEnd,
'start_time' => gmdate('H:i', $t),
'end_time' => gmdate('H:i', $t + $duration),
$slots[] = [
'start' => $dayStart + $t,
'end' => $dayStart + $t + $duration,
'start_time' => gmdate('H:i', $t),
'end_time' => gmdate('H:i', $t + $duration),
'location_id' => null,
];
}
}
@@ -91,9 +155,9 @@ class SlotCalculatorService
private function filterBookedSlots(Doctor $doctor, array $slots): array
{
return array_values(array_filter($slots, function (array $slot) use ($doctor): bool {
return !$this->appointmentRepo->isSlotTaken($doctor, $slot['start'], $slot['end']);
}));
return array_values(array_filter($slots, fn(array $slot): bool =>
!$this->appointmentRepo->isSlotTaken($doctor, $slot['start'], $slot['end'])
));
}
private function parseTime(string $time): int