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

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

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