feat: Оптимизация навигации AdminJS в логические группы
- Объединены ресурсы в 5 логических групп: Контент сайта, Бронирования, Отзывы и рейтинги, Персонал и гиды, Администрирование - Удалены дублирующие настройки navigation для чистой группировки - Добавлены CSS стили для визуального отображения иерархии с отступами - Добавлены эмодзи-иконки для каждого типа ресурсов через CSS - Улучшена навигация с правильной вложенностью элементов
This commit is contained in:
223
public/components/admin-calendar-resource.jsx
Normal file
223
public/components/admin-calendar-resource.jsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
const AdminCalendarResource = () => {
|
||||
const [currentDate, setCurrentDate] = useState(new Date())
|
||||
const [guides, setGuides] = useState([])
|
||||
const [selectedGuide, setSelectedGuide] = useState(null)
|
||||
const [workingDays, setWorkingDays] = useState([])
|
||||
const [holidays, setHolidays] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [currentDate])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [guidesRes, holidaysRes] = await Promise.all([
|
||||
fetch('/api/guides'),
|
||||
fetch('/api/holidays')
|
||||
])
|
||||
|
||||
const guidesData = await guidesRes.json()
|
||||
const holidaysData = await holidaysRes.json()
|
||||
|
||||
setGuides(guidesData.data || guidesData)
|
||||
setHolidays(holidaysData)
|
||||
|
||||
if (selectedGuide) {
|
||||
const workingRes = await fetch(`/api/guide-working-days?guide_id=${selectedGuide}&month=${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}`)
|
||||
const workingData = await workingRes.json()
|
||||
setWorkingDays(workingData)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading data:', error)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const getDaysInMonth = (date) => {
|
||||
const year = date.getFullYear()
|
||||
const month = date.getMonth()
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||
const firstDayOfWeek = new Date(year, month, 1).getDay()
|
||||
|
||||
const days = []
|
||||
|
||||
// Добавляем пустые дни в начале
|
||||
for (let i = 0; i < (firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1); i++) {
|
||||
days.push(null)
|
||||
}
|
||||
|
||||
// Добавляем дни месяца
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
days.push(day)
|
||||
}
|
||||
|
||||
return days
|
||||
}
|
||||
|
||||
const isWorkingDay = (day) => {
|
||||
if (!day || !selectedGuide) return false
|
||||
const dateStr = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
return workingDays.some(wd => wd.work_date === dateStr)
|
||||
}
|
||||
|
||||
const isHoliday = (day) => {
|
||||
if (!day) return false
|
||||
const dateStr = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
return holidays.some(h => h.date === dateStr)
|
||||
}
|
||||
|
||||
const toggleWorkingDay = async (day) => {
|
||||
if (!selectedGuide || !day) return
|
||||
|
||||
const dateStr = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
const isWorking = isWorkingDay(day)
|
||||
|
||||
try {
|
||||
if (isWorking) {
|
||||
await fetch('/api/guide-working-days', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ guide_id: selectedGuide, work_date: dateStr })
|
||||
})
|
||||
} else {
|
||||
await fetch('/api/guide-working-days', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ guide_id: selectedGuide, work_date: dateStr })
|
||||
})
|
||||
}
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error('Error toggling working day:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const changeMonth = (delta) => {
|
||||
const newDate = new Date(currentDate)
|
||||
newDate.setMonth(newDate.getMonth() + delta)
|
||||
setCurrentDate(newDate)
|
||||
}
|
||||
|
||||
const monthNames = [
|
||||
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||||
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
|
||||
]
|
||||
|
||||
const weekDays = ['ПН', 'ВТ', 'СР', 'ЧТ', 'ПТ', 'СБ', 'ВС']
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<div>Загрузка календаря...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', fontFamily: 'system-ui' }}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h2>Календарь рабочих дней гидов</h2>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px' }}>Выберите гида:</label>
|
||||
<select
|
||||
value={selectedGuide || ''}
|
||||
onChange={(e) => setSelectedGuide(e.target.value)}
|
||||
style={{ padding: '8px', borderRadius: '4px', border: '1px solid #ddd', minWidth: '200px' }}
|
||||
>
|
||||
<option value="">-- Выберите гида --</option>
|
||||
{guides.map(guide => (
|
||||
<option key={guide.id} value={guide.id}>{guide.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '20px', marginBottom: '20px' }}>
|
||||
<button
|
||||
onClick={() => changeMonth(-1)}
|
||||
style={{ padding: '8px 16px', border: '1px solid #ddd', borderRadius: '4px', background: 'white', cursor: 'pointer' }}
|
||||
>
|
||||
← Предыдущий
|
||||
</button>
|
||||
<h3 style={{ margin: 0 }}>
|
||||
{monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => changeMonth(1)}
|
||||
style={{ padding: '8px 16px', border: '1px solid #ddd', borderRadius: '4px', background: 'white', cursor: 'pointer' }}
|
||||
>
|
||||
Следующий →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedGuide && (
|
||||
<div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '1px', marginBottom: '20px' }}>
|
||||
{weekDays.map(day => (
|
||||
<div key={day} style={{
|
||||
padding: '10px',
|
||||
textAlign: 'center',
|
||||
fontWeight: 'bold',
|
||||
background: '#f5f5f5',
|
||||
border: '1px solid #ddd'
|
||||
}}>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '1px' }}>
|
||||
{getDaysInMonth(currentDate).map((day, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => toggleWorkingDay(day)}
|
||||
style={{
|
||||
padding: '15px',
|
||||
textAlign: 'center',
|
||||
border: '1px solid #ddd',
|
||||
minHeight: '50px',
|
||||
cursor: day ? 'pointer' : 'default',
|
||||
background: day ?
|
||||
(isHoliday(day) ? '#ffcccb' :
|
||||
isWorkingDay(day) ? '#c8e6c9' : 'white') : '#f9f9f9',
|
||||
color: day ? (isHoliday(day) ? '#d32f2f' : '#333') : '#ccc',
|
||||
fontWeight: day ? 'normal' : '300'
|
||||
}}
|
||||
>
|
||||
{day || ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '20px', display: 'flex', gap: '20px', fontSize: '14px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<div style={{ width: '20px', height: '20px', background: '#c8e6c9', border: '1px solid #ddd' }}></div>
|
||||
<span>Рабочий день</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<div style={{ width: '20px', height: '20px', background: '#ffcccb', border: '1px solid #ddd' }}></div>
|
||||
<span>Выходной/Праздник</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<div style={{ width: '20px', height: '20px', background: 'white', border: '1px solid #ddd' }}></div>
|
||||
<span>Не назначено</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedGuide && (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#666' }}>
|
||||
Выберите гида для просмотра календаря
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminCalendarResource
|
||||
306
public/components/availability-checker.js
Normal file
306
public/components/availability-checker.js
Normal file
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* AvailabilityChecker - Компонент для проверки доступности гидов
|
||||
* Используется в формах бронирования для быстрой проверки
|
||||
*/
|
||||
|
||||
class AvailabilityChecker {
|
||||
constructor(options = {}) {
|
||||
this.container = options.container || document.body;
|
||||
this.mode = options.mode || 'simple'; // 'simple', 'detailed', 'inline'
|
||||
this.onAvailabilityCheck = options.onAvailabilityCheck || null;
|
||||
this.showSuggestions = options.showSuggestions !== false;
|
||||
this.maxSuggestions = options.maxSuggestions || 3;
|
||||
|
||||
this.guides = [];
|
||||
this.schedules = [];
|
||||
this.holidays = [];
|
||||
this.bookings = [];
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.render();
|
||||
await this.loadData();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
render() {
|
||||
const modeClass = `availability-checker-${this.mode}`;
|
||||
|
||||
this.container.innerHTML = `
|
||||
<div class="availability-checker ${modeClass}">
|
||||
${this.mode === 'detailed' ? `
|
||||
<div class="checker-header">
|
||||
<h4>Проверка доступности</h4>
|
||||
<p>Укажите дату и тип тура для проверки доступности гидов</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="checker-form" id="checkerForm-${this.getId()}">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="checkDate-${this.getId()}">Дата тура:</label>
|
||||
<input type="date"
|
||||
id="checkDate-${this.getId()}"
|
||||
min="${new Date().toISOString().split('T')[0]}">
|
||||
</div>
|
||||
|
||||
${this.mode === 'detailed' ? `
|
||||
<div class="form-group">
|
||||
<label for="tourType-${this.getId()}">Тип тура:</label>
|
||||
<select id="tourType-${this.getId()}">
|
||||
<option value="">Любой</option>
|
||||
<option value="city">Городской тур</option>
|
||||
<option value="mountain">Горный поход</option>
|
||||
<option value="fishing">Рыбалка</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="groupSize-${this.getId()}">Размер группы:</label>
|
||||
<input type="number"
|
||||
id="groupSize-${this.getId()}"
|
||||
min="1"
|
||||
max="20"
|
||||
value="1">
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="form-group">
|
||||
<button type="button"
|
||||
id="checkButton-${this.getId()}"
|
||||
class="check-button">
|
||||
🔍 Проверить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checker-results" id="checkerResults-${this.getId()}" style="display: none;">
|
||||
<div class="results-content"></div>
|
||||
</div>
|
||||
|
||||
${this.showSuggestions ? `
|
||||
<div class="checker-suggestions" id="checkerSuggestions-${this.getId()}" style="display: none;">
|
||||
<h5>Альтернативные варианты:</h5>
|
||||
<div class="suggestions-list"></div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.injectStyles();
|
||||
}
|
||||
|
||||
getId() {
|
||||
if (!this._id) {
|
||||
this._id = 'availability-checker-' + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
return this._id;
|
||||
}
|
||||
|
||||
injectStyles() {
|
||||
if (document.getElementById('availability-checker-styles')) return;
|
||||
|
||||
const styles = `
|
||||
<style id="availability-checker-styles">
|
||||
.availability-checker {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.checker-header {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.checker-form {
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
padding: 10px 12px;
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.check-button {
|
||||
padding: 10px 20px;
|
||||
background: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.results-summary {
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.results-summary.available {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.results-summary.unavailable {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.available-guide {
|
||||
padding: 12px;
|
||||
background: white;
|
||||
border: 1px solid #28a745;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.guide-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.guide-name {
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
document.head.insertAdjacentHTML('beforeend', styles);
|
||||
}
|
||||
|
||||
async loadData() {
|
||||
try {
|
||||
const [guidesRes, holidaysRes, bookingsRes] = await Promise.all([
|
||||
fetch('/api/guides'),
|
||||
fetch('/api/holidays'),
|
||||
fetch('/api/bookings')
|
||||
]);
|
||||
|
||||
const guidesData = await guidesRes.json();
|
||||
const holidaysData = await holidaysRes.json();
|
||||
const bookingsData = await bookingsRes.json();
|
||||
|
||||
this.guides = Array.isArray(guidesData) ? guidesData : (guidesData.data || []);
|
||||
this.holidays = Array.isArray(holidaysData) ? holidaysData : (holidaysData.data || []);
|
||||
this.bookings = Array.isArray(bookingsData) ? bookingsData : (bookingsData.data || []);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки данных:', error);
|
||||
}
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
const checkButton = this.container.querySelector(`#checkButton-${this.getId()}`);
|
||||
checkButton.addEventListener('click', () => this.checkAvailability());
|
||||
}
|
||||
|
||||
async checkAvailability() {
|
||||
const dateInput = this.container.querySelector(`#checkDate-${this.getId()}`);
|
||||
const date = dateInput.value;
|
||||
|
||||
if (!date) {
|
||||
alert('Выберите дату');
|
||||
return;
|
||||
}
|
||||
|
||||
const availableGuides = this.getAvailableGuides(date);
|
||||
const resultsContainer = this.container.querySelector(`#checkerResults-${this.getId()}`);
|
||||
const resultsContent = resultsContainer.querySelector('.results-content');
|
||||
|
||||
if (availableGuides.length === 0) {
|
||||
resultsContent.innerHTML = `
|
||||
<div class="results-summary unavailable">
|
||||
<span>❌</span>
|
||||
<div>Нет доступных гидов на выбранную дату</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
resultsContent.innerHTML = `
|
||||
<div class="results-summary available">
|
||||
<span>✅</span>
|
||||
<div>Доступно ${availableGuides.length} гидов</div>
|
||||
</div>
|
||||
${availableGuides.map(guide => `
|
||||
<div class="available-guide">
|
||||
<div class="guide-info">
|
||||
<div class="guide-name">${guide.name}</div>
|
||||
<div>${guide.specialization || 'Универсальный'}</div>
|
||||
</div>
|
||||
<div>${guide.hourly_rate ? guide.hourly_rate + '₩/час' : 'По договоренности'}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
`;
|
||||
}
|
||||
|
||||
resultsContainer.style.display = 'block';
|
||||
}
|
||||
|
||||
getAvailableGuides(date) {
|
||||
return this.guides.filter(guide => {
|
||||
const holiday = this.holidays.find(h => h.guide_id === guide.id && h.holiday_date === date);
|
||||
if (holiday) return false;
|
||||
|
||||
const booking = this.bookings.find(b =>
|
||||
b.guide_id === guide.id &&
|
||||
new Date(b.preferred_date).toISOString().split('T')[0] === date
|
||||
);
|
||||
if (booking) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
getId() {
|
||||
if (!this._id) {
|
||||
this._id = 'checker-' + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
return this._id;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.AvailabilityChecker = AvailabilityChecker;
|
||||
}
|
||||
373
public/components/guide-calendar-view.jsx
Normal file
373
public/components/guide-calendar-view.jsx
Normal file
@@ -0,0 +1,373 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
const GuideCalendarView = () => {
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [workingDays, setWorkingDays] = useState([]);
|
||||
const [guides, setGuides] = useState([]);
|
||||
const [selectedGuide, setSelectedGuide] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stats, setStats] = useState({ totalDays: 0, totalGuides: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
loadGuides();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadWorkingDays();
|
||||
}, [currentDate, selectedGuide]);
|
||||
|
||||
const loadGuides = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/guides');
|
||||
const data = await response.json();
|
||||
setGuides(data.success ? data.data : data);
|
||||
} catch (error) {
|
||||
console.error('Error loading guides:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadWorkingDays = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const month = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}`;
|
||||
const url = selectedGuide
|
||||
? `/api/guide-working-days?month=${month}&guide_id=${selectedGuide}`
|
||||
: `/api/guide-working-days?month=${month}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
setWorkingDays(data);
|
||||
|
||||
// Подсчет статистики
|
||||
const uniqueGuides = new Set(data.map(d => d.guide_id));
|
||||
setStats({
|
||||
totalDays: data.length,
|
||||
totalGuides: uniqueGuides.size
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading working days:', error);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const getDaysInMonth = (date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const firstDayOfWeek = new Date(year, month, 1).getDay();
|
||||
|
||||
const days = [];
|
||||
|
||||
// Добавляем пустые дни в начале
|
||||
for (let i = 0; i < (firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1); i++) {
|
||||
days.push(null);
|
||||
}
|
||||
|
||||
// Добавляем дни месяца
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
days.push(day);
|
||||
}
|
||||
|
||||
return days;
|
||||
};
|
||||
|
||||
const getWorkingDaysForDate = (day) => {
|
||||
if (!day) return [];
|
||||
const dateStr = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
return workingDays.filter(wd => wd.work_date === dateStr);
|
||||
};
|
||||
|
||||
const getGuideById = (id) => {
|
||||
return guides.find(g => g.id === id);
|
||||
};
|
||||
|
||||
const changeMonth = (delta) => {
|
||||
const newDate = new Date(currentDate);
|
||||
newDate.setMonth(newDate.getMonth() + delta);
|
||||
setCurrentDate(newDate);
|
||||
};
|
||||
|
||||
const monthNames = [
|
||||
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||||
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
|
||||
];
|
||||
|
||||
const weekDays = ['ПН', 'ВТ', 'СР', 'ЧТ', 'ПТ', 'СБ', 'ВС'];
|
||||
|
||||
if (loading && workingDays.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
Загрузка календаря...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', fontFamily: 'system-ui' }}>
|
||||
{/* Заголовок и статистика */}
|
||||
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h2>📅 Календарь рабочих дней гидов</h2>
|
||||
<div style={{ display: 'flex', gap: '20px', fontSize: '14px' }}>
|
||||
<div style={{ padding: '8px 12px', background: '#e3f2fd', borderRadius: '6px' }}>
|
||||
<strong>{stats.totalDays}</strong> рабочих дней
|
||||
</div>
|
||||
<div style={{ padding: '8px 12px', background: '#f3e5f5', borderRadius: '6px' }}>
|
||||
<strong>{stats.totalGuides}</strong> активных гидов
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Фильтр по гиду */}
|
||||
<div style={{ marginBottom: '20px', display: 'flex', alignItems: 'center', gap: '15px' }}>
|
||||
<label style={{ fontWeight: 'bold' }}>Фильтр по гиду:</label>
|
||||
<select
|
||||
value={selectedGuide}
|
||||
onChange={(e) => setSelectedGuide(e.target.value)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #ddd',
|
||||
minWidth: '200px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
<option value="">Все гиды</option>
|
||||
{guides.map(guide => (
|
||||
<option key={guide.id} value={guide.id}>
|
||||
{guide.name} ({guide.specialization})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedGuide && (
|
||||
<button
|
||||
onClick={() => setSelectedGuide('')}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: '#ff5722',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
Очистить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Навигация по месяцам */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '20px',
|
||||
padding: '15px',
|
||||
background: '#f8f9fa',
|
||||
borderRadius: '8px'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => changeMonth(-1)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
background: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
← Предыдущий
|
||||
</button>
|
||||
<h3 style={{ margin: 0, color: '#333' }}>
|
||||
{monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => changeMonth(1)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
background: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
Следующий →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Календарная сетка */}
|
||||
<div style={{
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
|
||||
}}>
|
||||
{/* Заголовки дней недели */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
|
||||
{weekDays.map(day => (
|
||||
<div key={day} style={{
|
||||
padding: '15px',
|
||||
textAlign: 'center',
|
||||
fontWeight: 'bold',
|
||||
background: '#f5f5f5',
|
||||
borderBottom: '1px solid #ddd',
|
||||
color: '#333'
|
||||
}}>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Дни месяца */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
|
||||
{getDaysInMonth(currentDate).map((day, index) => {
|
||||
const dayWorkingData = getWorkingDaysForDate(day);
|
||||
const hasData = dayWorkingData.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
padding: '10px',
|
||||
minHeight: '120px',
|
||||
border: '1px solid #e0e0e0',
|
||||
background: day ? (hasData ? '#e8f5e8' : 'white') : '#f9f9f9',
|
||||
color: day ? '#333' : '#ccc',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
{day && (
|
||||
<>
|
||||
<div style={{
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
color: hasData ? '#2e7d32' : '#666'
|
||||
}}>
|
||||
{day}
|
||||
</div>
|
||||
|
||||
{dayWorkingData.map((workDay, idx) => {
|
||||
const guide = getGuideById(workDay.guide_id);
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
padding: '4px 6px',
|
||||
margin: '2px 0',
|
||||
background: guide?.specialization === 'city' ? '#bbdefb' :
|
||||
guide?.specialization === 'mountain' ? '#c8e6c9' :
|
||||
guide?.specialization === 'fishing' ? '#ffcdd2' : '#f0f0f0',
|
||||
borderRadius: '4px',
|
||||
color: '#333',
|
||||
lineHeight: '1.2'
|
||||
}}
|
||||
title={workDay.notes}
|
||||
>
|
||||
<div style={{ fontWeight: 'bold' }}>
|
||||
{guide?.name || `Гид #${workDay.guide_id}`}
|
||||
</div>
|
||||
{workDay.notes && (
|
||||
<div style={{ opacity: 0.8, marginTop: '2px' }}>
|
||||
{workDay.notes.length > 20 ? workDay.notes.substring(0, 20) + '...' : workDay.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Легенда */}
|
||||
<div style={{
|
||||
marginTop: '20px',
|
||||
display: 'flex',
|
||||
gap: '20px',
|
||||
fontSize: '14px',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{ width: '20px', height: '20px', background: '#bbdefb', borderRadius: '4px' }}></div>
|
||||
<span>Городские туры</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{ width: '20px', height: '20px', background: '#c8e6c9', borderRadius: '4px' }}></div>
|
||||
<span>Горные туры</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{ width: '20px', height: '20px', background: '#ffcdd2', borderRadius: '4px' }}></div>
|
||||
<span>Морская рыбалка</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{ width: '20px', height: '20px', background: '#e8f5e8', borderRadius: '4px' }}></div>
|
||||
<span>Рабочий день</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Быстрые действия */}
|
||||
<div style={{
|
||||
marginTop: '20px',
|
||||
padding: '15px',
|
||||
background: '#f8f9fa',
|
||||
borderRadius: '8px'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 10px 0' }}>Быстрые действия:</h4>
|
||||
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={() => window.open('/admin/calendar-view', '_blank')}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#2196f3',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
📅 Полный календарь
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.open('/admin/schedule-manager', '_blank')}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#4caf50',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
⚡ Планировщик смен
|
||||
</button>
|
||||
<button
|
||||
onClick={() => loadWorkingDays()}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#ff9800',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
🔄 Обновить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GuideCalendarView;
|
||||
633
public/components/guide-calendar-widget.js
Normal file
633
public/components/guide-calendar-widget.js
Normal 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;
|
||||
}
|
||||
865
public/components/guide-schedule-manager.js
Normal file
865
public/components/guide-schedule-manager.js
Normal 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;
|
||||
}
|
||||
639
public/components/guide-selector.js
Normal file
639
public/components/guide-selector.js
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user