feat: Оптимизация навигации AdminJS в логические группы

- Объединены ресурсы в 5 логических групп: Контент сайта, Бронирования, Отзывы и рейтинги, Персонал и гиды, Администрирование
- Удалены дублирующие настройки navigation для чистой группировки
- Добавлены CSS стили для визуального отображения иерархии с отступами
- Добавлены эмодзи-иконки для каждого типа ресурсов через CSS
- Улучшена навигация с правильной вложенностью элементов
This commit is contained in:
2025-11-30 21:57:58 +09:00
parent 1e7d7c06eb
commit 13c752b93a
47 changed files with 14148 additions and 61 deletions

View File

@@ -0,0 +1,462 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Календарь гидов</title>
<style>
.calendar-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.calendar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
}
.calendar-navigation {
display: flex;
align-items: center;
gap: 20px;
}
.nav-button {
background: #007bff;
color: white;
border: none;
padding: 10px 15px;
border-radius: 5px;
cursor: pointer;
font-size: 18px;
transition: background-color 0.3s;
}
.nav-button:hover {
background: #0056b3;
}
.current-date {
font-size: 24px;
font-weight: 600;
color: #343a40;
min-width: 200px;
text-align: center;
}
.guides-filter {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.filter-label {
font-weight: 600;
color: #495057;
margin-right: 15px;
}
.guide-checkbox {
display: flex;
align-items: center;
gap: 5px;
padding: 8px 12px;
border: 2px solid #dee2e6;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s;
background: white;
font-size: 14px;
}
.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;
margin-top: 20px;
}
.calendar-day {
background: white;
min-height: 120px;
padding: 8px;
position: relative;
border: 1px solid transparent;
}
.calendar-day.other-month {
background: #f8f9fa;
color: #6c757d;
}
.calendar-day.today {
background: #fff3cd;
border-color: #ffeaa7;
}
.day-number {
font-weight: 600;
font-size: 16px;
margin-bottom: 8px;
}
.day-header {
background: #343a40;
color: white;
padding: 15px 8px;
text-align: center;
font-weight: 600;
font-size: 14px;
}
.guide-status {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.guide-badge {
padding: 2px 6px;
border-radius: 12px;
font-size: 11px;
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;
}
.legend {
display: flex;
justify-content: center;
gap: 30px;
margin-top: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
font-size: 14px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 3px;
}
.error {
background: #f8d7da;
color: #721c24;
padding: 15px;
border-radius: 5px;
text-align: center;
margin: 20px 0;
}
</style>
</head>
<body>
<div class="calendar-container">
<div class="calendar-header">
<div class="calendar-navigation">
<button class="nav-button" id="prevMonth"></button>
<span class="current-date" id="currentDate"></span>
<button class="nav-button" id="nextMonth"></button>
</div>
<div class="guides-filter">
<span class="filter-label">Фильтр гидов:</span>
<div id="guidesFilter"></div>
</div>
</div>
<div id="calendarGrid" class="calendar-grid"></div>
<div class="legend">
<div class="legend-item">
<div class="legend-color" style="background: #d4edda;"></div>
<span>Работает</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #f8d7da;"></div>
<span>Выходной</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #fff3cd;"></div>
<span>Забронирован</span>
</div>
</div>
</div>
<script>
class GuideCalendar {
constructor() {
this.currentDate = new Date();
this.guides = [];
this.schedules = [];
this.holidays = [];
this.bookings = [];
this.selectedGuides = new Set();
this.init();
}
async init() {
await this.loadData();
this.renderGuidesFilter();
this.renderCalendar();
this.updateMonthDisplay();
this.bindEvents();
}
bindEvents() {
const prevBtn = document.getElementById('prevMonth');
const nextBtn = document.getElementById('nextMonth');
if (prevBtn) {
prevBtn.addEventListener('click', () => this.changeMonth(-1));
}
if (nextBtn) {
nextBtn.addEventListener('click', () => this.changeMonth(1));
}
}
async loadData() {
try {
// Загружаем гидов
const guidesResponse = await fetch('/api/guides');
const guidesData = await guidesResponse.json();
this.guides = Array.isArray(guidesData) ? guidesData : (guidesData.data || guidesData.guides || []);
// Загружаем расписания
const schedulesResponse = await fetch('/api/guide-schedules');
const schedulesData = await schedulesResponse.json();
this.schedules = Array.isArray(schedulesData) ? schedulesData : (schedulesData.data || schedulesData.schedules || []);
// Загружаем выходные дни
const holidaysResponse = await fetch('/api/holidays');
const holidaysData = await holidaysResponse.json();
this.holidays = Array.isArray(holidaysData) ? holidaysData : (holidaysData.data || holidaysData.holidays || []);
// Загружаем существующие бронирования
const bookingsResponse = await fetch('/api/bookings');
const bookingsData = await bookingsResponse.json();
this.bookings = Array.isArray(bookingsData) ? bookingsData : (bookingsData.data || bookingsData.bookings || []);
// По умолчанию показываем всех гидов
if (this.guides && this.guides.length > 0) {
this.guides.forEach(guide => this.selectedGuides.add(guide.id));
}
} catch (error) {
console.error('Ошибка загрузки данных:', error);
document.getElementById('calendarGrid').innerHTML =
'<div class="error">Ошибка загрузки данных календаря</div>';
}
}
renderGuidesFilter() {
const filterContainer = document.getElementById('guidesFilter');
filterContainer.innerHTML = '';
if (!this.guides || !Array.isArray(this.guides)) {
filterContainer.innerHTML = '<div class="error">Нет доступных гидов</div>';
return;
}
this.guides.forEach(guide => {
const checkbox = document.createElement('label');
checkbox.className = 'guide-checkbox';
if (this.selectedGuides.has(guide.id)) {
checkbox.classList.add('checked');
}
checkbox.innerHTML = `
<input type="checkbox"
${this.selectedGuides.has(guide.id) ? 'checked' : ''}
data-guide-id="${guide.id}">
<span>${guide.name}</span>
`;
checkbox.addEventListener('click', (e) => {
e.preventDefault();
this.toggleGuide(guide.id);
});
filterContainer.appendChild(checkbox);
});
}
toggleGuide(guideId) {
if (this.selectedGuides.has(guideId)) {
this.selectedGuides.delete(guideId);
} else {
this.selectedGuides.add(guideId);
}
this.renderGuidesFilter();
this.renderCalendar();
}
renderCalendar() {
const grid = document.getElementById('calendarGrid');
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 lastDay = new Date(year, month + 1, 0);
// Первый понедельник на календаре
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';
if (currentDay.getMonth() !== month) {
dayDiv.classList.add('other-month');
}
if (this.isToday(currentDay)) {
dayDiv.classList.add('today');
}
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}">${guide.name.split(' ')[0]}</div>`;
});
return `
<div class="day-number">${dayNumber}</div>
<div class="guide-status">${guideStatusHtml}</div>
`;
}
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 = document.getElementById('currentDate');
const monthNames = [
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
];
monthDisplay.textContent = `${monthNames[this.currentDate.getMonth()]} ${this.currentDate.getFullYear()}`;
}
changeMonth(delta) {
this.currentDate.setMonth(this.currentDate.getMonth() + delta);
this.renderCalendar();
this.updateMonthDisplay();
}
}
// Инициализация календаря
document.addEventListener('DOMContentLoaded', () => {
new GuideCalendar();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Календарь управления гидами</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
</head>
<body>
<div class="container-fluid p-0">
<!-- Заголовок -->
<div class="bg-primary text-white p-3 mb-0">
<h4 class="mb-0">
<i class="fas fa-calendar-alt me-2"></i>
Управление календарем гидов
</h4>
<small>Управляйте расписанием и доступностью гидов</small>
</div>
<!-- Контент календаря -->
<div class="p-3">
<div id="admin-calendar-container"></div>
</div>
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- Компоненты календаря -->
<script src="/components/guide-calendar-widget.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Инициализация календаря для администратора
const adminCalendar = new GuideCalendarWidget({
container: document.getElementById('admin-calendar-container'),
mode: 'admin',
showControls: true,
showGuideInfo: true,
allowEdit: true,
onDateSelect: function(date, guide) {
console.log('Выбрана дата:', date, 'Гид:', guide);
},
onHolidayAdd: function(guide, date) {
console.log('Добавлен выходной:', guide, date);
},
onBookingView: function(booking) {
console.log('Просмотр бронирования:', booking);
}
});
});
</script>
</body>
</html>

View 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

View 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;
}

View 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;

View 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;
}

View File

