feat: Оптимизация навигации AdminJS в логические группы

- Объединены ресурсы в 5 логических групп: Контент сайта, Бронирования, Отзывы и рейтинги, Персонал и гиды, Администрирование
- Удалены дублирующие настройки navigation для чистой группировки
- Добавлены CSS стили для визуального отображения иерархии с отступами
- Добавлены эмодзи-иконки для каждого типа ресурсов через CSS
- Улучшена навигация с правильной вложенностью элементов
This commit is contained in:
2025-11-30 21:57:58 +09:00
parent 1e7d7c06eb
commit 13c752b93a
47 changed files with 14148 additions and 61 deletions

View 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;
}