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,
|
||||
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">
|
||||
|
||||
Reference in New Issue
Block a user