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,639 @@
/**
* GuideSelector - Компонент для выбора гида
* Используется в формах бронирования и админке
*/
class GuideSelector {
constructor(options = {}) {
this.container = options.container || document.body;
this.mode = options.mode || 'booking'; // 'booking', 'admin', 'simple'
this.selectedDate = options.selectedDate || null;
this.selectedGuideId = options.selectedGuideId || null;
this.onGuideSelect = options.onGuideSelect || null;
this.onDateChange = options.onDateChange || null;
this.showAvailabilityOnly = options.showAvailabilityOnly !== false;
this.multiple = options.multiple || false;
this.placeholder = options.placeholder || 'Выберите гида';
this.guides = [];
this.schedules = [];
this.holidays = [];
this.bookings = [];
this.filteredGuides = [];
this.init();
}
async init() {
this.render();
await this.loadData();
this.updateGuidesList();
this.bindEvents();
}
render() {
const modeClass = `guide-selector-${this.mode}`;
const multipleClass = this.multiple ? 'guide-selector-multiple' : '';
this.container.innerHTML = `
<div class="guide-selector ${modeClass} ${multipleClass}">
${this.mode === 'booking' ? `
<div class="selector-header">
<h4>Выбор гида</h4>
<p class="selector-subtitle">Выберите подходящего гида для вашего тура</p>
</div>
` : ''}
<div class="selector-controls">
${this.showAvailabilityOnly ? `
<div class="date-filter">
<label for="dateInput-${this.getId()}">Дата тура:</label>
<input type="date"
id="dateInput-${this.getId()}"
value="${this.selectedDate || ''}"
min="${new Date().toISOString().split('T')[0]}">
</div>
` : ''}
<div class="availability-filter">
<label>
<input type="checkbox"
id="availabilityFilter-${this.getId()}"
${this.showAvailabilityOnly ? 'checked' : ''}>
Только доступные гиды
</label>
</div>
</div>
<div class="guides-list" id="guidesList-${this.getId()}">
<div class="loading">Загрузка гидов...</div>
</div>
${this.multiple ? `
<div class="selected-guides" id="selectedGuides-${this.getId()}" style="display: none;">
<h5>Выбранные гиды:</h5>
<div class="selected-guides-list"></div>
</div>
` : ''}
</div>
`;
this.injectStyles();
}
getId() {
if (!this._id) {
this._id = 'guide-selector-' + Math.random().toString(36).substr(2, 9);
}
return this._id;
}
injectStyles() {
if (document.getElementById('guide-selector-styles')) return;
const styles = `
<style id="guide-selector-styles">
.guide-selector {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 100%;
}
.selector-header {
margin-bottom: 20px;
padding: 15px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 8px;
text-align: center;
}
.selector-header h4 {
margin: 0 0 8px 0;
font-size: 20px;
font-weight: 600;
}
.selector-subtitle {
margin: 0;
opacity: 0.9;
font-size: 14px;
}
.selector-controls {
display: flex;
gap: 20px;
align-items: end;
margin-bottom: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
flex-wrap: wrap;
}
.date-filter {
display: flex;
flex-direction: column;
gap: 5px;
}
.date-filter label {
font-weight: 600;
font-size: 14px;
color: #495057;
}
.date-filter input[type="date"] {
padding: 8px 12px;
border: 2px solid #dee2e6;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.3s;
}
.date-filter input[type="date"]:focus {
outline: none;
border-color: #007bff;
}
.availability-filter {
display: flex;
align-items: center;
}
.availability-filter label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 14px;
}
.guides-list {
display: grid;
gap: 15px;
}
.guide-card {
padding: 15px;
border: 2px solid #dee2e6;
border-radius: 12px;
background: white;
transition: all 0.3s ease;
cursor: pointer;
position: relative;
}
.guide-card:hover {
border-color: #007bff;
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 123, 255, 0.15);
}
.guide-card.selected {
border-color: #007bff;
background: #f0f8ff;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
}
.guide-card.unavailable {
opacity: 0.6;
background: #f8f9fa;
cursor: not-allowed;
}
.guide-card.unavailable:hover {
transform: none;
box-shadow: none;
border-color: #dee2e6;
}
.guide-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 10px;
}
.guide-name {
font-size: 18px;
font-weight: 600;
color: #2c3e50;
margin: 0 0 5px 0;
}
.guide-specialization {
font-size: 14px;
color: #6c757d;
margin: 0;
}
.guide-status {
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.status-available {
background: #d4edda;
color: #155724;
}
.status-unavailable {
background: #f8d7da;
color: #721c24;
}
.status-busy {
background: #fff3cd;
color: #856404;
}
.guide-details {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 10px;
}
.guide-info {
font-size: 13px;
color: #6c757d;
}
.guide-info strong {
color: #495057;
}
.guide-rate {
font-size: 16px;
font-weight: 600;
color: #28a745;
text-align: right;
}
.selected-guides {
margin-top: 20px;
padding: 15px;
background: #e3f2fd;
border-radius: 8px;
border-left: 4px solid #007bff;
}
.selected-guides h5 {
margin: 0 0 10px 0;
color: #1976d2;
}
.selected-guides-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.selected-guide-tag {
padding: 6px 12px;
background: #007bff;
color: white;
border-radius: 15px;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.remove-guide {
cursor: pointer;
font-weight: bold;
opacity: 0.7;
}
.remove-guide:hover {
opacity: 1;
}
.loading {
text-align: center;
padding: 40px;
color: #6c757d;
}
.error {
background: #f8d7da;
color: #721c24;
padding: 15px;
border-radius: 5px;
text-align: center;
}
/* Режимы */
.guide-selector-simple .guides-list {
display: block;
}
.guide-selector-simple .guide-card {
display: flex;
align-items: center;
gap: 15px;
padding: 10px 15px;
}
.guide-selector-simple .guide-details {
display: none;
}
.guide-selector-admin .selector-header {
background: #6c757d;
}
/* Адаптивность */
@media (max-width: 768px) {
.selector-controls {
flex-direction: column;
align-items: stretch;
}
.guide-details {
grid-template-columns: 1fr;
}
}
</style>
`;
document.head.insertAdjacentHTML('beforeend', styles);
}
bindEvents() {
const dateInput = this.container.querySelector(`#dateInput-${this.getId()}`);
const availabilityFilter = this.container.querySelector(`#availabilityFilter-${this.getId()}`);
if (dateInput) {
dateInput.addEventListener('change', (e) => {
this.selectedDate = e.target.value;
this.updateGuidesList();
if (this.onDateChange) {
this.onDateChange(this.selectedDate);
}
});
}
if (availabilityFilter) {
availabilityFilter.addEventListener('change', (e) => {
this.showAvailabilityOnly = e.target.checked;
this.updateGuidesList();
});
}
this.container.addEventListener('click', (e) => {
const guideCard = e.target.closest('.guide-card');
if (guideCard && !guideCard.classList.contains('unavailable')) {
const guideId = parseInt(guideCard.dataset.guideId);
this.selectGuide(guideId);
}
const removeBtn = e.target.closest('.remove-guide');
if (removeBtn) {
const guideId = parseInt(removeBtn.dataset.guideId);
this.deselectGuide(guideId);
}
});
}
async loadData() {
try {
const [guidesRes, schedulesRes, holidaysRes, bookingsRes] = await Promise.all([
fetch('/api/guides'),
fetch('/api/guide-schedules'),
fetch('/api/holidays'),
fetch('/api/bookings')
]);
const guidesData = await guidesRes.json();
const schedulesData = await schedulesRes.json();
const holidaysData = await holidaysRes.json();
const bookingsData = await bookingsRes.json();
this.guides = Array.isArray(guidesData) ? guidesData : (guidesData.data || []);
this.schedules = Array.isArray(schedulesData) ? schedulesData : (schedulesData.data || []);
this.holidays = Array.isArray(holidaysData) ? holidaysData : (holidaysData.data || []);
this.bookings = Array.isArray(bookingsData) ? bookingsData : (bookingsData.data || []);
} catch (error) {
console.error('Ошибка загрузки данных:', error);
this.showError('Ошибка загрузки данных');
}
}
updateGuidesList() {
const listContainer = this.container.querySelector(`#guidesList-${this.getId()}`);
if (!listContainer) return;
if (!this.guides || this.guides.length === 0) {
listContainer.innerHTML = '<div class="error">Нет доступных гидов</div>';
return;
}
this.filteredGuides = this.guides.filter(guide => {
if (!this.showAvailabilityOnly) return true;
if (!this.selectedDate) return true;
const status = this.getGuideStatus(guide.id, this.selectedDate);
return status === 'working';
});
if (this.filteredGuides.length === 0) {
listContainer.innerHTML = `
<div class="error">
${this.selectedDate ?
'Нет доступных гидов на выбранную дату. Попробуйте другую дату.' :
'Нет доступных гидов'
}
</div>
`;
return;
}
listContainer.innerHTML = this.filteredGuides.map(guide => this.renderGuideCard(guide)).join('');
if (this.multiple) {
this.updateSelectedGuidesList();
}
}
renderGuideCard(guide) {
const status = this.selectedDate ? this.getGuideStatus(guide.id, this.selectedDate) : 'working';
const isSelected = this.multiple ?
this.selectedGuideIds.includes(guide.id) :
this.selectedGuideId === guide.id;
const statusClass = status === 'working' ? 'available' : 'unavailable';
const cardClass = status === 'working' ? '' : 'unavailable';
const selectedClass = isSelected ? 'selected' : '';
const specializations = {
'city': 'Городские туры',
'mountain': 'Горные походы',
'fishing': 'Рыбалка',
'general': 'Универсальный'
};
return `
<div class="guide-card ${cardClass} ${selectedClass}" data-guide-id="${guide.id}">
<div class="guide-header">
<div>
<h4 class="guide-name">${guide.name}</h4>
<p class="guide-specialization">${specializations[guide.specialization] || guide.specialization}</p>
</div>
<span class="guide-status status-${statusClass}">
${status === 'working' ? 'Доступен' : status === 'busy' ? 'Занят' : 'Выходной'}
</span>
</div>
${this.mode !== 'simple' ? `
<div class="guide-details">
<div class="guide-info">
<strong>Опыт:</strong> ${guide.experience || 'Не указан'} лет
</div>
<div class="guide-info">
<strong>Языки:</strong> ${guide.languages || 'Не указаны'}
</div>
<div class="guide-info">
<strong>Email:</strong> ${guide.email || 'Не указан'}
</div>
<div class="guide-rate">
${guide.hourly_rate ? `${guide.hourly_rate}₩/час` : 'Цена договорная'}
</div>
</div>
` : ''}
</div>
`;
}
getGuideStatus(guideId, dateStr) {
if (!dateStr) return 'working';
// Проверяем выходные дни
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 &&
new Date(b.preferred_date).toISOString().split('T')[0] === dateStr
);
if (booking) return 'busy';
return 'working';
}
selectGuide(guideId) {
if (this.multiple) {
if (!this.selectedGuideIds) {
this.selectedGuideIds = [];
}
if (!this.selectedGuideIds.includes(guideId)) {
this.selectedGuideIds.push(guideId);
this.updateGuidesList();
}
} else {
this.selectedGuideId = guideId;
this.updateGuidesList();
}
if (this.onGuideSelect) {
const selectedGuides = this.multiple ?
this.guides.filter(g => this.selectedGuideIds.includes(g.id)) :
this.guides.find(g => g.id === guideId);
this.onGuideSelect(selectedGuides);
}
}
deselectGuide(guideId) {
if (this.multiple && this.selectedGuideIds) {
this.selectedGuideIds = this.selectedGuideIds.filter(id => id !== guideId);
this.updateGuidesList();
if (this.onGuideSelect) {
const selectedGuides = this.guides.filter(g => this.selectedGuideIds.includes(g.id));
this.onGuideSelect(selectedGuides);
}
}
}
updateSelectedGuidesList() {
if (!this.multiple) return;
const selectedContainer = this.container.querySelector(`#selectedGuides-${this.getId()}`);
if (!selectedContainer) return;
if (!this.selectedGuideIds || this.selectedGuideIds.length === 0) {
selectedContainer.style.display = 'none';
return;
}
selectedContainer.style.display = 'block';
const listEl = selectedContainer.querySelector('.selected-guides-list');
listEl.innerHTML = this.selectedGuideIds.map(guideId => {
const guide = this.guides.find(g => g.id === guideId);
return `
<span class="selected-guide-tag">
${guide.name}
<span class="remove-guide" data-guide-id="${guideId}">×</span>
</span>
`;
}).join('');
}
showError(message) {
const listContainer = this.container.querySelector(`#guidesList-${this.getId()}`);
if (listContainer) {
listContainer.innerHTML = `<div class="error">${message}</div>`;
}
}
// Публичные методы
setDate(dateStr) {
this.selectedDate = dateStr;
const dateInput = this.container.querySelector(`#dateInput-${this.getId()}`);
if (dateInput) {
dateInput.value = dateStr;
}
this.updateGuidesList();
}
getSelectedGuides() {
if (this.multiple) {
return this.guides.filter(g => this.selectedGuideIds && this.selectedGuideIds.includes(g.id));
} else {
return this.guides.find(g => g.id === this.selectedGuideId) || null;
}
}
getAvailableGuides(dateStr = null) {
const date = dateStr || this.selectedDate;
if (!date) return this.guides;
return this.guides.filter(guide =>
this.getGuideStatus(guide.id, date) === 'working'
);
}
refresh() {
this.loadData().then(() => {
this.updateGuidesList();
});
}
}
// Экспорт
if (typeof module !== 'undefined' && module.exports) {
module.exports = GuideSelector;
}
if (typeof window !== 'undefined') {
window.GuideSelector = GuideSelector;
}