/** * GuideScheduleManager - Компонент для планирования рабочих смен гидов */ class GuideScheduleManager { constructor(options = {}) { this.container = options.container || document.body; this.onScheduleChange = options.onScheduleChange || null; this.allowMultiSelect = options.allowMultiSelect !== false; this.currentDate = new Date(); this.currentDate.setDate(1); // Установить на первый день месяца this.selectedGuides = new Set(); this.workingDays = new Map(); // guideId -> Set of dates this.guides = []; this.init(); } async init() { this.render(); await this.loadGuides(); this.bindEvents(); this.renderCalendar(); } render() { this.container.innerHTML = `

Планировщик рабочих смен

Выбор гидов
Выберите гидов для планирования
Быстрые действия
Статистика рабочих дней
`; this.injectStyles(); } injectStyles() { if (document.getElementById('schedule-manager-styles')) return; const styles = ` `; document.head.insertAdjacentHTML('beforeend', styles); } async loadGuides() { try { const response = await fetch('/api/guides'); const data = await response.json(); this.guides = Array.isArray(data) ? data : (data.data || []); this.renderGuidesList(); await this.loadSchedules(); } catch (error) { console.error('Ошибка загрузки гидов:', error); } } renderGuidesList() { const guidesContainer = document.getElementById('guidesList'); guidesContainer.innerHTML = this.guides.map(guide => `
`).join(''); } async loadSchedules() { const year = this.currentDate.getFullYear(); const month = this.currentDate.getMonth() + 1; try { const response = await fetch(`/api/guide-schedules?year=${year}&month=${month}`); const data = await response.json(); const schedules = Array.isArray(data) ? data : (data.data || []); this.workingDays.clear(); schedules.forEach(schedule => { if (!this.workingDays.has(schedule.guide_id)) { this.workingDays.set(schedule.guide_id, new Set()); } this.workingDays.get(schedule.guide_id).add(schedule.work_date); }); this.renderCalendar(); this.updateStats(); } catch (error) { console.error('Ошибка загрузки расписания:', error); } } renderCalendar() { const calendar = document.getElementById('scheduleCalendar'); const monthLabel = document.getElementById('currentMonthLabel'); const year = this.currentDate.getFullYear(); const month = this.currentDate.getMonth(); monthLabel.textContent = this.currentDate.toLocaleDateString('ru-RU', { year: 'numeric', month: 'long' }); const firstDay = new Date(year, month, 1); const lastDay = new Date(year, month + 1, 0); const startDate = new Date(firstDay); startDate.setDate(startDate.getDate() - firstDay.getDay() + 1); // Начать с понедельника const weeks = []; let currentWeek = []; let currentDate = new Date(startDate); for (let i = 0; i < 42; i++) { currentWeek.push(new Date(currentDate)); currentDate.setDate(currentDate.getDate() + 1); if (currentWeek.length === 7) { weeks.push(currentWeek); currentWeek = []; } } const calendarHTML = ` ${weeks.map(week => ` ${week.map(date => this.renderCalendarDay(date, month)).join('')} `).join('')}
Пн Вт Ср Чт Пт Сб Вс
`; calendar.innerHTML = calendarHTML; } renderCalendarDay(date, currentMonth) { const dateStr = date.toISOString().split('T')[0]; const dayOfWeek = date.getDay(); const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; const isCurrentMonth = date.getMonth() === currentMonth; let classes = ['calendar-day']; if (!isCurrentMonth) { classes.push('other-month'); } else if (isWeekend) { classes.push('weekend'); } // Проверить, работают ли гиды в этот день const workingGuidesCount = this.getWorkingGuidesForDate(dateStr).length; const selectedGuidesCount = this.selectedGuides.size; if (workingGuidesCount > 0) { if (selectedGuidesCount === 0 || workingGuidesCount === selectedGuidesCount) { classes.push('working'); } else { classes.push('partial-working'); } } const workingGuides = this.getWorkingGuidesForDate(dateStr); const workingGuidesHTML = workingGuides.length > 0 ? `
${workingGuides.slice(0, 5).map(guide => ` ${guide.name.charAt(0)} `).join('')} ${workingGuides.length > 5 ? `+${workingGuides.length - 5}` : ''}
` : ''; return `
${date.getDate()}
${workingGuidesHTML} `; } getWorkingGuidesForDate(dateStr) { const working = []; this.workingDays.forEach((dates, guideId) => { if (dates.has(dateStr)) { const guide = this.guides.find(g => g.id == guideId); if (guide) working.push(guide); } }); return working; } bindEvents() { // Навигация по месяцам document.getElementById('prevMonth').addEventListener('click', () => { this.currentDate.setMonth(this.currentDate.getMonth() - 1); this.loadSchedules(); }); document.getElementById('nextMonth').addEventListener('click', () => { this.currentDate.setMonth(this.currentDate.getMonth() + 1); this.loadSchedules(); }); // Выбор гидов document.getElementById('guidesList').addEventListener('change', (e) => { if (e.target.type === 'checkbox') { this.handleGuideSelection(e.target); } }); // Быстрые действия document.getElementById('selectAllGuides').addEventListener('click', () => this.selectAllGuides()); document.getElementById('clearGuideSelection').addEventListener('click', () => this.clearGuideSelection()); // Быстрое планирование document.getElementById('markWeekdays').addEventListener('click', () => this.markWeekdays()); document.getElementById('markWeekends').addEventListener('click', () => this.markWeekends()); document.getElementById('markFullMonth').addEventListener('click', () => this.markFullMonth()); document.getElementById('clearMonth').addEventListener('click', () => this.clearMonth()); // Копирование между месяцами document.getElementById('copyPrevMonth').addEventListener('click', () => this.copyFromPreviousMonth()); document.getElementById('copyNextMonth').addEventListener('click', () => this.copyToNextMonth()); // Сохранение document.getElementById('saveSchedule').addEventListener('click', () => this.saveSchedule()); // Клики по дням календаря this.container.addEventListener('click', (e) => { const calendarDay = e.target.closest('.calendar-day'); if (calendarDay && !calendarDay.classList.contains('other-month')) { this.handleDayClick(calendarDay); } }); } handleGuideSelection(checkbox) { const guideId = parseInt(checkbox.value); const guideCheckbox = checkbox.closest('.guide-checkbox'); if (checkbox.checked) { this.selectedGuides.add(guideId); guideCheckbox.classList.add('selected'); } else { this.selectedGuides.delete(guideId); guideCheckbox.classList.remove('selected'); } this.renderCalendar(); } selectAllGuides() { const checkboxes = document.querySelectorAll('#guidesList input[type="checkbox"]'); checkboxes.forEach(cb => { cb.checked = true; this.handleGuideSelection(cb); }); } clearGuideSelection() { const checkboxes = document.querySelectorAll('#guidesList input[type="checkbox"]'); checkboxes.forEach(cb => { cb.checked = false; this.handleGuideSelection(cb); }); } handleDayClick(dayElement) { if (this.selectedGuides.size === 0) { alert('Выберите хотя бы одного гида для планирования смен'); return; } const dateStr = dayElement.dataset.date; const isWorking = dayElement.classList.contains('working') || dayElement.classList.contains('partial-working'); this.selectedGuides.forEach(guideId => { if (!this.workingDays.has(guideId)) { this.workingDays.set(guideId, new Set()); } const guideDates = this.workingDays.get(guideId); if (isWorking && this.allSelectedGuidesWorkingOnDate(dateStr)) { // Если все выбранные гиды работают в этот день, убираем их guideDates.delete(dateStr); } else { // Иначе добавляем день guideDates.add(dateStr); } }); this.renderCalendar(); this.updateStats(); } allSelectedGuidesWorkingOnDate(dateStr) { for (let guideId of this.selectedGuides) { if (!this.workingDays.has(guideId) || !this.workingDays.get(guideId).has(dateStr)) { return false; } } return true; } markWeekdays() { if (this.selectedGuides.size === 0) return; const year = this.currentDate.getFullYear(); const month = this.currentDate.getMonth(); const daysInMonth = new Date(year, month + 1, 0).getDate(); for (let day = 1; day <= daysInMonth; day++) { const date = new Date(year, month, day); const dayOfWeek = date.getDay(); if (dayOfWeek >= 1 && dayOfWeek <= 5) { // Понедельник - Пятница const dateStr = date.toISOString().split('T')[0]; this.selectedGuides.forEach(guideId => { if (!this.workingDays.has(guideId)) { this.workingDays.set(guideId, new Set()); } this.workingDays.get(guideId).add(dateStr); }); } } this.renderCalendar(); this.updateStats(); } markWeekends() { if (this.selectedGuides.size === 0) return; const year = this.currentDate.getFullYear(); const month = this.currentDate.getMonth(); const daysInMonth = new Date(year, month + 1, 0).getDate(); for (let day = 1; day <= daysInMonth; day++) { const date = new Date(year, month, day); const dayOfWeek = date.getDay(); if (dayOfWeek === 0 || dayOfWeek === 6) { // Суббота - Воскресенье const dateStr = date.toISOString().split('T')[0]; this.selectedGuides.forEach(guideId => { if (!this.workingDays.has(guideId)) { this.workingDays.set(guideId, new Set()); } this.workingDays.get(guideId).add(dateStr); }); } } this.renderCalendar(); this.updateStats(); } markFullMonth() { if (this.selectedGuides.size === 0) return; const year = this.currentDate.getFullYear(); const month = this.currentDate.getMonth(); const daysInMonth = new Date(year, month + 1, 0).getDate(); for (let day = 1; day <= daysInMonth; day++) { const date = new Date(year, month, day); const dateStr = date.toISOString().split('T')[0]; this.selectedGuides.forEach(guideId => { if (!this.workingDays.has(guideId)) { this.workingDays.set(guideId, new Set()); } this.workingDays.get(guideId).add(dateStr); }); } this.renderCalendar(); this.updateStats(); } clearMonth() { if (this.selectedGuides.size === 0) return; const year = this.currentDate.getFullYear(); const month = this.currentDate.getMonth(); const daysInMonth = new Date(year, month + 1, 0).getDate(); for (let day = 1; day <= daysInMonth; day++) { const date = new Date(year, month, day); const dateStr = date.toISOString().split('T')[0]; this.selectedGuides.forEach(guideId => { if (this.workingDays.has(guideId)) { this.workingDays.get(guideId).delete(dateStr); } }); } this.renderCalendar(); this.updateStats(); } async copyFromPreviousMonth() { const prevMonth = new Date(this.currentDate); prevMonth.setMonth(prevMonth.getMonth() - 1); const year = prevMonth.getFullYear(); const month = prevMonth.getMonth() + 1; try { const response = await fetch(`/api/guide-schedules?year=${year}&month=${month}`); const data = await response.json(); const schedules = Array.isArray(data) ? data : (data.data || []); // Копируем расписание из предыдущего месяца в текущий schedules.forEach(schedule => { const prevDate = new Date(schedule.work_date); const currentMonthDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), prevDate.getDate()); // Проверяем, что день существует в текущем месяце if (currentMonthDate.getMonth() === this.currentDate.getMonth()) { const dateStr = currentMonthDate.toISOString().split('T')[0]; if (!this.workingDays.has(schedule.guide_id)) { this.workingDays.set(schedule.guide_id, new Set()); } this.workingDays.get(schedule.guide_id).add(dateStr); } }); this.renderCalendar(); this.updateStats(); alert('Расписание скопировано из предыдущего месяца'); } catch (error) { console.error('Ошибка копирования расписания:', error); alert('Ошибка при копировании расписания'); } } async copyToNextMonth() { // Сначала сохраняем текущие изменения await this.saveSchedule(false); const nextMonth = new Date(this.currentDate); nextMonth.setMonth(nextMonth.getMonth() + 1); const scheduleData = []; const year = nextMonth.getFullYear(); const month = nextMonth.getMonth(); const daysInMonth = new Date(year, month + 1, 0).getDate(); // Создаем расписание для следующего месяца this.workingDays.forEach((dates, guideId) => { dates.forEach(dateStr => { const currentDate = new Date(dateStr); const day = currentDate.getDate(); // Проверяем, что день существует в следующем месяце if (day <= daysInMonth) { const nextMonthDate = new Date(year, month, day); const nextDateStr = nextMonthDate.toISOString().split('T')[0]; scheduleData.push({ guide_id: guideId, work_date: nextDateStr }); } }); }); try { const response = await fetch('/api/guide-schedules/batch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ schedules: scheduleData }) }); if (response.ok) { alert('Расписание скопировано в следующий месяц'); } else { throw new Error('Ошибка сохранения'); } } catch (error) { console.error('Ошибка копирования расписания:', error); alert('Ошибка при копировании расписания в следующий месяц'); } } async saveSchedule(showAlert = true) { const scheduleData = []; const year = this.currentDate.getFullYear(); const month = this.currentDate.getMonth() + 1; this.workingDays.forEach((dates, guideId) => { dates.forEach(dateStr => { const date = new Date(dateStr); if (date.getFullYear() === year && date.getMonth() + 1 === month) { scheduleData.push({ guide_id: guideId, work_date: dateStr }); } }); }); try { const response = await fetch(`/api/guide-schedules?year=${year}&month=${month}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ schedules: scheduleData }) }); if (response.ok) { if (showAlert) { alert('Расписание сохранено успешно'); } if (this.onScheduleChange) { this.onScheduleChange(scheduleData); } } else { throw new Error('Ошибка сохранения'); } } catch (error) { console.error('Ошибка сохранения расписания:', error); if (showAlert) { alert('Ошибка при сохранении расписания'); } } } updateStats() { const statsContainer = document.getElementById('scheduleStats'); const year = this.currentDate.getFullYear(); const month = this.currentDate.getMonth(); const daysInMonth = new Date(year, month + 1, 0).getDate(); // Подсчет статистики const stats = { totalGuides: this.guides.length, activeGuides: 0, totalWorkingDays: 0, averageWorkingDays: 0 }; const guideWorkingDays = new Map(); this.workingDays.forEach((dates, guideId) => { const currentMonthDays = Array.from(dates).filter(dateStr => { const date = new Date(dateStr); return date.getFullYear() === year && date.getMonth() === month; }); if (currentMonthDays.length > 0) { stats.activeGuides++; guideWorkingDays.set(guideId, currentMonthDays.length); stats.totalWorkingDays += currentMonthDays.length; } }); stats.averageWorkingDays = stats.activeGuides > 0 ? Math.round(stats.totalWorkingDays / stats.activeGuides * 10) / 10 : 0; const coverage = stats.activeGuides > 0 ? Math.round((stats.totalWorkingDays / (daysInMonth * stats.activeGuides)) * 100) : 0; statsContainer.innerHTML = `
${stats.totalGuides}
Всего гидов
${stats.activeGuides}
Активных гидов
${stats.averageWorkingDays}
Ср. дней/гид
${coverage}%
Покрытие месяца
`; } } if (typeof window !== 'undefined') { window.GuideScheduleManager = GuideScheduleManager; }