Files
tourrism_site/public/components/guide-schedule-manager.js
Andrey K. Choi 13c752b93a feat: Оптимизация навигации AdminJS в логические группы
- Объединены ресурсы в 5 логических групп: Контент сайта, Бронирования, Отзывы и рейтинги, Персонал и гиды, Администрирование
- Удалены дублирующие настройки navigation для чистой группировки
- Добавлены CSS стили для визуального отображения иерархии с отступами
- Добавлены эмодзи-иконки для каждого типа ресурсов через CSS
- Улучшена навигация с правильной вложенностью элементов
2025-11-30 21:57:58 +09:00

865 lines
34 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 = `
<div class="schedule-manager">
<div class="schedule-header">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4><i class="fas fa-calendar-week me-2"></i>Планировщик рабочих смен</h4>
<div class="schedule-actions">
<button class="btn btn-outline-secondary btn-sm" id="copyPrevMonth">
<i class="fas fa-copy me-1"></i>Скопировать из прошлого месяца
</button>
<button class="btn btn-outline-secondary btn-sm" id="copyNextMonth">
<i class="fas fa-paste me-1"></i>Скопировать в следующий месяц
</button>
<button class="btn btn-primary btn-sm" id="saveSchedule">
<i class="fas fa-save me-1"></i>Сохранить изменения
</button>
</div>
</div>
</div>
<div class="row">
<!-- Панель выбора гидов -->
<div class="col-lg-3">
<div class="guides-panel">
<div class="card">
<div class="card-header">
<h6 class="mb-0">Выбор гидов</h6>
<small class="text-muted">Выберите гидов для планирования</small>
</div>
<div class="card-body p-2">
<div class="guide-selection-actions mb-3">
<button class="btn btn-sm btn-outline-primary w-100 mb-2" id="selectAllGuides">
<i class="fas fa-check-square me-1"></i>Выбрать всех
</button>
<button class="btn btn-sm btn-outline-secondary w-100" id="clearGuideSelection">
<i class="fas fa-square me-1"></i>Очистить выбор
</button>
</div>
<div id="guidesList" class="guides-list"></div>
</div>
</div>
<!-- Панель быстрых действий -->
<div class="card mt-3">
<div class="card-header">
<h6 class="mb-0">Быстрые действия</h6>
</div>
<div class="card-body p-2">
<div class="d-grid gap-2">
<button class="btn btn-sm btn-success" id="markWeekdays">
<i class="fas fa-business-time me-1"></i>Отметить будни
</button>
<button class="btn btn-sm btn-warning" id="markWeekends">
<i class="fas fa-calendar-day me-1"></i>Отметить выходные
</button>
<button class="btn btn-sm btn-info" id="markFullMonth">
<i class="fas fa-calendar-check me-1"></i>Весь месяц
</button>
<button class="btn btn-sm btn-danger" id="clearMonth">
<i class="fas fa-calendar-times me-1"></i>Очистить месяц
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Календарь -->
<div class="col-lg-9">
<div class="calendar-container">
<div class="card">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<button class="btn btn-outline-primary btn-sm" id="prevMonth">
<i class="fas fa-chevron-left"></i>
</button>
<h5 class="mb-0" id="currentMonthLabel"></h5>
<button class="btn btn-outline-primary btn-sm" id="nextMonth">
<i class="fas fa-chevron-right"></i>
</button>
</div>
</div>
<div class="card-body p-0">
<div id="scheduleCalendar"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Статистика -->
<div class="schedule-stats mt-4">
<div class="card">
<div class="card-header">
<h6 class="mb-0">Статистика рабочих дней</h6>
</div>
<div class="card-body">
<div id="scheduleStats" class="row g-3"></div>
</div>
</div>
</div>
</div>
`;
this.injectStyles();
}
injectStyles() {
if (document.getElementById('schedule-manager-styles')) return;
const styles = `
<style id="schedule-manager-styles">
.schedule-manager {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.guide-checkbox {
margin-bottom: 8px;
padding: 8px;
border: 1px solid #dee2e6;
border-radius: 4px;
transition: all 0.2s;
}
.guide-checkbox:hover {
background-color: #f8f9fa;
}
.guide-checkbox.selected {
background-color: #e3f2fd;
border-color: #2196f3;
}
.guide-checkbox input[type="checkbox"] {
margin-right: 8px;
}
.guide-info {
font-size: 14px;
}
.guide-name {
font-weight: 600;
color: #2c3e50;
}
.guide-specialization {
font-size: 12px;
color: #6c757d;
}
.schedule-calendar {
width: 100%;
}
.calendar-day {
min-height: 80px;
border: 1px solid #dee2e6;
position: relative;
cursor: pointer;
transition: all 0.2s;
padding: 8px;
}
.calendar-day:hover {
background-color: #f8f9fa;
}
.calendar-day.selected {
background-color: #e3f2fd;
}
.calendar-day.working {
background-color: #d4edda;
border-color: #28a745;
}
.calendar-day.partial-working {
background: linear-gradient(45deg, #d4edda 50%, #fff3cd 50%);
border-color: #ffc107;
}
.calendar-day.weekend {
background-color: #fff3cd;
}
.calendar-day.other-month {
color: #6c757d;
background-color: #f8f9fa;
}
.day-number {
font-weight: 600;
font-size: 14px;
}
.working-guides {
font-size: 10px;
color: #28a745;
margin-top: 4px;
}
.working-guides .guide-initial {
display: inline-block;
width: 16px;
height: 16px;
background: #28a745;
color: white;
border-radius: 50%;
text-align: center;
line-height: 16px;
margin-right: 2px;
font-size: 9px;
}
.calendar-header {
background: #f8f9fa;
padding: 8px;
font-weight: 600;
text-align: center;
border: 1px solid #dee2e6;
}
.schedule-stats .stat-item {
text-align: center;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
}
.stat-number {
font-size: 24px;
font-weight: 700;
color: #2c3e50;
}
.stat-label {
font-size: 12px;
color: #6c757d;
text-transform: uppercase;
}
@media (max-width: 768px) {
.schedule-actions {
flex-direction: column;
gap: 8px;
}
.calendar-day {
min-height: 60px;
padding: 4px;
}
.working-guides .guide-initial {
width: 12px;
height: 12px;
line-height: 12px;
font-size: 8px;
}
}
</style>
`;
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 => `
<div class="guide-checkbox" data-guide-id="${guide.id}">
<label class="d-flex align-items-center guide-info">
<input type="checkbox" value="${guide.id}">
<div class="flex-grow-1">
<div class="guide-name">${guide.name}</div>
<div class="guide-specialization">${guide.specialization || 'Универсальный'}</div>
</div>
</label>
</div>
`).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 = `
<table class="schedule-calendar table table-bordered mb-0">
<thead>
<tr>
<th class="calendar-header">Пн</th>
<th class="calendar-header">Вт</th>
<th class="calendar-header">Ср</th>
<th class="calendar-header">Чт</th>
<th class="calendar-header">Пт</th>
<th class="calendar-header">Сб</th>
<th class="calendar-header">Вс</th>
</tr>
</thead>
<tbody>
${weeks.map(week => `
<tr>
${week.map(date => this.renderCalendarDay(date, month)).join('')}
</tr>
`).join('')}
</tbody>
</table>
`;
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 ? `
<div class="working-guides">
${workingGuides.slice(0, 5).map(guide => `
<span class="guide-initial" title="${guide.name}">${guide.name.charAt(0)}</span>
`).join('')}
${workingGuides.length > 5 ? `<span title="+${workingGuides.length - 5} еще">+${workingGuides.length - 5}</span>` : ''}
</div>
` : '';
return `
<td class="${classes.join(' ')}" data-date="${dateStr}">
<div class="day-number">${date.getDate()}</div>
${workingGuidesHTML}
</td>
`;
}
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 = `
<div class="col-md-3">
<div class="stat-item">
<div class="stat-number text-primary">${stats.totalGuides}</div>
<div class="stat-label">Всего гидов</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-item">
<div class="stat-number text-success">${stats.activeGuides}</div>
<div class="stat-label">Активных гидов</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-item">
<div class="stat-number text-info">${stats.averageWorkingDays}</div>
<div class="stat-label">Ср. дней/гид</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-item">
<div class="stat-number text-warning">${coverage}%</div>
<div class="stat-label">Покрытие месяца</div>
</div>
</div>
`;
}
}
if (typeof window !== 'undefined') {
window.GuideScheduleManager = GuideScheduleManager;
}