feat: Оптимизация навигации AdminJS в логические группы
- Объединены ресурсы в 5 логических групп: Контент сайта, Бронирования, Отзывы и рейтинги, Персонал и гиды, Администрирование - Удалены дублирующие настройки navigation для чистой группировки - Добавлены CSS стили для визуального отображения иерархии с отступами - Добавлены эмодзи-иконки для каждого типа ресурсов через CSS - Улучшена навигация с правильной вложенностью элементов
This commit is contained in:
633
public/components/guide-calendar-widget.js
Normal file
633
public/components/guide-calendar-widget.js
Normal file
@@ -0,0 +1,633 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user