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:
@@ -11,7 +11,7 @@ import {
|
|||||||
ClipboardDocumentIcon, EllipsisVerticalIcon, StarIcon,
|
ClipboardDocumentIcon, EllipsisVerticalIcon, StarIcon,
|
||||||
BuildingOfficeIcon, MapPinIcon, AcademicCapIcon, UserIcon,
|
BuildingOfficeIcon, MapPinIcon, AcademicCapIcon, UserIcon,
|
||||||
PlusIcon, CameraIcon, ChevronDownIcon, ChevronRightIcon, ChevronLeftIcon,
|
PlusIcon, CameraIcon, ChevronDownIcon, ChevronRightIcon, ChevronLeftIcon,
|
||||||
CheckCircleIcon, XMarkIcon,
|
CheckCircleIcon, XMarkIcon, ExclamationTriangleIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { StarIcon as StarSolid } from '@heroicons/react/24/solid';
|
import { StarIcon as StarSolid } from '@heroicons/react/24/solid';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -66,12 +66,24 @@ interface AddressData {
|
|||||||
// ── Schedule types ─────────────────────────────────────────────────────────
|
// ── Schedule types ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface SlotConfig { start: string; end: string; duration: number; }
|
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 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 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 ──────────────────────────────────────────────────────────────
|
// ── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const DEGREE_LABELS: Record<string, string> = {
|
const DEGREE_LABELS: Record<string, string> = {
|
||||||
@@ -252,18 +264,62 @@ function PersianDateInput({ value, onChange, minDate, placeholder }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SCHEDULE_DAYS = [
|
const SCHEDULE_DAYS = [
|
||||||
{ key: 'saturday', label: 'شنبه' },
|
{ key: '0', label: 'شنبه' },
|
||||||
{ key: 'sunday', label: 'یکشنبه' },
|
{ key: '1', label: 'یکشنبه' },
|
||||||
{ key: 'monday', label: 'دوشنبه' },
|
{ key: '2', label: 'دوشنبه' },
|
||||||
{ key: 'tuesday', label: 'سهشنبه' },
|
{ key: '3', label: 'سهشنبه' },
|
||||||
{ key: 'wednesday', label: 'چهارشنبه' },
|
{ key: '4', label: 'چهارشنبه' },
|
||||||
{ key: 'thursday', label: 'پنجشنبه' },
|
{ key: '5', label: 'پنجشنبه' },
|
||||||
{ key: 'friday', label: 'جمعه' },
|
{ key: '6', label: 'جمعه' },
|
||||||
];
|
];
|
||||||
const DURATION_OPTS = [15, 20, 30, 45, 60];
|
const DURATION_OPTS = [10, 15, 20, 30, 45, 60];
|
||||||
const EMPTY_SCHEDULE: ScheduleMap = Object.fromEntries(
|
const DEFAULT_SESSION: SessionConfig = {
|
||||||
SCHEDULE_DAYS.map(d => [d.key, { active: false, slots: [] }])
|
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 {
|
function tsToDate(ts: number): string {
|
||||||
return new Date(ts * 1000).toISOString().slice(0, 10);
|
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 ────────────────────────────────────────────────────
|
// ── Weekly Schedule Tab ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function WeeklyScheduleTab({ doctorUuid }: { doctorUuid: string }) {
|
function WeeklyScheduleTab({ doctorUuid, addresses }: { doctorUuid: string; addresses: AddressData[] }) {
|
||||||
const qc = useQueryClient();
|
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 [scheduleUuid, setScheduleUuid] = useState<string | null>(null);
|
||||||
|
const [expandedDay, setExpandedDay] = useState<string | null>(null);
|
||||||
|
|
||||||
const scheduleQ = useQuery({
|
const scheduleQ = useQuery({
|
||||||
queryKey: ['doctor-schedule', doctorUuid],
|
queryKey: ['doctor-schedule', doctorUuid],
|
||||||
@@ -970,16 +1143,44 @@ function WeeklyScheduleTab({ doctorUuid }: { doctorUuid: string }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scheduleQ.data) {
|
if (scheduleQ.data) {
|
||||||
const d: WeeklyScheduleData = scheduleQ.data?.data?.data ?? scheduleQ.data?.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) {
|
} 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]);
|
}, [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({
|
const saveMut = useMutation({
|
||||||
mutationFn: () => scheduleUuid
|
mutationFn: () => {
|
||||||
? api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap })
|
if (hasAnyOverlap) throw new Error('تداخل زمانی در برنامه وجود دارد');
|
||||||
: api.post<ApiResponse<any>>('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap }),
|
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) => {
|
onSuccess: (res) => {
|
||||||
const d: WeeklyScheduleData = res?.data?.data ?? res?.data;
|
const d: WeeklyScheduleData = res?.data?.data ?? res?.data;
|
||||||
if (d?.uuid && !scheduleUuid) setScheduleUuid(d.uuid);
|
if (d?.uuid && !scheduleUuid) setScheduleUuid(d.uuid);
|
||||||
@@ -989,8 +1190,26 @@ function WeeklyScheduleTab({ doctorUuid }: { doctorUuid: string }) {
|
|||||||
onError: (e: Error) => toast.error(e.message),
|
onError: (e: Error) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
const setDay = (key: string, patch: Partial<DayConfig>) =>
|
const setDaySessions = (key: string, sessions: SessionConfig[]) =>
|
||||||
setScheduleMap(prev => ({ ...prev, [key]: { ...prev[key], ...patch } }));
|
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 (
|
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>
|
<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 (
|
return (
|
||||||
<div className="space-y-2">
|
<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 => {
|
{SCHEDULE_DAYS.map(day => {
|
||||||
const cfg = scheduleMap[day.key] ?? { active: false, slots: [] };
|
const sessions = scheduleMap[day.key]?.sessions ?? [];
|
||||||
const totalSlots = cfg.slots.reduce((acc, s) => {
|
const isOverlap = overlapDays[day.key];
|
||||||
const [sh, sm] = s.start.split(':').map(Number);
|
const isExpanded = expandedDay === day.key;
|
||||||
const [eh, em] = s.end.split(':').map(Number);
|
const activeSessions = sessions.filter(s => s.active);
|
||||||
const mins = (eh * 60 + em) - (sh * 60 + sm);
|
const daySlots = activeSessions.reduce((sum, s) => sum + calcSlotCount(s), 0);
|
||||||
return acc + Math.floor(mins / Math.max(s.duration, 1));
|
const hasAny = sessions.length > 0;
|
||||||
}, 0);
|
|
||||||
return (
|
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 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' : ''}`}>
|
||||||
<div className="flex items-center gap-3 px-4 py-3">
|
{/* ─ header هر روز */}
|
||||||
<button type="button"
|
<div
|
||||||
onClick={() => setDay(day.key, { active: !cfg.active })}
|
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'}`}
|
||||||
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'}`}>
|
onClick={() => setExpandedDay(isExpanded ? null : day.key)}
|
||||||
<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-semibold w-16 shrink-0 ${hasAny ? 'text-primary-700 dark:text-primary-300' : 'text-slate-500 dark:text-slate-400'}`}>
|
||||||
<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>
|
{day.label}
|
||||||
{!cfg.active && <span className="text-xs text-slate-400 dark:text-slate-500">تعطیل</span>}
|
</span>
|
||||||
{cfg.active && cfg.slots.length > 0 && (
|
|
||||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
{/* chips بازههای active */}
|
||||||
{cfg.slots.length} بازه · {totalSlots} نوبت
|
<div className="flex items-center gap-1.5 flex-wrap flex-1 min-w-0">
|
||||||
</span>
|
{sessions.length === 0 && (
|
||||||
)}
|
<span className="text-xs text-slate-400 dark:text-slate-500">تعطیل</span>
|
||||||
{cfg.active && cfg.slots.length === 0 && (
|
)}
|
||||||
<span className="text-xs text-amber-500 dark:text-amber-400">هنوز بازهای تنظیم نشده</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>
|
</div>
|
||||||
{cfg.active && (
|
|
||||||
<div className="border-t border-primary-100 dark:border-primary-500/20 px-4 pb-4 pt-3">
|
{/* ─ محتوای expanded */}
|
||||||
<SlotEditor slots={cfg.slots} onChange={slots => setDay(day.key, { slots })} />
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div className="flex justify-end pt-2">
|
|
||||||
<button type="button" onClick={() => saveMut.mutate()} disabled={saveMut.isPending}
|
<div className="flex items-center justify-between pt-3">
|
||||||
className="cp-btn-primary">
|
{(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
|
{saveMut.isPending
|
||||||
? <><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />در حال ذخیره...</>
|
? <><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;
|
existing: DateOverrideData | null; doctorUuid: string;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [dateStr, setDateStr] = useState('');
|
const [dateStr, setDateStr] = useState('');
|
||||||
const [active, setActive] = useState(false);
|
const [overrideType, setOverrideType] = useState<'closed' | 'custom'>('closed');
|
||||||
const [reason, setReason] = useState('');
|
const [reason, setReason] = useState('');
|
||||||
const [slots, setSlots] = useState<SlotConfig[]>([]);
|
const [slots, setSlots] = useState<SlotConfig[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
if (existing) {
|
if (existing) {
|
||||||
setDateStr(tsToDate(existing.date));
|
setDateStr(tsToDate(existing.date));
|
||||||
setActive(existing.active);
|
setOverrideType(existing.active ? 'custom' : 'closed');
|
||||||
setReason(existing.reason ?? '');
|
setReason(existing.reason ?? '');
|
||||||
setSlots(existing.custom_slots ?? []);
|
setSlots(existing.custom_slots ?? []);
|
||||||
} else {
|
} else {
|
||||||
setDateStr(new Date().toISOString().slice(0, 10));
|
setDateStr(new Date().toISOString().slice(0, 10));
|
||||||
setActive(false); setReason(''); setSlots([]);
|
setOverrideType('closed'); setReason(''); setSlots([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [open, existing]);
|
}, [open, existing]);
|
||||||
|
|
||||||
const saveMut = useMutation({
|
const saveMut = useMutation({
|
||||||
mutationFn: () => {
|
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)
|
if (existing)
|
||||||
return api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/date-override/${existing.uuid}`, body);
|
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 });
|
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>
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">تاریخ</label>
|
||||||
<PersianDateInput value={dateStr} onChange={setDateStr} placeholder="انتخاب تاریخ" />
|
<PersianDateInput value={dateStr} onChange={setDateStr} placeholder="انتخاب تاریخ" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button type="button" onClick={() => setActive(v => !v)}
|
{/* ─ نوع override */}
|
||||||
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'}`}>
|
<div>
|
||||||
<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'}`} />
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">نوع این روز</label>
|
||||||
</button>
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<span className="text-sm text-slate-700 dark:text-slate-300">
|
<button type="button"
|
||||||
{active ? 'این روز فعال است (نوبتدهی میشود)' : 'این روز تعطیل است (بدون نوبت)'}
|
onClick={() => setOverrideType('closed')}
|
||||||
</span>
|
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>
|
||||||
|
|
||||||
<div>
|
<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.5">دلیل (اختیاری)</label>
|
||||||
<input type="text" value={reason} onChange={e => setReason(e.target.value)}
|
<input type="text" value={reason} onChange={e => setReason(e.target.value)}
|
||||||
placeholder="مثال: شیفت اضطراری، جلسه..." className="cp-input" />
|
placeholder={overrideType === 'closed' ? 'مثال: سفر، مریضی، کنگره...' : 'مثال: شیفت اضطراری، ویزیت خاص...'}
|
||||||
|
className="cp-input" />
|
||||||
</div>
|
</div>
|
||||||
{active ? (
|
|
||||||
|
{overrideType === 'custom' && (
|
||||||
<div>
|
<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>
|
<p className="text-xs text-slate-400 dark:text-slate-500 mb-2">اگر خالی بماند از برنامه هفتگی استفاده میشود</p>
|
||||||
<SlotEditor slots={slots} onChange={setSlots} />
|
<SlotEditor slots={slots} onChange={setSlots} />
|
||||||
</div>
|
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1175,26 +1472,29 @@ function DateOverridesTab({ doctorUuid }: { doctorUuid: string }) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{overrides.map(ov => (
|
{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-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<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-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 ? (
|
||||||
{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">
|
||||||
</span>
|
{ov.custom_slots.length > 0 ? `${ov.custom_slots.length} بازه سفارشی` : 'ساعات خاص'}
|
||||||
{ov.custom_slots.length > 0 && (
|
</span>
|
||||||
<span className="text-xs text-slate-400 dark:text-slate-500">{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>
|
</div>
|
||||||
{ov.reason && <p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{ov.reason}</p>}
|
{ov.reason && <p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{ov.reason}</p>}
|
||||||
</div>
|
</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); }}
|
<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" />
|
<PencilIcon className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setDeletingUuid(ov.uuid)}
|
<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" />
|
<TrashIcon className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1250,7 +1550,7 @@ function HolidayModal({ open, onClose, existing, doctorUuid, onSaved }: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={open} title={existing ? 'ویرایش تعطیلات' : 'افزودن تعطیلات'} size="sm" onClose={onClose}
|
<Modal open={open} title={existing ? 'ویرایش تعطیلات' : 'افزودن تعطیلات'} size="md" onClose={onClose}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<button type="button" onClick={onClose} className="cp-btn-secondary px-5">لغو</button>
|
<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">
|
<div className="space-y-2">
|
||||||
{holidays.map(h => {
|
{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 (
|
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-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<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))}`}
|
{sameDay ? formatPersianDate(tsToDate(h.start_date)) : `${formatPersianDate(tsToDate(h.start_date))} تا ${formatPersianDate(tsToDate(h.end_date))}`}
|
||||||
</span>
|
</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'}`}>
|
{isCurrent && h.active && (
|
||||||
{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>
|
||||||
</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>
|
</div>
|
||||||
{h.reason && <p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{h.reason}</p>}
|
{h.reason && <p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{h.reason}</p>}
|
||||||
</div>
|
</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}
|
<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">
|
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 ? 'غیرفعال کن' : 'فعال کن'}
|
{h.active ? 'غیرفعال' : 'فعال'}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => { setEditing(h); setModalOpen(true); }}
|
<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" />
|
<PencilIcon className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setDeletingUuid(h.uuid)}
|
<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" />
|
<TrashIcon className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1385,7 +1697,7 @@ const SCHEDULE_TABS = [
|
|||||||
{ id: 'holidays' as const, label: 'تعطیلات' },
|
{ 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');
|
const [tab, setTab] = useState<'weekly' | 'overrides' | 'holidays'>('weekly');
|
||||||
return (
|
return (
|
||||||
<div className="cp-card p-6">
|
<div className="cp-card p-6">
|
||||||
@@ -1402,7 +1714,7 @@ function ScheduleSection({ doctorUuid }: { doctorUuid: string }) {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{tab === 'weekly' && <WeeklyScheduleTab doctorUuid={doctorUuid} />}
|
{tab === 'weekly' && <WeeklyScheduleTab doctorUuid={doctorUuid} addresses={addresses} />}
|
||||||
{tab === 'overrides' && <DateOverridesTab doctorUuid={doctorUuid} />}
|
{tab === 'overrides' && <DateOverridesTab doctorUuid={doctorUuid} />}
|
||||||
{tab === 'holidays' && <HolidaysTab doctorUuid={doctorUuid} />}
|
{tab === 'holidays' && <HolidaysTab doctorUuid={doctorUuid} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -1765,7 +2077,7 @@ export default function DoctorDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{uuid && <ScheduleSection doctorUuid={uuid} />}
|
{uuid && <ScheduleSection doctorUuid={uuid} addresses={doctor.address ?? []} />}
|
||||||
|
|
||||||
{doctor.clinics && doctor.clinics.length > 0 && (
|
{doctor.clinics && doctor.clinics.length > 0 && (
|
||||||
<div className="cp-card p-6">
|
<div className="cp-card p-6">
|
||||||
|
|||||||
@@ -2,20 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Appointment\Service;
|
namespace App\Appointment\Service;
|
||||||
|
|
||||||
use App\Appointment\Entity\Appointment;
|
|
||||||
use App\Appointment\Repository\AppointmentRepository;
|
use App\Appointment\Repository\AppointmentRepository;
|
||||||
use App\Appointment\Repository\DateOverrideRepository;
|
use App\Appointment\Repository\DateOverrideRepository;
|
||||||
use App\Appointment\Repository\HolidayRepository;
|
use App\Appointment\Repository\HolidayRepository;
|
||||||
use App\Appointment\Repository\WeeklyScheduleRepository;
|
use App\Appointment\Repository\WeeklyScheduleRepository;
|
||||||
use App\Doctor\Entity\Doctor;
|
use App\Doctor\Entity\Doctor;
|
||||||
|
|
||||||
|
|
||||||
class SlotCalculatorService
|
class SlotCalculatorService
|
||||||
{
|
{
|
||||||
private const DAY_MAP = [
|
|
||||||
0 => 'sunday', 1 => 'monday', 2 => 'tuesday', 3 => 'wednesday',
|
|
||||||
4 => 'thursday', 5 => 'friday', 6 => 'saturday',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly WeeklyScheduleRepository $scheduleRepo,
|
private readonly WeeklyScheduleRepository $scheduleRepo,
|
||||||
private readonly DateOverrideRepository $overrideRepo,
|
private readonly DateOverrideRepository $overrideRepo,
|
||||||
@@ -25,64 +20,133 @@ class SlotCalculatorService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns available slots for a doctor on a given date.
|
* Returns available slots for a doctor on a given date.
|
||||||
* @param string $date 'Y-m-d' format
|
* Day index convention: 0=Saturday(شنبه), 1=Sunday, ..., 6=Friday(جمعه)
|
||||||
* @return array[] [{start: int, end: int, start_time: string, end_time: string}]
|
*
|
||||||
|
* @return array[] [{start: int, end: int, start_time: string, end_time: string, location_id: int|null}]
|
||||||
*/
|
*/
|
||||||
public function getAvailableSlots(Doctor $doctor, string $date): array
|
public function getAvailableSlots(Doctor $doctor, string $date): array
|
||||||
{
|
{
|
||||||
$dayStart = (int) strtotime($date . ' 00:00:00');
|
$dayStart = (int) strtotime($date . ' 00:00:00');
|
||||||
$dayEnd = $dayStart + 86400;
|
$dayEnd = $dayStart + 86400;
|
||||||
|
|
||||||
// Check if in holiday
|
// 1. Blocked by holiday
|
||||||
$holidays = $this->holidayRepo->findActiveByDoctor($doctor, $dayStart, $dayEnd - 1);
|
if (!empty($this->holidayRepo->findActiveByDoctor($doctor, $dayStart, $dayEnd - 1))) {
|
||||||
if (!empty($holidays)) return [];
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
// Check date override first
|
// 2. Date override takes precedence over weekly schedule
|
||||||
$overrides = $this->overrideRepo->findByDoctor($doctor);
|
foreach ($this->overrideRepo->findByDoctor($doctor) as $override) {
|
||||||
foreach ($overrides as $override) {
|
if (date('Y-m-d', $override->getDate()) === $date) {
|
||||||
$overDate = date('Y-m-d', $override->getDate());
|
|
||||||
if ($overDate === $date) {
|
|
||||||
if (!$override->isActive()) return [];
|
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);
|
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
||||||
if ($schedule === null) return [];
|
if ($schedule === null) return [];
|
||||||
|
|
||||||
$dow = (int) date('w', $dayStart);
|
// Convert PHP date('w') (0=Sunday) to Iranian week index (0=Saturday)
|
||||||
$dayName = self::DAY_MAP[$dow];
|
$phpDow = (int) date('w', $dayStart);
|
||||||
$setting = $schedule->getSetting();
|
$dayKey = (string)(($phpDow + 1) % 7);
|
||||||
|
|
||||||
$dayConfig = $setting[$dayName] ?? null;
|
$dayConf = $schedule->getSetting()[$dayKey] ?? null;
|
||||||
if ($dayConfig === null || !($dayConfig['active'] ?? false)) return [];
|
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
|
$allSlots = [];
|
||||||
return $this->filterBookedSlots($doctor, $rawSlots);
|
$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 = [];
|
$slots = [];
|
||||||
foreach ($slotConfigs as $config) {
|
foreach ($slotConfigs as $config) {
|
||||||
$startSec = $this->parseTime($config['start'] ?? '00:00');
|
$startSec = $this->parseTime($config['start'] ?? '00:00');
|
||||||
$endSec = $this->parseTime($config['end'] ?? '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;
|
if ($duration <= 0 || $endSec <= $startSec) continue;
|
||||||
|
|
||||||
for ($t = $startSec; $t + $duration <= $endSec; $t += $duration) {
|
for ($t = $startSec; $t + $duration <= $endSec; $t += $duration) {
|
||||||
$slotStart = $dayStart + $t;
|
$slots[] = [
|
||||||
$slotEnd = $slotStart + $duration;
|
'start' => $dayStart + $t,
|
||||||
$slots[] = [
|
'end' => $dayStart + $t + $duration,
|
||||||
'start' => $slotStart,
|
'start_time' => gmdate('H:i', $t),
|
||||||
'end' => $slotEnd,
|
'end_time' => gmdate('H:i', $t + $duration),
|
||||||
'start_time' => gmdate('H:i', $t),
|
'location_id' => null,
|
||||||
'end_time' => gmdate('H:i', $t + $duration),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,9 +155,9 @@ class SlotCalculatorService
|
|||||||
|
|
||||||
private function filterBookedSlots(Doctor $doctor, array $slots): array
|
private function filterBookedSlots(Doctor $doctor, array $slots): array
|
||||||
{
|
{
|
||||||
return array_values(array_filter($slots, function (array $slot) use ($doctor): bool {
|
return array_values(array_filter($slots, fn(array $slot): bool =>
|
||||||
return !$this->appointmentRepo->isSlotTaken($doctor, $slot['start'], $slot['end']);
|
!$this->appointmentRepo->isSlotTaken($doctor, $slot['start'], $slot['end'])
|
||||||
}));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function parseTime(string $time): int
|
private function parseTime(string $time): int
|
||||||
|
|||||||
Reference in New Issue
Block a user