- Объединены ресурсы в 5 логических групп: Контент сайта, Бронирования, Отзывы и рейтинги, Персонал и гиды, Администрирование - Удалены дублирующие настройки navigation для чистой группировки - Добавлены CSS стили для визуального отображения иерархии с отступами - Добавлены эмодзи-иконки для каждого типа ресурсов через CSS - Улучшена навигация с правильной вложенностью элементов
639 lines
22 KiB
JavaScript
639 lines
22 KiB
JavaScript
/**
|
||
* 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;
|
||
} |