@@ -0,0 +1,865 @@
/**
* GuideScheduleManager - Компонент для планирования рабочих смен гидов
*/
class GuideScheduleManager {
constructor(options = {}) {
this.container = options.container || document.body;
this.onScheduleChange = options.onScheduleChange || null;
this.allowMultiSelect = options.allowMultiSelect !== false;
this.currentDate = new Date();
this.currentDate.setDate(1); // Установить на первый день месяца
this.selectedGuides = new Set();
this.workingDays = new Map(); // guideId -> Set of dates
this.guides = [];
this.init();
}
async init() {
this.render();
await this.loadGuides();
this.bindEvents();
this.renderCalendar();
}
render() {
this.container.innerHTML = `
<div class="schedule-manager">
<div class="schedule-header">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4><i class="fas fa-calendar-week me-2"></i>Планировщик рабочих смен</h4>
<div class="schedule-actions">
<button class="btn btn-outline-secondary btn-sm" id="copyPrevMonth">
<i class="fas fa-copy me-1"></i>Скопировать из прошлого месяца
</button>
<button class="btn btn-outline-secondary btn-sm" id="copyNextMonth">
<i class="fas fa-paste me-1"></i>Скопировать в следующий месяц
</button>
<button class="btn btn-primary btn-sm" id="saveSchedule">
<i class="fas fa-save me-1"></i>Сохранить изменения
</button>
</div>
</div>
</div>
<div class="row">
<!-- Панель выбора гидов -->
<div class="col-lg-3">
<div class="guides-panel">
<div class="card">
<div class="card-header">
<h6 class="mb-0">Выбор гидов</h6>
<small class="text-muted">Выберите гидов для планирования</small>
</div>
<div class="card-body p-2">
<div class="guide-selection-actions mb-3">
<button class="btn btn-sm btn-outline-primary w-100 mb-2" id="selectAllGuides">
<i class="fas fa-check-square me-1"></i>Выбрать всех
</button>
<button class="btn btn-sm btn-outline-secondary w-100" id="clearGuideSelection">
<i class="fas fa-square me-1"></i>Очистить выбор
</button>
</div>
<div id="guidesList" class="guides-list"></div>
</div>
</div>
<!-- Панель быстрых действий -->
<div class="card mt-3">
<div class="card-header">
<h6 class="mb-0">Быстрые действия</h6>
</div>
<div class="card-body p-2">
<div class="d-grid gap-2">
<button class="btn btn-sm btn-success" id="markWeekdays">
<i class="fas fa-business-time me-1"></i>Отметить будни
</button>
<button class="btn btn-sm btn-warning" id="markWeekends">
<i class="fas fa-calendar-day me-1"></i>Отметить выходные
</button>
<button class="btn btn-sm btn-info" id="markFullMonth">
<i class="fas fa-calendar-check me-1"></i>Весь месяц
</button>
<button class="btn btn-sm btn-danger" id="clearMonth">
<i class="fas fa-calendar-times me-1"></i>Очистить месяц
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Календарь -->
<div class="col-lg-9">
<div class="calendar-container">
<div class="card">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<button class="btn btn-outline-primary btn-sm" id="prevMonth">
<i class="fas fa-chevron-left"></i>
</button>
<h5 class="mb-0" id="currentMonthLabel"></h5>
<button class="btn btn-outline-primary btn-sm" id="nextMonth">
<i class="fas fa-chevron-right"></i>
</button>
</div>
</div>
<div class="card-body p-0">
<div id="scheduleCalendar"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Статистика -->
<div class="schedule-stats mt-4">
<div class="card">
<div class="card-header">
<h6 class="mb-0">Статистика рабочих дней</h6>
</div>
<div class="card-body">
<div id="scheduleStats" class="row g-3"></div>
</div>
</div>
</div>
</div>
`;
this.injectStyles();
}
injectStyles() {
if (document.getElementById('schedule-manager-styles')) return;
const styles = `
<style id="schedule-manager-styles">
.schedule-manager {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.guide-checkbox {
margin-bottom: 8px;
padding: 8px;
border: 1px solid #dee2e6;
border-radius: 4px;
transition: all 0.2s;
}
.guide-checkbox:hover {
background-color: #f8f9fa;
}
.guide-checkbox.selected {
background-color: #e3f2fd;
border-color: #2196f3;
}
.guide-checkbox input[type="checkbox"] {
margin-right: 8px;
}
.guide-info {
font-size: 14px;
}
.guide-name {
font-weight: 600;
color: #2c3e50;
}
.guide-specialization {
font-size: 12px;
color: #6c757d;
}
.schedule-calendar {
width: 100%;
}
.calendar-day {
min-height: 80px;
border: 1px solid #dee2e6;
position: relative;
cursor: pointer;
transition: all 0.2s;
padding: 8px;
}
.calendar-day:hover {
background-color: #f8f9fa;
}
.calendar-day.selected {
background-color: #e3f2fd;
}
.calendar-day.working {
background-color: #d4edda;
border-color: #28a745;
}
.calendar-day.partial-working {
background: linear-gradient(45deg, #d4edda 50%, #fff3cd 50%);
border-color: #ffc107;
}
.calendar-day.weekend {
background-color: #fff3cd;
}
.calendar-day.other-month {
color: #6c757d;
background-color: #f8f9fa;
}
.day-number {
font-weight: 600;
font-size: 14px;
}
.working-guides {
font-size: 10px;
color: #28a745;
margin-top: 4px;
}
.working-guides .guide-initial {
display: inline-block;
width: 16px;
height: 16px;
background: #28a745;
color: white;
border-radius: 50%;
text-align: center;
line-height: 16px;
margin-right: 2px;
font-size: 9px;
}
.calendar-header {
background: #f8f9fa;
padding: 8px;
font-weight: 600;
text-align: center;
border: 1px solid #dee2e6;
}
.schedule-stats .stat-item {
text-align: center;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
}
.stat-number {
font-size: 24px;
font-weight: 700;
color: #2c3e50;
}
.stat-label {
font-size: 12px;
color: #6c757d;
text-transform: uppercase;
}
@media (max-width: 768px) {
.schedule-actions {
flex-direction: column;
gap: 8px;
}
.calendar-day {
min-height: 60px;
padding: 4px;
}
.working-guides .guide-initial {
width: 12px;
height: 12px;
line-height: 12px;
font-size: 8px;
}
}
</style>
`;
document.head.insertAdjacentHTML('beforeend', styles);
}
async loadGuides() {
try {
const response = await fetch('/api/guides');
const data = await response.json();
this.guides = Array.isArray(data) ? data : (data.data || []);
this.renderGuidesList();
await this.loadSchedules();
} catch (error) {
console.error('Ошибка загрузки гидов:', error);
}
}
renderGuidesList() {
const guidesContainer = document.getElementById('guidesList');
guidesContainer.innerHTML = this.guides.map(guide => `
<div class="guide-checkbox" data-guide-id="${guide.id}">
<label class="d-flex align-items-center guide-info">
<input type="checkbox" value="${guide.id}">
<div class="flex-grow-1">
<div class="guide-name">${guide.name}</div>
<div class="guide-specialization">${guide.specialization || 'Универсальный'}</div>
</div>
</label>
</div>
`).join('');
}
async loadSchedules() {
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth() + 1;
try {
const response = await fetch(`/api/guide-schedules?year=${year}&month=${month}`);
const data = await response.json();
const schedules = Array.isArray(data) ? data : (data.data || []);
this.workingDays.clear();
schedules.forEach(schedule => {
if (!this.workingDays.has(schedule.guide_id)) {
this.workingDays.set(schedule.guide_id, new Set());
}
this.workingDays.get(schedule.guide_id).add(schedule.work_date);
});
this.renderCalendar();
this.updateStats();
} catch (error) {
console.error('Ошибка загрузки расписания:', error);
}
}
renderCalendar() {
const calendar = document.getElementById('scheduleCalendar');
const monthLabel = document.getElementById('currentMonthLabel');
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth();
monthLabel.textContent = this.currentDate.toLocaleDateString('ru-RU', {
year: 'numeric',
month: 'long'
});
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startDate = new Date(firstDay);
startDate.setDate(startDate.getDate() - firstDay.getDay() + 1); // Начать с понедельника
const weeks = [];
let currentWeek = [];
let currentDate = new Date(startDate);
for (let i = 0; i < 42; i++) {
currentWeek.push(new Date(currentDate));
currentDate.setDate(currentDate.getDate() + 1);
if (currentWeek.length === 7) {
weeks.push(currentWeek);
currentWeek = [];
}
}
const calendarHTML = `
<table class="schedule-calendar table table-bordered mb-0">
<thead>
<tr>
<th class="calendar-header">Пн</th>
<th class="calendar-header">Вт</th>
<th class="calendar-header">Ср</th>
<th class="calendar-header">Чт</th>
<th class="calendar-header">Пт</th>
<th class="calendar-header">Сб</th>
<th class="calendar-header">Вс</th>
</tr>
</thead>
<tbody>
${weeks.map(week => `
<tr>
${week.map(date => this.renderCalendarDay(date, month)).join('')}
</tr>
`).join('')}
</tbody>
</table>
`;
calendar.innerHTML = calendarHTML;
}
renderCalendarDay(date, currentMonth) {
const dateStr = date.toISOString().split('T')[0];
const dayOfWeek = date.getDay();
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
const isCurrentMonth = date.getMonth() === currentMonth;
let classes = ['calendar-day'];
if (!isCurrentMonth) {
classes.push('other-month');
} else if (isWeekend) {
classes.push('weekend');
}
// Проверить, работают ли гиды в этот день
const workingGuidesCount = this.getWorkingGuidesForDate(dateStr).length;
const selectedGuidesCount = this.selectedGuides.size;
if (workingGuidesCount > 0) {
if (selectedGuidesCount === 0 || workingGuidesCount === selectedGuidesCount) {
classes.push('working');
} else {
classes.push('partial-working');
}
}
const workingGuides = this.getWorkingGuidesForDate(dateStr);
const workingGuidesHTML = workingGuides.length > 0 ? `
<div class="working-guides">
${workingGuides.slice(0, 5).map(guide => `
<span class="guide-initial" title="${guide.name}">${guide.name.charAt(0)}</span>
`).join('')}
${workingGuides.length > 5 ? `<span title="+${workingGuides.length - 5} еще">+${workingGuides.length - 5}</span>` : ''}
</div>
` : '';
return `
<td class="${classes.join(' ')}" data-date="${dateStr}">
<div class="day-number">${date.getDate()}</div>
${workingGuidesHTML}
</td>
`;
}
getWorkingGuidesForDate(dateStr) {
const working = [];
this.workingDays.forEach((dates, guideId) => {
if (dates.has(dateStr)) {
const guide = this.guides.find(g => g.id == guideId);
if (guide) working.push(guide);
}
});
return working;
}
bindEvents() {
// Навигация по месяцам
document.getElementById('prevMonth').addEventListener('click', () => {
this.currentDate.setMonth(this.currentDate.getMonth() - 1);
this.loadSchedules();
});
document.getElementById('nextMonth').addEventListener('click', () => {
this.currentDate.setMonth(this.currentDate.getMonth() + 1);
this.loadSchedules();
});
// Выбор гидов
document.getElementById('guidesList').addEventListener('change', (e) => {
if (e.target.type === 'checkbox') {
this.handleGuideSelection(e.target);
}
});
// Быстрые действия
document.getElementById('selectAllGuides').addEventListener('click', () => this.selectAllGuides());
document.getElementById('clearGuideSelection').addEventListener('click', () => this.clearGuideSelection());
// Быстрое планирование
document.getElementById('markWeekdays').addEventListener('click', () => this.markWeekdays());
document.getElementById('markWeekends').addEventListener('click', () => this.markWeekends());
document.getElementById('markFullMonth').addEventListener('click', () => this.markFullMonth());
document.getElementById('clearMonth').addEventListener('click', () => this.clearMonth());
// Копирование между месяцами
document.getElementById('copyPrevMonth').addEventListener('click', () => this.copyFromPreviousMonth());
document.getElementById('copyNextMonth').addEventListener('click', () => this.copyToNextMonth());
// Сохранение
document.getElementById('saveSchedule').addEventListener('click', () => this.saveSchedule());
// Клики по дням календаря
this.container.addEventListener('click', (e) => {
const calendarDay = e.target.closest('.calendar-day');
if (calendarDay && !calendarDay.classList.contains('other-month')) {
this.handleDayClick(calendarDay);
}
});
}
handleGuideSelection(checkbox) {
const guideId = parseInt(checkbox.value);
const guideCheckbox = checkbox.closest('.guide-checkbox');
if (checkbox.checked) {
this.selectedGuides.add(guideId);
guideCheckbox.classList.add('selected');
} else {
this.selectedGuides.delete(guideId);
guideCheckbox.classList.remove('selected');
}
this.renderCalendar();
}
selectAllGuides() {
const checkboxes = document.querySelectorAll('#guidesList input[type="checkbox"]');
checkboxes.forEach(cb => {
cb.checked = true;
this.handleGuideSelection(cb);
});
}
clearGuideSelection() {
const checkboxes = document.querySelectorAll('#guidesList input[type="checkbox"]');
checkboxes.forEach(cb => {
cb.checked = false;
this.handleGuideSelection(cb);
});
}
handleDayClick(dayElement) {
if (this.selectedGuides.size === 0) {
alert('Выберите хотя бы одного гида для планирования смен');
return;
}
const dateStr = dayElement.dataset.date;
const isWorking = dayElement.classList.contains('working') || dayElement.classList.contains('partial-working');
this.selectedGuides.forEach(guideId => {
if (!this.workingDays.has(guideId)) {
this.workingDays.set(guideId, new Set());
}
const guideDates = this.workingDays.get(guideId);
if (isWorking && this.allSelectedGuidesWorkingOnDate(dateStr)) {
// Если все выбранные гиды работают в этот день, убираем их
guideDates.delete(dateStr);
} else {
// Иначе добавляем день
guideDates.add(dateStr);
}
});
this.renderCalendar();
this.updateStats();
}
allSelectedGuidesWorkingOnDate(dateStr) {
for (let guideId of this.selectedGuides) {
if (!this.workingDays.has(guideId) || !this.workingDays.get(guideId).has(dateStr)) {
return false;
}
}
return true;
}
markWeekdays() {
if (this.selectedGuides.size === 0) return;
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(year, month, day);
const dayOfWeek = date.getDay();
if (dayOfWeek >= 1 && dayOfWeek <= 5) { // Понедельник - Пятница
const dateStr = date.toISOString().split('T')[0];
this.selectedGuides.forEach(guideId => {
if (!this.workingDays.has(guideId)) {
this.workingDays.set(guideId, new Set());
}
this.workingDays.get(guideId).add(dateStr);
});
}
}
this.renderCalendar();
this.updateStats();
}
markWeekends() {
if (this.selectedGuides.size === 0) return;
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(year, month, day);
const dayOfWeek = date.getDay();
if (dayOfWeek === 0 || dayOfWeek === 6) { // Суббота - Воскресенье
const dateStr = date.toISOString().split('T')[0];
this.selectedGuides.forEach(guideId => {
if (!this.workingDays.has(guideId)) {
this.workingDays.set(guideId, new Set());
}
this.workingDays.get(guideId).add(dateStr);
});
}
}
this.renderCalendar();
this.updateStats();
}
markFullMonth() {
if (this.selectedGuides.size === 0) return;
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(year, month, day);
const dateStr = date.toISOString().split('T')[0];
this.selectedGuides.forEach(guideId => {
if (!this.workingDays.has(guideId)) {
this.workingDays.set(guideId, new Set());
}
this.workingDays.get(guideId).add(dateStr);
});
}
this.renderCalendar();
this.updateStats();
}
clearMonth() {
if (this.selectedGuides.size === 0) return;
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(year, month, day);
const dateStr = date.toISOString().split('T')[0];
this.selectedGuides.forEach(guideId => {
if (this.workingDays.has(guideId)) {
this.workingDays.get(guideId).delete(dateStr);
}
});
}
this.renderCalendar();
this.updateStats();
}
async copyFromPreviousMonth() {
const prevMonth = new Date(this.currentDate);
prevMonth.setMonth(prevMonth.getMonth() - 1);
const year = prevMonth.getFullYear();
const month = prevMonth.getMonth() + 1;
try {
const response = await fetch(`/api/guide-schedules?year=${year}&month=${month}`);
const data = await response.json();
const schedules = Array.isArray(data) ? data : (data.data || []);
// Копируем расписание из предыдущего месяца в текущий
schedules.forEach(schedule => {
const prevDate = new Date(schedule.work_date);
const currentMonthDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), prevDate.getDate());
// Проверяем, что день существует в текущем месяце
if (currentMonthDate.getMonth() === this.currentDate.getMonth()) {
const dateStr = currentMonthDate.toISOString().split('T')[0];
if (!this.workingDays.has(schedule.guide_id)) {
this.workingDays.set(schedule.guide_id, new Set());
}
this.workingDays.get(schedule.guide_id).add(dateStr);
}
});
this.renderCalendar();
this.updateStats();
alert('Расписание скопировано из предыдущего месяца');
} catch (error) {
console.error('Ошибка копирования расписания:', error);
alert('Ошибка при копировании расписания');
}
}
async copyToNextMonth() {
// Сначала сохраняем текущие изменения
await this.saveSchedule(false);
const nextMonth = new Date(this.currentDate);
nextMonth.setMonth(nextMonth.getMonth() + 1);
const scheduleData = [];
const year = nextMonth.getFullYear();
const month = nextMonth.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
// Создаем расписание для следующего месяца
this.workingDays.forEach((dates, guideId) => {
dates.forEach(dateStr => {
const currentDate = new Date(dateStr);
const day = currentDate.getDate();
// Проверяем, что день существует в следующем месяце
if (day <= daysInMonth) {
const nextMonthDate = new Date(year, month, day);
const nextDateStr = nextMonthDate.toISOString().split('T')[0];
scheduleData.push({
guide_id: guideId,
work_date: nextDateStr
});
}
});
});
try {
const response = await fetch('/api/guide-schedules/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schedules: scheduleData })
});
if (response.ok) {
alert('Расписание скопировано в следующий месяц');
} else {
throw new Error('Ошибка сохранения');
}
} catch (error) {
console.error('Ошибка копирования расписания:', error);
alert('Ошибка при копировании расписания в следующий месяц');
}
}
async saveSchedule(showAlert = true) {
const scheduleData = [];
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth() + 1;
this.workingDays.forEach((dates, guideId) => {
dates.forEach(dateStr => {
const date = new Date(dateStr);
if (date.getFullYear() === year && date.getMonth() + 1 === month) {
scheduleData.push({
guide_id: guideId,
work_date: dateStr
});
}
});
});
try {
const response = await fetch(`/api/guide-schedules?year=${year}&month=${month}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schedules: scheduleData })
});
if (response.ok) {
if (showAlert) {
alert('Расписание сохранено успешно');
}
if (this.onScheduleChange) {
this.onScheduleChange(scheduleData);
}
} else {
throw new Error('Ошибка сохранения');
}
} catch (error) {
console.error('Ошибка сохранения расписания:', error);
if (showAlert) {
alert('Ошибка при сохранении расписания');
}
}
}
updateStats() {
const statsContainer = document.getElementById('scheduleStats');
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
// Подсчет статистики
const stats = {
totalGuides: this.guides.length,
activeGuides: 0,
totalWorkingDays: 0,
averageWorkingDays: 0
};
const guideWorkingDays = new Map();
this.workingDays.forEach((dates, guideId) => {
const currentMonthDays = Array.from(dates).filter(dateStr => {
const date = new Date(dateStr);
return date.getFullYear() === year && date.getMonth() === month;
});
if (currentMonthDays.length > 0) {
stats.activeGuides++;
guideWorkingDays.set(guideId, currentMonthDays.length);
stats.totalWorkingDays += currentMonthDays.length;
}
});
stats.averageWorkingDays = stats.activeGuides > 0 ?
Math.round(stats.totalWorkingDays / stats.activeGuides * 10) / 10 : 0;
const coverage = stats.activeGuides > 0 ?
Math.round((stats.totalWorkingDays / (daysInMonth * stats.activeGuides)) * 100) : 0;
statsContainer.innerHTML = `
<div class="col-md-3">
<div class="stat-item">
<div class="stat-number text-primary">${stats.totalGuides}</div>
<div class="stat-label">Всего гидов</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-item">
<div class="stat-number text-success">${stats.activeGuides}</div>
<div class="stat-label">Активных гидов</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-item">
<div class="stat-number text-info">${stats.averageWorkingDays}</div>
<div class="stat-label">Ср. дней/гид</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-item">
<div class="stat-number text-warning">${coverage}%</div>
<div class="stat-label">Покрытие месяца</div>
</div>
</div>
`;
}
}
if (typeof window !== 'undefined') {
window.GuideScheduleManager = GuideScheduleManager;
}

View File

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

View File

@@ -15,6 +15,46 @@
background: linear-gradient(180deg, #1f2937 0%, #111827 100%) !important;
}
/* Navigation Group Styling */
/* Parent groups (main categories) */
nav[data-testid="sidebar"] > ul > li > a[href*="parent"] {
font-weight: 600 !important;
color: #ffffff !important;
background-color: rgba(255,255,255,0.1) !important;
margin-bottom: 0.25rem !important;
border-radius: 0.375rem !important;
}
/* Child resources (nested items) */
nav[data-testid="sidebar"] > ul > li > ul > li > a {
padding-left: 3rem !important;
color: #d1d5db !important;
border-left: 2px solid rgba(255,255,255,0.1) !important;
margin-left: 1rem !important;
position: relative !important;
}
/* Icons for nested resources */
nav[data-testid="sidebar"] > ul > li > ul > li > a:before {
content: "📄" !important;
margin-right: 0.5rem !important;
opacity: 0.7 !important;
}
/* Specific icons for different resource types */
nav[data-testid="sidebar"] a[href*="routes"]:before { content: "🗺️" !important; }
nav[data-testid="sidebar"] a[href*="articles"]:before { content: "📝" !important; }
nav[data-testid="sidebar"] a[href*="bookings"]:before { content: "📋" !important; }
nav[data-testid="sidebar"] a[href*="reviews"]:before { content: "⭐" !important; }
nav[data-testid="sidebar"] a[href*="ratings"]:before { content: "📈" !important; }
nav[data-testid="sidebar"] a[href*="guides"]:before { content: "👥" !important; }
nav[data-testid="sidebar"] a[href*="guide_schedules"]:before { content: "📅" !important; }
nav[data-testid="sidebar"] a[href*="holidays"]:before { content: "🏛️" !important; }
nav[data-testid="sidebar"] a[href*="guide_working_days"]:before { content: "📅" !important; }
nav[data-testid="sidebar"] a[href*="contact_messages"]:before { content: "📧" !important; }
nav[data-testid="sidebar"] a[href*="admins"]:before { content: "👤" !important; }
nav[data-testid="sidebar"] a[href*="site_settings"]:before { content: "⚙️" !important; }
.nav-sidebar .nav-item > .nav-link {
color: #d1d5db !important;
transition: all 0.3s ease;
@@ -301,4 +341,65 @@ input[name*="avatar"]:focus {
.image-preview {
max-width: 100% !important;
}
}
/* ===== НОВЫЕ СТИЛИ ДЛЯ КАСТОМНЫХ СТРАНИЦ ===== */
/* Улучшение кнопок редактора изображений */
.image-editor-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
border: none !important;
color: white !important;
padding: 8px 16px !important;
border-radius: 6px !important;
font-size: 12px !important;
font-weight: 500 !important;
cursor: pointer !important;
transition: all 0.2s ease !important;
margin-left: 10px !important;
display: inline-flex !important;
align-items: center !important;
gap: 6px !important;
}
.image-editor-btn:hover {
transform: translateY(-1px) !important;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3) !important;
}
/* Улучшение модальных окон */
.image-editor-modal {
backdrop-filter: blur(5px) !important;
}
.image-editor-modal .modal-content {
border-radius: 12px !important;
box-shadow: 0 20px 40px rgba(0,0,0,0.3) !important;
overflow: hidden !important;
}
/* Превью изображений */
.image-preview {
border-radius: 8px !important;
box-shadow: 0 4px 12px rgba(0,0,0,0.1) !important;
border: 2px solid #e9ecef !important;
transition: all 0.2s ease !important;
}
.image-preview:hover {
transform: scale(1.05) !important;
box-shadow: 0 8px 20px rgba(0,0,0,0.15) !important;
}
/* Стили для кастомных страниц в AdminJS */
.adminjs-page {
background: #f8f9fa;
}
/* Улучшение интеграции iframe */
iframe[src*="style-editor-advanced.html"],
iframe[src*="image-manager.html"] {
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
overflow: hidden;
}

View File

@@ -0,0 +1,3 @@
/* Автоматически сгенерированные стили - 2025-11-30T02:42:19.565Z */
:root { --primary-color: #ff6b6b; --secondary-color: #38C172; }

557
public/guide-calendar.html Normal file
View File

@@ -0,0 +1,557 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Календарь гидов</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 20px;
background: #f8f9fa;
}
.calendar-container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
overflow: hidden;
}
.calendar-header {
background: #2d3748;
color: white;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.calendar-title {
font-size: 24px;
font-weight: 600;
margin: 0;
}
.month-navigation {
display: flex;
align-items: center;
gap: 15px;
}
.nav-button {
background: rgba(255,255,255,0.1);
border: none;
color: white;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
transition: all 0.3s ease;
}
.nav-button:hover {
background: rgba(255,255,255,0.2);
}
.current-month {
font-size: 18px;
font-weight: 600;
min-width: 200px;
text-align: center;
}
.calendar-content {
padding: 20px;
}
.guides-filter {
margin-bottom: 20px;
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.filter-label {
font-weight: 600;
color: #495057;
}
.guide-checkbox {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border: 1px solid #dee2e6;
border-radius: 6px;
background: white;
cursor: pointer;
transition: all 0.3s ease;
}
.guide-checkbox:hover {
border-color: #ff6b6b;
background: #fff5f5;
}
.guide-checkbox input[type="checkbox"] {
margin: 0;
}
.guide-checkbox.checked {
border-color: #ff6b6b;
background: #fff5f5;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background: #dee2e6;
border-radius: 8px;
overflow: hidden;
}
.day-header {
background: #6c757d;
color: white;
padding: 10px;
text-align: center;
font-weight: 600;
font-size: 14px;
}
.day-cell {
background: white;
min-height: 120px;
padding: 8px;
position: relative;
display: flex;
flex-direction: column;
}
.day-cell.other-month {
background: #f8f9fa;
color: #6c757d;
}
.day-cell.today {
background: #fff3cd;
}
.day-number {
font-weight: 600;
margin-bottom: 5px;
color: #495057;
}
.day-cell.other-month .day-number {
color: #adb5bd;
}
.guide-schedule {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.guide-item {
background: #e9ecef;
color: #495057;
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
}
.guide-item.working {
background: #d1f2eb;
color: #00875a;
}
.guide-item.holiday {
background: #ffcdd2;
color: #d32f2f;
}
.guide-item.partial {
background: #fff3cd;
color: #856404;
}
.guide-item:hover {
transform: scale(1.05);
}
.legend {
display: flex;
gap: 20px;
margin-top: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
justify-content: center;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.legend-color {
width: 16px;
height: 16px;
border-radius: 3px;
}
.working { background: #d1f2eb; }
.holiday { background: #ffcdd2; }
.partial { background: #fff3cd; }
.unavailable { background: #e9ecef; }
.loading {
text-align: center;
padding: 40px;
color: #6c757d;
}
.error {
text-align: center;
padding: 40px;
color: #dc3545;
background: #f8d7da;
border-radius: 8px;
margin: 20px 0;
}
@media (max-width: 768px) {
.calendar-header {
flex-direction: column;
gap: 15px;
}
.guides-filter {
justify-content: center;
}
.day-cell {
min-height: 80px;
padding: 4px;
}
.guide-item {
font-size: 10px;
padding: 1px 4px;
}
}
</style>
</head>
<body>
<div class="calendar-container">
<div class="calendar-header">
<h1 class="calendar-title">📅 Календарь работы гидов</h1>
<div class="month-navigation">
<button class="nav-button" onclick="changeMonth(-1)"></button>
<div class="current-month" id="currentMonth"></div>
<button class="nav-button" onclick="changeMonth(1)"></button>
</div>
</div>
<div class="calendar-content">
<div class="guides-filter">
<span class="filter-label">Показать гидов:</span>
<div id="guidesFilter"></div>
</div>
<div id="calendarGrid"></div>
<div class="legend">
<div class="legend-item">
<div class="legend-color working"></div>
<span>Рабочий день</span>
</div>
<div class="legend-item">
<div class="legend-color partial"></div>
<span>Частично доступен</span>
</div>
<div class="legend-item">
<div class="legend-color holiday"></div>
<span>Выходной</span>
</div>
<div class="legend-item">
<div class="legend-color unavailable"></div>
<span>Не работает</span>
</div>
</div>
</div>
</div>
<script>
class GuideCalendar {
constructor() {
this.currentDate = new Date();
this.guides = [];
this.schedules = [];
this.holidays = [];
this.selectedGuides = new Set();
this.bookings = [];
this.init();
}
async init() {
await this.loadData();
this.renderGuidesFilter();
this.renderCalendar();
this.updateMonthDisplay();
}
async loadData() {
try {
// Загружаем гидов
const guidesResponse = await fetch('/api/guides');
const guidesData = await guidesResponse.json();
this.guides = Array.isArray(guidesData) ? guidesData : (guidesData.data || guidesData.guides || []);
// Загружаем расписания
const schedulesResponse = await fetch('/api/guide-schedules');
const schedulesData = await schedulesResponse.json();
this.schedules = Array.isArray(schedulesData) ? schedulesData : (schedulesData.data || schedulesData.schedules || []);
// Загружаем выходные дни
const holidaysResponse = await fetch('/api/holidays');
const holidaysData = await holidaysResponse.json();
this.holidays = Array.isArray(holidaysData) ? holidaysData : (holidaysData.data || holidaysData.holidays || []);
// Загружаем существующие бронирования
const bookingsResponse = await fetch('/api/bookings');
const bookingsData = await bookingsResponse.json();
this.bookings = Array.isArray(bookingsData) ? bookingsData : (bookingsData.data || bookingsData.bookings || []);
// По умолчанию показываем всех гидов
if (this.guides && this.guides.length > 0) {
this.guides.forEach(guide => this.selectedGuides.add(guide.id));
}
} catch (error) {
console.error('Ошибка загрузки данных:', error);
document.getElementById('calendarGrid').innerHTML =
'<div class="error">Ошибка загрузки данных календаря</div>';
}
}
renderGuidesFilter() {
const filterContainer = document.getElementById('guidesFilter');
filterContainer.innerHTML = '';
if (!this.guides || !Array.isArray(this.guides)) {
filterContainer.innerHTML = '<div class="error">Нет доступных гидов</div>';
return;
}
this.guides.forEach(guide => {
const checkbox = document.createElement('label');
checkbox.className = 'guide-checkbox';
if (this.selectedGuides.has(guide.id)) {
checkbox.classList.add('checked');
}
checkbox.innerHTML = `
<input type="checkbox"
${this.selectedGuides.has(guide.id) ? 'checked' : ''}
onchange="calendar.toggleGuide(${guide.id})">
<span>${guide.name}</span>
`;
filterContainer.appendChild(checkbox);
});
}
toggleGuide(guideId) {
if (this.selectedGuides.has(guideId)) {
this.selectedGuides.delete(guideId);
} else {
this.selectedGuides.add(guideId);
}
this.renderGuidesFilter();
this.renderCalendar();
}
renderCalendar() {
const grid = document.getElementById('calendarGrid');
grid.innerHTML = '';
grid.className = 'calendar-grid';
// Заголовки дней недели
const dayHeaders = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
dayHeaders.forEach(day => {
const header = document.createElement('div');
header.className = 'day-header';
header.textContent = day;
grid.appendChild(header);
});
// Дни месяца
const firstDay = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), 1);
const lastDay = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + 1, 0);
// Начинаем с понедельника
const startDate = new Date(firstDay);
const dayOfWeek = firstDay.getDay();
const daysToSubtract = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
startDate.setDate(startDate.getDate() - daysToSubtract);
// Генерируем 42 дня (6 недель)
for (let i = 0; i < 42; i++) {
const currentDay = new Date(startDate);
currentDay.setDate(startDate.getDate() + i);
const dayCell = this.createDayCell(currentDay);
grid.appendChild(dayCell);
}
}
createDayCell(date) {
const cell = document.createElement('div');
cell.className = 'day-cell';
const isCurrentMonth = date.getMonth() === this.currentDate.getMonth();
const isToday = this.isToday(date);
if (!isCurrentMonth) {
cell.classList.add('other-month');
}
if (isToday) {
cell.classList.add('today');
}
const dayNumber = document.createElement('div');
dayNumber.className = 'day-number';
dayNumber.textContent = date.getDate();
cell.appendChild(dayNumber);
const scheduleContainer = document.createElement('div');
scheduleContainer.className = 'guide-schedule';
// Добавляем информацию о гидах для этого дня
this.guides.forEach(guide => {
if (this.selectedGuides.has(guide.id)) {
const guideStatus = this.getGuideStatusForDate(guide, date);
const guideItem = document.createElement('div');
guideItem.className = `guide-item ${guideStatus.type}`;
guideItem.textContent = `${guide.name.split(' ')[0]} ${guideStatus.time}`;
guideItem.title = `${guide.name} - ${guideStatus.description}`;
scheduleContainer.appendChild(guideItem);
}
});
cell.appendChild(scheduleContainer);
return cell;
}
getGuideStatusForDate(guide, date) {
const dayOfWeek = date.getDay();
const dayName = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'][dayOfWeek];
// Проверяем выходные дни
const holiday = this.holidays.find(h =>
h.guide_id === guide.id &&
new Date(h.date).toDateString() === date.toDateString()
);
if (holiday) {
return {
type: 'holiday',
time: '',
description: holiday.title
};
}
// Проверяем расписание
const schedule = this.schedules.find(s => s.guide_id === guide.id);
if (!schedule || !schedule[dayName]) {
return {
type: 'unavailable',
time: '',
description: 'Не работает'
};
}
// Проверяем существующие бронирования
const dateStr = date.toISOString().split('T')[0];
const dayBookings = this.bookings.filter(b =>
b.guide_id === guide.id &&
b.booking_date === dateStr
);
const startTime = schedule.start_time || '09:00';
const endTime = schedule.end_time || '18:00';
if (dayBookings.length > 0) {
// Если есть бронирования, показываем частично доступен
return {
type: 'partial',
time: `${startTime}-${endTime}`,
description: `Рабочий день (${dayBookings.length} бронирований)`
};
}
return {
type: 'working',
time: `${startTime}-${endTime}`,
description: `Рабочий день ${startTime}-${endTime}`
};
}
isToday(date) {
const today = new Date();
return date.toDateString() === today.toDateString();
}
updateMonthDisplay() {
const monthNames = [
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
];
const monthDisplay = document.getElementById('currentMonth');
monthDisplay.textContent = `${monthNames[this.currentDate.getMonth()]} ${this.currentDate.getFullYear()}`;
}
changeMonth(delta) {
this.currentDate.setMonth(this.currentDate.getMonth() + delta);
this.renderCalendar();
this.updateMonthDisplay();
}
}
// Инициализация календаря
document.addEventListener('DOMContentLoaded', () => {
calendar = new GuideCalendar();
// Добавляем обработчики событий для кнопок навигации
const prevBtn = document.getElementById('prevMonth');
const nextBtn = document.getElementById('nextMonth');
if (prevBtn) {
prevBtn.addEventListener('click', () => calendar.changeMonth(-1));
}
if (nextBtn) {
nextBtn.addEventListener('click', () => calendar.changeMonth(1));
}
});
</script>
</body>
</html>

953
public/image-manager.html Normal file
View File

@@ -0,0 +1,953 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Менеджер изображений</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f8f9fa;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding: 20px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
.header h1 {
color: #333;
font-size: 24px;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 12px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
}
.btn-secondary {
background: #f8f9fa;
color: #6c757d;
border: 1px solid #dee2e6;
}
.btn-secondary:hover {
background: #e9ecef;
}
/* Загрузка файлов */
.upload-area {
background: white;
border: 2px dashed #ddd;
border-radius: 12px;
padding: 40px;
text-align: center;
margin-bottom: 30px;
cursor: pointer;
transition: all 0.3s;
}
.upload-area:hover {
border-color: #667eea;
background: #f8f9ff;
}
.upload-area.dragover {
border-color: #667eea;
background: #f0f4ff;
transform: scale(1.02);
}
.upload-icon {
font-size: 48px;
color: #ddd;
margin-bottom: 16px;
}
.upload-area:hover .upload-icon {
color: #667eea;
}
.upload-text {
font-size: 18px;
font-weight: 500;
margin-bottom: 8px;
}
.upload-subtext {
color: #6c757d;
font-size: 14px;
}
/* Фильтры и поиск */
.controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 20px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
flex-wrap: wrap;
gap: 15px;
}
.search-box {
flex: 1;
min-width: 250px;
position: relative;
}
.search-input {
width: 100%;
padding: 12px 40px 12px 15px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
}
.search-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.search-icon {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
color: #6c757d;
}
.filter-group {
display: flex;
gap: 10px;
align-items: center;
}
.filter-select {
padding: 10px 15px;
border: 1px solid #ddd;
border-radius: 8px;
background: white;
font-size: 14px;
}
.view-toggle {
display: flex;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.view-btn {
padding: 8px 12px;
border: none;
background: white;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.view-btn.active {
background: #667eea;
color: white;
}
/* Сетка изображений */
.images-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.images-list {
display: none;
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
.image-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
transition: all 0.3s;
cursor: pointer;
}
.image-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
}
.image-preview {
width: 100%;
height: 150px;
background-size: cover;
background-position: center;
background-color: #f8f9fa;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.image-preview img {
max-width: 100%;
max-height: 100%;
object-fit: cover;
}
.image-placeholder {
color: #ddd;
font-size: 48px;
}
.image-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
opacity: 0;
transition: opacity 0.3s;
}
.image-card:hover .image-overlay {
opacity: 1;
}
.overlay-btn {
padding: 8px;
border: none;
border-radius: 6px;
background: rgba(255,255,255,0.2);
color: white;
cursor: pointer;
font-size: 16px;
transition: all 0.2s;
}
.overlay-btn:hover {
background: rgba(255,255,255,0.3);
transform: scale(1.1);
}
.image-info {
padding: 15px;
}
.image-name {
font-weight: 500;
margin-bottom: 5px;
word-break: break-word;
}
.image-details {
font-size: 12px;
color: #6c757d;
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
.image-url {
font-size: 11px;
color: #6c757d;
background: #f8f9fa;
padding: 4px 8px;
border-radius: 4px;
font-family: monospace;
word-break: break-all;
}
/* Модальные окна */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: all 0.3s;
}
.modal.show {
opacity: 1;
visibility: visible;
}
.modal-content {
background: white;
border-radius: 12px;
max-width: 90%;
max-height: 90%;
overflow: auto;
position: relative;
}
.modal-header {
padding: 20px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
font-size: 18px;
font-weight: 600;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #6c757d;
}
.modal-body {
padding: 20px;
}
.preview-image {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
border-radius: 8px;
}
/* Прогресс загрузки */
.upload-progress {
margin-top: 20px;
}
.progress-bar {
width: 100%;
height: 6px;
background: #e9ecef;
border-radius: 3px;
overflow: hidden;
margin-bottom: 10px;
}
.progress-fill {
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 3px;
transition: width 0.3s;
width: 0%;
}
.progress-text {
font-size: 14px;
color: #6c757d;
text-align: center;
}
/* Уведомления */
.notification {
position: fixed;
top: 20px;
right: 20px;
padding: 15px 20px;
border-radius: 8px;
color: white;
font-weight: 500;
z-index: 1001;
transform: translateX(100%);
transition: transform 0.3s;
max-width: 400px;
}
.notification.show {
transform: translateX(0);
}
.notification.success {
background: #28a745;
}
.notification.error {
background: #dc3545;
}
.notification.info {
background: #17a2b8;
}
/* Скелетон загрузки */
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.skeleton-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
.skeleton-image {
width: 100%;
height: 150px;
}
.skeleton-text {
height: 16px;
margin: 15px;
border-radius: 4px;
}
.skeleton-text.short {
width: 60%;
}
/* Адаптив */
@media (max-width: 768px) {
.container {
padding: 15px;
}
.header {
flex-direction: column;
gap: 15px;
text-align: center;
}
.controls {
flex-direction: column;
align-items: stretch;
}
.filter-group {
justify-content: center;
}
.images-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 15px;
}
}
</style>
</head>
<body>
<div class="container">
<!-- Заголовок -->
<div class="header">
<h1>🖼️ Менеджер изображений</h1>
<div class="header-actions">
<button class="btn btn-secondary" onclick="refreshGallery()">
🔄 Обновить
</button>
<button class="btn btn-primary" onclick="openUpload()">
📤 Загрузить изображения
</button>
</div>
</div>
<!-- Область загрузки -->
<div class="upload-area" id="uploadArea" onclick="openUpload()">
<div class="upload-icon">📸</div>
<div class="upload-text">Перетащите изображения сюда или нажмите для выбора</div>
<div class="upload-subtext">Поддерживаются JPG, PNG, GIF, WEBP до 10MB</div>
<input type="file" id="fileInput" multiple accept="image/*" style="display: none;" onchange="handleFileUpload(this.files)">
</div>
<!-- Прогресс загрузки -->
<div class="upload-progress" id="uploadProgress" style="display: none;">
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="progress-text" id="progressText">Загрузка...</div>
</div>
<!-- Контролы -->
<div class="controls">
<div class="search-box">
<input type="text" class="search-input" placeholder="Поиск по названию или типу..."
oninput="searchImages(this.value)">
<span class="search-icon">🔍</span>
</div>
<div class="filter-group">
<select class="filter-select" onchange="filterByType(this.value)">
<option value="">Все типы</option>
<option value="routes">Маршруты</option>
<option value="guides">Гиды</option>
<option value="articles">Статьи</option>
<option value="general">Общие</option>
</select>
<div class="view-toggle">
<button class="view-btn active" onclick="setView('grid')" data-view="grid"></button>
<button class="view-btn" onclick="setView('list')" data-view="list"></button>
</div>
</div>
</div>
<!-- Сетка изображений -->
<div class="images-grid" id="imagesGrid">
<!-- Скелетоны загрузки -->
<div class="skeleton-card">
<div class="skeleton skeleton-image"></div>
<div class="skeleton skeleton-text"></div>
<div class="skeleton skeleton-text short"></div>
</div>
<div class="skeleton-card">
<div class="skeleton skeleton-image"></div>
<div class="skeleton skeleton-text"></div>
<div class="skeleton skeleton-text short"></div>
</div>
<div class="skeleton-card">
<div class="skeleton skeleton-image"></div>
<div class="skeleton skeleton-text"></div>
<div class="skeleton skeleton-text short"></div>
</div>
</div>
<!-- Список изображений (альтернативный вид) -->
<div class="images-list" id="imagesList">
<!-- Будет заполнено динамически -->
</div>
</div>
<!-- Модальное окно просмотра -->
<div class="modal" id="viewModal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Просмотр изображения</h3>
<button class="close-btn" onclick="closeModal('viewModal')">&times;</button>
</div>
<div class="modal-body">
<img class="preview-image" id="previewImage" src="" alt="">
<div style="margin-top: 20px;">
<p><strong>Имя файла:</strong> <span id="fileName"></span></p>
<p><strong>Размер:</strong> <span id="fileSize"></span></p>
<p><strong>URL:</strong> <span id="fileUrl" style="font-family: monospace; background: #f8f9fa; padding: 2px 6px; border-radius: 4px;"></span></p>
</div>
<div style="margin-top: 20px; text-align: center;">
<button class="btn btn-primary" onclick="copyToClipboard()">📋 Копировать URL</button>
<button class="btn btn-secondary" onclick="downloadImage()">💾 Скачать</button>
</div>
</div>
</div>
</div>
<!-- Уведомления -->
<div id="notification" class="notification"></div>
<script>
let currentImages = [];
let filteredImages = [];
let currentView = 'grid';
let currentImage = null;
// Инициализация
document.addEventListener('DOMContentLoaded', function() {
loadImages();
setupDragAndDrop();
});
// Настройка Drag & Drop
function setupDragAndDrop() {
const uploadArea = document.getElementById('uploadArea');
uploadArea.addEventListener('dragover', function(e) {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', function(e) {
e.preventDefault();
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', function(e) {
e.preventDefault();
uploadArea.classList.remove('dragover');
handleFileUpload(e.dataTransfer.files);
});
}
// Загрузка изображений
async function loadImages() {
try {
const response = await fetch('/api/images/gallery');
const data = await response.json();
if (data.success) {
currentImages = data.images || [];
filteredImages = [...currentImages];
renderImages();
} else {
throw new Error(data.error || 'Ошибка загрузки изображений');
}
} catch (error) {
console.error('Ошибка загрузки изображений:', error);
showNotification('Ошибка загрузки изображений', 'error');
currentImages = [];
filteredImages = [];
renderEmptyState();
}
}
// Отображение изображений
function renderImages() {
const grid = document.getElementById('imagesGrid');
const list = document.getElementById('imagesList');
if (filteredImages.length === 0) {
renderEmptyState();
return;
}
const gridHTML = filteredImages.map(image => createImageCard(image)).join('');
grid.innerHTML = gridHTML;
// Список пока не реализован
list.innerHTML = '';
}
// Создание карточки изображения
function createImageCard(image) {
const fileName = image.path.split('/').pop();
const fileExtension = fileName.split('.').pop().toUpperCase();
const fileSize = image.size ? formatFileSize(image.size) : 'Неизвестно';
return `
<div class="image-card" onclick="viewImage('${image.path}', '${fileName}', '${fileSize}')">
<div class="image-preview" style="background-image: url('${image.path}')">
<div class="image-overlay">
<button class="overlay-btn" onclick="event.stopPropagation(); viewImage('${image.path}', '${fileName}', '${fileSize}')" title="Просмотр">👁️</button>
<button class="overlay-btn" onclick="event.stopPropagation(); copyToClipboard('${image.path}')" title="Копировать URL">📋</button>
<button class="overlay-btn" onclick="event.stopPropagation(); deleteImage('${image.path}')" title="Удалить">🗑️</button>
</div>
</div>
<div class="image-info">
<div class="image-name">${fileName}</div>
<div class="image-details">
<span>${fileExtension}</span>
<span>${fileSize}</span>
</div>
<div class="image-url">${image.path}</div>
</div>
</div>
`;
}
// Пустое состояние
function renderEmptyState() {
const grid = document.getElementById('imagesGrid');
grid.innerHTML = `
<div style="grid-column: 1 / -1; text-align: center; padding: 60px 20px; color: #6c757d;">
<div style="font-size: 64px; margin-bottom: 20px;">📷</div>
<h3>Изображения не найдены</h3>
<p>Загрузите первые изображения или проверьте фильтры поиска</p>
</div>
`;
}
// Открытие диалога загрузки
function openUpload() {
document.getElementById('fileInput').click();
}
// Обработка загрузки файлов
async function handleFileUpload(files) {
if (!files.length) return;
const uploadProgress = document.getElementById('uploadProgress');
const progressFill = document.getElementById('progressFill');
const progressText = document.getElementById('progressText');
uploadProgress.style.display = 'block';
for (let i = 0; i < files.length; i++) {
const file = files[i];
// Проверка типа файла
if (!file.type.startsWith('image/')) {
showNotification(\`Файл \${file.name} не является изображением\`, 'error');
continue;
}
// Проверка размера файла (10MB)
if (file.size > 10 * 1024 * 1024) {
showNotification(\`Файл \${file.name} слишком большой (максимум 10MB)\`, 'error');
continue;
}
try {
const progress = ((i + 1) / files.length) * 100;
progressFill.style.width = progress + '%';
progressText.textContent = \`Загрузка \${i + 1} из \${files.length}: \${file.name}\`;
const formData = new FormData();
formData.append('image', file);
const response = await fetch('/api/images/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
showNotification(\`Изображение \${file.name} загружено успешно\`, 'success');
} else {
throw new Error(result.error || 'Ошибка загрузки');
}
} catch (error) {
console.error('Ошибка загрузки:', error);
showNotification(\`Ошибка загрузки \${file.name}\`, 'error');
}
}
uploadProgress.style.display = 'none';
await loadImages(); // Перезагружаем галерею
}
// Поиск изображений
function searchImages(query) {
if (!query.trim()) {
filteredImages = [...currentImages];
} else {
const searchTerm = query.toLowerCase();
filteredImages = currentImages.filter(image => {
const fileName = image.path.toLowerCase();
return fileName.includes(searchTerm);
});
}
renderImages();
}
// Фильтрация по типу
function filterByType(type) {
if (!type) {
filteredImages = [...currentImages];
} else {
filteredImages = currentImages.filter(image => {
return image.path.includes(\`/uploads/\${type}/\`) ||
image.path.includes(\`/\${type}/\`);
});
}
renderImages();
}
// Переключение вида
function setView(view) {
currentView = view;
// Обновляем кнопки
document.querySelectorAll('.view-btn').forEach(btn => {
btn.classList.remove('active');
});
document.querySelector(\`[data-view="\${view}"]\`).classList.add('active');
// Переключаем отображение
const grid = document.getElementById('imagesGrid');
const list = document.getElementById('imagesList');
if (view === 'grid') {
grid.style.display = 'grid';
list.style.display = 'none';
} else {
grid.style.display = 'none';
list.style.display = 'block';
}
}
// Просмотр изображения
function viewImage(path, name, size) {
currentImage = { path, name, size };
document.getElementById('previewImage').src = path;
document.getElementById('fileName').textContent = name;
document.getElementById('fileSize').textContent = size;
document.getElementById('fileUrl').textContent = path;
showModal('viewModal');
}
// Копирование URL в буфер обмена
function copyToClipboard(url = null) {
const textToCopy = url || currentImage?.path || document.getElementById('fileUrl').textContent;
navigator.clipboard.writeText(textToCopy).then(() => {
showNotification('URL скопирован в буфер обмена', 'success');
}).catch(() => {
// Fallback для старых браузеров
const textArea = document.createElement('textarea');
textArea.value = textToCopy;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
showNotification('URL скопирован в буфер обмена', 'success');
});
}
// Скачивание изображения
function downloadImage() {
if (currentImage) {
const link = document.createElement('a');
link.href = currentImage.path;
link.download = currentImage.name;
link.click();
}
}
// Удаление изображения
async function deleteImage(path) {
if (!confirm('Вы уверены, что хотите удалить это изображение?')) {
return;
}
try {
const response = await fetch('/api/images/delete', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ path })
});
const result = await response.json();
if (result.success) {
showNotification('Изображение удалено', 'success');
await loadImages();
} else {
throw new Error(result.error || 'Ошибка удаления');
}
} catch (error) {
console.error('Ошибка удаления:', error);
showNotification('Ошибка удаления изображения', 'error');
}
}
// Обновление галереи
async function refreshGallery() {
showNotification('Обновление галереи...', 'info');
await loadImages();
}
// Показ модального окна
function showModal(modalId) {
document.getElementById(modalId).classList.add('show');
}
// Закрытие модального окна
function closeModal(modalId) {
document.getElementById(modalId).classList.remove('show');
}
// Показ уведомлений
function showNotification(message, type = 'info') {
const notification = document.getElementById('notification');
notification.textContent = message;
notification.className = \`notification \${type} show\`;
setTimeout(() => {
notification.classList.remove('show');
}, 3000);
}
// Форматирование размера файла
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// Закрытие модального окна по клику на overlay
document.addEventListener('click', function(e) {
if (e.target.classList.contains('modal')) {
e.target.classList.remove('show');
}
});
// Горячие клавиши
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
document.querySelectorAll('.modal.show').forEach(modal => {
modal.classList.remove('show');
});
}
});
</script>
</body>
</html>

BIN
public/images/logo_dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -230,19 +230,14 @@
}
}
// Проверяем по имени поля или тексту label
return fieldName.includes('image') ||
fieldName.includes('photo') ||
fieldName.includes('avatar') ||
fieldName.includes('picture') ||
fieldName.includes('banner') ||
fieldName.includes('thumbnail') ||
(fieldName.includes('url') && (labelText.includes('image') || labelText.includes('изображение'))) ||
labelText.includes('изображение') ||
labelText.includes('картинка') ||
labelText.includes('фото') ||
labelText.includes('image') ||
labelText.includes('picture');
// Проверяем по имени поля и содержанию
const isImageByName = fieldName.includes('image') && !fieldName.includes('title') && !fieldName.includes('alt');
const isImageByLabel = labelText.includes('image') || labelText.includes('изображение') || labelText.includes('фото');
const isImageUrlField = fieldName.includes('image_url') || fieldName === 'image_url';
console.log(`🔍 Проверка поля "${fieldName}": isImageByName=${isImageByName}, isImageByLabel=${isImageByLabel}, isImageUrlField=${isImageUrlField}`);
return isImageUrlField || isImageByName || isImageByLabel;
}
// Функция сканирования и добавления кнопок к полям изображений

View File

@@ -32,7 +32,101 @@ document.addEventListener('DOMContentLoaded', function() {
});
// ==========================================
// Поиск по сайту
// Инициализация компонентов бронирования
// ==========================================
// Компонент для проверки доступности на главной странице
const availabilityContainer = document.getElementById('availability-checker-container');
const guideSelectorContainer = document.getElementById('guide-selector-container');
if (availabilityContainer) {
const availabilityChecker = new AvailabilityChecker({
container: availabilityContainer,
mode: 'detailed',
showSuggestions: true,
onAvailabilityCheck: function(result) {
if (result.availableGuides && result.availableGuides.length > 0) {
// Показать селектор гидов если есть доступные
if (guideSelectorContainer) {
guideSelectorContainer.style.display = 'block';
const guideSelector = new GuideSelector({
container: guideSelectorContainer,
mode: 'booking',
showAvailability: true,
selectedDate: result.date,
onGuideSelect: function(guide) {
// Перейти к бронированию с выбранным гидом
window.location.href = `/routes?guide=${guide.id}&date=${result.date}`;
}
});
}
} else {
if (guideSelectorContainer) {
guideSelectorContainer.style.display = 'none';
}
}
}
});
}
// Календарь гидов на странице гидов
const guidesCalendarContainer = document.getElementById('guides-calendar-container');
if (guidesCalendarContainer) {
const guidesCalendar = new GuideCalendarWidget({
container: guidesCalendarContainer,
mode: 'readonly',
showControls: false,
showGuideInfo: true
});
}
// Компоненты бронирования на странице маршрута
const bookingAvailabilityContainer = document.getElementById('booking-availability-checker');
const bookingGuideSelectorContainer = document.getElementById('booking-guide-selector');
if (bookingAvailabilityContainer) {
const bookingAvailabilityChecker = new AvailabilityChecker({
container: bookingAvailabilityContainer,
mode: 'inline',
showSuggestions: false,
onAvailabilityCheck: function(result) {
if (result.availableGuides && result.availableGuides.length > 0) {
if (bookingGuideSelectorContainer) {
bookingGuideSelectorContainer.style.display = 'block';
const bookingGuideSelector = new GuideSelector({
container: bookingGuideSelectorContainer,
mode: 'booking',
showAvailability: false,
availableGuides: result.availableGuides,
onGuideSelect: function(guide) {
// Заполнить скрытое поле с ID гида
const selectedGuideIdInput = document.getElementById('selectedGuideId');
const preferredDateInput = document.getElementById('preferred_date');
const submitBtn = document.getElementById('submitBookingBtn');
if (selectedGuideIdInput) {
selectedGuideIdInput.value = guide.id;
}
if (preferredDateInput) {
preferredDateInput.value = result.date;
}
if (submitBtn) {
submitBtn.disabled = false;
}
}
});
}
}
}
});
}
// ==========================================
// Поиск по сайту (обновленная версия)
// ==========================================
const searchInput = document.getElementById('search-input');
const searchResults = document.getElementById('search-results');
@@ -378,6 +472,101 @@ document.addEventListener('DOMContentLoaded', function() {
}, 5000);
}
// ==========================================
// Вспомогательные функции для компонентов
// ==========================================
// Очистка результатов поиска
function clearSearchResults() {
const resultsContainer = document.getElementById('searchResults');
if (resultsContainer) {
resultsContainer.style.display = 'none';
}
const guideSelectorContainer = document.getElementById('guide-selector-container');
if (guideSelectorContainer) {
guideSelectorContainer.style.display = 'none';
}
}
// Функция для быстрого бронирования (вызывается из компонентов)
function quickBookTour(routeId, guideId, date, peopleCount = 1) {
// Создаем модальное окно для быстрого бронирования
const modal = document.createElement('div');
modal.className = 'modal fade';
modal.innerHTML = `
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Бронирование тура</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="quickBookingForm" action="/bookings" method="POST">
<input type="hidden" name="route_id" value="${routeId}">
<input type="hidden" name="guide_id" value="${guideId}">
<input type="hidden" name="preferred_date" value="${date}">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Ваше имя *</label>
<input type="text" class="form-control" name="customer_name" required>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Количество человек</label>
<input type="number" class="form-control" name="people_count" value="${peopleCount}" min="1" max="20" required>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Email *</label>
<input type="email" class="form-control" name="customer_email" required>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Телефон *</label>
<input type="tel" class="form-control" name="customer_phone" required>
</div>
</div>
<div class="mb-3">
<label class="form-label">Особые пожелания</label>
<textarea class="form-control" name="special_requirements" rows="3"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" form="quickBookingForm" class="btn btn-primary">
<i class="fas fa-credit-card me-1"></i>Забронировать
</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
const bootstrapModal = new bootstrap.Modal(modal);
bootstrapModal.show();
// Удаление модального окна после закрытия
modal.addEventListener('hidden.bs.modal', function() {
document.body.removeChild(modal);
});
}
// Делаем функции доступными глобально для использования в компонентах
window.clearSearchResults = clearSearchResults;
window.quickBookTour = quickBookTour;
// ==========================================
// Утилитарные функции (продолжение)
// ==========================================
// ==========================================
// Финальные утилитарные функции
// ==========================================
function createAlertContainer() {
const container = document.createElement('div');
container.id = 'alert-container';
@@ -387,5 +576,13 @@ document.addEventListener('DOMContentLoaded', function() {
return container;
}
console.log('Korea Tourism Agency - JavaScript loaded successfully! 🇰🇷');
// Функция для форматирования чисел (валюта)
function formatNumber(num) {
return new Intl.NumberFormat('ru-RU').format(num);
}
// Делаем утилитарные функции доступными глобально
window.formatNumber = formatNumber;
console.log('Korea Tourism Agency - JavaScript with components loaded successfully! 🇰🇷');
});

View File

@@ -0,0 +1,477 @@
/**
* Универсальная интеграция медиа-менеджера в AdminJS
* Заменяет все стандартные диалоги выбора файлов на медиа-менеджер
*/
(function() {
'use strict';
console.log('🚀 Загружается универсальный медиа-менеджер для AdminJS...');
let mediaManagerModal = null;
let currentCallback = null;
// Создание модального окна медиа-менеджера
function createMediaManagerModal() {
if (mediaManagerModal) return mediaManagerModal;
const modal = document.createElement('div');
modal.className = 'universal-media-modal';
modal.innerHTML = `
<div class="universal-media-overlay"></div>
<div class="universal-media-content">
<div class="universal-media-header">
<h3>📁 Выбор изображения</h3>
<button class="universal-media-close">×</button>
</div>
<iframe class="universal-media-frame" src="/universal-media-manager.html"></iframe>
</div>
`;
// CSS стили
const style = document.createElement('style');
style.textContent = `
.universal-media-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10000;
display: none;
align-items: center;
justify-content: center;
}
.universal-media-modal.active {
display: flex;
}
.universal-media-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
}
.universal-media-content {
position: relative;
width: 90vw;
height: 90vh;
max-width: 1200px;
max-height: 800px;
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}
.universal-media-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.universal-media-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.universal-media-close {
background: none;
border: none;
color: white;
font-size: 24px;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s ease;
}
.universal-media-close:hover {
background: rgba(255, 255, 255, 0.2);
}
.universal-media-frame {
width: 100%;
height: calc(100% - 60px);
border: none;
}
/* Стили для кнопок медиа-менеджера */
.media-manager-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: #007bff;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
margin: 5px;
}
.media-manager-btn:hover {
background: #0056b3;
transform: translateY(-1px);
}
.media-manager-btn.small {
padding: 4px 8px;
font-size: 12px;
}
/* Скрываем стандартные input[type="file"] */
.media-replaced input[type="file"] {
display: none !important;
}
/* Стили для preview изображений */
.media-preview {
max-width: 200px;
max-height: 150px;
border-radius: 6px;
margin: 10px 0;
border: 2px solid #e9ecef;
object-fit: cover;
}
.media-preview.selected {
border-color: #28a745;
}
`;
if (!document.querySelector('#universal-media-styles')) {
style.id = 'universal-media-styles';
document.head.appendChild(style);
}
// События
const closeBtn = modal.querySelector('.universal-media-close');
const overlay = modal.querySelector('.universal-media-overlay');
closeBtn.addEventListener('click', closeMediaManager);
overlay.addEventListener('click', closeMediaManager);
document.body.appendChild(modal);
mediaManagerModal = modal;
return modal;
}
// Открытие медиа-менеджера
function openMediaManager(callback, options = {}) {
const modal = createMediaManagerModal();
currentCallback = callback;
// Обновляем заголовок если нужно
const header = modal.querySelector('.universal-media-header h3');
header.textContent = options.title || '📁 Выбор изображения';
modal.classList.add('active');
document.body.style.overflow = 'hidden';
}
// Закрытие медиа-менеджера
function closeMediaManager() {
if (mediaManagerModal) {
mediaManagerModal.classList.remove('active');
document.body.style.overflow = '';
currentCallback = null;
}
}
// Обработка сообщений от медиа-менеджера
window.addEventListener('message', function(event) {
if (event.data.type === 'media-manager-selection' && currentCallback) {
const files = event.data.files;
if (files && files.length > 0) {
currentCallback(files);
closeMediaManager();
}
}
});
// Замена стандартных input[type="file"] на медиа-менеджер
function replaceFileInputs() {
const fileInputs = document.querySelectorAll('input[type="file"]:not(.media-replaced)');
fileInputs.forEach(input => {
if (input.accept && !input.accept.includes('image')) {
return; // Пропускаем не-изображения
}
input.classList.add('media-replaced');
// Создаем кнопку медиа-менеджера
const button = document.createElement('button');
button.type = 'button';
button.className = 'media-manager-btn';
button.innerHTML = '📷 Выбрать изображение';
// Добавляем preview
const preview = document.createElement('img');
preview.className = 'media-preview';
preview.style.display = 'none';
// Добавляем скрытый input для хранения пути
const hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.name = input.name;
hiddenInput.value = input.value || '';
// Событие клика
button.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
openMediaManager((files) => {
const file = files[0];
// Обновляем значения
hiddenInput.value = file.url;
input.value = file.url;
// Показываем preview
preview.src = file.url;
preview.style.display = 'block';
preview.alt = file.name;
// Обновляем кнопку
button.innerHTML = '✏️ Заменить изображение';
// Добавляем кнопку удаления
if (!button.nextElementSibling?.classList.contains('media-remove-btn')) {
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'media-manager-btn small';
removeBtn.style.background = '#dc3545';
removeBtn.innerHTML = '🗑️ Удалить';
removeBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
// Очищаем значения
hiddenInput.value = '';
input.value = '';
// Скрываем preview
preview.style.display = 'none';
// Восстанавливаем кнопку
button.innerHTML = '📷 Выбрать изображение';
removeBtn.remove();
});
button.parentElement.insertBefore(removeBtn, button.nextSibling);
}
// Вызываем событие change для совместимости
const changeEvent = new Event('change', { bubbles: true });
input.dispatchEvent(changeEvent);
}, {
title: input.dataset.title || 'Выбор изображения'
});
});
// Вставляем элементы
input.parentElement.insertBefore(button, input.nextSibling);
input.parentElement.insertBefore(preview, button.nextSibling);
input.parentElement.insertBefore(hiddenInput, preview.nextSibling);
// Если есть начальное значение, показываем preview
if (input.value) {
preview.src = input.value;
preview.style.display = 'block';
button.innerHTML = '✏️ Заменить изображение';
hiddenInput.value = input.value;
}
});
}
// Замена кнопок "Browse" в формах AdminJS
function replaceAdminJSBrowseButtons() {
// Ищем кнопки загрузки файлов AdminJS
const browseButtons = document.querySelectorAll('button[type="button"]:not(.media-replaced)');
browseButtons.forEach(button => {
const buttonText = button.textContent.toLowerCase();
if (buttonText.includes('browse') ||
buttonText.includes('выбрать') ||
buttonText.includes('загрузить') ||
buttonText.includes('upload')) {
button.classList.add('media-replaced');
// Заменяем обработчик клика
const originalHandler = button.onclick;
button.onclick = null;
button.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
openMediaManager((files) => {
const file = files[0];
// Ищем соответствующий input
const container = button.closest('.form-group, .field, .input-group');
const input = container?.querySelector('input[type="text"], input[type="url"], input[type="hidden"]');
if (input) {
input.value = file.url;
// Вызываем событие change
const changeEvent = new Event('change', { bubbles: true });
input.dispatchEvent(changeEvent);
// Обновляем preview если есть
const preview = container.querySelector('img');
if (preview) {
preview.src = file.url;
}
}
});
});
// Обновляем текст кнопки
button.innerHTML = '📷 Медиа-менеджер';
}
});
}
// Интеграция с полями изображений AdminJS
function integrateWithAdminJSImageFields() {
// Ищем поля с атрибутом accept="image/*"
const imageFields = document.querySelectorAll('input[accept*="image"]:not(.media-replaced)');
imageFields.forEach(field => {
field.classList.add('media-replaced');
const container = field.closest('.form-group, .field');
if (!container) return;
// Создаем кнопку медиа-менеджера
const mediaBtn = document.createElement('button');
mediaBtn.type = 'button';
mediaBtn.className = 'media-manager-btn';
mediaBtn.innerHTML = '📷 Открыть медиа-менеджер';
mediaBtn.addEventListener('click', (e) => {
e.preventDefault();
openMediaManager((files) => {
const file = files[0];
// Обновляем поле
field.value = file.url;
// Создаем событие change
const event = new Event('change', { bubbles: true });
field.dispatchEvent(event);
// Если есть label, обновляем его
const label = container.querySelector('label');
if (label && !label.querySelector('.selected-file')) {
const selectedSpan = document.createElement('span');
selectedSpan.className = 'selected-file';
selectedSpan.style.cssText = 'color: #28a745; font-weight: 500; margin-left: 10px;';
selectedSpan.textContent = `${file.name}`;
label.appendChild(selectedSpan);
}
});
});
// Вставляем кнопку после поля
field.parentElement.insertBefore(mediaBtn, field.nextSibling);
});
}
// Основная функция инициализации
function initMediaManager() {
console.log('🔧 Инициализация медиа-менеджера...');
// Замена различных типов полей
replaceFileInputs();
replaceAdminJSBrowseButtons();
integrateWithAdminJSImageFields();
console.log('✅ Медиа-менеджер инициализирован');
}
// Наблюдатель за изменениями DOM
const observer = new MutationObserver((mutations) => {
let shouldReinit = false;
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1) { // Element node
if (node.querySelector && (
node.querySelector('input[type="file"]') ||
node.querySelector('input[accept*="image"]') ||
node.querySelector('button[type="button"]')
)) {
shouldReinit = true;
}
}
});
}
});
if (shouldReinit) {
setTimeout(initMediaManager, 100);
}
});
// Запуск
function start() {
// Ждем загрузки DOM
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initMediaManager);
} else {
initMediaManager();
}
// Запуск наблюдателя
observer.observe(document.body, {
childList: true,
subtree: true
});
// Переинициализация при изменениях в SPA
let lastUrl = location.href;
setInterval(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
setTimeout(initMediaManager, 500);
}
}, 1000);
}
// Глобальная функция для ручного открытия медиа-менеджера
window.openUniversalMediaManager = function(callback, options) {
openMediaManager(callback, options);
};
// Запуск
start();
})();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,282 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Планировщик рабочих смен - Корея Тур Агентство</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
min-height: 100vh;
padding: 20px 0;
}
.main-container {
background: white;
border-radius: 15px;
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
overflow: hidden;
}
.header-section {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
.header-title {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 10px;
}
.header-subtitle {
font-size: 1.1rem;
opacity: 0.9;
}
.back-button {
position: absolute;
top: 20px;
left: 20px;
color: white;
text-decoration: none;
padding: 10px 15px;
background: rgba(255,255,255,0.2);
border-radius: 8px;
transition: all 0.3s;
}
.back-button:hover {
background: rgba(255,255,255,0.3);
color: white;
text-decoration: none;
}
.content-section {
padding: 30px;
}
.loading-spinner {
text-align: center;
padding: 60px;
}
.spinner-border {
width: 4rem;
height: 4rem;
}
.error-message {
text-align: center;
padding: 60px;
color: #dc3545;
}
.error-icon {
font-size: 4rem;
margin-bottom: 20px;
}
/* Адаптивность */
@media (max-width: 768px) {
.header-title {
font-size: 2rem;
}
.content-section {
padding: 20px;
}
body {
padding: 10px;
}
}
/* Анимации */
.main-container {
animation: slideUp 0.6s ease-out;
}
@keyframes slideUp {
from {
transform: translateY(30px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.fade-in {
animation: fadeIn 0.8s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>
</head>
<body>
<div class="container-fluid">
<div class="row justify-content-center">
<div class="col-12">
<div class="main-container">
<!-- Header -->
<div class="header-section position-relative">
<a href="/admin" class="back-button">
<i class="fas fa-arrow-left me-2"></i>Назад в админку
</a>
<div class="header-title">
<i class="fas fa-calendar-week me-3"></i>
Планировщик рабочих смен
</div>
<div class="header-subtitle">
Управление расписанием работы гидов
</div>
</div>
<!-- Content -->
<div class="content-section">
<!-- Loading State -->
<div id="loading" class="loading-spinner">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
<div class="mt-3">
<h5>Загрузка планировщика смен...</h5>
<p class="text-muted">Подождите, пожалуйста</p>
</div>
</div>
<!-- Error State -->
<div id="error" class="error-message" style="display: none;">
<div class="error-icon">
<i class="fas fa-exclamation-triangle"></i>
</div>
<h4>Ошибка загрузки</h4>
<p>Не удалось загрузить планировщик смен. Попробуйте перезагрузить страницу.</p>
<button class="btn btn-primary" onclick="location.reload()">
<i class="fas fa-redo me-2"></i>Перезагрузить
</button>
</div>
<!-- Schedule Manager Container -->
<div id="scheduleManagerContainer" class="fade-in" style="display: none;">
<!-- Компонент будет загружен здесь -->
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- Schedule Manager Component -->
<script src="/components/guide-schedule-manager.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
let retryCount = 0;
const maxRetries = 3;
async function initializeScheduleManager() {
try {
// Проверяем доступность компонента
if (typeof GuideScheduleManager === 'undefined') {
throw new Error('GuideScheduleManager component not loaded');
}
// Инициализируем планировщик смен
const scheduleManager = new GuideScheduleManager({
container: document.getElementById('scheduleManagerContainer'),
onScheduleChange: function(scheduleData) {
console.log('Расписание изменено:', scheduleData);
// Можно добавить уведомления об успешном сохранении
showNotification('Расписание успешно сохранено!', 'success');
}
});
// Показываем контейнер и скрываем загрузку
document.getElementById('loading').style.display = 'none';
document.getElementById('scheduleManagerContainer').style.display = 'block';
console.log('Планировщик смен инициализирован успешно');
} catch (error) {
console.error('Ошибка инициализации планировщика смен:', error);
if (retryCount < maxRetries) {
retryCount++;
console.log(`Попытка повторной загрузки ${retryCount}/${maxRetries}`);
setTimeout(initializeScheduleManager, 2000);
} else {
// Показываем ошибку
document.getElementById('loading').style.display = 'none';
document.getElementById('error').style.display = 'block';
}
}
}
// Функция для показа уведомлений
function showNotification(message, type = 'info') {
// Создаем контейнер для уведомлений если его нет
let container = document.getElementById('notification-container');
if (!container) {
container = document.createElement('div');
container.id = 'notification-container';
container.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 9999;';
document.body.appendChild(container);
}
// Создаем уведомление
const notification = document.createElement('div');
notification.className = `alert alert-${type} alert-dismissible fade show`;
notification.style.cssText = 'min-width: 300px; margin-bottom: 10px;';
notification.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
container.appendChild(notification);
// Автоматически удаляем через 5 секунд
setTimeout(() => {
if (notification.parentNode) {
notification.remove();
}
}, 5000);
}
// Глобальная функция для уведомлений
window.showNotification = showNotification;
// Запускаем инициализацию после небольшой задержки
setTimeout(initializeScheduleManager, 500);
});
// Обработка ошибок загрузки скриптов
window.addEventListener('error', function(e) {
if (e.filename && e.filename.includes('guide-schedule-manager.js')) {
console.error('Ошибка загрузки компонента планировщика смен:', e);
document.getElementById('loading').style.display = 'none';
document.getElementById('error').style.display = 'block';
}
});
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,143 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Тест редактора изображений</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
background: #f5f5f5;
}
.test-form {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
max-width: 600px;
}
.field {
margin: 15px 0;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
input[type="text"] {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.test-results {
margin-top: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 4px;
}
.adminjs-app { /* Имитируем класс AdminJS */ }
.status {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: bold;
}
.status.success { background: #d4edda; color: #155724; }
.status.error { background: #f8d7da; color: #721c24; }
</style>
</head>
<body class="adminjs-app">
<h1>🧪 Тест редактора изображений</h1>
<p>Эта страница тестирует интеграцию редактора изображений с AdminJS</p>
<form class="test-form">
<div class="field">
<label for="image_url">URL изображения маршрута:</label>
<input type="text" name="image_url" id="image_url" value="/uploads/routes/example.jpg">
</div>
<div class="field">
<label for="profile_image">Изображение профиля:</label>
<input type="text" name="profile_image" id="profile_image" value="">
</div>
<div class="field">
<label for="article_image_url">Изображение статьи:</label>
<input type="text" name="article_image_url" id="article_image_url" value="">
</div>
<div class="field">
<label for="title">Обычное поле (заголовок):</label>
<input type="text" name="title" id="title" value="Тест обычного поля">
</div>
<div class="field">
<label for="description">Описание:</label>
<input type="text" name="description" id="description" value="Это поле не должно иметь кнопку редактора">
</div>
</form>
<div class="test-results">
<h3>📊 Результаты теста:</h3>
<div id="test-output">
<p>⏳ Загрузка и инициализация скрипта...</p>
</div>
</div>
<script>
// Имитируем AdminJS окружение
document.addEventListener('DOMContentLoaded', function() {
console.log('🧪 Страница теста загружена');
// Загружаем наш скрипт
const script = document.createElement('script');
script.src = '/js/admin-image-selector-fixed.js';
script.onload = function() {
console.log('✅ Скрипт admin-image-selector-fixed.js загружен');
updateTestResults();
};
script.onerror = function() {
console.error('❌ Ошибка загрузки скрипта admin-image-selector-fixed.js');
updateTestResults();
};
document.head.appendChild(script);
// Обновляем результаты тестирования через некоторое время
setTimeout(updateTestResults, 2000);
setTimeout(updateTestResults, 5000);
});
function updateTestResults() {
const outputDiv = document.getElementById('test-output');
const imageFields = document.querySelectorAll('input[name*="image"]');
const regularFields = document.querySelectorAll('input:not([name*="image"])');
let html = '<h4>🔍 Анализ полей:</h4>';
// Проверяем поля изображений
html += '<h5>Поля изображений:</h5>';
imageFields.forEach(field => {
const hasButton = field.parentNode.querySelector('.image-editor-btn');
const status = hasButton ? 'success' : 'error';
const statusText = hasButton ? '✅ Кнопка добавлена' : '❌ Кнопка отсутствует';
html += `<p><strong>${field.name}:</strong> <span class="status ${status}">${statusText}</span></p>`;
});
// Проверяем обычные поля
html += '<h5>Обычные поля:</h5>';
regularFields.forEach(field => {
const hasButton = field.parentNode.querySelector('.image-editor-btn');
const status = hasButton ? 'error' : 'success';
const statusText = hasButton ? '❌ Кнопка добавлена (ошибка)' : '✅ Кнопка отсутствует';
html += `<p><strong>${field.name}:</strong> <span class="status ${status}">${statusText}</span></p>`;
});
outputDiv.innerHTML = html;
}
</script>
</body>
</html>

625
public/tours-calendar.html Normal file
View File

@@ -0,0 +1,625 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Календарь туров - Korea Tourism</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.calendar-container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
font-size: 2.5rem;
margin-bottom: 10px;
}
.header p {
opacity: 0.9;
font-size: 1.1rem;
}
.calendar-nav {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 30px;
background: #f8f9fa;
border-bottom: 1px solid #e0e0e0;
}
.nav-btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
cursor: pointer;
font-weight: bold;
transition: all 0.3s;
}
.nav-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.3);
}
.month-title {
font-size: 1.8rem;
color: #333;
font-weight: bold;
}
.calendar-grid {
padding: 20px;
}
.week-header {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
margin-bottom: 10px;
}
.week-day {
padding: 15px;
text-align: center;
font-weight: bold;
color: #666;
background: #f8f9fa;
border-radius: 8px;
}
.days-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 10px;
}
.day-cell {
min-height: 120px;
padding: 12px;
border-radius: 12px;
background: #fafafa;
cursor: pointer;
transition: all 0.3s;
border: 2px solid transparent;
position: relative;
}
.day-cell:hover {
transform: translateY(-3px);
box-shadow: 0 8px 20px rgba(0,0,0,0.1);
}
.day-cell.empty {
background: transparent;
cursor: default;
}
.day-cell.empty:hover {
transform: none;
box-shadow: none;
}
.day-cell.has-tours {
background: linear-gradient(135deg, #e8f5e8 0%, #f0f8f0 100%);
border-color: #4caf50;
}
.day-cell.has-tours:hover {
border-color: #2e7d32;
box-shadow: 0 8px 25px rgba(76, 175, 80, 0.3);
}
.day-number {
font-weight: bold;
font-size: 1.1rem;
color: #333;
margin-bottom: 8px;
}
.day-cell.has-tours .day-number {
color: #2e7d32;
}
.tours-count {
font-size: 12px;
color: #666;
margin-bottom: 6px;
}
.tour-types {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.tour-type {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.tour-city { background: #2196f3; }
.tour-mountain { background: #4caf50; }
.tour-fishing { background: #ff5722; }
.guides-info {
font-size: 11px;
color: #888;
margin-top: 4px;
}
/* Модальное окно */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.7);
z-index: 1000;
backdrop-filter: blur(5px);
}
.modal-content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
border-radius: 16px;
padding: 30px;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 25px 50px rgba(0,0,0,0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #f0f0f0;
}
.modal-title {
font-size: 1.5rem;
color: #333;
font-weight: bold;
}
.close-btn {
width: 35px;
height: 35px;
border: none;
border-radius: 50%;
background: #f44336;
color: white;
cursor: pointer;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.close-btn:hover {
background: #d32f2f;
transform: rotate(90deg);
}
.tour-card {
background: #f8f9fa;
border-radius: 12px;
padding: 20px;
margin-bottom: 15px;
transition: all 0.3s;
border-left: 4px solid #ddd;
}
.tour-card:hover {
transform: translateX(5px);
box-shadow: 0 6px 15px rgba(0,0,0,0.1);
}
.tour-card.city {
border-left-color: #2196f3;
background: linear-gradient(135deg, #e3f2fd 0%, #f8f9fa 100%);
}
.tour-card.mountain {
border-left-color: #4caf50;
background: linear-gradient(135deg, #e8f5e8 0%, #f8f9fa 100%);
}
.tour-card.fishing {
border-left-color: #ff5722;
background: linear-gradient(135deg, #ffebee 0%, #f8f9fa 100%);
}
.tour-title {
font-size: 1.2rem;
font-weight: bold;
color: #333;
margin-bottom: 8px;
}
.tour-details {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 10px;
}
.tour-detail {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: #666;
}
.tour-description {
color: #777;
line-height: 1.5;
margin-top: 10px;
}
.book-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
cursor: pointer;
font-weight: bold;
margin-top: 10px;
transition: all 0.3s;
}
.book-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.4);
}
.no-tours {
text-align: center;
padding: 40px;
color: #888;
}
.legend {
display: flex;
justify-content: center;
gap: 20px;
padding: 20px;
background: #f8f9fa;
margin-top: 20px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #666;
}
.legend-color {
width: 16px;
height: 16px;
border-radius: 50%;
}
@media (max-width: 768px) {
.calendar-nav {
flex-direction: column;
gap: 15px;
}
.days-grid {
gap: 5px;
}
.day-cell {
min-height: 80px;
padding: 8px;
}
}
</style>
</head>
<body>
<div class="calendar-container">
<div class="header">
<h1>🗓️ Календарь туров</h1>
<p>Выберите дату и найдите доступные туры с лучшими гидами Кореи</p>
</div>
<div class="calendar-nav">
<button class="nav-btn" onclick="changeMonth(-1)">← Предыдущий месяц</button>
<div class="month-title" id="currentMonth">Загрузка...</div>
<button class="nav-btn" onclick="changeMonth(1)">Следующий месяц →</button>
</div>
<div class="calendar-grid">
<div class="week-header">
<div class="week-day">ПН</div>
<div class="week-day">ВТ</div>
<div class="week-day">СР</div>
<div class="week-day">ЧТ</div>
<div class="week-day">ПТ</div>
<div class="week-day">СБ</div>
<div class="week-day">ВС</div>
</div>
<div class="days-grid" id="calendarDays">
<!-- Дни будут добавлены через JavaScript -->
</div>
</div>
<div class="legend">
<div class="legend-item">
<div class="legend-color tour-city"></div>
<span>Городские туры</span>
</div>
<div class="legend-item">
<div class="legend-color tour-mountain"></div>
<span>Горные походы</span>
</div>
<div class="legend-item">
<div class="legend-color tour-fishing"></div>
<span>Морская рыбалка</span>
</div>
</div>
</div>
<!-- Модальное окно -->
<div id="tourModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title" id="modalTitle">Туры на выбранную дату</div>
<button class="close-btn" onclick="closeModal()">×</button>
</div>
<div id="modalContent">
<!-- Содержимое будет добавлено через JavaScript -->
</div>
</div>
</div>
<script>
let currentDate = new Date();
let calendarData = {};
const monthNames = [
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
];
async function init() {
await loadCalendarData();
renderCalendar();
}
async function loadCalendarData() {
try {
const year = currentDate.getFullYear();
const month = currentDate.getMonth() + 1;
const response = await fetch(`/api/tours-calendar?year=${year}&month=${month}`);
const data = await response.json();
if (data.success) {
calendarData = {};
data.data.forEach(item => {
calendarData[item.work_date] = item;
});
}
} catch (error) {
console.error('Ошибка загрузки календарных данных:', error);
}
}
function renderCalendar() {
document.getElementById('currentMonth').textContent =
monthNames[currentDate.getMonth()] + ' ' + currentDate.getFullYear();
const calendarDays = document.getElementById('calendarDays');
calendarDays.innerHTML = '';
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const firstDay = new Date(year, month, 1).getDay();
const startDay = firstDay === 0 ? 6 : firstDay - 1;
// Пустые дни в начале
for (let i = 0; i < startDay; i++) {
const dayCell = document.createElement('div');
dayCell.className = 'day-cell empty';
calendarDays.appendChild(dayCell);
}
// Дни месяца
for (let day = 1; day <= daysInMonth; day++) {
const dayCell = document.createElement('div');
dayCell.className = 'day-cell';
const dateStr = year + '-' + String(month + 1).padStart(2, '0') + '-' + String(day).padStart(2, '0');
const dayData = calendarData[dateStr];
const dayNumber = document.createElement('div');
dayNumber.className = 'day-number';
dayNumber.textContent = day;
dayCell.appendChild(dayNumber);
if (dayData) {
dayCell.classList.add('has-tours');
dayCell.onclick = () => openTourModal(dateStr);
const toursCount = document.createElement('div');
toursCount.className = 'tours-count';
toursCount.textContent = `${dayData.routes_count} туров`;
dayCell.appendChild(toursCount);
const tourTypes = document.createElement('div');
tourTypes.className = 'tour-types';
const specializations = new Set();
dayData.guides_data.forEach(guide => {
if (guide.specialization) {
specializations.add(guide.specialization);
}
});
specializations.forEach(spec => {
const tourType = document.createElement('div');
tourType.className = `tour-type tour-${spec}`;
tourTypes.appendChild(tourType);
});
dayCell.appendChild(tourTypes);
const guidesInfo = document.createElement('div');
guidesInfo.className = 'guides-info';
guidesInfo.textContent = `${dayData.guides_count} гидов`;
dayCell.appendChild(guidesInfo);
}
calendarDays.appendChild(dayCell);
}
}
async function openTourModal(date) {
try {
const response = await fetch(`/api/tours-by-date?date=${date}`);
const data = await response.json();
if (!data.success) {
alert('Ошибка загрузки туров');
return;
}
const modal = document.getElementById('tourModal');
const modalTitle = document.getElementById('modalTitle');
const modalContent = document.getElementById('modalContent');
const dateObj = new Date(date);
const formattedDate = dateObj.toLocaleDateString('ru-RU', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
modalTitle.textContent = `Туры на ${formattedDate}`;
if (data.data.length === 0) {
modalContent.innerHTML = `
<div class="no-tours">
<h3>На эту дату туры недоступны</h3>
<p>Выберите другую дату или свяжитесь с нами для индивидуального тура</p>
</div>
`;
} else {
modalContent.innerHTML = data.data.map(tour => `
<div class="tour-card ${tour.type}">
<div class="tour-title">${tour.title}</div>
<div class="tour-details">
<div class="tour-detail">
👤 <strong>${tour.guide_name}</strong>
</div>
<div class="tour-detail">
💰 ${tour.price ? tour.price + ' ₩' : 'По запросу'}
</div>
<div class="tour-detail">
${tour.duration ? tour.duration + ' часов' : 'По договоренности'}
</div>
<div class="tour-detail">
🎯 ${getTypeLabel(tour.type)}
</div>
</div>
${tour.description ? `<div class="tour-description">${tour.description.substring(0, 200)}...</div>` : ''}
${tour.guide_notes ? `<div style="font-style: italic; color: #666; margin-top: 8px;">Заметки гида: ${tour.guide_notes}</div>` : ''}
<button class="book-btn" onclick="bookTour(${tour.id}, '${date}')">
📞 Забронировать тур
</button>
</div>
`).join('');
}
modal.style.display = 'block';
} catch (error) {
console.error('Ошибка загрузки туров:', error);
alert('Ошибка загрузки туров');
}
}
function closeModal() {
document.getElementById('tourModal').style.display = 'none';
}
function getTypeLabel(type) {
const types = {
'city': 'Городские экскурсии',
'mountain': 'Горные походы',
'fishing': 'Морская рыбалка'
};
return types[type] || type;
}
function bookTour(tourId, date) {
// Здесь можно добавить логику бронирования
alert(`Функция бронирования тура #${tourId} на ${date} будет добавлена позже`);
closeModal();
}
async function changeMonth(delta) {
currentDate.setMonth(currentDate.getMonth() + delta);
await loadCalendarData();
renderCalendar();
}
// Закрытие модального окна при клике вне его
window.onclick = function(event) {
const modal = document.getElementById('tourModal');
if (event.target === modal) {
closeModal();
}
}
// Инициализация
init();
</script>
</body>
</html>

View File

@@ -0,0 +1,909 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Универсальный медиа-менеджер</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f8f9fa;
overflow: hidden;
}
.media-manager {
height: 100vh;
display: flex;
flex-direction: column;
background: white;
}
/* Header */
.media-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 15px 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.media-title {
font-size: 20px;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
}
/* Toolbar */
.media-toolbar {
background: white;
padding: 15px 20px;
border-bottom: 1px solid #e9ecef;
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.upload-zone {
position: relative;
padding: 8px 16px;
background: #28a745;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
}
.upload-zone:hover {
background: #218838;
transform: translateY(-1px);
}
.upload-zone input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.search-box {
flex: 1;
max-width: 300px;
padding: 8px 12px;
border: 1px solid #ced4da;
border-radius: 6px;
font-size: 14px;
}
.view-toggle {
display: flex;
gap: 5px;
}
.view-btn {
padding: 6px 10px;
border: 1px solid #ced4da;
background: white;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s ease;
}
.view-btn.active {
background: #007bff;
color: white;
border-color: #007bff;
}
/* Content Area */
.media-content {
flex: 1;
display: flex;
overflow: hidden;
}
/* Sidebar */
.media-sidebar {
width: 250px;
background: #f8f9fa;
border-right: 1px solid #e9ecef;
padding: 20px;
overflow-y: auto;
}
.folder-tree {
list-style: none;
}
.folder-item {
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
margin-bottom: 2px;
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.folder-item:hover {
background: #e9ecef;
}
.folder-item.active {
background: #007bff;
color: white;
}
/* Gallery */
.media-gallery {
flex: 1;
padding: 20px;
overflow-y: auto;
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 15px;
}
.gallery-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.media-item {
background: white;
border: 2px solid #e9ecef;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
}
.media-item:hover {
border-color: #007bff;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,123,255,0.15);
}
.media-item.selected {
border-color: #28a745;
background: #f8fff8;
}
.media-item.selected::after {
content: '✓';
position: absolute;
top: 5px;
right: 5px;
background: #28a745;
color: white;
border-radius: 50%;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
}
.media-preview {
width: 100%;
height: 120px;
object-fit: cover;
background: #f8f9fa;
}
.media-info {
padding: 10px;
}
.media-name {
font-size: 12px;
font-weight: 500;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.media-size {
font-size: 11px;
color: #6c757d;
}
/* List View */
.list-item {
display: flex;
align-items: center;
padding: 12px;
background: white;
border: 1px solid #e9ecef;
border-radius: 6px;
}
.list-preview {
width: 60px;
height: 45px;
object-fit: cover;
border-radius: 4px;
margin-right: 15px;
}
.list-info {
flex: 1;
}
.list-actions {
display: flex;
gap: 8px;
}
/* Actions */
.action-btn {
padding: 4px 8px;
border: 1px solid #ced4da;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s ease;
}
.action-btn:hover {
background: #f8f9fa;
}
.action-btn.delete {
color: #dc3545;
border-color: #dc3545;
}
.action-btn.delete:hover {
background: #dc3545;
color: white;
}
/* Drop Zone */
.drop-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,123,255,0.8);
color: white;
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
font-size: 24px;
font-weight: 600;
}
.drop-overlay.active {
display: flex;
}
/* Status Bar */
.media-status {
background: #f8f9fa;
padding: 10px 20px;
border-top: 1px solid #e9ecef;
font-size: 14px;
color: #6c757d;
display: flex;
justify-content: between;
align-items: center;
}
.status-left {
flex: 1;
}
.status-right {
display: flex;
gap: 15px;
}
/* Selection Actions */
.selection-actions {
background: #007bff;
color: white;
padding: 10px 20px;
display: none;
align-items: center;
gap: 15px;
}
.selection-actions.visible {
display: flex;
}
.select-btn {
background: #28a745;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
}
.select-btn:hover {
background: #218838;
}
/* Modal */
.media-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.8);
display: none;
align-items: center;
justify-content: center;
z-index: 2000;
}
.modal-content {
background: white;
max-width: 90vw;
max-height: 90vh;
border-radius: 8px;
overflow: hidden;
}
.modal-image {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
}
.modal-footer {
padding: 15px;
background: #f8f9fa;
text-align: center;
}
/* Loading */
.loading {
display: none;
text-align: center;
padding: 40px;
color: #6c757d;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Responsive */
@media (max-width: 768px) {
.media-sidebar {
display: none;
}
.gallery-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px;
}
}
/* Empty State */
.empty-state {
text-align: center;
padding: 60px 20px;
color: #6c757d;
}
.empty-icon {
font-size: 48px;
margin-bottom: 15px;
opacity: 0.5;
}
</style>
</head>
<body>
<div class="media-manager">
<!-- Header -->
<div class="media-header">
<div class="media-title">
<span>📁</span>
Медиа-менеджер
</div>
</div>
<!-- Toolbar -->
<div class="media-toolbar">
<div class="upload-zone">
<input type="file" id="fileInput" multiple accept="image/*">
<span>📤</span>
Загрузить файлы
</div>
<input type="text" class="search-box" id="searchBox" placeholder="🔍 Поиск файлов...">
<div class="view-toggle">
<button class="view-btn active" data-view="grid"></button>
<button class="view-btn" data-view="list"></button>
</div>
</div>
<!-- Selection Actions -->
<div class="selection-actions" id="selectionActions">
<span id="selectionCount">0 файлов выбрано</span>
<button class="select-btn" id="useSelectedBtn">Использовать выбранные</button>
<button class="action-btn delete" id="deleteSelectedBtn">Удалить выбранные</button>
</div>
<!-- Content -->
<div class="media-content">
<!-- Sidebar -->
<div class="media-sidebar">
<ul class="folder-tree" id="folderTree">
<li class="folder-item active" data-folder="all">
<span>📁</span> Все файлы
</li>
<li class="folder-item" data-folder="routes">
<span>📁</span> Маршруты
</li>
<li class="folder-item" data-folder="guides">
<span>👥</span> Гиды
</li>
<li class="folder-item" data-folder="articles">
<span>📰</span> Статьи
</li>
<li class="folder-item" data-folder="general">
<span>🗂️</span> Общие
</li>
</ul>
</div>
<!-- Gallery -->
<div class="media-gallery">
<div class="loading" id="loadingIndicator">
<div class="spinner"></div>
Загрузка файлов...
</div>
<div class="gallery-grid" id="mediaGrid"></div>
<div class="empty-state" id="emptyState" style="display: none;">
<div class="empty-icon">📷</div>
<h3>Нет изображений</h3>
<p>Загрузите изображения или выберите другую папку</p>
</div>
</div>
</div>
<!-- Status Bar -->
<div class="media-status">
<div class="status-left" id="statusInfo">
Загрузка...
</div>
<div class="status-right">
<span id="totalFiles">0 файлов</span>
<span id="totalSize">0 KB</span>
</div>
</div>
<!-- Drop Overlay -->
<div class="drop-overlay" id="dropOverlay">
📤 Отпустите файлы для загрузки
</div>
<!-- Modal -->
<div class="media-modal" id="mediaModal">
<div class="modal-content">
<img class="modal-image" id="modalImage" src="" alt="">
<div class="modal-footer">
<button class="action-btn" onclick="closeModal()">Закрыть</button>
</div>
</div>
</div>
</div>
<script>
class MediaManager {
constructor() {
this.selectedFiles = new Set();
this.currentFolder = 'all';
this.currentView = 'grid';
this.allFiles = [];
this.filteredFiles = [];
this.initializeEventListeners();
this.loadFiles();
}
initializeEventListeners() {
// File upload
document.getElementById('fileInput').addEventListener('change', (e) => {
this.handleFileUpload(e.target.files);
});
// Drag and drop
document.addEventListener('dragover', (e) => {
e.preventDefault();
document.getElementById('dropOverlay').classList.add('active');
});
document.addEventListener('dragleave', (e) => {
if (!e.relatedTarget) {
document.getElementById('dropOverlay').classList.remove('active');
}
});
document.addEventListener('drop', (e) => {
e.preventDefault();
document.getElementById('dropOverlay').classList.remove('active');
if (e.dataTransfer.files.length > 0) {
this.handleFileUpload(e.dataTransfer.files);
}
});
// Search
document.getElementById('searchBox').addEventListener('input', (e) => {
this.filterFiles(e.target.value);
});
// View toggle
document.querySelectorAll('.view-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
this.currentView = btn.dataset.view;
this.renderFiles();
});
});
// Folder selection
document.querySelectorAll('.folder-item').forEach(item => {
item.addEventListener('click', () => {
document.querySelectorAll('.folder-item').forEach(i => i.classList.remove('active'));
item.classList.add('active');
this.currentFolder = item.dataset.folder;
this.filterFiles();
});
});
// Selection actions
document.getElementById('useSelectedBtn').addEventListener('click', () => {
this.useSelectedFiles();
});
document.getElementById('deleteSelectedBtn').addEventListener('click', () => {
this.deleteSelectedFiles();
});
// Modal close
document.getElementById('mediaModal').addEventListener('click', (e) => {
if (e.target.id === 'mediaModal') {
this.closeModal();
}
});
}
async loadFiles() {
try {
this.showLoading(true);
const response = await fetch('/api/images/gallery');
const data = await response.json();
if (data.success) {
this.allFiles = data.data.map(file => ({
...file,
id: file.name,
url: file.path,
folder: file.folder || 'general'
}));
this.filterFiles();
} else {
throw new Error(data.message || 'Ошибка загрузки файлов');
}
} catch (error) {
console.error('Ошибка загрузки файлов:', error);
this.showError('Ошибка загрузки файлов: ' + error.message);
} finally {
this.showLoading(false);
}
}
filterFiles(search = '') {
let files = this.allFiles;
// Фильтр по папке
if (this.currentFolder !== 'all') {
files = files.filter(file => file.folder === this.currentFolder);
}
// Фильтр по поиску
if (search) {
const searchTerm = search.toLowerCase();
files = files.filter(file =>
file.name.toLowerCase().includes(searchTerm)
);
}
this.filteredFiles = files;
this.renderFiles();
this.updateStatus();
}
renderFiles() {
const grid = document.getElementById('mediaGrid');
const empty = document.getElementById('emptyState');
if (this.filteredFiles.length === 0) {
grid.style.display = 'none';
empty.style.display = 'block';
return;
}
grid.style.display = this.currentView === 'grid' ? 'grid' : 'flex';
grid.className = this.currentView === 'grid' ? 'gallery-grid' : 'gallery-list';
empty.style.display = 'none';
grid.innerHTML = this.filteredFiles.map(file => {
const isSelected = this.selectedFiles.has(file.id);
const sizeText = this.formatFileSize(file.size);
if (this.currentView === 'grid') {
return `
<div class="media-item ${isSelected ? 'selected' : ''}"
data-id="${file.id}" onclick="mediaManager.selectFile('${file.id}')">
<img class="media-preview" src="${file.url}" alt="${file.name}"
onerror="this.src='/images/placeholders/no-image.png'">
<div class="media-info">
<div class="media-name" title="${file.name}">${file.name}</div>
<div class="media-size">${sizeText}</div>
</div>
</div>
`;
} else {
return `
<div class="media-item list-item ${isSelected ? 'selected' : ''}"
data-id="${file.id}">
<img class="list-preview" src="${file.url}" alt="${file.name}"
onerror="this.src='/images/placeholders/no-image.png'">
<div class="list-info">
<div class="media-name">${file.name}</div>
<div class="media-size">${sizeText}${file.folder}</div>
</div>
<div class="list-actions">
<button class="action-btn" onclick="mediaManager.selectFile('${file.id}')">
${isSelected ? 'Отменить' : 'Выбрать'}
</button>
<button class="action-btn" onclick="mediaManager.previewFile('${file.id}')">Просмотр</button>
<button class="action-btn delete" onclick="mediaManager.deleteFile('${file.id}')">Удалить</button>
</div>
</div>
`;
}
}).join('');
}
selectFile(fileId) {
if (this.selectedFiles.has(fileId)) {
this.selectedFiles.delete(fileId);
} else {
this.selectedFiles.add(fileId);
}
this.updateSelection();
this.renderFiles();
}
updateSelection() {
const count = this.selectedFiles.size;
const actions = document.getElementById('selectionActions');
const countEl = document.getElementById('selectionCount');
if (count > 0) {
actions.classList.add('visible');
countEl.textContent = `${count} файл${count > 1 ? (count > 4 ? 'ов' : 'а') : ''} выбрано`;
} else {
actions.classList.remove('visible');
}
}
previewFile(fileId) {
const file = this.allFiles.find(f => f.id === fileId);
if (file) {
document.getElementById('modalImage').src = file.url;
document.getElementById('mediaModal').style.display = 'flex';
}
}
closeModal() {
document.getElementById('mediaModal').style.display = 'none';
}
async deleteFile(fileId) {
if (!confirm('Удалить этот файл?')) return;
try {
const response = await fetch(`/api/images/delete/${fileId}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
this.selectedFiles.delete(fileId);
await this.loadFiles();
this.showSuccess('Файл удален');
} else {
throw new Error(data.message);
}
} catch (error) {
this.showError('Ошибка удаления: ' + error.message);
}
}
async deleteSelectedFiles() {
if (this.selectedFiles.size === 0) return;
if (!confirm(`Удалить ${this.selectedFiles.size} файл(ов)?`)) return;
try {
const promises = Array.from(this.selectedFiles).map(fileId =>
fetch(`/api/images/delete/${fileId}`, { method: 'DELETE' })
);
await Promise.all(promises);
this.selectedFiles.clear();
await this.loadFiles();
this.showSuccess('Файлы удалены');
} catch (error) {
this.showError('Ошибка удаления: ' + error.message);
}
}
async handleFileUpload(files) {
const formData = new FormData();
Array.from(files).forEach(file => {
formData.append('images', file);
});
// Добавляем текущую папку
formData.append('folder', this.currentFolder === 'all' ? 'general' : this.currentFolder);
try {
this.showLoading(true, 'Загрузка файлов...');
const response = await fetch('/api/images/upload', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
await this.loadFiles();
this.showSuccess(`Загружено ${files.length} файл(ов)`);
} else {
throw new Error(data.message);
}
} catch (error) {
this.showError('Ошибка загрузки: ' + error.message);
} finally {
this.showLoading(false);
document.getElementById('fileInput').value = '';
}
}
useSelectedFiles() {
const selectedData = Array.from(this.selectedFiles).map(fileId => {
const file = this.allFiles.find(f => f.id === fileId);
return {
id: file.id,
name: file.name,
url: file.url,
path: file.path
};
});
// Отправляем сообщение родительскому окну (если в iframe)
if (window.parent !== window) {
window.parent.postMessage({
type: 'media-manager-selection',
files: selectedData
}, '*');
}
// Или вызываем callback если определен
if (window.mediaManagerCallback) {
window.mediaManagerCallback(selectedData);
}
}
updateStatus() {
const total = this.filteredFiles.length;
const totalSize = this.filteredFiles.reduce((sum, file) => sum + (file.size || 0), 0);
document.getElementById('statusInfo').textContent =
`Папка: ${this.getFolderName(this.currentFolder)}`;
document.getElementById('totalFiles').textContent = `${total} файл${total !== 1 ? (total > 4 ? 'ов' : 'а') : ''}`;
document.getElementById('totalSize').textContent = this.formatFileSize(totalSize);
}
getFolderName(folder) {
const names = {
'all': 'Все файлы',
'routes': 'Маршруты',
'guides': 'Гиды',
'articles': 'Статьи',
'general': 'Общие'
};
return names[folder] || folder;
}
formatFileSize(bytes) {
if (!bytes) return '0 B';
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
}
showLoading(show, text = 'Загрузка...') {
const loading = document.getElementById('loadingIndicator');
if (show) {
loading.style.display = 'block';
loading.querySelector('div:last-child').textContent = text;
} else {
loading.style.display = 'none';
}
}
showError(message) {
alert('Ошибка: ' + message);
}
showSuccess(message) {
// Можно заменить на toast уведомление
console.log('Успех:', message);
}
}
// Инициализация
const mediaManager = new MediaManager();
// Глобальные функции для обратной совместимости
window.selectFile = (id) => mediaManager.selectFile(id);
window.previewFile = (id) => mediaManager.previewFile(id);
window.deleteFile = (id) => mediaManager.deleteFile(id);
window.closeModal = () => mediaManager.closeModal();
</script>
</body>
</html>