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,865 @@
/**
* 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;
}