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

633 lines
22 KiB
JavaScript
Raw 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.

/**
* GuideCalendarWidget - Переиспользуемый компонент календаря гидов
* Может использоваться на фронтенде для бронирования и в админке
*/
class GuideCalendarWidget {
constructor(options = {}) {
this.container = options.container || document.body;
this.mode = options.mode || 'booking'; // 'booking', 'admin', 'readonly'
this.onDateSelect = options.onDateSelect || null;
this.onGuideSelect = options.onGuideSelect || null;
this.showGuideFilter = options.showGuideFilter !== false;
this.showLegend = options.showLegend !== false;
this.compact = options.compact || false;
this.selectedDate = options.selectedDate || null;
this.selectedGuideId = options.selectedGuideId || null;
this.currentDate = new Date();
this.guides = [];
this.schedules = [];
this.holidays = [];
this.bookings = [];
this.selectedGuides = new Set();
this.init();
}
async init() {
this.render();
await this.loadData();
this.renderGuidesFilter();
this.renderCalendar();
this.updateMonthDisplay();
this.bindEvents();
}
render() {
const compactClass = this.compact ? 'calendar-compact' : '';
const modeClass = `calendar-mode-${this.mode}`;
this.container.innerHTML = `
<div class="guide-calendar-widget ${compactClass} ${modeClass}">
<div class="calendar-header">
<div class="calendar-navigation">
<button class="nav-button" data-action="prev-month"></button>
<span class="current-date" id="currentDate-${this.getId()}"></span>
<button class="nav-button" data-action="next-month"></button>
</div>
${this.showGuideFilter ? `
<div class="guides-filter">
<span class="filter-label">Гиды:</span>
<div class="guides-filter-container" id="guidesFilter-${this.getId()}"></div>
</div>
` : ''}
</div>
<div class="calendar-grid" id="calendarGrid-${this.getId()}"></div>
${this.showLegend ? `
<div class="calendar-legend">
<div class="legend-item">
<div class="legend-color legend-working"></div>
<span>Доступен</span>
</div>
<div class="legend-item">
<div class="legend-color legend-holiday"></div>
<span>Выходной</span>
</div>
<div class="legend-item">
<div class="legend-color legend-busy"></div>
<span>Занят</span>
</div>
${this.mode === 'booking' ? `
<div class="legend-item">
<div class="legend-color legend-selected"></div>
<span>Выбранная дата</span>
</div>
` : ''}
</div>
` : ''}
</div>
`;
this.injectStyles();
}
getId() {
if (!this._id) {
this._id = 'calendar-' + Math.random().toString(36).substr(2, 9);
}
return this._id;
}
injectStyles() {
if (document.getElementById('guide-calendar-styles')) return;
const styles = `
<style id="guide-calendar-styles">
.guide-calendar-widget {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 100%;
}
.calendar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
flex-wrap: wrap;
gap: 15px;
}
.calendar-navigation {
display: flex;
align-items: center;
gap: 15px;
}
.nav-button {
background: #007bff;
color: white;
border: none;
padding: 8px 12px;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s;
}
.nav-button:hover {
background: #0056b3;
}
.current-date {
font-size: 18px;
font-weight: 600;
color: #343a40;
min-width: 150px;
text-align: center;
}
.guides-filter {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.filter-label {
font-weight: 600;
color: #495057;
}
.guides-filter-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.guide-checkbox {
display: flex;
align-items: center;
gap: 5px;
padding: 6px 10px;
border: 2px solid #dee2e6;
border-radius: 15px;
cursor: pointer;
transition: all 0.3s;
background: white;
font-size: 12px;
}
.guide-checkbox:hover {
border-color: #007bff;
background: #e3f2fd;
}
.guide-checkbox.checked {
background: #007bff;
color: white;
border-color: #007bff;
}
.guide-checkbox input[type="checkbox"] {
display: none;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background: #dee2e6;
border-radius: 8px;
overflow: hidden;
}
.calendar-day {
background: white;
min-height: 80px;
padding: 6px;
position: relative;
border: 1px solid transparent;
cursor: pointer;
transition: all 0.2s;
}
.calendar-day:hover {
background: #f0f8ff;
}
.calendar-day.other-month {
background: #f8f9fa;
color: #6c757d;
}
.calendar-day.today {
background: #fff3cd;
border-color: #ffeaa7;
}
.calendar-day.selected {
background: #e3f2fd;
border-color: #007bff;
box-shadow: inset 0 0 0 2px #007bff;
}
.day-number {
font-weight: 600;
font-size: 14px;
margin-bottom: 6px;
}
.day-header {
background: #343a40;
color: white;
padding: 10px 6px;
text-align: center;
font-weight: 600;
font-size: 12px;
}
.guide-status {
display: flex;
flex-wrap: wrap;
gap: 2px;
}
.guide-badge {
padding: 1px 4px;
border-radius: 8px;
font-size: 9px;
font-weight: 500;
white-space: nowrap;
}
.guide-badge.working {
background: #d4edda;
color: #155724;
}
.guide-badge.holiday {
background: #f8d7da;
color: #721c24;
}
.guide-badge.busy {
background: #fff3cd;
color: #856404;
}
.calendar-legend {
display: flex;
justify-content: center;
gap: 20px;
margin-top: 15px;
padding: 10px;
background: #f8f9fa;
border-radius: 8px;
font-size: 12px;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 5px;
}
.legend-color {
width: 10px;
height: 10px;
border-radius: 2px;
}
.legend-working { background: #d4edda; }
.legend-holiday { background: #f8d7da; }
.legend-busy { background: #fff3cd; }
.legend-selected { background: #e3f2fd; border: 1px solid #007bff; }
/* Компактный режим */
.calendar-compact .calendar-day {
min-height: 60px;
padding: 4px;
}
.calendar-compact .day-number {
font-size: 12px;
margin-bottom: 4px;
}
.calendar-compact .guide-badge {
font-size: 8px;
padding: 1px 3px;
}
/* Режим бронирования */
.calendar-mode-booking .calendar-day:hover {
background: #e3f2fd;
border-color: #007bff;
}
/* Режим только для чтения */
.calendar-mode-readonly .calendar-day {
cursor: default;
}
.calendar-mode-readonly .calendar-day:hover {
background: white;
}
.error {
background: #f8d7da;
color: #721c24;
padding: 15px;
border-radius: 5px;
text-align: center;
margin: 20px 0;
}
</style>
`;
document.head.insertAdjacentHTML('beforeend', styles);
}
bindEvents() {
this.container.addEventListener('click', (e) => {
if (e.target.matches('[data-action="prev-month"]')) {
this.changeMonth(-1);
} else if (e.target.matches('[data-action="next-month"]')) {
this.changeMonth(1);
} else if (e.target.closest('.guide-checkbox')) {
const checkbox = e.target.closest('.guide-checkbox');
const guideId = parseInt(checkbox.dataset.guideId);
this.toggleGuide(guideId);
} else if (e.target.closest('.calendar-day')) {
const dayEl = e.target.closest('.calendar-day');
const dateStr = dayEl.dataset.date;
if (dateStr && this.mode === 'booking') {
this.selectDate(dateStr);
}
}
});
}
async loadData() {
try {
// Загружаем гидов
const guidesResponse = await fetch('/api/guides');
const guidesData = await guidesResponse.json();
this.guides = Array.isArray(guidesData) ? guidesData : (guidesData.data || []);
// Загружаем остальные данные параллельно
const [schedulesRes, holidaysRes, bookingsRes] = await Promise.all([
fetch('/api/guide-schedules'),
fetch('/api/holidays'),
fetch('/api/bookings')
]);
const schedulesData = await schedulesRes.json();
const holidaysData = await holidaysRes.json();
const bookingsData = await bookingsRes.json();
this.schedules = Array.isArray(schedulesData) ? schedulesData : (schedulesData.data || []);
this.holidays = Array.isArray(holidaysData) ? holidaysData : (holidaysData.data || []);
this.bookings = Array.isArray(bookingsData) ? bookingsData : (bookingsData.data || []);
// Инициализируем выбранных гидов
if (this.guides && this.guides.length > 0) {
if (this.selectedGuideId) {
this.selectedGuides.add(this.selectedGuideId);
} else {
this.guides.forEach(guide => this.selectedGuides.add(guide.id));
}
}
} catch (error) {
console.error('Ошибка загрузки данных календаря:', error);
this.showError('Ошибка загрузки данных календаря');
}
}
showError(message) {
const gridEl = this.container.querySelector(`#calendarGrid-${this.getId()}`);
if (gridEl) {
gridEl.innerHTML = `<div class="error">${message}</div>`;
}
}
renderGuidesFilter() {
if (!this.showGuideFilter) return;
const filterContainer = this.container.querySelector(`#guidesFilter-${this.getId()}`);
if (!filterContainer) return;
filterContainer.innerHTML = '';
if (!this.guides || !Array.isArray(this.guides) || this.guides.length === 0) {
filterContainer.innerHTML = '<div class="error">Нет доступных гидов</div>';
return;
}
this.guides.forEach(guide => {
const checkbox = document.createElement('label');
checkbox.className = 'guide-checkbox';
checkbox.dataset.guideId = guide.id;
if (this.selectedGuides.has(guide.id)) {
checkbox.classList.add('checked');
}
checkbox.innerHTML = `
<input type="checkbox" ${this.selectedGuides.has(guide.id) ? 'checked' : ''}>
<span>${guide.name.split(' ')[0]}</span>
`;
filterContainer.appendChild(checkbox);
});
}
toggleGuide(guideId) {
if (this.selectedGuides.has(guideId)) {
this.selectedGuides.delete(guideId);
} else {
this.selectedGuides.add(guideId);
}
this.renderGuidesFilter();
this.renderCalendar();
if (this.onGuideSelect) {
this.onGuideSelect(Array.from(this.selectedGuides));
}
}
selectDate(dateStr) {
this.selectedDate = dateStr;
this.renderCalendar();
if (this.onDateSelect) {
this.onDateSelect(dateStr);
}
}
renderCalendar() {
const grid = this.container.querySelector(`#calendarGrid-${this.getId()}`);
if (!grid) return;
grid.innerHTML = '';
// Заголовки дней недели
const dayHeaders = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
dayHeaders.forEach(day => {
const headerDiv = document.createElement('div');
headerDiv.className = 'day-header';
headerDiv.textContent = day;
grid.appendChild(headerDiv);
});
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth();
// Первый день месяца
const firstDay = new Date(year, month, 1);
// Первый понедельник на календаре
const startDate = new Date(firstDay);
const dayOfWeek = firstDay.getDay();
const mondayOffset = dayOfWeek === 0 ? -6 : -(dayOfWeek - 1);
startDate.setDate(firstDay.getDate() + mondayOffset);
// Генерируем 6 недель
for (let week = 0; week < 6; week++) {
for (let day = 0; day < 7; day++) {
const currentDay = new Date(startDate);
currentDay.setDate(startDate.getDate() + week * 7 + day);
const dayDiv = document.createElement('div');
dayDiv.className = 'calendar-day';
dayDiv.dataset.date = this.formatDate(currentDay);
if (currentDay.getMonth() !== month) {
dayDiv.classList.add('other-month');
}
if (this.isToday(currentDay)) {
dayDiv.classList.add('today');
}
if (this.selectedDate === this.formatDate(currentDay)) {
dayDiv.classList.add('selected');
}
dayDiv.innerHTML = this.renderDay(currentDay);
grid.appendChild(dayDiv);
}
}
}
renderDay(date) {
const dayNumber = date.getDate();
const dateStr = this.formatDate(date);
let guideStatusHtml = '';
// Получаем статусы выбранных гидов для этого дня
this.guides.forEach(guide => {
if (!this.selectedGuides.has(guide.id)) return;
const status = this.getGuideStatus(guide.id, dateStr);
const statusClass = status === 'holiday' ? 'holiday' :
status === 'busy' ? 'busy' : 'working';
guideStatusHtml += `<div class="guide-badge ${statusClass}" title="${guide.name} - ${this.getStatusText(status)}">${guide.name.split(' ')[0]}</div>`;
});
return `
<div class="day-number">${dayNumber}</div>
<div class="guide-status">${guideStatusHtml}</div>
`;
}
getStatusText(status) {
const statusMap = {
'working': 'Доступен',
'holiday': 'Выходной',
'busy': 'Занят'
};
return statusMap[status] || 'Неизвестно';
}
getGuideStatus(guideId, dateStr) {
// Проверяем выходные дни
const holiday = this.holidays.find(h =>
h.guide_id === guideId && h.holiday_date === dateStr
);
if (holiday) return 'holiday';
// Проверяем бронирования
const booking = this.bookings.find(b =>
b.guide_id === guideId &&
this.formatDate(new Date(b.preferred_date)) === dateStr
);
if (booking) return 'busy';
return 'working';
}
formatDate(date) {
return date.toISOString().split('T')[0];
}
isToday(date) {
const today = new Date();
return date.toDateString() === today.toDateString();
}
updateMonthDisplay() {
const monthDisplay = this.container.querySelector(`#currentDate-${this.getId()}`);
if (!monthDisplay) return;
const monthNames = [
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
];
monthDisplay.textContent = `${monthNames[this.currentDate.getMonth()]} ${this.currentDate.getFullYear()}`;
}
changeMonth(delta) {
this.currentDate.setMonth(this.currentDate.getMonth() + delta);
this.renderCalendar();
this.updateMonthDisplay();
}
// Публичные методы для внешнего управления
setSelectedDate(dateStr) {
this.selectedDate = dateStr;
this.renderCalendar();
}
setSelectedGuide(guideId) {
this.selectedGuides.clear();
this.selectedGuides.add(guideId);
this.renderGuidesFilter();
this.renderCalendar();
}
getAvailableGuides(dateStr) {
return this.guides.filter(guide =>
this.getGuideStatus(guide.id, dateStr) === 'working'
);
}
refresh() {
this.loadData().then(() => {
this.renderGuidesFilter();
this.renderCalendar();
});
}
}
// Экспортируем для использования в других файлах
if (typeof module !== 'undefined' && module.exports) {
module.exports = GuideCalendarWidget;
}
// Глобальная доступность в браузере
if (typeof window !== 'undefined') {
window.GuideCalendarWidget = GuideCalendarWidget;
}